use crate::slog; use serde::Deserialize; use std::collections::HashSet; use std::path::Path; #[derive(Debug, Clone, Deserialize)] pub struct ProjectConfig { #[serde(default)] pub component: Vec, #[serde(default)] pub agent: Vec, #[serde(default)] pub watcher: WatcherConfig, /// Project-wide default QA mode: "server", "agent", or "human". /// Per-story `qa` front matter overrides this. Default: "server". #[serde(default = "default_qa")] pub default_qa: String, /// Default model for coder-stage agents (e.g. "sonnet"). /// When set, `find_free_agent_for_stage` only considers coder agents whose /// model matches this value, so opus agents are only used when explicitly /// requested via story front matter `agent:` field. #[serde(default)] pub default_coder_model: Option, /// Maximum number of concurrent coder-stage agents. /// When set, `auto_assign_available_work` will not start more than this many /// coder agents at once. Stories wait in `2_current/` until a slot frees up. #[serde(default)] pub max_coders: Option, /// Maximum number of retries per story per pipeline stage before marking as blocked. /// Default: 2. Set to 0 to disable retry limits. #[serde(default = "default_max_retries")] pub max_retries: u32, } /// Configuration for the filesystem watcher's sweep behaviour. /// /// Controls how often the watcher checks `5_done/` for items to promote to /// `6_archived/`, and how long items must remain in `5_done/` before promotion. #[derive(Debug, Clone, Deserialize, PartialEq)] pub struct WatcherConfig { /// How often (in seconds) to check `5_done/` for items to archive. /// Default: 60 seconds. #[serde(default = "default_sweep_interval_secs")] pub sweep_interval_secs: u64, /// How long (in seconds) an item must remain in `5_done/` before being /// moved to `6_archived/`. Default: 14400 (4 hours). #[serde(default = "default_done_retention_secs")] pub done_retention_secs: u64, } impl Default for WatcherConfig { fn default() -> Self { Self { sweep_interval_secs: default_sweep_interval_secs(), done_retention_secs: default_done_retention_secs(), } } } fn default_sweep_interval_secs() -> u64 { 60 } fn default_done_retention_secs() -> u64 { 4 * 60 * 60 // 4 hours } fn default_qa() -> String { "server".to_string() } fn default_max_retries() -> u32 { 2 } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct ComponentConfig { pub name: String, #[serde(default = "default_path")] pub path: String, #[serde(default)] pub setup: Vec, #[serde(default)] pub teardown: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct AgentConfig { #[serde(default = "default_agent_name")] pub name: String, #[serde(default)] pub role: String, #[serde(default = "default_agent_command")] pub command: String, #[serde(default)] pub args: Vec, #[serde(default = "default_agent_prompt")] pub prompt: String, #[serde(default)] pub model: Option, #[serde(default)] pub allowed_tools: Option>, #[serde(default)] pub max_turns: Option, #[serde(default)] pub max_budget_usd: Option, #[serde(default)] pub system_prompt: Option, /// Pipeline stage this agent belongs to. Supported values: "coder", "qa", /// "mergemaster", "other". When set, overrides the legacy name-based /// detection used by `pipeline_stage()`. #[serde(default)] pub stage: Option, /// Inactivity timeout in seconds for the PTY read loop. /// If no output is received within this duration, the agent process is killed /// and marked as Failed. Default: 300 (5 minutes). Set to 0 to disable. #[serde(default = "default_inactivity_timeout_secs")] pub inactivity_timeout_secs: u64, } fn default_path() -> String { ".".to_string() } fn default_agent_name() -> String { "default".to_string() } fn default_inactivity_timeout_secs() -> u64 { 300 } fn default_agent_command() -> String { "claude".to_string() } fn default_agent_prompt() -> String { "You are working in a git worktree on story {{story_id}}. \ Read .storkit/README.md to understand the dev process, then pick up the story. \ Commit all your work when done — the server will automatically run acceptance \ gates (cargo clippy + tests) when your process exits." .to_string() } /// Legacy config format with `agent` as an optional single table (`[agent]`). #[derive(Debug, Deserialize)] struct LegacyProjectConfig { #[serde(default)] component: Vec, agent: Option, #[serde(default)] watcher: WatcherConfig, #[serde(default = "default_qa")] default_qa: String, #[serde(default)] default_coder_model: Option, #[serde(default)] max_coders: Option, #[serde(default = "default_max_retries")] max_retries: u32, } impl Default for ProjectConfig { fn default() -> Self { Self { component: Vec::new(), agent: vec![AgentConfig { name: default_agent_name(), role: String::new(), command: default_agent_command(), args: vec![], prompt: default_agent_prompt(), model: None, allowed_tools: None, max_turns: None, max_budget_usd: None, system_prompt: None, stage: None, inactivity_timeout_secs: default_inactivity_timeout_secs(), }], watcher: WatcherConfig::default(), default_qa: default_qa(), default_coder_model: None, max_coders: None, max_retries: default_max_retries(), } } } impl ProjectConfig { /// Load from `.storkit/project.toml` relative to the given root. /// Falls back to sensible defaults if the file doesn't exist. /// /// Supports both the new `[[agent]]` array format and the legacy /// `[agent]` single-table format (with a deprecation warning). pub fn load(project_root: &Path) -> Result { let config_path = project_root.join(".storkit/project.toml"); if !config_path.exists() { return Ok(Self::default()); } let content = std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?; Self::parse(&content) } /// Parse config from a TOML string, supporting both new and legacy formats. pub fn parse(content: &str) -> Result { // Try new format first (agent as array of tables) match toml::from_str::(content) { Ok(config) if !config.agent.is_empty() => { validate_agents(&config.agent)?; Ok(config) } Ok(config) => { // Parsed successfully but no agents — could be legacy or no agent section. // Try legacy format. if let Ok(legacy) = toml::from_str::(content) && let Some(agent) = legacy.agent { slog!( "[config] Warning: [agent] table is deprecated. \ Use [[agent]] array format instead." ); let config = ProjectConfig { component: legacy.component, agent: vec![agent], watcher: legacy.watcher, default_qa: legacy.default_qa, default_coder_model: legacy.default_coder_model, max_coders: legacy.max_coders, max_retries: legacy.max_retries, }; validate_agents(&config.agent)?; return Ok(config); } // No agent section at all Ok(config) } Err(_) => { // New format failed — try legacy let legacy: LegacyProjectConfig = toml::from_str(content).map_err(|e| format!("Parse config: {e}"))?; if let Some(agent) = legacy.agent { slog!( "[config] Warning: [agent] table is deprecated. \ Use [[agent]] array format instead." ); let config = ProjectConfig { component: legacy.component, agent: vec![agent], watcher: legacy.watcher, default_qa: legacy.default_qa, default_coder_model: legacy.default_coder_model, max_coders: legacy.max_coders, max_retries: legacy.max_retries, }; validate_agents(&config.agent)?; Ok(config) } else { Ok(ProjectConfig { component: legacy.component, agent: Vec::new(), watcher: legacy.watcher, default_qa: legacy.default_qa, default_coder_model: legacy.default_coder_model, max_coders: legacy.max_coders, max_retries: legacy.max_retries, }) } } } } /// Return the project-wide default QA mode parsed from `default_qa`. /// Falls back to `Server` if the value is unrecognised. pub fn default_qa_mode(&self) -> crate::io::story_metadata::QaMode { crate::io::story_metadata::QaMode::from_str(&self.default_qa) .unwrap_or(crate::io::story_metadata::QaMode::Server) } /// Look up an agent config by name. pub fn find_agent(&self, name: &str) -> Option<&AgentConfig> { self.agent.iter().find(|a| a.name == name) } /// Get the default (first) agent config. pub fn default_agent(&self) -> Option<&AgentConfig> { self.agent.first() } /// Render template variables in agent args and prompt for the given agent. /// If `agent_name` is None, uses the first (default) agent. pub fn render_agent_args( &self, worktree_path: &str, story_id: &str, agent_name: Option<&str>, base_branch: Option<&str>, ) -> Result<(String, Vec, String), String> { let agent = match agent_name { Some(name) => self .find_agent(name) .ok_or_else(|| format!("No agent named '{name}' in config"))?, None => self .default_agent() .ok_or_else(|| "No agents configured".to_string())?, }; let bb = base_branch.unwrap_or("master"); let aname = agent.name.as_str(); let render = |s: &str| { s.replace("{{worktree_path}}", worktree_path) .replace("{{story_id}}", story_id) .replace("{{base_branch}}", bb) .replace("{{agent_name}}", aname) }; let command = render(&agent.command); let mut args: Vec = agent.args.iter().map(|a| render(a)).collect(); let prompt = render(&agent.prompt); // Append structured CLI flags if let Some(ref model) = agent.model { args.push("--model".to_string()); args.push(model.clone()); } if let Some(ref tools) = agent.allowed_tools && !tools.is_empty() { args.push("--allowedTools".to_string()); args.push(tools.join(",")); } if let Some(turns) = agent.max_turns { args.push("--max-turns".to_string()); args.push(turns.to_string()); } if let Some(budget) = agent.max_budget_usd { args.push("--max-budget-usd".to_string()); args.push(budget.to_string()); } if let Some(ref sp) = agent.system_prompt { args.push("--append-system-prompt".to_string()); args.push(render(sp)); } Ok((command, args, prompt)) } } /// Validate agent configs: no duplicate names, no empty names, positive budgets/turns. fn validate_agents(agents: &[AgentConfig]) -> Result<(), String> { let mut names = HashSet::new(); for agent in agents { if agent.name.trim().is_empty() { return Err("Agent name must not be empty".to_string()); } if !names.insert(&agent.name) { return Err(format!("Duplicate agent name: '{}'", agent.name)); } if let Some(budget) = agent.max_budget_usd && budget <= 0.0 { return Err(format!( "Agent '{}': max_budget_usd must be positive, got {budget}", agent.name )); } if let Some(turns) = agent.max_turns && turns == 0 { return Err(format!( "Agent '{}': max_turns must be positive, got 0", agent.name )); } } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn default_config_when_missing() { let tmp = tempfile::tempdir().unwrap(); let config = ProjectConfig::load(tmp.path()).unwrap(); assert_eq!(config.agent.len(), 1); assert_eq!(config.agent[0].name, "default"); assert!(config.component.is_empty()); } #[test] fn parse_multi_agent_toml() { let toml_str = r#" [[component]] name = "server" path = "." setup = ["cargo check"] [[agent]] name = "supervisor" role = "Coordinates work" model = "opus" max_turns = 50 max_budget_usd = 10.00 system_prompt = "You are a senior engineer" [[agent]] name = "coder-1" role = "Full-stack engineer" model = "sonnet" max_turns = 30 max_budget_usd = 5.00 "#; let config = ProjectConfig::parse(toml_str).unwrap(); assert_eq!(config.agent.len(), 2); assert_eq!(config.agent[0].name, "supervisor"); assert_eq!(config.agent[0].role, "Coordinates work"); assert_eq!(config.agent[0].model, Some("opus".to_string())); assert_eq!(config.agent[0].max_turns, Some(50)); assert_eq!(config.agent[0].max_budget_usd, Some(10.0)); assert_eq!( config.agent[0].system_prompt, Some("You are a senior engineer".to_string()) ); assert_eq!(config.agent[1].name, "coder-1"); assert_eq!(config.agent[1].model, Some("sonnet".to_string())); assert_eq!(config.component.len(), 1); } #[test] fn parse_legacy_single_agent() { let toml_str = r#" [[component]] name = "server" path = "." [agent] command = "claude" args = ["--print", "--directory", "{{worktree_path}}"] prompt = "Pick up story {{story_id}}" "#; let config = ProjectConfig::parse(toml_str).unwrap(); assert_eq!(config.agent.len(), 1); assert_eq!(config.agent[0].name, "default"); assert_eq!(config.agent[0].command, "claude"); } #[test] fn validate_duplicate_names() { let toml_str = r#" [[agent]] name = "coder" role = "Engineer" [[agent]] name = "coder" role = "Another engineer" "#; let err = ProjectConfig::parse(toml_str).unwrap_err(); assert!(err.contains("Duplicate agent name: 'coder'")); } #[test] fn validate_empty_name() { let toml_str = r#" [[agent]] name = "" role = "Engineer" "#; let err = ProjectConfig::parse(toml_str).unwrap_err(); assert!(err.contains("Agent name must not be empty")); } #[test] fn validate_non_positive_budget() { let toml_str = r#" [[agent]] name = "coder" max_budget_usd = -1.0 "#; let err = ProjectConfig::parse(toml_str).unwrap_err(); assert!(err.contains("must be positive")); } #[test] fn validate_zero_max_turns() { let toml_str = r#" [[agent]] name = "coder" max_turns = 0 "#; let err = ProjectConfig::parse(toml_str).unwrap_err(); assert!(err.contains("max_turns must be positive")); } #[test] fn render_agent_args_default() { let config = ProjectConfig::default(); let (cmd, args, prompt) = config .render_agent_args("/tmp/wt", "42_foo", None, None) .unwrap(); assert_eq!(cmd, "claude"); assert!(args.is_empty()); assert!(prompt.contains("42_foo")); } #[test] fn render_agent_args_by_name() { let toml_str = r#" [[agent]] name = "supervisor" model = "opus" max_turns = 50 max_budget_usd = 10.00 system_prompt = "You lead story {{story_id}}" allowed_tools = ["Read", "Write", "Bash"] [[agent]] name = "coder" model = "sonnet" max_turns = 30 "#; let config = ProjectConfig::parse(toml_str).unwrap(); let (cmd, args, prompt) = config .render_agent_args("/tmp/wt", "42_foo", Some("supervisor"), Some("master")) .unwrap(); assert_eq!(cmd, "claude"); assert!(args.contains(&"--model".to_string())); assert!(args.contains(&"opus".to_string())); assert!(args.contains(&"--max-turns".to_string())); assert!(args.contains(&"50".to_string())); assert!(args.contains(&"--max-budget-usd".to_string())); assert!(args.contains(&"10".to_string())); assert!(args.contains(&"--allowedTools".to_string())); assert!(args.contains(&"Read,Write,Bash".to_string())); assert!(args.contains(&"--append-system-prompt".to_string())); // System prompt should have template rendered assert!(args.contains(&"You lead story 42_foo".to_string())); assert!(prompt.contains("42_foo")); // Render for coder let (_, coder_args, _) = config .render_agent_args("/tmp/wt", "42_foo", Some("coder"), Some("master")) .unwrap(); assert!(coder_args.contains(&"sonnet".to_string())); assert!(coder_args.contains(&"30".to_string())); assert!(!coder_args.contains(&"--max-budget-usd".to_string())); assert!(!coder_args.contains(&"--append-system-prompt".to_string())); } #[test] fn render_agent_args_not_found() { let config = ProjectConfig::default(); let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent"), None); assert!(result.is_err()); assert!(result.unwrap_err().contains("No agent named 'nonexistent'")); } #[test] fn find_agent_and_default() { let toml_str = r#" [[agent]] name = "first" [[agent]] name = "second" "#; let config = ProjectConfig::parse(toml_str).unwrap(); assert_eq!(config.default_agent().unwrap().name, "first"); assert_eq!(config.find_agent("second").unwrap().name, "second"); assert!(config.find_agent("missing").is_none()); } #[test] fn parse_project_toml_from_file() { let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".storkit"); fs::create_dir_all(&sk).unwrap(); fs::write( sk.join("project.toml"), r#" [[component]] name = "server" path = "." setup = ["cargo check"] teardown = [] [[component]] name = "frontend" path = "frontend" setup = ["pnpm install"] [[agent]] name = "main" command = "claude" args = ["--print", "--directory", "{{worktree_path}}"] prompt = "Pick up story {{story_id}}" model = "sonnet" "#, ) .unwrap(); let config = ProjectConfig::load(tmp.path()).unwrap(); assert_eq!(config.component.len(), 2); assert_eq!(config.component[0].name, "server"); assert_eq!(config.component[1].setup, vec!["pnpm install"]); assert_eq!(config.agent.len(), 1); assert_eq!(config.agent[0].name, "main"); assert_eq!(config.agent[0].model, Some("sonnet".to_string())); } // ── WatcherConfig ────────────────────────────────────────────────────── #[test] fn watcher_config_defaults_when_omitted() { let toml_str = r#" [[agent]] name = "coder" "#; let config = ProjectConfig::parse(toml_str).unwrap(); assert_eq!(config.watcher.sweep_interval_secs, 60); assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60); } #[test] fn watcher_config_custom_values() { let toml_str = r#" [watcher] sweep_interval_secs = 30 done_retention_secs = 7200 [[agent]] name = "coder" "#; let config = ProjectConfig::parse(toml_str).unwrap(); assert_eq!(config.watcher.sweep_interval_secs, 30); assert_eq!(config.watcher.done_retention_secs, 7200); } #[test] fn watcher_config_partial_override() { let toml_str = r#" [watcher] sweep_interval_secs = 10 [[agent]] name = "coder" "#; let config = ProjectConfig::parse(toml_str).unwrap(); assert_eq!(config.watcher.sweep_interval_secs, 10); // done_retention_secs should fall back to the default (4 hours). assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60); } #[test] fn watcher_config_from_file() { let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".storkit"); fs::create_dir_all(&sk).unwrap(); fs::write( sk.join("project.toml"), r#" [watcher] sweep_interval_secs = 120 done_retention_secs = 3600 [[agent]] name = "coder" "#, ) .unwrap(); let config = ProjectConfig::load(tmp.path()).unwrap(); assert_eq!(config.watcher.sweep_interval_secs, 120); assert_eq!(config.watcher.done_retention_secs, 3600); } #[test] fn watcher_config_default_when_no_file() { let tmp = tempfile::tempdir().unwrap(); let config = ProjectConfig::load(tmp.path()).unwrap(); assert_eq!(config.watcher, WatcherConfig::default()); } #[test] fn coder_agents_have_root_cause_guidance() { // Load the actual project.toml and verify all coder-stage agents // include root cause investigation guidance for bugs. let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let project_root = manifest_dir.parent().unwrap(); let config = ProjectConfig::load(project_root).unwrap(); let coder_agents: Vec<_> = config .agent .iter() .filter(|a| a.stage.as_deref() == Some("coder")) .collect(); assert!( !coder_agents.is_empty(), "Expected at least one coder-stage agent in project.toml" ); for agent in coder_agents { let prompt = &agent.prompt; let system_prompt = agent.system_prompt.as_deref().unwrap_or(""); let combined = format!("{prompt} {system_prompt}"); assert!( combined.contains("root cause"), "Coder agent '{}' must mention 'root cause' in prompt or system_prompt", agent.name ); assert!( combined.contains("git bisect") || combined.contains("git log"), "Coder agent '{}' must mention 'git bisect' or 'git log' for bug investigation", agent.name ); assert!( combined.to_lowercase().contains("do not") || combined.contains("surgical"), "Coder agent '{}' must discourage adding abstractions/workarounds", agent.name ); } } #[test] fn watcher_config_preserved_in_legacy_format() { let toml_str = r#" [watcher] sweep_interval_secs = 15 done_retention_secs = 900 [agent] command = "claude" "#; let config = ProjectConfig::parse(toml_str).unwrap(); assert_eq!(config.watcher.sweep_interval_secs, 15); assert_eq!(config.watcher.done_retention_secs, 900); assert_eq!(config.agent.len(), 1); } // ── default_coder_model & max_coders ───────────────────────────────── #[test] fn parse_default_coder_model_and_max_coders() { let toml_str = r#" default_coder_model = "sonnet" max_coders = 3 [[agent]] name = "coder-1" stage = "coder" model = "sonnet" [[agent]] name = "coder-opus" stage = "coder" model = "opus" "#; let config = ProjectConfig::parse(toml_str).unwrap(); assert_eq!(config.default_coder_model, Some("sonnet".to_string())); assert_eq!(config.max_coders, Some(3)); } #[test] fn default_coder_model_and_max_coders_default_to_none() { let toml_str = r#" [[agent]] name = "coder-1" "#; let config = ProjectConfig::parse(toml_str).unwrap(); assert_eq!(config.default_coder_model, None); assert_eq!(config.max_coders, None); } #[test] fn project_toml_has_default_coder_model_and_max_coders() { // Verify the actual project.toml has the new settings. let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let project_root = manifest_dir.parent().unwrap(); let config = ProjectConfig::load(project_root).unwrap(); assert_eq!(config.default_coder_model, Some("sonnet".to_string())); assert_eq!(config.max_coders, Some(3)); } #[test] fn project_toml_has_three_sonnet_coders() { let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let project_root = manifest_dir.parent().unwrap(); let config = ProjectConfig::load(project_root).unwrap(); let sonnet_coders: Vec<_> = config .agent .iter() .filter(|a| a.stage.as_deref() == Some("coder") && a.model.as_deref() == Some("sonnet")) .collect(); assert_eq!( sonnet_coders.len(), 3, "Expected 3 sonnet coders (coder-1, coder-2, coder-3), found {}", sonnet_coders.len() ); } }