//! Project configuration — parses `project.toml` for agents, components, and server settings. 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, /// Optional base branch name (e.g. "main", "master", "develop"). /// When set, overrides the auto-detection logic (`detect_base_branch`) for all /// worktree creation, merge operations, and agent prompt `{{base_branch}}` substitution. /// When not set, the system falls back to `detect_base_branch` (reads current HEAD). #[serde(default)] pub base_branch: Option, /// Whether to send `RateLimitWarning` chat notifications. /// Set to `false` to suppress noisy soft rate-limit warnings while still /// receiving `RateLimitHardBlock` and `StoryBlocked` notifications. /// Default: `true`. #[serde(default = "default_rate_limit_notifications")] pub rate_limit_notifications: bool, /// Whether the web UI WebSocket consumer subscribes to the status broadcaster. /// Set to `false` to disable status event forwarding to the web UI without /// affecting other consumers (chat transports, agent context). /// Default: `true`. #[serde(default = "default_web_ui_status_consumer")] pub web_ui_status_consumer: bool, /// Whether the Matrix bot subscribes to the status broadcaster and forwards /// pipeline events to its configured rooms. /// Set to `false` to silence Matrix status notifications without affecting /// other consumers (web UI, Slack, Discord, WhatsApp, agent context). /// Default: `true`. #[serde(default = "default_matrix_status_consumer")] pub matrix_status_consumer: bool, /// Whether the Slack bot subscribes to the status broadcaster and forwards /// pipeline events to its configured channels. /// Set to `false` to silence Slack status notifications without affecting /// other consumers (web UI, Matrix, Discord, WhatsApp, agent context). /// Default: `true`. #[serde(default = "default_slack_status_consumer")] pub slack_status_consumer: bool, /// Whether the Discord bot subscribes to the status broadcaster and forwards /// pipeline events to its configured channels. /// Set to `false` to silence Discord status notifications without affecting /// other consumers (web UI, Matrix, Slack, WhatsApp, agent context). /// Default: `true`. #[serde(default = "default_discord_status_consumer")] pub discord_status_consumer: bool, /// Whether the WhatsApp bot subscribes to the status broadcaster and forwards /// pipeline events to all active senders. /// Set to `false` to silence WhatsApp status notifications without affecting /// other consumers (web UI, Matrix, Slack, Discord, agent context). /// Default: `true`. #[serde(default = "default_whatsapp_status_consumer")] pub whatsapp_status_consumer: bool, /// IANA timezone name (e.g. `"Europe/London"`, `"America/New_York"`). /// When set, timer HH:MM inputs are interpreted in this timezone instead /// of the container/host local time. Falls back to `chrono::Local` when absent. #[serde(default)] pub timezone: Option, /// WebSocket URL of a remote huskies node to sync CRDT state with. /// Example: `rendezvous = "ws://server:3001/crdt-sync"` /// When set, this node connects to the remote and exchanges CRDT ops /// so both machines see the same pipeline state in real-time. #[serde(default)] pub rendezvous: Option, /// Hex-encoded Ed25519 public keys of trusted peers for WebSocket mutual auth. /// When present, only peers whose pubkey is in this list are allowed to connect. /// When empty or missing, all peers are rejected (closed-by-default). #[serde(default)] pub trusted_keys: Vec, /// When `true`, `/crdt-sync` WebSocket connections must supply a valid /// `?token=` query parameter or receive HTTP 401. /// Defaults to `false` so trusted-network deployments keep the current /// open behaviour. #[serde(default)] pub crdt_require_token: bool, /// Static bearer tokens accepted for `/crdt-sync` connections. /// Each entry is a raw token string; tokens expire 30 days after the /// server starts. Only meaningful when `crdt_require_token` is `true`. #[serde(default)] pub crdt_tokens: Vec, /// Maximum number of supplementary mesh peer connections an agent opens. /// The mesh discovery loop reads the CRDT `nodes` list and connects to up /// to this many alive peers in addition to the primary rendezvous connection. /// Default: 3. Set to 0 to disable mesh discovery entirely. #[serde(default = "default_max_mesh_peers")] pub max_mesh_peers: usize, /// Base URL of the gateway this project should push status events to. /// /// When set, a relay task is started that connects to the gateway's /// `/gateway/events/push` WebSocket and forwards every [`StatusEvent`] from /// the local broadcaster. Example: `gateway_url = "http://gateway:3000"`. /// Disabled when absent. Also readable from the `HUSKIES_GATEWAY_URL` /// environment variable. #[serde(default)] pub gateway_url: Option, /// Project name this instance identifies as when pushing events to the /// gateway. Defaults to the project root directory name when not set. /// Example: `gateway_project = "huskies"`. #[serde(default)] pub gateway_project: Option, } /// 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 } fn default_rate_limit_notifications() -> bool { true } fn default_web_ui_status_consumer() -> bool { true } fn default_matrix_status_consumer() -> bool { true } fn default_slack_status_consumer() -> bool { true } fn default_discord_status_consumer() -> bool { true } fn default_whatsapp_status_consumer() -> bool { true } fn default_max_mesh_peers() -> usize { 3 } #[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, /// Agent runtime backend. Controls how the agent process is spawned and /// how events are streamed. Default: `"claude-code"` (spawns the `claude` /// CLI in a PTY). Future values: `"openai"`, `"gemini"`. #[serde(default)] pub runtime: Option, } 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 .huskies/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, #[serde(default)] base_branch: Option, #[serde(default = "default_rate_limit_notifications")] rate_limit_notifications: bool, #[serde(default)] timezone: Option, } 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(), runtime: None, }], watcher: WatcherConfig::default(), default_qa: default_qa(), default_coder_model: None, max_coders: None, max_retries: default_max_retries(), base_branch: None, rate_limit_notifications: default_rate_limit_notifications(), web_ui_status_consumer: default_web_ui_status_consumer(), matrix_status_consumer: default_matrix_status_consumer(), slack_status_consumer: default_slack_status_consumer(), discord_status_consumer: default_discord_status_consumer(), whatsapp_status_consumer: default_whatsapp_status_consumer(), timezone: None, rendezvous: None, trusted_keys: Vec::new(), crdt_require_token: false, crdt_tokens: Vec::new(), max_mesh_peers: default_max_mesh_peers(), gateway_url: None, gateway_project: None, } } } /// Config parsed from `.huskies/agents.toml` — agent definitions only. #[derive(Debug, Deserialize)] struct AgentsConfig { #[serde(default)] agent: Vec, } impl ProjectConfig { /// Load from `.huskies/project.toml` relative to the given root, /// then overlay agents from `.huskies/agents.toml` if present. /// /// Loading order: /// 1. Project settings (watcher, default_qa, etc.) always come from `project.toml`. /// 2. Agent definitions come from `agents.toml` when that file exists. /// 3. Falls back to inline `[[agent]]` blocks in `project.toml` for backwards /// compatibility with projects that haven't migrated yet. /// 4. Falls back to a single default agent when neither file defines agents. pub fn load(project_root: &Path) -> Result { let config_path = project_root.join(".huskies/project.toml"); let mut config = if config_path.exists() { let content = std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?; Self::parse(&content)? } else { Self::default() }; // agents.toml takes priority over inline [[agent]] in project.toml. let agents_path = project_root.join(".huskies/agents.toml"); if agents_path.exists() { let content = std::fs::read_to_string(&agents_path) .map_err(|e| format!("Read agents.toml: {e}"))?; let agents_cfg: AgentsConfig = toml::from_str(&content).map_err(|e| format!("Parse agents.toml: {e}"))?; if !agents_cfg.agent.is_empty() { validate_agents(&agents_cfg.agent)?; config.agent = agents_cfg.agent; } } Ok(config) } /// 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, base_branch: legacy.base_branch, rate_limit_notifications: legacy.rate_limit_notifications, web_ui_status_consumer: default_web_ui_status_consumer(), matrix_status_consumer: default_matrix_status_consumer(), slack_status_consumer: default_slack_status_consumer(), discord_status_consumer: default_discord_status_consumer(), whatsapp_status_consumer: default_whatsapp_status_consumer(), timezone: legacy.timezone, rendezvous: None, trusted_keys: Vec::new(), crdt_require_token: false, crdt_tokens: Vec::new(), max_mesh_peers: default_max_mesh_peers(), gateway_url: None, gateway_project: None, }; 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, base_branch: legacy.base_branch, rate_limit_notifications: legacy.rate_limit_notifications, web_ui_status_consumer: default_web_ui_status_consumer(), matrix_status_consumer: default_matrix_status_consumer(), slack_status_consumer: default_slack_status_consumer(), discord_status_consumer: default_discord_status_consumer(), whatsapp_status_consumer: default_whatsapp_status_consumer(), timezone: legacy.timezone, rendezvous: None, trusted_keys: Vec::new(), crdt_require_token: false, crdt_tokens: Vec::new(), max_mesh_peers: default_max_mesh_peers(), gateway_url: None, gateway_project: None, }; 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, base_branch: legacy.base_branch, rate_limit_notifications: legacy.rate_limit_notifications, web_ui_status_consumer: default_web_ui_status_consumer(), matrix_status_consumer: default_matrix_status_consumer(), slack_status_consumer: default_slack_status_consumer(), discord_status_consumer: default_discord_status_consumer(), whatsapp_status_consumer: default_whatsapp_status_consumer(), timezone: legacy.timezone, rendezvous: None, trusted_keys: Vec::new(), crdt_require_token: false, crdt_tokens: Vec::new(), max_mesh_peers: default_max_mesh_peers(), gateway_url: None, gateway_project: None, }) } } } } /// 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 .or(self.base_branch.as_deref()) .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 )); } if let Some(ref runtime) = agent.runtime { match runtime.as_str() { "claude-code" | "gemini" => {} other => { return Err(format!( "Agent '{}': unknown runtime '{other}'. Supported: 'claude-code', 'gemini'", agent.name )); } } } } Ok(()) } #[cfg(test)] mod tests;