Skip to main content

ralph_core/
config.rs

1//! Configuration types for the Ralph Orchestrator.
2//!
3//! This module supports both v1.x flat configuration format and v2.0 nested format.
4//! Users can switch from Python v1.x to Rust v2.0 with zero config changes.
5
6use ralph_proto::Topic;
7use serde::{Deserialize, Deserializer, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use tracing::debug;
11
12/// Scratchpad configuration with enabled flag and path.
13///
14/// Supports both plain string (legacy) and structured object in YAML:
15/// ```yaml
16/// # Legacy (plain string) — treated as { enabled: true, path: "..." }
17/// core:
18///   scratchpad: ".ralph/agent/scratchpad.md"
19///
20/// # Structured object
21/// core:
22///   scratchpad:
23///     enabled: true
24///     path: .ralph/agent/scratchpad.md
25/// ```
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct ScratchpadConfig {
28    #[serde(default = "scratchpad_enabled_default")]
29    pub enabled: bool,
30
31    #[serde(default = "default_scratchpad_path")]
32    pub path: String,
33}
34
35fn scratchpad_enabled_default() -> bool {
36    true
37}
38
39fn default_scratchpad_path() -> String {
40    ".ralph/agent/scratchpad.md".to_string()
41}
42
43impl Default for ScratchpadConfig {
44    fn default() -> Self {
45        Self {
46            enabled: scratchpad_enabled_default(),
47            path: default_scratchpad_path(),
48        }
49    }
50}
51
52impl ScratchpadConfig {
53    /// Resolves the effective scratchpad config for a hat run.
54    ///
55    /// Resolution order: hat override → global core config → defaults.
56    pub fn resolve(
57        hat_config: Option<&ScratchpadConfig>,
58        global: &ScratchpadConfig,
59    ) -> ScratchpadConfig {
60        match hat_config {
61            Some(override_config) => override_config.clone(),
62            None => global.clone(),
63        }
64    }
65}
66
67/// Custom deserializer that accepts both a plain string and a structured object.
68///
69/// - Plain string → `ScratchpadConfig { enabled: true, path: <string> }`
70/// - Map → normal `ScratchpadConfig` deserialization
71fn deserialize_scratchpad_config<'de, D>(deserializer: D) -> Result<ScratchpadConfig, D::Error>
72where
73    D: Deserializer<'de>,
74{
75    use serde::de;
76
77    struct ScratchpadConfigVisitor;
78
79    impl<'de> de::Visitor<'de> for ScratchpadConfigVisitor {
80        type Value = ScratchpadConfig;
81
82        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
83            formatter.write_str("a string or a scratchpad config object")
84        }
85
86        fn visit_str<E: de::Error>(self, value: &str) -> Result<ScratchpadConfig, E> {
87            Ok(ScratchpadConfig {
88                enabled: true,
89                path: value.to_string(),
90            })
91        }
92
93        fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<ScratchpadConfig, M::Error> {
94            Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))
95        }
96    }
97
98    deserializer.deserialize_any(ScratchpadConfigVisitor)
99}
100
101/// Custom deserializer for optional scratchpad config on hats.
102///
103/// Handles: absent (None), plain string, or structured object.
104fn deserialize_optional_scratchpad_config<'de, D>(
105    deserializer: D,
106) -> Result<Option<ScratchpadConfig>, D::Error>
107where
108    D: Deserializer<'de>,
109{
110    use serde::de;
111
112    struct OptionalScratchpadConfigVisitor;
113
114    impl<'de> de::Visitor<'de> for OptionalScratchpadConfigVisitor {
115        type Value = Option<ScratchpadConfig>;
116
117        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
118            formatter.write_str("null, a string, or a scratchpad config object")
119        }
120
121        fn visit_none<E: de::Error>(self) -> Result<Option<ScratchpadConfig>, E> {
122            Ok(None)
123        }
124
125        fn visit_unit<E: de::Error>(self) -> Result<Option<ScratchpadConfig>, E> {
126            Ok(None)
127        }
128
129        fn visit_str<E: de::Error>(self, value: &str) -> Result<Option<ScratchpadConfig>, E> {
130            Ok(Some(ScratchpadConfig {
131                enabled: true,
132                path: value.to_string(),
133            }))
134        }
135
136        fn visit_map<M: de::MapAccess<'de>>(
137            self,
138            map: M,
139        ) -> Result<Option<ScratchpadConfig>, M::Error> {
140            let config: ScratchpadConfig =
141                Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?;
142            Ok(Some(config))
143        }
144    }
145
146    deserializer.deserialize_any(OptionalScratchpadConfigVisitor)
147}
148
149/// Top-level configuration for Ralph Orchestrator.
150///
151/// Supports both v1.x flat format and v2.0 nested format:
152/// - v1: `agent: claude`, `max_iterations: 100`
153/// - v2: `cli: { backend: claude }`, `event_loop: { max_iterations: 100 }`
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[allow(clippy::struct_excessive_bools)] // Configuration struct with multiple feature flags
156pub struct RalphConfig {
157    /// Event loop configuration (v2 nested style).
158    #[serde(default)]
159    pub event_loop: EventLoopConfig,
160
161    /// CLI backend configuration (v2 nested style).
162    #[serde(default)]
163    pub cli: CliConfig,
164
165    /// Core paths and settings shared across all hats.
166    #[serde(default)]
167    pub core: CoreConfig,
168
169    /// Custom hat definitions (optional).
170    /// If empty, default planner and builder hats are used.
171    #[serde(default)]
172    pub hats: HashMap<String, HatConfig>,
173
174    /// Event metadata definitions (optional).
175    /// Defines what each event topic means, enabling auto-derived instructions.
176    /// If a hat uses custom events, define them here for proper behavior injection.
177    #[serde(default)]
178    pub events: HashMap<String, EventMetadata>,
179
180    // ─────────────────────────────────────────────────────────────────────────
181    // V1 COMPATIBILITY FIELDS (flat format)
182    // These map to nested v2 fields for backwards compatibility.
183    // ─────────────────────────────────────────────────────────────────────────
184    /// V1 field: Backend CLI (maps to cli.backend).
185    /// Values include "auto", "claude", "kiro", "kiro-acp", "gemini",
186    /// "codex", "forge", "amp", "copilot", "opencode", "pi", "roo", or "custom".
187    #[serde(default)]
188    pub agent: Option<String>,
189
190    /// V1 field: Fallback order for auto-detection.
191    #[serde(default)]
192    pub agent_priority: Vec<String>,
193
194    /// V1 field: Path to prompt file (maps to `event_loop.prompt_file`).
195    #[serde(default)]
196    pub prompt_file: Option<String>,
197
198    /// V1 field: Completion detection string (maps to event_loop.completion_promise).
199    #[serde(default)]
200    pub completion_promise: Option<String>,
201
202    /// V1 field: Maximum loop iterations (maps to event_loop.max_iterations).
203    #[serde(default)]
204    pub max_iterations: Option<u32>,
205
206    /// V1 field: Maximum runtime in seconds (maps to event_loop.max_runtime_seconds).
207    #[serde(default)]
208    pub max_runtime: Option<u64>,
209
210    /// V1 field: Maximum cost in USD (maps to event_loop.max_cost_usd).
211    #[serde(default)]
212    pub max_cost: Option<f64>,
213
214    // ─────────────────────────────────────────────────────────────────────────
215    // FEATURE FLAGS
216    // ─────────────────────────────────────────────────────────────────────────
217    /// Enable verbose output.
218    #[serde(default)]
219    pub verbose: bool,
220
221    /// Archive prompts after completion (DEFERRED: warn if enabled).
222    #[serde(default)]
223    pub archive_prompts: bool,
224
225    /// Enable metrics collection (DEFERRED: warn if enabled).
226    #[serde(default)]
227    pub enable_metrics: bool,
228
229    // ─────────────────────────────────────────────────────────────────────────
230    // DROPPED FIELDS (accepted but ignored with warning)
231    // ─────────────────────────────────────────────────────────────────────────
232    /// V1 field: Token limits (DROPPED: controlled by CLI tool).
233    #[serde(default)]
234    pub max_tokens: Option<u32>,
235
236    /// V1 field: Retry delay (DROPPED: handled differently in v2).
237    #[serde(default)]
238    pub retry_delay: Option<u32>,
239
240    /// V1 adapter settings (partially supported).
241    #[serde(default)]
242    pub adapters: AdaptersConfig,
243
244    // ─────────────────────────────────────────────────────────────────────────
245    // WARNING CONTROL
246    // ─────────────────────────────────────────────────────────────────────────
247    /// Suppress all warnings (for CI environments).
248    #[serde(default, rename = "_suppress_warnings")]
249    pub suppress_warnings: bool,
250
251    /// TUI configuration.
252    #[serde(default)]
253    pub tui: TuiConfig,
254
255    /// Memories configuration for persistent learning across sessions.
256    #[serde(default)]
257    pub memories: MemoriesConfig,
258
259    /// Tasks configuration for runtime work tracking.
260    #[serde(default)]
261    pub tasks: TasksConfig,
262
263    /// Lifecycle hooks configuration.
264    #[serde(default)]
265    pub hooks: HooksConfig,
266
267    /// Skills configuration for the skill discovery and injection system.
268    #[serde(default)]
269    pub skills: SkillsConfig,
270
271    /// Feature flags for optional capabilities.
272    #[serde(default)]
273    pub features: FeaturesConfig,
274
275    /// RObot (Ralph-Orchestrator bot) configuration for Telegram-based interaction.
276    #[serde(default, rename = "RObot")]
277    pub robot: RobotConfig,
278}
279
280fn default_true() -> bool {
281    true
282}
283
284#[allow(clippy::derivable_impls)] // Cannot derive due to serde default functions
285impl Default for RalphConfig {
286    fn default() -> Self {
287        Self {
288            event_loop: EventLoopConfig::default(),
289            cli: CliConfig::default(),
290            core: CoreConfig::default(),
291            hats: HashMap::new(),
292            events: HashMap::new(),
293            // V1 compatibility fields
294            agent: None,
295            agent_priority: vec![],
296            prompt_file: None,
297            completion_promise: None,
298            max_iterations: None,
299            max_runtime: None,
300            max_cost: None,
301            // Feature flags
302            verbose: false,
303            archive_prompts: false,
304            enable_metrics: false,
305            // Dropped fields
306            max_tokens: None,
307            retry_delay: None,
308            adapters: AdaptersConfig::default(),
309            // Warning control
310            suppress_warnings: false,
311            // TUI
312            tui: TuiConfig::default(),
313            // Memories
314            memories: MemoriesConfig::default(),
315            // Tasks
316            tasks: TasksConfig::default(),
317            // Hooks
318            hooks: HooksConfig::default(),
319            // Skills
320            skills: SkillsConfig::default(),
321            // Features
322            features: FeaturesConfig::default(),
323            // RObot (Ralph-Orchestrator bot)
324            robot: RobotConfig::default(),
325        }
326    }
327}
328
329/// V1 adapter settings per backend.
330#[derive(Debug, Clone, Default, Serialize, Deserialize)]
331pub struct AdaptersConfig {
332    /// Claude adapter settings.
333    #[serde(default)]
334    pub claude: AdapterSettings,
335
336    /// Gemini adapter settings.
337    #[serde(default)]
338    pub gemini: AdapterSettings,
339
340    /// Kiro adapter settings.
341    #[serde(default)]
342    pub kiro: AdapterSettings,
343
344    /// Codex adapter settings.
345    #[serde(default)]
346    pub codex: AdapterSettings,
347
348    /// Amp adapter settings.
349    #[serde(default)]
350    pub amp: AdapterSettings,
351}
352
353/// Per-adapter settings.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct AdapterSettings {
356    /// CLI execution inactivity timeout in seconds.
357    #[serde(default = "default_timeout")]
358    pub timeout: u64,
359
360    /// Include in auto-detection.
361    #[serde(default = "default_true")]
362    pub enabled: bool,
363
364    /// Tool permissions (DROPPED: CLI tool manages its own permissions).
365    #[serde(default)]
366    pub tool_permissions: Option<Vec<String>>,
367}
368
369fn default_timeout() -> u64 {
370    300 // 5 minutes
371}
372
373impl Default for AdapterSettings {
374    fn default() -> Self {
375        Self {
376            timeout: default_timeout(),
377            enabled: true,
378            tool_permissions: None,
379        }
380    }
381}
382
383impl RalphConfig {
384    /// Loads configuration from a YAML file.
385    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
386        let path_ref = path.as_ref();
387        debug!(path = %path_ref.display(), "Loading configuration from file");
388        let content = std::fs::read_to_string(path_ref)?;
389        Self::parse_yaml(&content)
390    }
391
392    /// Parses configuration from a YAML string.
393    pub fn parse_yaml(content: &str) -> Result<Self, ConfigError> {
394        // Pre-flight check for deprecated/invalid keys to improve UX.
395        let value: serde_yaml::Value = serde_yaml::from_str(content)?;
396        if let Some(map) = value.as_mapping()
397            && map.contains_key(serde_yaml::Value::String("project".to_string()))
398        {
399            return Err(ConfigError::DeprecatedProjectKey);
400        }
401
402        validate_hooks_phase_event_keys(&value)?;
403
404        let config: Self = serde_yaml::from_value(value)?;
405        debug!(
406            backend = %config.cli.backend,
407            has_v1_fields = config.agent.is_some(),
408            custom_hats = config.hats.len(),
409            "Configuration loaded"
410        );
411        Ok(config)
412    }
413
414    /// Normalizes v1 flat fields into v2 nested structure.
415    ///
416    /// V1 flat fields take precedence over v2 nested fields when both are present.
417    /// This allows users to use either format or mix them.
418    pub fn normalize(&mut self) {
419        let mut normalized_count = 0;
420
421        // Map v1 `agent` to v2 `cli.backend`
422        if let Some(ref agent) = self.agent {
423            debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
424            self.cli.backend = agent.clone();
425            normalized_count += 1;
426        }
427
428        // Map v1 `prompt_file` to v2 `event_loop.prompt_file`
429        if let Some(ref pf) = self.prompt_file {
430            debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
431            self.event_loop.prompt_file = pf.clone();
432            normalized_count += 1;
433        }
434
435        // Map v1 `completion_promise` to v2 `event_loop.completion_promise`
436        if let Some(ref cp) = self.completion_promise {
437            debug!(
438                from = "completion_promise",
439                to = "event_loop.completion_promise",
440                "Normalizing v1 field"
441            );
442            self.event_loop.completion_promise = cp.clone();
443            normalized_count += 1;
444        }
445
446        // Map v1 `max_iterations` to v2 `event_loop.max_iterations`
447        if let Some(mi) = self.max_iterations {
448            debug!(
449                from = "max_iterations",
450                to = "event_loop.max_iterations",
451                value = mi,
452                "Normalizing v1 field"
453            );
454            self.event_loop.max_iterations = mi;
455            normalized_count += 1;
456        }
457
458        // Map v1 `max_runtime` to v2 `event_loop.max_runtime_seconds`
459        if let Some(mr) = self.max_runtime {
460            debug!(
461                from = "max_runtime",
462                to = "event_loop.max_runtime_seconds",
463                value = mr,
464                "Normalizing v1 field"
465            );
466            self.event_loop.max_runtime_seconds = mr;
467            normalized_count += 1;
468        }
469
470        // Map v1 `max_cost` to v2 `event_loop.max_cost_usd`
471        if self.max_cost.is_some() {
472            debug!(
473                from = "max_cost",
474                to = "event_loop.max_cost_usd",
475                "Normalizing v1 field"
476            );
477            self.event_loop.max_cost_usd = self.max_cost;
478            normalized_count += 1;
479        }
480
481        // Merge extra_instructions into instructions for each hat
482        for (hat_id, hat) in &mut self.hats {
483            if !hat.extra_instructions.is_empty() {
484                for fragment in hat.extra_instructions.drain(..) {
485                    if !hat.instructions.ends_with('\n') {
486                        hat.instructions.push('\n');
487                    }
488                    hat.instructions.push_str(&fragment);
489                }
490                debug!(hat = %hat_id, "Merged extra_instructions into hat instructions");
491                normalized_count += 1;
492            }
493        }
494
495        if normalized_count > 0 {
496            debug!(
497                fields_normalized = normalized_count,
498                "V1 to V2 config normalization complete"
499            );
500        }
501    }
502
503    /// Validates the configuration and returns warnings.
504    ///
505    /// This method checks for:
506    /// - Deferred features that are enabled (archive_prompts, enable_metrics)
507    /// - Dropped fields that are present (max_tokens, retry_delay, tool_permissions)
508    /// - Ambiguous trigger routing across custom hats
509    /// - Mutual exclusivity of prompt and prompt_file
510    ///
511    /// Returns a list of warnings that should be displayed to the user.
512    pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
513        let mut warnings = Vec::new();
514
515        // Skip all warnings if suppressed
516        if self.suppress_warnings {
517            return Ok(warnings);
518        }
519
520        // Check for mutual exclusivity of prompt and prompt_file in config
521        // Only error if both are explicitly set (not defaults)
522        if self.event_loop.prompt.is_some()
523            && !self.event_loop.prompt_file.is_empty()
524            && self.event_loop.prompt_file != default_prompt_file()
525        {
526            return Err(ConfigError::MutuallyExclusive {
527                field1: "event_loop.prompt".to_string(),
528                field2: "event_loop.prompt_file".to_string(),
529            });
530        }
531        if self.event_loop.completion_promise.trim().is_empty() {
532            return Err(ConfigError::InvalidCompletionPromise);
533        }
534
535        // Check custom backend has a command
536        if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
537            return Err(ConfigError::CustomBackendRequiresCommand);
538        }
539
540        // Check for deferred features
541        if self.archive_prompts {
542            warnings.push(ConfigWarning::DeferredFeature {
543                field: "archive_prompts".to_string(),
544                message: "Feature not yet available in v2".to_string(),
545            });
546        }
547
548        if self.enable_metrics {
549            warnings.push(ConfigWarning::DeferredFeature {
550                field: "enable_metrics".to_string(),
551                message: "Feature not yet available in v2".to_string(),
552            });
553        }
554
555        // Check for dropped fields
556        if self.max_tokens.is_some() {
557            warnings.push(ConfigWarning::DroppedField {
558                field: "max_tokens".to_string(),
559                reason: "Token limits are controlled by the CLI tool".to_string(),
560            });
561        }
562
563        if self.retry_delay.is_some() {
564            warnings.push(ConfigWarning::DroppedField {
565                field: "retry_delay".to_string(),
566                reason: "Retry logic handled differently in v2".to_string(),
567            });
568        }
569
570        if let Some(threshold) = self.event_loop.mutation_score_warn_threshold
571            && !(0.0..=100.0).contains(&threshold)
572        {
573            warnings.push(ConfigWarning::InvalidValue {
574                field: "event_loop.mutation_score_warn_threshold".to_string(),
575                message: "Value must be between 0 and 100".to_string(),
576            });
577        }
578
579        // Check adapter tool_permissions (dropped field)
580        if self.adapters.claude.tool_permissions.is_some()
581            || self.adapters.gemini.tool_permissions.is_some()
582            || self.adapters.codex.tool_permissions.is_some()
583            || self.adapters.amp.tool_permissions.is_some()
584        {
585            warnings.push(ConfigWarning::DroppedField {
586                field: "adapters.*.tool_permissions".to_string(),
587                reason: "CLI tool manages its own permissions".to_string(),
588            });
589        }
590
591        // Validate RObot config
592        self.robot.validate()?;
593
594        // Validate hooks config semantics (v1 guardrails)
595        self.validate_hooks()?;
596
597        // Check for required description field on all hats
598        for (hat_id, hat_config) in &self.hats {
599            if hat_config
600                .description
601                .as_ref()
602                .is_none_or(|d| d.trim().is_empty())
603            {
604                return Err(ConfigError::MissingDescription {
605                    hat: hat_id.clone(),
606                });
607            }
608        }
609
610        // Check wave config validity
611        for (hat_id, hat_config) in &self.hats {
612            if hat_config.concurrency == 0 {
613                return Err(ConfigError::InvalidConcurrency {
614                    hat: hat_id.clone(),
615                    value: 0,
616                });
617            }
618            if hat_config.aggregate.is_some() && hat_config.concurrency > 1 {
619                return Err(ConfigError::AggregateOnConcurrentHat {
620                    hat: hat_id.clone(),
621                });
622            }
623        }
624
625        // Check for reserved triggers: task.start and task.resume are reserved for Ralph
626        // Per design: Ralph coordinates first, then delegates to custom hats via events
627        const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
628        for (hat_id, hat_config) in &self.hats {
629            for trigger in &hat_config.triggers {
630                if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
631                    return Err(ConfigError::ReservedTrigger {
632                        trigger: trigger.clone(),
633                        hat: hat_id.clone(),
634                    });
635                }
636            }
637        }
638
639        // Check for ambiguous routing: each trigger topic must map to exactly one hat
640        // Per spec: "Every trigger maps to exactly one hat | No ambiguous routing"
641        if !self.hats.is_empty() {
642            let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
643            for (hat_id, hat_config) in &self.hats {
644                for trigger in &hat_config.triggers {
645                    if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
646                        return Err(ConfigError::AmbiguousRouting {
647                            trigger: trigger.clone(),
648                            hat1: (*existing_hat).to_string(),
649                            hat2: hat_id.clone(),
650                        });
651                    }
652                    trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
653                }
654            }
655        }
656
657        Ok(warnings)
658    }
659
660    fn validate_hooks(&self) -> Result<(), ConfigError> {
661        Self::validate_non_v1_hook_fields("hooks", &self.hooks.extra)?;
662
663        if self.hooks.defaults.timeout_seconds == 0 {
664            return Err(ConfigError::HookValidation {
665                field: "hooks.defaults.timeout_seconds".to_string(),
666                message: "must be greater than 0".to_string(),
667            });
668        }
669
670        if self.hooks.defaults.max_output_bytes == 0 {
671            return Err(ConfigError::HookValidation {
672                field: "hooks.defaults.max_output_bytes".to_string(),
673                message: "must be greater than 0".to_string(),
674            });
675        }
676
677        for (phase_event, hook_specs) in &self.hooks.events {
678            for (index, hook) in hook_specs.iter().enumerate() {
679                let hook_field_base = format!("hooks.events.{phase_event}[{index}]");
680
681                if hook.name.trim().is_empty() {
682                    return Err(ConfigError::HookValidation {
683                        field: format!("{hook_field_base}.name"),
684                        message: "is required and must be non-empty".to_string(),
685                    });
686                }
687
688                if hook
689                    .command
690                    .first()
691                    .is_none_or(|command| command.trim().is_empty())
692                {
693                    return Err(ConfigError::HookValidation {
694                        field: format!("{hook_field_base}.command"),
695                        message: "is required and must include an executable at command[0]"
696                            .to_string(),
697                    });
698                }
699
700                if hook.on_error.is_none() {
701                    return Err(ConfigError::HookValidation {
702                        field: format!("{hook_field_base}.on_error"),
703                        message: "is required in v1 (warn | block | suspend)".to_string(),
704                    });
705                }
706
707                if let Some(timeout_seconds) = hook.timeout_seconds
708                    && timeout_seconds == 0
709                {
710                    return Err(ConfigError::HookValidation {
711                        field: format!("{hook_field_base}.timeout_seconds"),
712                        message: "must be greater than 0 when specified".to_string(),
713                    });
714                }
715
716                if let Some(max_output_bytes) = hook.max_output_bytes
717                    && max_output_bytes == 0
718                {
719                    return Err(ConfigError::HookValidation {
720                        field: format!("{hook_field_base}.max_output_bytes"),
721                        message: "must be greater than 0 when specified".to_string(),
722                    });
723                }
724
725                if hook.suspend_mode.is_some() && hook.on_error != Some(HookOnError::Suspend) {
726                    return Err(ConfigError::HookValidation {
727                        field: format!("{hook_field_base}.suspend_mode"),
728                        message: "requires on_error: suspend".to_string(),
729                    });
730                }
731
732                Self::validate_non_v1_hook_fields(&hook_field_base, &hook.extra)?;
733                Self::validate_mutation_contract(&hook_field_base, &hook.mutate)?;
734            }
735        }
736
737        Ok(())
738    }
739
740    fn validate_non_v1_hook_fields(
741        path_prefix: &str,
742        fields: &HashMap<String, serde_yaml::Value>,
743    ) -> Result<(), ConfigError> {
744        for key in fields.keys() {
745            let field = format!("{path_prefix}.{key}");
746            match key.as_str() {
747                "global" | "globals" | "global_defaults" | "global_hooks" | "scope" => {
748                    return Err(ConfigError::UnsupportedHookField {
749                        field,
750                        reason: "Use ~/.ralph/config.yml for user-level defaults; per-hook `global`/`scope` fields are not supported in v1"
751                            .to_string(),
752                    });
753                }
754                "parallel" | "parallelism" | "max_parallel" | "concurrency" | "run_in_parallel" => {
755                    return Err(ConfigError::UnsupportedHookField {
756                        field,
757                        reason:
758                            "Parallel hook execution is out of scope for v1; hooks must run sequentially"
759                                .to_string(),
760                    });
761                }
762                _ => {}
763            }
764        }
765
766        Ok(())
767    }
768
769    fn validate_mutation_contract(
770        hook_field_base: &str,
771        mutate: &HookMutationConfig,
772    ) -> Result<(), ConfigError> {
773        let mutate_field_base = format!("{hook_field_base}.mutate");
774
775        if !mutate.enabled {
776            if mutate.format.is_some() || !mutate.extra.is_empty() {
777                return Err(ConfigError::HookValidation {
778                    field: mutate_field_base,
779                    message: "mutation settings require mutate.enabled: true".to_string(),
780                });
781            }
782            return Ok(());
783        }
784
785        if let Some(format) = mutate.format.as_deref()
786            && !format.eq_ignore_ascii_case("json")
787        {
788            return Err(ConfigError::HookValidation {
789                field: format!("{mutate_field_base}.format"),
790                message: "only 'json' is supported for v1 mutation payloads".to_string(),
791            });
792        }
793
794        if let Some(key) = mutate.extra.keys().next() {
795            let field = format!("{mutate_field_base}.{key}");
796            let reason = match key.as_str() {
797                "prompt" | "prompt_mutation" | "events" | "event" | "config" | "full_context" => {
798                    "v1 allows metadata-only mutation; prompt/event/config mutation is unsupported"
799                        .to_string()
800                }
801                "xml" => "v1 mutation payloads are JSON-only".to_string(),
802                _ => "unsupported mutate field in v1 (supported keys: enabled, format)".to_string(),
803            };
804
805            return Err(ConfigError::UnsupportedHookField { field, reason });
806        }
807
808        Ok(())
809    }
810
811    /// Gets the effective backend name, resolving "auto" using the priority list.
812    pub fn effective_backend(&self) -> &str {
813        &self.cli.backend
814    }
815
816    /// Returns the agent priority list for auto-detection.
817    /// If empty, returns the default priority order.
818    pub fn get_agent_priority(&self) -> Vec<&str> {
819        if self.agent_priority.is_empty() {
820            vec![
821                "claude", "kiro", "kiro-acp", "gemini", "codex", "forge", "amp", "copilot",
822                "opencode", "pi", "roo",
823            ]
824        } else {
825            self.agent_priority.iter().map(String::as_str).collect()
826        }
827    }
828
829    /// Gets the adapter settings for a specific backend.
830    #[allow(clippy::match_same_arms)] // Explicit match arms for each backend improves readability
831    pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
832        match backend {
833            "claude" => &self.adapters.claude,
834            "gemini" => &self.adapters.gemini,
835            "kiro" => &self.adapters.kiro,
836            "codex" => &self.adapters.codex,
837            "amp" => &self.adapters.amp,
838            _ => &self.adapters.claude, // Default fallback
839        }
840    }
841}
842
843/// Configuration warnings emitted during validation.
844#[derive(Debug, Clone)]
845pub enum ConfigWarning {
846    /// Feature is enabled but not yet available in v2.
847    DeferredFeature { field: String, message: String },
848    /// Field is present but ignored in v2.
849    DroppedField { field: String, reason: String },
850    /// Field has an invalid value.
851    InvalidValue { field: String, message: String },
852}
853
854impl std::fmt::Display for ConfigWarning {
855    #[allow(clippy::match_same_arms)] // Different arms have different messages despite similar structure
856    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
857        match self {
858            ConfigWarning::DeferredFeature { field, message }
859            | ConfigWarning::InvalidValue { field, message } => {
860                write!(f, "Warning [{field}]: {message}")
861            }
862            ConfigWarning::DroppedField { field, reason } => {
863                write!(f, "Warning [{field}]: Field ignored - {reason}")
864            }
865        }
866    }
867}
868
869/// Event loop configuration.
870#[derive(Debug, Clone, Serialize, Deserialize)]
871pub struct EventLoopConfig {
872    /// Inline prompt text (mutually exclusive with prompt_file).
873    pub prompt: Option<String>,
874
875    /// Path to the prompt file.
876    #[serde(default = "default_prompt_file")]
877    pub prompt_file: String,
878
879    /// Event topic that signals loop completion (must be emitted via `ralph emit`).
880    #[serde(default = "default_completion_promise")]
881    pub completion_promise: String,
882
883    /// Maximum number of iterations before timeout.
884    #[serde(default = "default_max_iterations")]
885    pub max_iterations: u32,
886
887    /// Maximum runtime in seconds.
888    #[serde(default = "default_max_runtime")]
889    pub max_runtime_seconds: u64,
890
891    /// Maximum cost in USD before stopping.
892    pub max_cost_usd: Option<f64>,
893
894    /// Stop after this many consecutive failures.
895    #[serde(default = "default_max_failures")]
896    pub max_consecutive_failures: u32,
897
898    /// Delay in seconds before starting the next iteration.
899    /// Skipped when the next iteration is triggered by a human event.
900    #[serde(default)]
901    pub cooldown_delay_seconds: u64,
902
903    /// Starting hat for multi-hat mode (deprecated, use starting_event instead).
904    pub starting_hat: Option<String>,
905
906    /// Event to publish after Ralph completes initial coordination.
907    ///
908    /// When custom hats are defined, Ralph handles `task.start` to do gap analysis
909    /// and planning, then publishes this event to delegate to the first hat.
910    ///
911    /// Example: `starting_event: "tdd.start"` for TDD workflow.
912    ///
913    /// If not specified and hats are defined, Ralph will determine the appropriate
914    /// event from the hat topology.
915    pub starting_event: Option<String>,
916
917    /// Warn when mutation testing score drops below this percentage (0-100).
918    ///
919    /// Warning-only: build.done is still accepted even if below threshold.
920    #[serde(default)]
921    pub mutation_score_warn_threshold: Option<f64>,
922
923    /// When true, LOOP_COMPLETE does not terminate the loop.
924    ///
925    /// Instead of exiting, the loop injects a `task.resume` event and continues
926    /// idling until new work arrives (human guidance, Telegram commands, etc.).
927    /// The loop will only terminate on hard limits (max_iterations, max_runtime,
928    /// max_cost), consecutive failures, or explicit interrupt/stop.
929    #[serde(default)]
930    pub persistent: bool,
931
932    /// Event topics that must have been seen before LOOP_COMPLETE is accepted.
933    /// If any required event has not been seen during the loop's lifetime,
934    /// completion is rejected and a task.resume event is injected.
935    #[serde(default)]
936    pub required_events: Vec<String>,
937
938    /// Event topic that triggers graceful early termination WITHOUT chain validation.
939    /// Use this for human rejection, timeout escalation, or other abort paths.
940    /// Defaults to "" (disabled). Set to "loop.cancel" to enable.
941    #[serde(default)]
942    pub cancellation_promise: String,
943
944    /// When true, events emitted by a hat are validated against its declared
945    /// `publishes` list. Out-of-scope events are dropped and replaced with
946    /// `{hat_id}.scope_violation` diagnostic events. Defaults to false (permissive).
947    #[serde(default)]
948    pub enforce_hat_scope: bool,
949
950    /// Context window size in tokens. When `None`, resolved from backend
951    /// default (claude/pi = 200_000; other backends = 0, which suppresses the
952    /// `Context:` display suffix). Set explicitly to override the default
953    /// (e.g. Sonnet 4.x `[1m]` users: `1_000_000`).
954    #[serde(default)]
955    pub context_window_tokens: Option<u64>,
956}
957
958/// Resolves the context-window ceiling in tokens for this run.
959///
960/// Precedence:
961/// 1. `event_loop.context_window_tokens` (explicit user override).
962/// 2. Backend default: `claude` | `pi` → `200_000`.
963/// 3. Unknown backends → `0` (suppresses the `Context:` suffix downstream).
964pub fn resolve_context_window(cfg: &RalphConfig) -> u64 {
965    resolve_context_window_for_backend(cfg, cfg.cli.backend.as_str())
966}
967
968/// Resolves the context-window ceiling for an already-selected backend.
969///
970/// Use this in hat execution paths where the effective backend may differ from
971/// `cli.backend` because of per-hat backend configuration.
972pub fn resolve_context_window_for_backend(cfg: &RalphConfig, backend: &str) -> u64 {
973    if let Some(n) = cfg.event_loop.context_window_tokens {
974        return n;
975    }
976    match backend {
977        "claude" | "pi" => 200_000,
978        _ => 0,
979    }
980}
981
982fn default_prompt_file() -> String {
983    "PROMPT.md".to_string()
984}
985
986fn default_completion_promise() -> String {
987    "LOOP_COMPLETE".to_string()
988}
989
990fn default_max_iterations() -> u32 {
991    100
992}
993
994fn default_max_runtime() -> u64 {
995    14400 // 4 hours
996}
997
998fn default_max_failures() -> u32 {
999    5
1000}
1001
1002impl Default for EventLoopConfig {
1003    fn default() -> Self {
1004        Self {
1005            prompt: None,
1006            prompt_file: default_prompt_file(),
1007            completion_promise: default_completion_promise(),
1008            max_iterations: default_max_iterations(),
1009            max_runtime_seconds: default_max_runtime(),
1010            max_cost_usd: None,
1011            max_consecutive_failures: default_max_failures(),
1012            cooldown_delay_seconds: 0,
1013            starting_hat: None,
1014            starting_event: None,
1015            mutation_score_warn_threshold: None,
1016            persistent: false,
1017            required_events: Vec::new(),
1018            cancellation_promise: String::new(),
1019            enforce_hat_scope: false,
1020            context_window_tokens: None,
1021        }
1022    }
1023}
1024
1025/// Core paths and settings shared across all hats.
1026///
1027/// Per spec: "Core behaviors (always injected, can customize paths)"
1028#[derive(Debug, Clone, Serialize, Deserialize)]
1029pub struct CoreConfig {
1030    /// Scratchpad configuration (path and enabled flag).
1031    /// Accepts both plain string (legacy) and structured object.
1032    #[serde(default, deserialize_with = "deserialize_scratchpad_config")]
1033    pub scratchpad: ScratchpadConfig,
1034
1035    /// Path to the specs directory (source of truth for requirements).
1036    #[serde(default = "default_specs_dir")]
1037    pub specs_dir: String,
1038
1039    /// Guardrails injected into every prompt (core behaviors).
1040    ///
1041    /// Per spec: These are always present regardless of hat.
1042    #[serde(default = "default_guardrails")]
1043    pub guardrails: Vec<String>,
1044
1045    /// Root directory for workspace-relative paths (.ralph/, specs, etc.).
1046    ///
1047    /// All relative paths (scratchpad, specs_dir, memories) are resolved relative
1048    /// to this directory. Defaults to the current working directory.
1049    ///
1050    /// This is especially important for E2E tests that run in isolated workspaces.
1051    #[serde(skip)]
1052    pub workspace_root: std::path::PathBuf,
1053}
1054
1055fn default_specs_dir() -> String {
1056    ".ralph/specs/".to_string()
1057}
1058
1059fn default_guardrails() -> Vec<String> {
1060    vec![
1061        "Fresh context each iteration - scratchpad is memory".to_string(),
1062        "Don't assume 'not implemented' - search first".to_string(),
1063        "Backpressure is law - tests/typecheck/lint/audit must pass".to_string(),
1064        "When behavior is runnable or user-facing, exercise the real app with the strongest available harness (Playwright, tmux, real CLI/API) and try at least one adversarial path before reporting done".to_string(),
1065        "Confidence protocol: score decisions 0-100. >80 proceed autonomously; 50-80 proceed + document in .ralph/agent/decisions.md; <50 choose safe default + document".to_string(),
1066        "Commit atomically - one logical change per commit, capture the why".to_string(),
1067    ]
1068}
1069
1070impl Default for CoreConfig {
1071    fn default() -> Self {
1072        Self {
1073            scratchpad: ScratchpadConfig::default(),
1074            specs_dir: default_specs_dir(),
1075            guardrails: default_guardrails(),
1076            workspace_root: std::env::var("RALPH_WORKSPACE_ROOT")
1077                .map(std::path::PathBuf::from)
1078                .unwrap_or_else(|_| {
1079                    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
1080                }),
1081        }
1082    }
1083}
1084
1085impl CoreConfig {
1086    /// Sets the workspace root for resolving relative paths.
1087    ///
1088    /// This is used by E2E tests to point to their isolated test workspace.
1089    pub fn with_workspace_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
1090        self.workspace_root = root.into();
1091        self
1092    }
1093
1094    /// Resolves a relative path against the workspace root.
1095    ///
1096    /// If the path is already absolute, it is returned as-is.
1097    /// Otherwise, it is joined with the workspace root.
1098    pub fn resolve_path(&self, relative: &str) -> std::path::PathBuf {
1099        let path = std::path::Path::new(relative);
1100        if path.is_absolute() {
1101            path.to_path_buf()
1102        } else {
1103            self.workspace_root.join(path)
1104        }
1105    }
1106}
1107
1108/// CLI backend configuration.
1109#[derive(Debug, Clone, Serialize, Deserialize)]
1110pub struct CliConfig {
1111    /// Backend to use: "auto", "claude", "kiro", "kiro-acp", "gemini",
1112    /// "codex", "forge", "amp", "copilot", "opencode", "pi", "roo", or "custom".
1113    #[serde(default = "default_backend")]
1114    pub backend: String,
1115
1116    /// Command override. Required for "custom" backend.
1117    /// For named backends, overrides the default binary path.
1118    pub command: Option<String>,
1119
1120    /// How to pass prompts: "arg" or "stdin".
1121    #[serde(default = "default_prompt_mode")]
1122    pub prompt_mode: String,
1123
1124    /// Execution mode when --interactive not specified.
1125    /// Values: "autonomous" (default), "interactive"
1126    #[serde(default = "default_mode")]
1127    pub default_mode: String,
1128
1129    /// Idle timeout in seconds for interactive mode.
1130    /// Process is terminated after this many seconds of inactivity (no output AND no user input).
1131    /// Set to 0 to disable idle timeout.
1132    #[serde(default = "default_idle_timeout")]
1133    pub idle_timeout_secs: u32,
1134
1135    /// Custom arguments to pass to the CLI command (for backend: "custom").
1136    /// These are inserted before the prompt argument.
1137    #[serde(default)]
1138    pub args: Vec<String>,
1139
1140    /// Custom prompt flag for arg mode (for backend: "custom").
1141    /// If None, defaults to "-p" for arg mode.
1142    #[serde(default)]
1143    pub prompt_flag: Option<String>,
1144}
1145
1146fn default_backend() -> String {
1147    "claude".to_string()
1148}
1149
1150fn default_prompt_mode() -> String {
1151    "arg".to_string()
1152}
1153
1154fn default_mode() -> String {
1155    "autonomous".to_string()
1156}
1157
1158fn default_idle_timeout() -> u32 {
1159    30 // 30 seconds per spec
1160}
1161
1162impl Default for CliConfig {
1163    fn default() -> Self {
1164        Self {
1165            backend: default_backend(),
1166            command: None,
1167            prompt_mode: default_prompt_mode(),
1168            default_mode: default_mode(),
1169            idle_timeout_secs: default_idle_timeout(),
1170            args: Vec::new(),
1171            prompt_flag: None,
1172        }
1173    }
1174}
1175
1176/// TUI configuration.
1177#[derive(Debug, Clone, Serialize, Deserialize)]
1178pub struct TuiConfig {
1179    /// Prefix key combination (e.g., "ctrl-a", "ctrl-b").
1180    #[serde(default = "default_prefix_key")]
1181    pub prefix_key: String,
1182}
1183
1184/// Memory injection mode.
1185///
1186/// Controls how memories are injected into agent context.
1187#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1188#[serde(rename_all = "lowercase")]
1189pub enum InjectMode {
1190    /// Ralph automatically injects memories at the start of each iteration.
1191    #[default]
1192    Auto,
1193    /// Agent must explicitly run `ralph memory search` to access memories.
1194    Manual,
1195    /// Memories feature is disabled.
1196    None,
1197}
1198
1199impl std::fmt::Display for InjectMode {
1200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1201        match self {
1202            Self::Auto => write!(f, "auto"),
1203            Self::Manual => write!(f, "manual"),
1204            Self::None => write!(f, "none"),
1205        }
1206    }
1207}
1208
1209/// Memories configuration.
1210///
1211/// Controls the persistent learning system that allows Ralph to accumulate
1212/// wisdom across sessions. Memories are stored in `.ralph/agent/memories.md`.
1213///
1214/// When enabled, the memories skill is automatically injected to teach
1215/// agents how to create and search memories (skill injection is implicit).
1216///
1217/// Example configuration:
1218/// ```yaml
1219/// memories:
1220///   enabled: true
1221///   inject: auto
1222///   budget: 2000
1223/// ```
1224#[derive(Debug, Clone, Serialize, Deserialize)]
1225pub struct MemoriesConfig {
1226    /// Whether the memories feature is enabled.
1227    ///
1228    /// When true, memories are injected and the skill is taught to the agent.
1229    #[serde(default)]
1230    pub enabled: bool,
1231
1232    /// How memories are injected into agent context.
1233    #[serde(default)]
1234    pub inject: InjectMode,
1235
1236    /// Maximum tokens to inject (0 = unlimited).
1237    ///
1238    /// When set, memories are truncated to fit within this budget.
1239    #[serde(default)]
1240    pub budget: usize,
1241
1242    /// Filter configuration for memory injection.
1243    #[serde(default)]
1244    pub filter: MemoriesFilter,
1245}
1246
1247impl Default for MemoriesConfig {
1248    fn default() -> Self {
1249        Self {
1250            enabled: true, // Memories enabled by default
1251            inject: InjectMode::Auto,
1252            budget: 0,
1253            filter: MemoriesFilter::default(),
1254        }
1255    }
1256}
1257
1258/// Filter configuration for memory injection.
1259///
1260/// Controls which memories are included when priming context.
1261#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1262pub struct MemoriesFilter {
1263    /// Filter by memory types (empty = all types).
1264    #[serde(default)]
1265    pub types: Vec<String>,
1266
1267    /// Filter by tags (empty = all tags).
1268    #[serde(default)]
1269    pub tags: Vec<String>,
1270
1271    /// Only include memories from the last N days (0 = no time limit).
1272    #[serde(default)]
1273    pub recent: u32,
1274}
1275
1276/// Tasks configuration.
1277///
1278/// Controls the runtime task tracking system that allows Ralph to manage
1279/// work items across iterations. Tasks are stored in `.ralph/agent/tasks.jsonl`.
1280///
1281/// When enabled, tasks replace scratchpad for loop completion verification.
1282///
1283/// Example configuration:
1284/// ```yaml
1285/// tasks:
1286///   enabled: true
1287/// ```
1288#[derive(Debug, Clone, Serialize, Deserialize)]
1289pub struct TasksConfig {
1290    /// Whether the tasks feature is enabled.
1291    ///
1292    /// When true, tasks are used for loop completion verification.
1293    #[serde(default = "default_true")]
1294    pub enabled: bool,
1295}
1296
1297impl Default for TasksConfig {
1298    fn default() -> Self {
1299        Self {
1300            enabled: true, // Tasks enabled by default
1301        }
1302    }
1303}
1304
1305/// Hooks configuration.
1306///
1307/// Controls per-project orchestrator lifecycle hooks. Hooks are disabled by
1308/// default and are inert until explicitly enabled.
1309///
1310/// Example configuration:
1311/// ```yaml
1312/// hooks:
1313///   enabled: true
1314///   defaults:
1315///     timeout_seconds: 30
1316///     max_output_bytes: 8192
1317///     suspend_mode: wait_for_resume
1318///   events:
1319///     pre.loop.start:
1320///       - name: env-guard
1321///         command: ["./scripts/hooks/env-guard.sh"]
1322///         on_error: block
1323/// ```
1324#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1325pub struct HooksConfig {
1326    /// Whether lifecycle hooks are enabled.
1327    #[serde(default)]
1328    pub enabled: bool,
1329
1330    /// Default guardrails applied to hook specs when per-hook values are absent.
1331    #[serde(default)]
1332    pub defaults: HookDefaults,
1333
1334    /// Hook lists by lifecycle phase-event key.
1335    #[serde(default)]
1336    pub events: HashMap<HookPhaseEvent, Vec<HookSpec>>,
1337
1338    /// Unknown keys captured for v1 guardrails.
1339    #[serde(default, flatten)]
1340    pub extra: HashMap<String, serde_yaml::Value>,
1341}
1342
1343/// Hook defaults applied when a hook spec omits optional limits.
1344#[derive(Debug, Clone, Serialize, Deserialize)]
1345pub struct HookDefaults {
1346    /// Maximum execution time per hook in seconds.
1347    #[serde(default = "default_hook_timeout_seconds")]
1348    pub timeout_seconds: u64,
1349
1350    /// Maximum stdout/stderr bytes stored per stream.
1351    #[serde(default = "default_hook_max_output_bytes")]
1352    pub max_output_bytes: u64,
1353
1354    /// Suspend strategy used when `on_error: suspend` and no per-hook mode is set.
1355    #[serde(default)]
1356    pub suspend_mode: HookSuspendMode,
1357}
1358
1359fn default_hook_timeout_seconds() -> u64 {
1360    30
1361}
1362
1363fn default_hook_max_output_bytes() -> u64 {
1364    8192
1365}
1366
1367impl Default for HookDefaults {
1368    fn default() -> Self {
1369        Self {
1370            timeout_seconds: default_hook_timeout_seconds(),
1371            max_output_bytes: default_hook_max_output_bytes(),
1372            suspend_mode: HookSuspendMode::default(),
1373        }
1374    }
1375}
1376
1377/// Supported lifecycle phase-event keys for v1 hooks.
1378#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1379pub enum HookPhaseEvent {
1380    #[serde(rename = "pre.loop.start")]
1381    PreLoopStart,
1382    #[serde(rename = "post.loop.start")]
1383    PostLoopStart,
1384    #[serde(rename = "pre.iteration.start")]
1385    PreIterationStart,
1386    #[serde(rename = "post.iteration.start")]
1387    PostIterationStart,
1388    #[serde(rename = "pre.plan.created")]
1389    PrePlanCreated,
1390    #[serde(rename = "post.plan.created")]
1391    PostPlanCreated,
1392    #[serde(rename = "pre.human.interact")]
1393    PreHumanInteract,
1394    #[serde(rename = "post.human.interact")]
1395    PostHumanInteract,
1396    #[serde(rename = "pre.loop.complete")]
1397    PreLoopComplete,
1398    #[serde(rename = "post.loop.complete")]
1399    PostLoopComplete,
1400    #[serde(rename = "pre.loop.error")]
1401    PreLoopError,
1402    #[serde(rename = "post.loop.error")]
1403    PostLoopError,
1404}
1405
1406impl HookPhaseEvent {
1407    /// Canonical string value used in YAML keys and telemetry.
1408    pub fn as_str(self) -> &'static str {
1409        match self {
1410            Self::PreLoopStart => "pre.loop.start",
1411            Self::PostLoopStart => "post.loop.start",
1412            Self::PreIterationStart => "pre.iteration.start",
1413            Self::PostIterationStart => "post.iteration.start",
1414            Self::PrePlanCreated => "pre.plan.created",
1415            Self::PostPlanCreated => "post.plan.created",
1416            Self::PreHumanInteract => "pre.human.interact",
1417            Self::PostHumanInteract => "post.human.interact",
1418            Self::PreLoopComplete => "pre.loop.complete",
1419            Self::PostLoopComplete => "post.loop.complete",
1420            Self::PreLoopError => "pre.loop.error",
1421            Self::PostLoopError => "post.loop.error",
1422        }
1423    }
1424
1425    /// Parses a phase-event string to the canonical enum.
1426    pub fn parse(value: &str) -> Option<Self> {
1427        match value {
1428            "pre.loop.start" => Some(Self::PreLoopStart),
1429            "post.loop.start" => Some(Self::PostLoopStart),
1430            "pre.iteration.start" => Some(Self::PreIterationStart),
1431            "post.iteration.start" => Some(Self::PostIterationStart),
1432            "pre.plan.created" => Some(Self::PrePlanCreated),
1433            "post.plan.created" => Some(Self::PostPlanCreated),
1434            "pre.human.interact" => Some(Self::PreHumanInteract),
1435            "post.human.interact" => Some(Self::PostHumanInteract),
1436            "pre.loop.complete" => Some(Self::PreLoopComplete),
1437            "post.loop.complete" => Some(Self::PostLoopComplete),
1438            "pre.loop.error" => Some(Self::PreLoopError),
1439            "post.loop.error" => Some(Self::PostLoopError),
1440            _ => None,
1441        }
1442    }
1443}
1444
1445impl std::fmt::Display for HookPhaseEvent {
1446    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1447        f.write_str((*self).as_str())
1448    }
1449}
1450
1451fn validate_hooks_phase_event_keys(value: &serde_yaml::Value) -> Result<(), ConfigError> {
1452    let Some(root) = value.as_mapping() else {
1453        return Ok(());
1454    };
1455
1456    let Some(hooks) = root.get(serde_yaml::Value::String("hooks".to_string())) else {
1457        return Ok(());
1458    };
1459
1460    let Some(hooks_map) = hooks.as_mapping() else {
1461        return Ok(());
1462    };
1463
1464    let Some(events) = hooks_map.get(serde_yaml::Value::String("events".to_string())) else {
1465        return Ok(());
1466    };
1467
1468    let Some(events_map) = events.as_mapping() else {
1469        return Ok(());
1470    };
1471
1472    for key in events_map.keys() {
1473        if let Some(phase_event) = key.as_str()
1474            && HookPhaseEvent::parse(phase_event).is_none()
1475        {
1476            return Err(ConfigError::InvalidHookPhaseEvent {
1477                phase_event: phase_event.to_string(),
1478            });
1479        }
1480    }
1481
1482    Ok(())
1483}
1484
1485/// Per-hook failure disposition.
1486#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1487#[serde(rename_all = "snake_case")]
1488pub enum HookOnError {
1489    /// Continue orchestration and record warning telemetry.
1490    Warn,
1491    /// Stop the current lifecycle action as a failure.
1492    Block,
1493    /// Suspend orchestration and await policy-specific recovery.
1494    Suspend,
1495}
1496
1497/// Suspend mode used for `on_error: suspend`.
1498#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1499#[serde(rename_all = "snake_case")]
1500pub enum HookSuspendMode {
1501    /// Pause the loop until an explicit operator resume signal is received.
1502    #[default]
1503    WaitForResume,
1504    /// Retry automatically with bounded backoff.
1505    RetryBackoff,
1506    /// Wait for resume, then retry once.
1507    WaitThenRetry,
1508}
1509
1510/// Mutation settings for a hook.
1511#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1512pub struct HookMutationConfig {
1513    /// Opt-in flag for parsing stdout as mutation JSON.
1514    #[serde(default)]
1515    pub enabled: bool,
1516
1517    /// Optional explicit payload format guardrail. Only `json` is valid in v1.
1518    #[serde(default)]
1519    pub format: Option<String>,
1520
1521    /// Unknown keys captured for v1 mutation guardrails.
1522    #[serde(default, flatten)]
1523    pub extra: HashMap<String, serde_yaml::Value>,
1524}
1525
1526/// Hook specification for a single lifecycle event mapping.
1527#[derive(Debug, Clone, Serialize, Deserialize)]
1528pub struct HookSpec {
1529    /// Stable hook identifier used in telemetry and diagnostics.
1530    #[serde(default)]
1531    pub name: String,
1532
1533    /// Command argv form (`command[0]` executable + args).
1534    #[serde(default)]
1535    pub command: Vec<String>,
1536
1537    /// Optional working directory override.
1538    #[serde(default)]
1539    pub cwd: Option<PathBuf>,
1540
1541    /// Optional environment variable overrides.
1542    #[serde(default)]
1543    pub env: HashMap<String, String>,
1544
1545    /// Per-hook timeout override in seconds.
1546    #[serde(default)]
1547    pub timeout_seconds: Option<u64>,
1548
1549    /// Per-hook output cap override in bytes (applies per stream).
1550    #[serde(default)]
1551    pub max_output_bytes: Option<u64>,
1552
1553    /// Failure behavior (`warn`, `block`, `suspend`). Required in v1.
1554    #[serde(default)]
1555    pub on_error: Option<HookOnError>,
1556
1557    /// Optional suspend strategy override for `on_error: suspend`.
1558    #[serde(default)]
1559    pub suspend_mode: Option<HookSuspendMode>,
1560
1561    /// Mutation policy (opt-in, JSON-only contract enforced by validation/runtime).
1562    #[serde(default)]
1563    pub mutate: HookMutationConfig,
1564
1565    /// Unknown keys captured for v1 guardrails.
1566    #[serde(default, flatten)]
1567    pub extra: HashMap<String, serde_yaml::Value>,
1568}
1569
1570/// Skills configuration.
1571///
1572/// Controls the skill discovery and injection system that makes tool
1573/// knowledge and domain expertise available to agents during loops.
1574///
1575/// Skills use a two-tier injection model: a compact skill index is always
1576/// present in every prompt, and the agent loads full skill content on demand
1577/// via `ralph tools skill load <name>`.
1578///
1579/// Example configuration:
1580/// ```yaml
1581/// skills:
1582///   enabled: true
1583///   dirs:
1584///     - ".claude/skills"
1585///   overrides:
1586///     pdd:
1587///       enabled: false
1588///     memories:
1589///       auto_inject: true
1590///       hats: ["ralph"]
1591/// ```
1592#[derive(Debug, Clone, Serialize, Deserialize)]
1593pub struct SkillsConfig {
1594    /// Whether the skills system is enabled.
1595    #[serde(default = "default_true")]
1596    pub enabled: bool,
1597
1598    /// Directories to scan for skill files.
1599    /// Relative paths resolved against workspace root.
1600    #[serde(default)]
1601    pub dirs: Vec<PathBuf>,
1602
1603    /// Per-skill overrides keyed by skill name.
1604    #[serde(default)]
1605    pub overrides: HashMap<String, SkillOverride>,
1606}
1607
1608impl Default for SkillsConfig {
1609    fn default() -> Self {
1610        Self {
1611            enabled: true, // Skills enabled by default
1612            dirs: vec![],
1613            overrides: HashMap::new(),
1614        }
1615    }
1616}
1617
1618/// Per-skill configuration override.
1619///
1620/// Allows enabling/disabling individual skills and overriding their
1621/// frontmatter fields (hats, backends, tags, auto_inject).
1622#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1623pub struct SkillOverride {
1624    /// Disable a discovered skill.
1625    #[serde(default)]
1626    pub enabled: Option<bool>,
1627
1628    /// Restrict skill to specific hats.
1629    #[serde(default)]
1630    pub hats: Vec<String>,
1631
1632    /// Restrict skill to specific backends.
1633    #[serde(default)]
1634    pub backends: Vec<String>,
1635
1636    /// Tags for categorization.
1637    #[serde(default)]
1638    pub tags: Vec<String>,
1639
1640    /// Inject full content into prompt (not just index entry).
1641    #[serde(default)]
1642    pub auto_inject: Option<bool>,
1643}
1644
1645/// Preflight check configuration.
1646#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1647pub struct PreflightConfig {
1648    /// Whether to run preflight checks before `ralph run`.
1649    #[serde(default)]
1650    pub enabled: bool,
1651
1652    /// Whether to treat warnings as failures.
1653    #[serde(default)]
1654    pub strict: bool,
1655
1656    /// Specific checks to skip (by name). Empty = run all checks.
1657    #[serde(default)]
1658    pub skip: Vec<String>,
1659}
1660
1661/// Feature flags for optional Ralph capabilities.
1662///
1663/// Example configuration:
1664/// ```yaml
1665/// features:
1666///   parallel: true  # Enable parallel loops via git worktrees
1667///   auto_merge: false  # Auto-merge worktree branches on completion
1668///   preflight:
1669///     enabled: false      # Opt-in: run preflight checks before `ralph run`
1670///     strict: false       # Treat warnings as failures
1671///     skip: ["telegram"]  # Skip specific checks by name
1672///   loop_naming:
1673///     format: human-readable  # or "timestamp" for legacy format
1674///     max_length: 50
1675/// ```
1676#[derive(Debug, Clone, Serialize, Deserialize)]
1677pub struct FeaturesConfig {
1678    /// Whether parallel loops are enabled.
1679    ///
1680    /// When true (default), if another loop holds the lock, Ralph spawns
1681    /// a parallel loop in a git worktree. When false, Ralph errors instead.
1682    #[serde(default = "default_true")]
1683    pub parallel: bool,
1684
1685    /// Whether to automatically merge worktree branches on completion.
1686    ///
1687    /// When false (default), completed worktree loops queue for manual merge.
1688    /// When true, Ralph automatically merges the worktree branch into the
1689    /// main branch after a parallel loop completes.
1690    #[serde(default)]
1691    pub auto_merge: bool,
1692
1693    /// Loop naming configuration for worktree branches.
1694    ///
1695    /// Controls how loop IDs are generated for parallel loops.
1696    /// Default uses human-readable format: `fix-header-swift-peacock`
1697    /// Legacy timestamp format: `ralph-YYYYMMDD-HHMMSS-XXXX`
1698    #[serde(default)]
1699    pub loop_naming: crate::loop_name::LoopNamingConfig,
1700
1701    /// Preflight check configuration.
1702    #[serde(default)]
1703    pub preflight: PreflightConfig,
1704}
1705
1706impl Default for FeaturesConfig {
1707    fn default() -> Self {
1708        Self {
1709            parallel: true,    // Parallel loops enabled by default
1710            auto_merge: false, // Auto-merge disabled by default for safety
1711            loop_naming: crate::loop_name::LoopNamingConfig::default(),
1712            preflight: PreflightConfig::default(),
1713        }
1714    }
1715}
1716
1717fn default_prefix_key() -> String {
1718    "ctrl-a".to_string()
1719}
1720
1721impl Default for TuiConfig {
1722    fn default() -> Self {
1723        Self {
1724            prefix_key: default_prefix_key(),
1725        }
1726    }
1727}
1728
1729impl TuiConfig {
1730    /// Parses the prefix_key string into KeyCode and KeyModifiers.
1731    /// Returns an error if the format is invalid.
1732    pub fn parse_prefix(
1733        &self,
1734    ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
1735        use crossterm::event::{KeyCode, KeyModifiers};
1736
1737        let parts: Vec<&str> = self.prefix_key.split('-').collect();
1738        if parts.len() != 2 {
1739            return Err(format!(
1740                "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
1741                self.prefix_key
1742            ));
1743        }
1744
1745        let modifier = match parts[0].to_lowercase().as_str() {
1746            "ctrl" => KeyModifiers::CONTROL,
1747            _ => {
1748                return Err(format!(
1749                    "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
1750                    parts[0]
1751                ));
1752            }
1753        };
1754
1755        let key_str = parts[1];
1756        if key_str.len() != 1 {
1757            return Err(format!(
1758                "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
1759                key_str
1760            ));
1761        }
1762
1763        let key_char = key_str.chars().next().unwrap();
1764        let key_code = KeyCode::Char(key_char);
1765
1766        Ok((key_code, modifier))
1767    }
1768}
1769
1770/// Metadata for an event topic.
1771///
1772/// Defines what an event means, enabling auto-derived instructions for hats.
1773/// When a hat triggers on or publishes an event, this metadata is used to
1774/// generate appropriate behavior instructions.
1775///
1776/// Example:
1777/// ```yaml
1778/// events:
1779///   deploy.start:
1780///     description: "Deployment has been requested"
1781///     on_trigger: "Prepare artifacts, validate config, check dependencies"
1782///     on_publish: "Signal that deployment should begin"
1783/// ```
1784#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1785pub struct EventMetadata {
1786    /// Brief description of what this event represents.
1787    #[serde(default)]
1788    pub description: String,
1789
1790    /// Instructions for a hat that triggers on (receives) this event.
1791    /// Describes what the hat should do when it receives this event.
1792    #[serde(default)]
1793    pub on_trigger: String,
1794
1795    /// Instructions for a hat that publishes (emits) this event.
1796    /// Describes when/how the hat should emit this event.
1797    #[serde(default)]
1798    pub on_publish: String,
1799}
1800
1801/// Backend configuration for a hat.
1802#[derive(Debug, Clone, Serialize, Deserialize)]
1803#[serde(untagged)]
1804pub enum HatBackend {
1805    // Order matters for serde untagged - most specific first
1806    /// Kiro agent with custom agent name and optional args.
1807    KiroAgent {
1808        #[serde(rename = "type")]
1809        backend_type: String,
1810        agent: String,
1811        #[serde(default)]
1812        args: Vec<String>,
1813    },
1814    /// Named backend with args (has `type` but no `agent`).
1815    NamedWithArgs {
1816        #[serde(rename = "type")]
1817        backend_type: String,
1818        #[serde(default)]
1819        args: Vec<String>,
1820    },
1821    /// Simple named backend (string form).
1822    Named(String),
1823    /// Custom backend with command and args.
1824    Custom {
1825        command: String,
1826        #[serde(default)]
1827        args: Vec<String>,
1828    },
1829}
1830
1831impl HatBackend {
1832    /// Converts to CLI backend string for execution.
1833    pub fn to_cli_backend(&self) -> String {
1834        match self {
1835            HatBackend::Named(name) => name.clone(),
1836            HatBackend::NamedWithArgs { backend_type, .. } => backend_type.clone(),
1837            HatBackend::KiroAgent { backend_type, .. } => backend_type.clone(),
1838            HatBackend::Custom { .. } => "custom".to_string(),
1839        }
1840    }
1841}
1842
1843/// Configuration for a single hat.
1844#[derive(Debug, Clone, Serialize, Deserialize)]
1845pub struct HatConfig {
1846    /// Human-readable name for the hat.
1847    pub name: String,
1848
1849    /// Short description of the hat's purpose (required).
1850    /// Used in the HATS table to help Ralph understand when to delegate to this hat.
1851    pub description: Option<String>,
1852
1853    /// Events that trigger this hat to be worn.
1854    /// Per spec: "Hats define triggers — which events cause Ralph to wear this hat."
1855    #[serde(default)]
1856    pub triggers: Vec<String>,
1857
1858    /// Topics this hat publishes.
1859    #[serde(default)]
1860    pub publishes: Vec<String>,
1861
1862    /// Instructions prepended to prompts.
1863    #[serde(default)]
1864    pub instructions: String,
1865
1866    /// Additional instruction fragments appended to `instructions`.
1867    ///
1868    /// Use with YAML anchors to share common instruction blocks across hats:
1869    /// ```yaml
1870    /// _confidence_protocol: &confidence_protocol |
1871    ///   ### Confidence-Based Decision Protocol
1872    ///   ...
1873    ///
1874    /// hats:
1875    ///   architect:
1876    ///     instructions: |
1877    ///       ## ARCHITECT MODE
1878    ///       ...
1879    ///     extra_instructions:
1880    ///       - *confidence_protocol
1881    /// ```
1882    #[serde(default)]
1883    pub extra_instructions: Vec<String>,
1884
1885    /// Backend to use for this hat (inherits from cli.backend if not specified).
1886    #[serde(default)]
1887    pub backend: Option<HatBackend>,
1888
1889    /// Custom args to append to the backend CLI when this hat is active.
1890    ///
1891    /// Accepts both `backend_args:` and shorthand `args:`.
1892    #[serde(default, alias = "args")]
1893    pub backend_args: Option<Vec<String>>,
1894
1895    /// Default event to publish if hat forgets to write an event.
1896    #[serde(default)]
1897    pub default_publishes: Option<String>,
1898
1899    /// Maximum number of times this hat may be activated in a single loop run.
1900    ///
1901    /// When the limit is exceeded, the orchestrator publishes `<hat_id>.exhausted`
1902    /// instead of activating the hat again.
1903    pub max_activations: Option<u32>,
1904
1905    /// Per-hat scratchpad override. If None, inherits from core.scratchpad.
1906    /// Accepts both a plain string shorthand and a structured object.
1907    #[serde(default, deserialize_with = "deserialize_optional_scratchpad_config")]
1908    pub scratchpad: Option<ScratchpadConfig>,
1909
1910    /// Tools the hat is not allowed to use.
1911    ///
1912    /// Injected as a TOOL RESTRICTIONS section in the prompt (soft enforcement).
1913    /// After each iteration, a file-modification audit checks compliance when
1914    /// `Edit` or `Write` are disallowed (hard enforcement via scope_violation event).
1915    #[serde(default)]
1916    pub disallowed_tools: Vec<String>,
1917
1918    /// Execution timeout in seconds for this hat.
1919    ///
1920    /// For wave workers, this controls how long each parallel worker can run.
1921    /// Defaults to the adapter-level timeout (typically 300s) if not set.
1922    #[serde(default)]
1923    pub timeout: Option<u32>,
1924
1925    /// Maximum concurrent wave instances for this hat.
1926    ///
1927    /// When > 1, the loop runner spawns multiple backend instances in parallel
1928    /// for wave events targeting this hat. Default is 1 (sequential execution).
1929    #[serde(default = "default_concurrency")]
1930    pub concurrency: u32,
1931
1932    /// Aggregation configuration for this hat.
1933    ///
1934    /// When set, this hat acts as an aggregator — it buffers wave results and
1935    /// activates only when all correlated results have arrived (or timeout).
1936    /// Cannot be set on a hat with `concurrency > 1`.
1937    #[serde(default)]
1938    pub aggregate: Option<AggregateConfig>,
1939}
1940
1941fn default_concurrency() -> u32 {
1942    1
1943}
1944
1945/// Configuration for wave result aggregation.
1946#[derive(Debug, Clone, Serialize, Deserialize)]
1947pub struct AggregateConfig {
1948    /// Aggregation mode.
1949    pub mode: AggregateMode,
1950
1951    /// Timeout in seconds for waiting on all wave results.
1952    /// After this timeout, the aggregator activates with whatever results are available.
1953    pub timeout: u32,
1954}
1955
1956/// Aggregation mode for wave results.
1957#[derive(Debug, Clone, Serialize, Deserialize)]
1958#[serde(rename_all = "snake_case")]
1959pub enum AggregateMode {
1960    /// Wait for all wave instances to complete before activating the aggregator.
1961    WaitForAll,
1962}
1963
1964impl HatConfig {
1965    /// Converts trigger strings to Topic objects.
1966    pub fn trigger_topics(&self) -> Vec<Topic> {
1967        self.triggers.iter().map(|s| Topic::new(s)).collect()
1968    }
1969
1970    /// Converts publish strings to Topic objects.
1971    pub fn publish_topics(&self) -> Vec<Topic> {
1972        self.publishes.iter().map(|s| Topic::new(s)).collect()
1973    }
1974}
1975
1976/// RObot communication channel.
1977#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1978#[serde(rename_all = "lowercase")]
1979pub enum RobotMode {
1980    /// Use the Telegram-backed RObot service.
1981    #[default]
1982    Telegram,
1983    /// Use JSON files in `.ralph/api/` for API-driven interaction.
1984    Web,
1985}
1986
1987impl RobotMode {
1988    /// Returns true when the Telegram service should be used.
1989    pub fn is_telegram(self) -> bool {
1990        matches!(self, Self::Telegram)
1991    }
1992
1993    /// Returns true when the file-backed web service should be used.
1994    pub fn is_web(self) -> bool {
1995        matches!(self, Self::Web)
1996    }
1997}
1998
1999/// RObot (Ralph-Orchestrator bot) configuration.
2000///
2001/// Enables bidirectional communication between AI agents and humans
2002/// during orchestration loops. When enabled, agents can emit `human.interact`
2003/// events to request clarification (blocking the loop), and humans can
2004/// send proactive guidance.
2005///
2006/// Example configuration:
2007/// ```yaml
2008/// RObot:
2009///   enabled: true
2010///   mode: telegram
2011///   timeout_seconds: 300
2012///   checkin_interval_seconds: 120  # Optional: send status every 2 min
2013///   telegram:
2014///     bot_token: "..."  # Or set RALPH_TELEGRAM_BOT_TOKEN env var
2015/// ```
2016#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2017pub struct RobotConfig {
2018    /// Whether the RObot is enabled.
2019    #[serde(default)]
2020    pub enabled: bool,
2021
2022    /// Communication channel to use. Defaults to Telegram for existing configs.
2023    #[serde(default)]
2024    pub mode: RobotMode,
2025
2026    /// Timeout in seconds for waiting on human responses.
2027    /// Required when enabled (no default — must be explicit).
2028    /// A value of 0 means wait indefinitely.
2029    pub timeout_seconds: Option<u64>,
2030
2031    /// Interval in seconds between periodic check-in messages sent via Telegram.
2032    /// When set, Ralph sends a status message every N seconds so the human
2033    /// knows it's still working. If `None`, no check-ins are sent.
2034    pub checkin_interval_seconds: Option<u64>,
2035
2036    /// Telegram bot configuration.
2037    #[serde(default)]
2038    pub telegram: Option<TelegramBotConfig>,
2039}
2040
2041impl RobotConfig {
2042    /// Validates the RObot config. Returns an error if enabled but misconfigured.
2043    pub fn validate(&self) -> Result<(), ConfigError> {
2044        if !self.enabled {
2045            return Ok(());
2046        }
2047
2048        if self.timeout_seconds.is_none() {
2049            return Err(ConfigError::RobotMissingField {
2050                field: "RObot.timeout_seconds".to_string(),
2051                hint: "timeout_seconds is required when RObot is enabled".to_string(),
2052            });
2053        }
2054
2055        // Telegram mode needs a bot token from config, keychain, or env var.
2056        if self.mode.is_telegram() && self.resolve_bot_token().is_none() {
2057            return Err(ConfigError::RobotMissingField {
2058                field: "RObot.telegram.bot_token".to_string(),
2059                hint: "Run `ralph bot onboard --telegram`, set RALPH_TELEGRAM_BOT_TOKEN env var, or set RObot.telegram.bot_token in config"
2060                    .to_string(),
2061            });
2062        }
2063
2064        Ok(())
2065    }
2066
2067    /// Resolves the bot token from multiple sources.
2068    ///
2069    /// Resolution order (highest to lowest priority):
2070    /// 1. `RALPH_TELEGRAM_BOT_TOKEN` environment variable
2071    /// 2. `RObot.telegram.bot_token` in config file (explicit project override)
2072    /// 3. OS keychain (service: "ralph", user: "telegram-bot-token")
2073    pub fn resolve_bot_token(&self) -> Option<String> {
2074        // 1. Env var (highest priority)
2075        let env_token = std::env::var("RALPH_TELEGRAM_BOT_TOKEN").ok();
2076        let config_token = self
2077            .telegram
2078            .as_ref()
2079            .and_then(|telegram| telegram.bot_token.clone());
2080
2081        if cfg!(test) {
2082            return env_token.or(config_token);
2083        }
2084
2085        env_token
2086            // 2. Config file (explicit override)
2087            .or(config_token)
2088            // 3. OS keychain (best effort)
2089            .or_else(|| {
2090                std::panic::catch_unwind(|| {
2091                    keyring::Entry::new("ralph", "telegram-bot-token")
2092                        .ok()
2093                        .and_then(|e| e.get_password().ok())
2094                })
2095                .ok()
2096                .flatten()
2097            })
2098    }
2099
2100    /// Resolves the custom Telegram API URL from multiple sources.
2101    ///
2102    /// Resolution order (highest to lowest priority):
2103    /// 1. `RALPH_TELEGRAM_API_URL` environment variable
2104    /// 2. `RObot.telegram.api_url` in config file
2105    pub fn resolve_api_url(&self) -> Option<String> {
2106        std::env::var("RALPH_TELEGRAM_API_URL").ok().or_else(|| {
2107            self.telegram
2108                .as_ref()
2109                .and_then(|telegram| telegram.api_url.clone())
2110        })
2111    }
2112}
2113
2114/// Telegram bot configuration.
2115#[derive(Debug, Clone, Serialize, Deserialize)]
2116pub struct TelegramBotConfig {
2117    /// Bot token. Optional if `RALPH_TELEGRAM_BOT_TOKEN` env var is set.
2118    pub bot_token: Option<String>,
2119
2120    /// Custom Telegram Bot API URL. Optional; when set, all API requests
2121    /// are sent to this URL instead of the default `https://api.telegram.org`.
2122    /// Useful for targeting a local mock server (e.g., `telegram-test-api`)
2123    /// in CI/CD. Can also be set via `RALPH_TELEGRAM_API_URL` env var.
2124    pub api_url: Option<String>,
2125}
2126
2127/// Configuration errors.
2128#[derive(Debug, thiserror::Error)]
2129pub enum ConfigError {
2130    #[error("IO error: {0}")]
2131    Io(#[from] std::io::Error),
2132
2133    #[error("YAML parse error: {0}")]
2134    Yaml(#[from] serde_yaml::Error),
2135
2136    #[error(
2137        "Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'.\nFix: ensure only one hat claims this trigger or delegate with a new event.\nSee: docs/reference/troubleshooting.md#ambiguous-routing"
2138    )]
2139    AmbiguousRouting {
2140        trigger: String,
2141        hat1: String,
2142        hat2: String,
2143    },
2144
2145    #[error(
2146        "Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified.\nFix: remove one field or split into separate configs.\nSee: docs/reference/troubleshooting.md#mutually-exclusive-fields"
2147    )]
2148    MutuallyExclusive { field1: String, field2: String },
2149
2150    #[error("Invalid completion_promise: must be non-empty and non-whitespace")]
2151    InvalidCompletionPromise,
2152
2153    #[error(
2154        "Custom backend requires a command.\nFix: set 'cli.command' in your config (or run `ralph init --backend custom`).\nSee: docs/reference/troubleshooting.md#custom-backend-command"
2155    )]
2156    CustomBackendRequiresCommand,
2157
2158    #[error(
2159        "Reserved trigger '{trigger}' used by hat '{hat}' - task.start and task.resume are reserved for Ralph (the coordinator). Use a delegated event like 'work.start' instead.\nSee: docs/reference/troubleshooting.md#reserved-trigger"
2160    )]
2161    ReservedTrigger { trigger: String, hat: String },
2162
2163    #[error(
2164        "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose.\nSee: docs/reference/troubleshooting.md#missing-hat-description"
2165    )]
2166    MissingDescription { hat: String },
2167
2168    #[error(
2169        "RObot config error: {field} - {hint}\nSee: docs/reference/troubleshooting.md#robot-config"
2170    )]
2171    RobotMissingField { field: String, hint: String },
2172
2173    #[error(
2174        "Invalid hooks phase-event '{phase_event}'. Supported v1 phase-events: pre.loop.start, post.loop.start, pre.iteration.start, post.iteration.start, pre.plan.created, post.plan.created, pre.human.interact, post.human.interact, pre.loop.complete, post.loop.complete, pre.loop.error, post.loop.error.\nFix: use one of the supported keys under hooks.events."
2175    )]
2176    InvalidHookPhaseEvent { phase_event: String },
2177
2178    #[error(
2179        "Hook config validation error at '{field}': {message}\nSee: specs/add-hooks-to-ralph-orchestrator-lifecycle/design.md#hookspec-fields-v1"
2180    )]
2181    HookValidation { field: String, message: String },
2182
2183    #[error(
2184        "Unsupported hooks field '{field}' for v1. {reason}\nSee: specs/add-hooks-to-ralph-orchestrator-lifecycle/design.md#out-of-scope-v1-non-goals"
2185    )]
2186    UnsupportedHookField { field: String, reason: String },
2187
2188    #[error(
2189        "Invalid config key 'project'. Use 'core' instead (e.g. 'core.specs_dir' instead of 'project.specs_dir').\nSee: docs/guide/configuration.md"
2190    )]
2191    DeprecatedProjectKey,
2192
2193    #[error(
2194        "Hat '{hat}' has invalid concurrency: {value}. Must be >= 1.\nFix: set 'concurrency' to 1 or higher."
2195    )]
2196    InvalidConcurrency { hat: String, value: u32 },
2197
2198    #[error(
2199        "Hat '{hat}' has both 'aggregate' and 'concurrency > 1'. An aggregator hat cannot also be a concurrent worker.\nFix: remove 'aggregate' or set 'concurrency' to 1."
2200    )]
2201    AggregateOnConcurrentHat { hat: String },
2202}
2203
2204#[cfg(test)]
2205mod tests {
2206    use super::*;
2207
2208    #[test]
2209    fn test_default_config() {
2210        let config = RalphConfig::default();
2211        // Default config has no custom hats (uses default planner+builder)
2212        assert!(config.hats.is_empty());
2213        assert_eq!(config.event_loop.max_iterations, 100);
2214        assert!(!config.verbose);
2215        assert!(!config.features.preflight.enabled);
2216        assert!(!config.features.preflight.strict);
2217        assert!(config.features.preflight.skip.is_empty());
2218    }
2219
2220    #[test]
2221    fn test_resolve_context_window_explicit_override_wins() {
2222        let mut config = RalphConfig::default();
2223        config.cli.backend = "claude".to_string();
2224        config.event_loop.context_window_tokens = Some(1_000_000);
2225        assert_eq!(resolve_context_window(&config), 1_000_000);
2226    }
2227
2228    #[test]
2229    fn test_resolve_context_window_claude_default() {
2230        let mut config = RalphConfig::default();
2231        config.cli.backend = "claude".to_string();
2232        config.event_loop.context_window_tokens = None;
2233        assert_eq!(resolve_context_window(&config), 200_000);
2234    }
2235
2236    #[test]
2237    fn test_resolve_context_window_pi_default() {
2238        let mut config = RalphConfig::default();
2239        config.cli.backend = "pi".to_string();
2240        config.event_loop.context_window_tokens = None;
2241        assert_eq!(resolve_context_window(&config), 200_000);
2242    }
2243
2244    #[test]
2245    fn test_resolve_context_window_unknown_backend_is_zero() {
2246        let mut config = RalphConfig::default();
2247        config.cli.backend = "gemini".to_string();
2248        config.event_loop.context_window_tokens = None;
2249        assert_eq!(resolve_context_window(&config), 0);
2250    }
2251
2252    #[test]
2253    fn test_resolve_context_window_override_beats_unknown_backend() {
2254        // Explicit override wins even for backends without a default window.
2255        let mut config = RalphConfig::default();
2256        config.cli.backend = "kiro".to_string();
2257        config.event_loop.context_window_tokens = Some(128_000);
2258        assert_eq!(resolve_context_window(&config), 128_000);
2259    }
2260
2261    #[test]
2262    fn test_resolve_context_window_for_effective_hat_backend() {
2263        let mut config = RalphConfig::default();
2264        config.cli.backend = "kiro".to_string();
2265        config.event_loop.context_window_tokens = None;
2266
2267        assert_eq!(resolve_context_window(&config), 0);
2268        assert_eq!(resolve_context_window_for_backend(&config, "pi"), 200_000);
2269        assert_eq!(
2270            resolve_context_window_for_backend(&config, "claude"),
2271            200_000
2272        );
2273        assert_eq!(resolve_context_window_for_backend(&config, "gemini"), 0);
2274
2275        config.event_loop.context_window_tokens = Some(1_000_000);
2276        assert_eq!(
2277            resolve_context_window_for_backend(&config, "gemini"),
2278            1_000_000
2279        );
2280    }
2281
2282    #[test]
2283    fn test_parse_yaml_with_custom_hats() {
2284        let yaml = r#"
2285event_loop:
2286  prompt_file: "TASK.md"
2287  completion_promise: "DONE"
2288  max_iterations: 50
2289cli:
2290  backend: "claude"
2291hats:
2292  implementer:
2293    name: "Implementer"
2294    triggers: ["task.*", "review.done"]
2295    publishes: ["impl.done"]
2296    instructions: "You are the implementation agent."
2297"#;
2298        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2299        // Custom hats are defined
2300        assert_eq!(config.hats.len(), 1);
2301        assert_eq!(config.event_loop.prompt_file, "TASK.md");
2302
2303        let hat = config.hats.get("implementer").unwrap();
2304        assert_eq!(hat.triggers.len(), 2);
2305    }
2306
2307    #[test]
2308    fn test_preflight_config_deserialize() {
2309        let yaml = r#"
2310features:
2311  preflight:
2312    enabled: true
2313    strict: true
2314    skip: ["telegram", "git"]
2315"#;
2316        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2317        assert!(config.features.preflight.enabled);
2318        assert!(config.features.preflight.strict);
2319        assert_eq!(
2320            config.features.preflight.skip,
2321            vec!["telegram".to_string(), "git".to_string()]
2322        );
2323    }
2324
2325    #[test]
2326    fn test_parse_yaml_v1_format() {
2327        // V1 flat format - identical to Python v1.x config
2328        let yaml = r#"
2329agent: gemini
2330prompt_file: "TASK.md"
2331completion_promise: "RALPH_DONE"
2332max_iterations: 75
2333max_runtime: 7200
2334max_cost: 10.0
2335verbose: true
2336"#;
2337        let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2338
2339        // Before normalization, v2 fields have defaults
2340        assert_eq!(config.cli.backend, "claude"); // default
2341        assert_eq!(config.event_loop.max_iterations, 100); // default
2342
2343        // Normalize v1 -> v2
2344        config.normalize();
2345
2346        // After normalization, v2 fields have v1 values
2347        assert_eq!(config.cli.backend, "gemini");
2348        assert_eq!(config.event_loop.prompt_file, "TASK.md");
2349        assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
2350        assert_eq!(config.event_loop.max_iterations, 75);
2351        assert_eq!(config.event_loop.max_runtime_seconds, 7200);
2352        assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
2353        assert!(config.verbose);
2354    }
2355
2356    #[test]
2357    fn test_agent_priority() {
2358        let yaml = r"
2359agent: auto
2360agent_priority: [gemini, claude, codex]
2361";
2362        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2363        let priority = config.get_agent_priority();
2364        assert_eq!(priority, vec!["gemini", "claude", "codex"]);
2365    }
2366
2367    #[test]
2368    fn test_default_agent_priority() {
2369        let config = RalphConfig::default();
2370        let priority = config.get_agent_priority();
2371        assert_eq!(
2372            priority,
2373            vec![
2374                "claude", "kiro", "kiro-acp", "gemini", "codex", "forge", "amp", "copilot",
2375                "opencode", "pi", "roo"
2376            ]
2377        );
2378    }
2379
2380    #[test]
2381    fn test_validate_deferred_features() {
2382        let yaml = r"
2383archive_prompts: true
2384enable_metrics: true
2385";
2386        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2387        let warnings = config.validate().unwrap();
2388
2389        assert_eq!(warnings.len(), 2);
2390        assert!(warnings
2391            .iter()
2392            .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
2393        assert!(warnings
2394            .iter()
2395            .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
2396    }
2397
2398    #[test]
2399    fn test_validate_dropped_fields() {
2400        let yaml = r#"
2401max_tokens: 4096
2402retry_delay: 5
2403adapters:
2404  claude:
2405    tool_permissions: ["read", "write"]
2406"#;
2407        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2408        let warnings = config.validate().unwrap();
2409
2410        assert_eq!(warnings.len(), 3);
2411        assert!(warnings.iter().any(
2412            |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
2413        ));
2414        assert!(warnings.iter().any(
2415            |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
2416        ));
2417        assert!(warnings
2418            .iter()
2419            .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
2420    }
2421
2422    #[test]
2423    fn test_suppress_warnings() {
2424        let yaml = r"
2425_suppress_warnings: true
2426archive_prompts: true
2427max_tokens: 4096
2428";
2429        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2430        let warnings = config.validate().unwrap();
2431
2432        // All warnings should be suppressed
2433        assert!(warnings.is_empty());
2434    }
2435
2436    #[test]
2437    fn test_adapter_settings() {
2438        let yaml = r"
2439adapters:
2440  claude:
2441    timeout: 600
2442    enabled: true
2443  gemini:
2444    timeout: 300
2445    enabled: false
2446";
2447        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2448
2449        let claude = config.adapter_settings("claude");
2450        assert_eq!(claude.timeout, 600);
2451        assert!(claude.enabled);
2452
2453        let gemini = config.adapter_settings("gemini");
2454        assert_eq!(gemini.timeout, 300);
2455        assert!(!gemini.enabled);
2456    }
2457
2458    #[test]
2459    fn test_unknown_fields_ignored() {
2460        // Unknown fields should be silently ignored (forward compatibility)
2461        let yaml = r#"
2462agent: claude
2463unknown_field: "some value"
2464future_feature: true
2465"#;
2466        let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
2467        // Should parse successfully, ignoring unknown fields
2468        assert!(result.is_ok());
2469    }
2470
2471    #[test]
2472    fn test_custom_backend_args_shorthand() {
2473        let yaml = r#"
2474hats:
2475  opencode_builder:
2476    name: "Opencode"
2477    description: "Opencode hat"
2478    backend: "opencode"
2479    args: ["-m", "model"]
2480"#;
2481        let config = RalphConfig::parse_yaml(yaml).unwrap();
2482        let hat = config.hats.get("opencode_builder").unwrap();
2483        assert!(hat.backend_args.is_some());
2484        assert_eq!(
2485            hat.backend_args.as_ref().unwrap(),
2486            &vec!["-m".to_string(), "model".to_string()]
2487        );
2488    }
2489
2490    #[test]
2491    fn test_custom_backend_args_explicit_key() {
2492        let yaml = r#"
2493hats:
2494  opencode_builder:
2495    name: "Opencode"
2496    description: "Opencode hat"
2497    backend: "opencode"
2498    backend_args: ["-m", "model"]
2499"#;
2500        let config = RalphConfig::parse_yaml(yaml).unwrap();
2501        let hat = config.hats.get("opencode_builder").unwrap();
2502        assert!(hat.backend_args.is_some());
2503        assert_eq!(
2504            hat.backend_args.as_ref().unwrap(),
2505            &vec!["-m".to_string(), "model".to_string()]
2506        );
2507    }
2508
2509    #[test]
2510    fn test_project_key_rejected() {
2511        let yaml = r#"
2512project:
2513  specs_dir: "my_specs"
2514"#;
2515        let result = RalphConfig::parse_yaml(yaml);
2516        assert!(result.is_err());
2517        assert!(matches!(
2518            result.unwrap_err(),
2519            ConfigError::DeprecatedProjectKey
2520        ));
2521    }
2522
2523    #[test]
2524    fn test_ambiguous_routing_rejected() {
2525        // Per spec: "Every trigger maps to exactly one hat | No ambiguous routing"
2526        // Note: using semantic events since task.start is reserved
2527        let yaml = r#"
2528hats:
2529  planner:
2530    name: "Planner"
2531    description: "Plans tasks"
2532    triggers: ["planning.start", "build.done"]
2533  builder:
2534    name: "Builder"
2535    description: "Builds code"
2536    triggers: ["build.task", "build.done"]
2537"#;
2538        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2539        let result = config.validate();
2540
2541        assert!(result.is_err());
2542        let err = result.unwrap_err();
2543        assert!(
2544            matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
2545            "Expected AmbiguousRouting error for 'build.done', got: {:?}",
2546            err
2547        );
2548    }
2549
2550    #[test]
2551    fn test_unique_triggers_accepted() {
2552        // Valid config: each trigger maps to exactly one hat
2553        // Note: task.start is reserved for Ralph, so use semantic events
2554        let yaml = r#"
2555hats:
2556  planner:
2557    name: "Planner"
2558    description: "Plans tasks"
2559    triggers: ["planning.start", "build.done", "build.blocked"]
2560  builder:
2561    name: "Builder"
2562    description: "Builds code"
2563    triggers: ["build.task"]
2564"#;
2565        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2566        let result = config.validate();
2567
2568        assert!(
2569            result.is_ok(),
2570            "Expected valid config, got: {:?}",
2571            result.unwrap_err()
2572        );
2573    }
2574
2575    #[test]
2576    fn test_reserved_trigger_task_start_rejected() {
2577        // Per design: task.start is reserved for Ralph (the coordinator)
2578        let yaml = r#"
2579hats:
2580  my_hat:
2581    name: "My Hat"
2582    description: "Test hat"
2583    triggers: ["task.start"]
2584"#;
2585        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2586        let result = config.validate();
2587
2588        assert!(result.is_err());
2589        let err = result.unwrap_err();
2590        assert!(
2591            matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
2592                if trigger == "task.start" && hat == "my_hat"),
2593            "Expected ReservedTrigger error for 'task.start', got: {:?}",
2594            err
2595        );
2596    }
2597
2598    #[test]
2599    fn test_reserved_trigger_task_resume_rejected() {
2600        // Per design: task.resume is reserved for Ralph (the coordinator)
2601        let yaml = r#"
2602hats:
2603  my_hat:
2604    name: "My Hat"
2605    description: "Test hat"
2606    triggers: ["task.resume", "other.event"]
2607"#;
2608        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2609        let result = config.validate();
2610
2611        assert!(result.is_err());
2612        let err = result.unwrap_err();
2613        assert!(
2614            matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
2615                if trigger == "task.resume" && hat == "my_hat"),
2616            "Expected ReservedTrigger error for 'task.resume', got: {:?}",
2617            err
2618        );
2619    }
2620
2621    #[test]
2622    fn test_missing_description_rejected() {
2623        // Description is required for all hats
2624        let yaml = r#"
2625hats:
2626  my_hat:
2627    name: "My Hat"
2628    triggers: ["build.task"]
2629"#;
2630        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2631        let result = config.validate();
2632
2633        assert!(result.is_err());
2634        let err = result.unwrap_err();
2635        assert!(
2636            matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
2637            "Expected MissingDescription error, got: {:?}",
2638            err
2639        );
2640    }
2641
2642    #[test]
2643    fn test_empty_description_rejected() {
2644        // Empty description should also be rejected
2645        let yaml = r#"
2646hats:
2647  my_hat:
2648    name: "My Hat"
2649    description: "   "
2650    triggers: ["build.task"]
2651"#;
2652        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2653        let result = config.validate();
2654
2655        assert!(result.is_err());
2656        let err = result.unwrap_err();
2657        assert!(
2658            matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
2659            "Expected MissingDescription error for empty description, got: {:?}",
2660            err
2661        );
2662    }
2663
2664    #[test]
2665    fn test_core_config_defaults() {
2666        let config = RalphConfig::default();
2667        assert_eq!(config.core.scratchpad, ScratchpadConfig::default());
2668        assert_eq!(config.core.scratchpad.path, ".ralph/agent/scratchpad.md");
2669        assert!(config.core.scratchpad.enabled);
2670        assert_eq!(config.core.specs_dir, ".ralph/specs/");
2671        // Default guardrails per spec
2672        assert_eq!(config.core.guardrails.len(), 6);
2673        assert!(config.core.guardrails[0].contains("Fresh context"));
2674        assert!(config.core.guardrails[1].contains("search first"));
2675        assert!(config.core.guardrails[2].contains("Backpressure"));
2676        assert!(config.core.guardrails[3].contains("strongest available harness"));
2677        assert!(config.core.guardrails[4].contains("Confidence protocol"));
2678        assert!(config.core.guardrails[5].contains("Commit atomically"));
2679    }
2680
2681    #[test]
2682    fn test_core_config_customizable() {
2683        let yaml = r#"
2684core:
2685  scratchpad: ".workspace/plan.md"
2686  specs_dir: "./specifications/"
2687"#;
2688        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2689        assert_eq!(config.core.scratchpad.path, ".workspace/plan.md");
2690        assert!(config.core.scratchpad.enabled);
2691        assert_eq!(config.core.specs_dir, "./specifications/");
2692        // Guardrails should use defaults when not specified
2693        assert_eq!(config.core.guardrails.len(), 6);
2694    }
2695
2696    #[test]
2697    fn test_core_config_custom_guardrails() {
2698        let yaml = r#"
2699core:
2700  scratchpad: ".ralph/agent/scratchpad.md"
2701  specs_dir: "./specs/"
2702  guardrails:
2703    - "Custom rule one"
2704    - "Custom rule two"
2705"#;
2706        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2707        assert_eq!(config.core.guardrails.len(), 2);
2708        assert_eq!(config.core.guardrails[0], "Custom rule one");
2709        assert_eq!(config.core.guardrails[1], "Custom rule two");
2710    }
2711
2712    #[test]
2713    fn test_prompt_and_prompt_file_mutually_exclusive() {
2714        // Both prompt and prompt_file specified in config should error
2715        let yaml = r#"
2716event_loop:
2717  prompt: "inline text"
2718  prompt_file: "custom.md"
2719"#;
2720        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2721        let result = config.validate();
2722
2723        assert!(result.is_err());
2724        let err = result.unwrap_err();
2725        assert!(
2726            matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
2727                if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
2728            "Expected MutuallyExclusive error, got: {:?}",
2729            err
2730        );
2731    }
2732
2733    #[test]
2734    fn test_prompt_with_default_prompt_file_allowed() {
2735        // Having inline prompt with default prompt_file value should be OK
2736        let yaml = r#"
2737event_loop:
2738  prompt: "inline text"
2739"#;
2740        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2741        let result = config.validate();
2742
2743        assert!(
2744            result.is_ok(),
2745            "Should allow inline prompt with default prompt_file"
2746        );
2747        assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
2748        assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
2749    }
2750
2751    #[test]
2752    fn test_custom_backend_requires_command() {
2753        // Custom backend without command should error
2754        let yaml = r#"
2755cli:
2756  backend: "custom"
2757"#;
2758        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2759        let result = config.validate();
2760
2761        assert!(result.is_err());
2762        let err = result.unwrap_err();
2763        assert!(
2764            matches!(&err, ConfigError::CustomBackendRequiresCommand),
2765            "Expected CustomBackendRequiresCommand error, got: {:?}",
2766            err
2767        );
2768    }
2769
2770    #[test]
2771    fn test_empty_completion_promise_rejected() {
2772        let yaml = r#"
2773event_loop:
2774  completion_promise: "   "
2775"#;
2776        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2777        let result = config.validate();
2778
2779        assert!(result.is_err());
2780        let err = result.unwrap_err();
2781        assert!(
2782            matches!(&err, ConfigError::InvalidCompletionPromise),
2783            "Expected InvalidCompletionPromise error, got: {:?}",
2784            err
2785        );
2786    }
2787
2788    #[test]
2789    fn test_custom_backend_with_empty_command_errors() {
2790        // Custom backend with empty command should error
2791        let yaml = r#"
2792cli:
2793  backend: "custom"
2794  command: ""
2795"#;
2796        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2797        let result = config.validate();
2798
2799        assert!(result.is_err());
2800        let err = result.unwrap_err();
2801        assert!(
2802            matches!(&err, ConfigError::CustomBackendRequiresCommand),
2803            "Expected CustomBackendRequiresCommand error, got: {:?}",
2804            err
2805        );
2806    }
2807
2808    #[test]
2809    fn test_custom_backend_with_command_succeeds() {
2810        // Custom backend with valid command should pass validation
2811        let yaml = r#"
2812cli:
2813  backend: "custom"
2814  command: "my-agent"
2815"#;
2816        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2817        let result = config.validate();
2818
2819        assert!(
2820            result.is_ok(),
2821            "Should allow custom backend with command: {:?}",
2822            result.unwrap_err()
2823        );
2824    }
2825
2826    #[test]
2827    fn test_custom_backend_requires_command_message_actionable() {
2828        let err = ConfigError::CustomBackendRequiresCommand;
2829        let msg = err.to_string();
2830        assert!(msg.contains("cli.command"));
2831        assert!(msg.contains("ralph init --backend custom"));
2832        assert!(msg.contains("docs/reference/troubleshooting.md#custom-backend-command"));
2833    }
2834
2835    #[test]
2836    fn test_reserved_trigger_message_actionable() {
2837        let err = ConfigError::ReservedTrigger {
2838            trigger: "task.start".to_string(),
2839            hat: "builder".to_string(),
2840        };
2841        let msg = err.to_string();
2842        assert!(msg.contains("Reserved trigger"));
2843        assert!(msg.contains("docs/reference/troubleshooting.md#reserved-trigger"));
2844    }
2845
2846    #[test]
2847    fn test_prompt_file_with_no_inline_allowed() {
2848        // Having only prompt_file specified should be OK
2849        let yaml = r#"
2850event_loop:
2851  prompt_file: "custom.md"
2852"#;
2853        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2854        let result = config.validate();
2855
2856        assert!(
2857            result.is_ok(),
2858            "Should allow prompt_file without inline prompt"
2859        );
2860        assert_eq!(config.event_loop.prompt, None);
2861        assert_eq!(config.event_loop.prompt_file, "custom.md");
2862    }
2863
2864    #[test]
2865    fn test_default_prompt_file_value() {
2866        let config = RalphConfig::default();
2867        assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
2868        assert_eq!(config.event_loop.prompt, None);
2869    }
2870
2871    #[test]
2872    fn test_tui_config_default() {
2873        let config = RalphConfig::default();
2874        assert_eq!(config.tui.prefix_key, "ctrl-a");
2875    }
2876
2877    #[test]
2878    fn test_tui_config_parse_ctrl_b() {
2879        let yaml = r#"
2880tui:
2881  prefix_key: "ctrl-b"
2882"#;
2883        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2884        let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
2885
2886        use crossterm::event::{KeyCode, KeyModifiers};
2887        assert_eq!(key_code, KeyCode::Char('b'));
2888        assert_eq!(key_modifiers, KeyModifiers::CONTROL);
2889    }
2890
2891    #[test]
2892    fn test_tui_config_parse_invalid_format() {
2893        let tui_config = TuiConfig {
2894            prefix_key: "invalid".to_string(),
2895        };
2896        let result = tui_config.parse_prefix();
2897        assert!(result.is_err());
2898        assert!(result.unwrap_err().contains("Invalid prefix_key format"));
2899    }
2900
2901    #[test]
2902    fn test_tui_config_parse_invalid_modifier() {
2903        let tui_config = TuiConfig {
2904            prefix_key: "alt-a".to_string(),
2905        };
2906        let result = tui_config.parse_prefix();
2907        assert!(result.is_err());
2908        assert!(result.unwrap_err().contains("Invalid modifier"));
2909    }
2910
2911    #[test]
2912    fn test_tui_config_parse_invalid_key() {
2913        let tui_config = TuiConfig {
2914            prefix_key: "ctrl-abc".to_string(),
2915        };
2916        let result = tui_config.parse_prefix();
2917        assert!(result.is_err());
2918        assert!(result.unwrap_err().contains("Invalid key"));
2919    }
2920
2921    #[test]
2922    fn test_hat_backend_named() {
2923        let yaml = r#""claude""#;
2924        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2925        assert_eq!(backend.to_cli_backend(), "claude");
2926        match backend {
2927            HatBackend::Named(name) => assert_eq!(name, "claude"),
2928            _ => panic!("Expected Named variant"),
2929        }
2930    }
2931
2932    #[test]
2933    fn test_hat_backend_kiro_agent() {
2934        let yaml = r#"
2935type: "kiro"
2936agent: "builder"
2937"#;
2938        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2939        assert_eq!(backend.to_cli_backend(), "kiro");
2940        match backend {
2941            HatBackend::KiroAgent {
2942                backend_type,
2943                agent,
2944                args,
2945            } => {
2946                assert_eq!(backend_type, "kiro");
2947                assert_eq!(agent, "builder");
2948                assert!(args.is_empty());
2949            }
2950            _ => panic!("Expected KiroAgent variant"),
2951        }
2952    }
2953
2954    #[test]
2955    fn test_hat_backend_kiro_agent_with_args() {
2956        let yaml = r#"
2957type: "kiro"
2958agent: "builder"
2959args: ["--verbose", "--debug"]
2960"#;
2961        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2962        assert_eq!(backend.to_cli_backend(), "kiro");
2963        match backend {
2964            HatBackend::KiroAgent {
2965                backend_type,
2966                agent,
2967                args,
2968            } => {
2969                assert_eq!(backend_type, "kiro");
2970                assert_eq!(agent, "builder");
2971                assert_eq!(args, vec!["--verbose", "--debug"]);
2972            }
2973            _ => panic!("Expected KiroAgent variant"),
2974        }
2975    }
2976
2977    #[test]
2978    fn test_hat_backend_named_with_args() {
2979        let yaml = r#"
2980type: "claude"
2981args: ["--model", "claude-sonnet-4"]
2982"#;
2983        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2984        assert_eq!(backend.to_cli_backend(), "claude");
2985        match backend {
2986            HatBackend::NamedWithArgs { backend_type, args } => {
2987                assert_eq!(backend_type, "claude");
2988                assert_eq!(args, vec!["--model", "claude-sonnet-4"]);
2989            }
2990            _ => panic!("Expected NamedWithArgs variant"),
2991        }
2992    }
2993
2994    #[test]
2995    fn test_hat_backend_named_with_args_empty() {
2996        // type: claude without args should still work (NamedWithArgs with empty args)
2997        let yaml = r#"
2998type: "gemini"
2999"#;
3000        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
3001        assert_eq!(backend.to_cli_backend(), "gemini");
3002        match backend {
3003            HatBackend::NamedWithArgs { backend_type, args } => {
3004                assert_eq!(backend_type, "gemini");
3005                assert!(args.is_empty());
3006            }
3007            _ => panic!("Expected NamedWithArgs variant"),
3008        }
3009    }
3010
3011    #[test]
3012    fn test_hat_backend_custom() {
3013        let yaml = r#"
3014command: "/usr/bin/my-agent"
3015args: ["--flag", "value"]
3016"#;
3017        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
3018        assert_eq!(backend.to_cli_backend(), "custom");
3019        match backend {
3020            HatBackend::Custom { command, args } => {
3021                assert_eq!(command, "/usr/bin/my-agent");
3022                assert_eq!(args, vec!["--flag", "value"]);
3023            }
3024            _ => panic!("Expected Custom variant"),
3025        }
3026    }
3027
3028    #[test]
3029    fn test_hat_config_with_backend() {
3030        let yaml = r#"
3031name: "Custom Builder"
3032triggers: ["build.task"]
3033publishes: ["build.done"]
3034instructions: "Build stuff"
3035backend: "gemini"
3036default_publishes: "task.done"
3037"#;
3038        let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
3039        assert_eq!(hat.name, "Custom Builder");
3040        assert!(hat.backend.is_some());
3041        match hat.backend.unwrap() {
3042            HatBackend::Named(name) => assert_eq!(name, "gemini"),
3043            _ => panic!("Expected Named backend"),
3044        }
3045        assert_eq!(hat.default_publishes, Some("task.done".to_string()));
3046    }
3047
3048    #[test]
3049    fn test_hat_config_without_backend() {
3050        let yaml = r#"
3051name: "Default Hat"
3052triggers: ["task.start"]
3053publishes: ["task.done"]
3054instructions: "Do work"
3055"#;
3056        let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
3057        assert_eq!(hat.name, "Default Hat");
3058        assert!(hat.backend.is_none());
3059        assert!(hat.default_publishes.is_none());
3060    }
3061
3062    #[test]
3063    fn test_mixed_backends_config() {
3064        let yaml = r#"
3065event_loop:
3066  prompt_file: "TASK.md"
3067  max_iterations: 50
3068
3069cli:
3070  backend: "claude"
3071
3072hats:
3073  planner:
3074    name: "Planner"
3075    triggers: ["task.start"]
3076    publishes: ["build.task"]
3077    instructions: "Plan the work"
3078    backend: "claude"
3079    
3080  builder:
3081    name: "Builder"
3082    triggers: ["build.task"]
3083    publishes: ["build.done"]
3084    instructions: "Build the thing"
3085    backend:
3086      type: "kiro"
3087      agent: "builder"
3088      
3089  reviewer:
3090    name: "Reviewer"
3091    triggers: ["build.done"]
3092    publishes: ["review.complete"]
3093    instructions: "Review the work"
3094    backend:
3095      command: "/usr/local/bin/custom-agent"
3096      args: ["--mode", "review"]
3097    default_publishes: "review.complete"
3098"#;
3099        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3100        assert_eq!(config.hats.len(), 3);
3101
3102        // Check planner (Named backend)
3103        let planner = config.hats.get("planner").unwrap();
3104        assert!(planner.backend.is_some());
3105        match planner.backend.as_ref().unwrap() {
3106            HatBackend::Named(name) => assert_eq!(name, "claude"),
3107            _ => panic!("Expected Named backend for planner"),
3108        }
3109
3110        // Check builder (KiroAgent backend)
3111        let builder = config.hats.get("builder").unwrap();
3112        assert!(builder.backend.is_some());
3113        match builder.backend.as_ref().unwrap() {
3114            HatBackend::KiroAgent {
3115                backend_type,
3116                agent,
3117                args,
3118            } => {
3119                assert_eq!(backend_type, "kiro");
3120                assert_eq!(agent, "builder");
3121                assert!(args.is_empty());
3122            }
3123            _ => panic!("Expected KiroAgent backend for builder"),
3124        }
3125
3126        // Check reviewer (Custom backend)
3127        let reviewer = config.hats.get("reviewer").unwrap();
3128        assert!(reviewer.backend.is_some());
3129        match reviewer.backend.as_ref().unwrap() {
3130            HatBackend::Custom { command, args } => {
3131                assert_eq!(command, "/usr/local/bin/custom-agent");
3132                assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
3133            }
3134            _ => panic!("Expected Custom backend for reviewer"),
3135        }
3136        assert_eq!(
3137            reviewer.default_publishes,
3138            Some("review.complete".to_string())
3139        );
3140    }
3141
3142    #[test]
3143    fn test_features_config_auto_merge_defaults_to_false() {
3144        // Per spec: auto_merge should default to false for safety
3145        // This prevents automatic merging of parallel loop branches
3146        let config = RalphConfig::default();
3147        assert!(
3148            !config.features.auto_merge,
3149            "auto_merge should default to false"
3150        );
3151    }
3152
3153    #[test]
3154    fn test_features_config_auto_merge_from_yaml() {
3155        // Users can opt into auto_merge via config
3156        let yaml = r"
3157features:
3158  auto_merge: true
3159";
3160        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3161        assert!(
3162            config.features.auto_merge,
3163            "auto_merge should be true when configured"
3164        );
3165    }
3166
3167    #[test]
3168    fn test_features_config_auto_merge_false_from_yaml() {
3169        // Explicit false should work too
3170        let yaml = r"
3171features:
3172  auto_merge: false
3173";
3174        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3175        assert!(
3176            !config.features.auto_merge,
3177            "auto_merge should be false when explicitly configured"
3178        );
3179    }
3180
3181    #[test]
3182    fn test_features_config_preserves_parallel_when_adding_auto_merge() {
3183        // Ensure adding auto_merge doesn't break existing parallel feature
3184        let yaml = r"
3185features:
3186  parallel: false
3187  auto_merge: true
3188";
3189        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3190        assert!(!config.features.parallel, "parallel should be false");
3191        assert!(config.features.auto_merge, "auto_merge should be true");
3192    }
3193
3194    #[test]
3195    fn test_skills_config_defaults_when_absent() {
3196        // Configs without a skills: section should still parse (backwards compat)
3197        let yaml = r"
3198agent: claude
3199";
3200        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3201        assert!(config.skills.enabled);
3202        assert!(config.skills.dirs.is_empty());
3203        assert!(config.skills.overrides.is_empty());
3204    }
3205
3206    #[test]
3207    fn test_skills_config_deserializes_all_fields() {
3208        let yaml = r#"
3209skills:
3210  enabled: true
3211  dirs:
3212    - ".claude/skills"
3213    - "/shared/skills"
3214  overrides:
3215    pdd:
3216      enabled: false
3217    memories:
3218      auto_inject: true
3219      hats: ["ralph"]
3220      backends: ["claude"]
3221      tags: ["core"]
3222"#;
3223        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3224        assert!(config.skills.enabled);
3225        assert_eq!(config.skills.dirs.len(), 2);
3226        assert_eq!(
3227            config.skills.dirs[0],
3228            std::path::PathBuf::from(".claude/skills")
3229        );
3230        assert_eq!(config.skills.overrides.len(), 2);
3231
3232        let pdd = config.skills.overrides.get("pdd").unwrap();
3233        assert_eq!(pdd.enabled, Some(false));
3234
3235        let memories = config.skills.overrides.get("memories").unwrap();
3236        assert_eq!(memories.auto_inject, Some(true));
3237        assert_eq!(memories.hats, vec!["ralph"]);
3238        assert_eq!(memories.backends, vec!["claude"]);
3239        assert_eq!(memories.tags, vec!["core"]);
3240    }
3241
3242    #[test]
3243    fn test_skills_config_disabled() {
3244        let yaml = r"
3245skills:
3246  enabled: false
3247";
3248        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3249        assert!(!config.skills.enabled);
3250        assert!(config.skills.dirs.is_empty());
3251    }
3252
3253    #[test]
3254    fn test_skill_override_partial_fields() {
3255        let yaml = r#"
3256skills:
3257  overrides:
3258    my-skill:
3259      hats: ["builder", "reviewer"]
3260"#;
3261        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3262        let override_ = config.skills.overrides.get("my-skill").unwrap();
3263        assert_eq!(override_.enabled, None);
3264        assert_eq!(override_.auto_inject, None);
3265        assert_eq!(override_.hats, vec!["builder", "reviewer"]);
3266        assert!(override_.backends.is_empty());
3267        assert!(override_.tags.is_empty());
3268    }
3269
3270    #[test]
3271    fn test_hooks_config_valid_yaml_parses_and_validates() {
3272        let yaml = r#"
3273hooks:
3274  enabled: true
3275  defaults:
3276    timeout_seconds: 45
3277    max_output_bytes: 16384
3278    suspend_mode: wait_for_resume
3279  events:
3280    pre.loop.start:
3281      - name: env-guard
3282        command: ["./scripts/hooks/env-guard.sh", "--check"]
3283        on_error: block
3284    post.loop.complete:
3285      - name: notify
3286        command: ["./scripts/hooks/notify.sh"]
3287        on_error: warn
3288        mutate:
3289          enabled: true
3290          format: json
3291"#;
3292        let config = RalphConfig::parse_yaml(yaml).unwrap();
3293
3294        assert!(config.hooks.enabled);
3295        assert_eq!(config.hooks.defaults.timeout_seconds, 45);
3296        assert_eq!(config.hooks.defaults.max_output_bytes, 16384);
3297        assert_eq!(config.hooks.events.len(), 2);
3298
3299        let warnings = config.validate().unwrap();
3300        assert!(warnings.is_empty());
3301    }
3302
3303    #[test]
3304    fn test_hooks_parse_rejects_invalid_phase_event_key() {
3305        let yaml = r#"
3306hooks:
3307  enabled: true
3308  events:
3309    pre.loop.launch:
3310      - name: bad-phase
3311        command: ["./scripts/hooks/bad-phase.sh"]
3312        on_error: warn
3313"#;
3314
3315        let result = RalphConfig::parse_yaml(yaml);
3316        assert!(result.is_err());
3317
3318        let err = result.unwrap_err();
3319        assert!(matches!(
3320            &err,
3321            ConfigError::InvalidHookPhaseEvent { phase_event }
3322            if phase_event == "pre.loop.launch"
3323        ));
3324    }
3325
3326    #[test]
3327    fn test_hooks_parse_rejects_backpressure_phase_event_keys_in_v1() {
3328        let yaml = r#"
3329hooks:
3330  enabled: true
3331  events:
3332    pre.backpressure.triggered:
3333      - name: unsupported-backpressure
3334        command: ["./scripts/hooks/backpressure.sh"]
3335        on_error: warn
3336"#;
3337
3338        let result = RalphConfig::parse_yaml(yaml);
3339        assert!(result.is_err());
3340
3341        let err = result.unwrap_err();
3342        assert!(matches!(
3343            &err,
3344            ConfigError::InvalidHookPhaseEvent { phase_event }
3345            if phase_event == "pre.backpressure.triggered"
3346        ));
3347
3348        let message = err.to_string();
3349        assert!(message.contains("Supported v1 phase-events"));
3350        assert!(message.contains("pre.plan.created"));
3351        assert!(message.contains("post.loop.error"));
3352    }
3353
3354    #[test]
3355    fn test_hooks_parse_rejects_invalid_on_error_enum_value() {
3356        let yaml = r#"
3357hooks:
3358  enabled: true
3359  events:
3360    pre.loop.start:
3361      - name: bad-on-error
3362        command: ["./scripts/hooks/bad-on-error.sh"]
3363        on_error: explode
3364"#;
3365
3366        let result = RalphConfig::parse_yaml(yaml);
3367        assert!(result.is_err());
3368
3369        let err = result.unwrap_err();
3370        assert!(matches!(&err, ConfigError::Yaml(_)));
3371
3372        let message = err.to_string();
3373        assert!(message.contains("unknown variant `explode`"));
3374        assert!(message.contains("warn"));
3375        assert!(message.contains("block"));
3376        assert!(message.contains("suspend"));
3377    }
3378
3379    #[test]
3380    fn test_hooks_validate_rejects_missing_name() {
3381        let yaml = r#"
3382hooks:
3383  enabled: true
3384  events:
3385    pre.loop.start:
3386      - command: ["./scripts/hooks/no-name.sh"]
3387        on_error: block
3388"#;
3389        let config = RalphConfig::parse_yaml(yaml).unwrap();
3390
3391        let result = config.validate();
3392        assert!(result.is_err());
3393
3394        let err = result.unwrap_err();
3395        assert!(matches!(
3396            &err,
3397            ConfigError::HookValidation { field, .. }
3398            if field == "hooks.events.pre.loop.start[0].name"
3399        ));
3400    }
3401
3402    #[test]
3403    fn test_hooks_validate_rejects_missing_command() {
3404        let yaml = r"
3405hooks:
3406  enabled: true
3407  events:
3408    pre.loop.start:
3409      - name: missing-command
3410        on_error: block
3411";
3412        let config = RalphConfig::parse_yaml(yaml).unwrap();
3413
3414        let result = config.validate();
3415        assert!(result.is_err());
3416
3417        let err = result.unwrap_err();
3418        assert!(matches!(
3419            &err,
3420            ConfigError::HookValidation { field, .. }
3421            if field == "hooks.events.pre.loop.start[0].command"
3422        ));
3423    }
3424
3425    #[test]
3426    fn test_hooks_validate_rejects_missing_on_error() {
3427        let yaml = r#"
3428hooks:
3429  enabled: true
3430  events:
3431    pre.loop.start:
3432      - name: missing-on-error
3433        command: ["./scripts/hooks/no-on-error.sh"]
3434"#;
3435        let config = RalphConfig::parse_yaml(yaml).unwrap();
3436
3437        let result = config.validate();
3438        assert!(result.is_err());
3439
3440        let err = result.unwrap_err();
3441        assert!(matches!(
3442            &err,
3443            ConfigError::HookValidation { field, .. }
3444            if field == "hooks.events.pre.loop.start[0].on_error"
3445        ));
3446    }
3447
3448    #[test]
3449    fn test_hooks_validate_rejects_zero_timeout_seconds() {
3450        let yaml = r"
3451hooks:
3452  enabled: true
3453  defaults:
3454    timeout_seconds: 0
3455";
3456        let config = RalphConfig::parse_yaml(yaml).unwrap();
3457
3458        let result = config.validate();
3459        assert!(result.is_err());
3460
3461        let err = result.unwrap_err();
3462        assert!(matches!(
3463            &err,
3464            ConfigError::HookValidation { field, .. }
3465            if field == "hooks.defaults.timeout_seconds"
3466        ));
3467    }
3468
3469    #[test]
3470    fn test_hooks_validate_rejects_zero_max_output_bytes() {
3471        let yaml = r"
3472hooks:
3473  enabled: true
3474  defaults:
3475    max_output_bytes: 0
3476";
3477        let config = RalphConfig::parse_yaml(yaml).unwrap();
3478
3479        let result = config.validate();
3480        assert!(result.is_err());
3481
3482        let err = result.unwrap_err();
3483        assert!(matches!(
3484            &err,
3485            ConfigError::HookValidation { field, .. }
3486            if field == "hooks.defaults.max_output_bytes"
3487        ));
3488    }
3489
3490    #[test]
3491    fn test_hooks_validate_rejects_parallel_non_v1_field() {
3492        let yaml = r"
3493hooks:
3494  enabled: true
3495  parallel: true
3496";
3497        let config = RalphConfig::parse_yaml(yaml).unwrap();
3498
3499        let result = config.validate();
3500        assert!(result.is_err());
3501
3502        let err = result.unwrap_err();
3503        assert!(matches!(
3504            &err,
3505            ConfigError::UnsupportedHookField { field, .. }
3506            if field == "hooks.parallel"
3507        ));
3508    }
3509
3510    #[test]
3511    fn test_hooks_validate_rejects_global_scope_non_v1_field() {
3512        let yaml = r#"
3513hooks:
3514  enabled: true
3515  events:
3516    pre.loop.start:
3517      - name: global-scope
3518        command: ["./scripts/hooks/global.sh"]
3519        on_error: warn
3520        scope: global
3521"#;
3522        let config = RalphConfig::parse_yaml(yaml).unwrap();
3523
3524        let result = config.validate();
3525        assert!(result.is_err());
3526
3527        let err = result.unwrap_err();
3528        assert!(matches!(
3529            &err,
3530            ConfigError::UnsupportedHookField { field, .. }
3531            if field == "hooks.events.pre.loop.start[0].scope"
3532        ));
3533    }
3534
3535    // ─────────────────────────────────────────────────────────────────────────
3536    // ROBOT CONFIG TESTS
3537    // ─────────────────────────────────────────────────────────────────────────
3538
3539    #[test]
3540    fn test_robot_config_defaults_disabled() {
3541        let config = RalphConfig::default();
3542        assert!(!config.robot.enabled);
3543        assert_eq!(config.robot.mode, RobotMode::Telegram);
3544        assert!(config.robot.timeout_seconds.is_none());
3545        assert!(config.robot.telegram.is_none());
3546    }
3547
3548    #[test]
3549    fn test_robot_config_absent_parses_as_default() {
3550        // Existing configs without RObot: section should still parse
3551        let yaml = r"
3552agent: claude
3553";
3554        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3555        assert!(!config.robot.enabled);
3556        assert_eq!(config.robot.mode, RobotMode::Telegram);
3557        assert!(config.robot.timeout_seconds.is_none());
3558    }
3559
3560    #[test]
3561    fn test_robot_config_valid_full() {
3562        let yaml = r#"
3563RObot:
3564  enabled: true
3565  timeout_seconds: 300
3566  telegram:
3567    bot_token: "123456:ABC-DEF"
3568"#;
3569        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3570        assert!(config.robot.enabled);
3571        assert_eq!(config.robot.mode, RobotMode::Telegram);
3572        assert_eq!(config.robot.timeout_seconds, Some(300));
3573        let telegram = config.robot.telegram.as_ref().unwrap();
3574        assert_eq!(telegram.bot_token, Some("123456:ABC-DEF".to_string()));
3575
3576        // Validation should pass
3577        assert!(config.validate().is_ok());
3578    }
3579
3580    #[test]
3581    fn test_robot_config_web_mode_does_not_require_telegram() {
3582        let yaml = r"
3583RObot:
3584  enabled: true
3585  mode: web
3586  timeout_seconds: 0
3587";
3588        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3589        assert!(config.robot.enabled);
3590        assert_eq!(config.robot.mode, RobotMode::Web);
3591        assert_eq!(config.robot.timeout_seconds, Some(0));
3592        assert!(config.robot.telegram.is_none());
3593        assert!(config.validate().is_ok());
3594    }
3595
3596    #[test]
3597    fn test_robot_config_disabled_skips_validation() {
3598        // Disabled RObot config should pass validation even with missing fields
3599        let yaml = r"
3600RObot:
3601  enabled: false
3602";
3603        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3604        assert!(!config.robot.enabled);
3605        assert!(config.validate().is_ok());
3606    }
3607
3608    #[test]
3609    fn test_robot_config_enabled_missing_timeout_fails() {
3610        let yaml = r#"
3611RObot:
3612  enabled: true
3613  telegram:
3614    bot_token: "123456:ABC-DEF"
3615"#;
3616        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3617        let result = config.validate();
3618        assert!(result.is_err());
3619        let err = result.unwrap_err();
3620        assert!(
3621            matches!(&err, ConfigError::RobotMissingField { field, .. }
3622                if field == "RObot.timeout_seconds"),
3623            "Expected RobotMissingField for timeout_seconds, got: {:?}",
3624            err
3625        );
3626    }
3627
3628    #[test]
3629    fn test_robot_config_enabled_missing_timeout_and_token_fails_on_timeout_first() {
3630        // Both timeout and token are missing, but timeout is checked first
3631        let robot = RobotConfig {
3632            enabled: true,
3633            mode: RobotMode::Telegram,
3634            timeout_seconds: None,
3635            checkin_interval_seconds: None,
3636            telegram: None,
3637        };
3638        let result = robot.validate();
3639        assert!(result.is_err());
3640        let err = result.unwrap_err();
3641        assert!(
3642            matches!(&err, ConfigError::RobotMissingField { field, .. }
3643                if field == "RObot.timeout_seconds"),
3644            "Expected timeout validation failure first, got: {:?}",
3645            err
3646        );
3647    }
3648
3649    #[test]
3650    fn test_robot_config_resolve_bot_token_from_config() {
3651        // Config has a token — resolve_bot_token returns it
3652        // (env var behavior is tested separately via integration tests since
3653        // forbid(unsafe_code) prevents env var manipulation in unit tests)
3654        let config = RobotConfig {
3655            enabled: true,
3656            mode: RobotMode::Telegram,
3657            timeout_seconds: Some(300),
3658            checkin_interval_seconds: None,
3659            telegram: Some(TelegramBotConfig {
3660                bot_token: Some("config-token".to_string()),
3661                api_url: None,
3662            }),
3663        };
3664
3665        // When RALPH_TELEGRAM_BOT_TOKEN is not set, config token is returned
3666        // (Can't set/unset env vars in tests due to forbid(unsafe_code))
3667        let resolved = config.resolve_bot_token();
3668        // The result depends on whether RALPH_TELEGRAM_BOT_TOKEN is set in the
3669        // test environment. We can at least assert it's Some.
3670        assert!(resolved.is_some());
3671    }
3672
3673    #[test]
3674    fn test_robot_config_resolve_bot_token_none_without_config() {
3675        // No config token and no telegram section
3676        let config = RobotConfig {
3677            enabled: true,
3678            mode: RobotMode::Telegram,
3679            timeout_seconds: Some(300),
3680            checkin_interval_seconds: None,
3681            telegram: None,
3682        };
3683
3684        // Without env var AND without config token, resolve returns None
3685        // (unless RALPH_TELEGRAM_BOT_TOKEN happens to be set in test env)
3686        let resolved = config.resolve_bot_token();
3687        if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_err() {
3688            assert!(resolved.is_none());
3689        }
3690    }
3691
3692    #[test]
3693    fn test_robot_config_validate_with_config_token() {
3694        // Validation passes when bot_token is in config
3695        let robot = RobotConfig {
3696            enabled: true,
3697            mode: RobotMode::Telegram,
3698            timeout_seconds: Some(300),
3699            checkin_interval_seconds: None,
3700            telegram: Some(TelegramBotConfig {
3701                bot_token: Some("test-token".to_string()),
3702                api_url: None,
3703            }),
3704        };
3705        assert!(robot.validate().is_ok());
3706    }
3707
3708    #[test]
3709    fn test_robot_config_validate_missing_telegram_section() {
3710        // No telegram section at all and no env var → fails
3711        // (Skip if env var happens to be set)
3712        if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
3713            return;
3714        }
3715
3716        let robot = RobotConfig {
3717            enabled: true,
3718            mode: RobotMode::Telegram,
3719            timeout_seconds: Some(300),
3720            checkin_interval_seconds: None,
3721            telegram: None,
3722        };
3723        let result = robot.validate();
3724        assert!(result.is_err());
3725        let err = result.unwrap_err();
3726        assert!(
3727            matches!(&err, ConfigError::RobotMissingField { field, .. }
3728                if field == "RObot.telegram.bot_token"),
3729            "Expected bot_token validation failure, got: {:?}",
3730            err
3731        );
3732    }
3733
3734    #[test]
3735    fn test_robot_config_validate_empty_bot_token() {
3736        // telegram section present but bot_token is None
3737        // (Skip if env var happens to be set)
3738        if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
3739            return;
3740        }
3741
3742        let robot = RobotConfig {
3743            enabled: true,
3744            mode: RobotMode::Telegram,
3745            timeout_seconds: Some(300),
3746            checkin_interval_seconds: None,
3747            telegram: Some(TelegramBotConfig {
3748                bot_token: None,
3749                api_url: None,
3750            }),
3751        };
3752        let result = robot.validate();
3753        assert!(result.is_err());
3754        let err = result.unwrap_err();
3755        assert!(
3756            matches!(&err, ConfigError::RobotMissingField { field, .. }
3757                if field == "RObot.telegram.bot_token"),
3758            "Expected bot_token validation failure, got: {:?}",
3759            err
3760        );
3761    }
3762
3763    #[test]
3764    fn test_extra_instructions_merged_during_normalize() {
3765        let yaml = r#"
3766_fragments:
3767  shared_protocol: &shared_protocol |
3768    ### Shared Protocol
3769    Follow this protocol.
3770
3771hats:
3772  builder:
3773    name: "Builder"
3774    triggers: ["build.start"]
3775    instructions: |
3776      ## BUILDER MODE
3777      Build things.
3778    extra_instructions:
3779      - *shared_protocol
3780"#;
3781        let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3782        let hat = config.hats.get("builder").unwrap();
3783
3784        // Before normalize: extra_instructions has content, instructions does not include it
3785        assert_eq!(hat.extra_instructions.len(), 1);
3786        assert!(!hat.instructions.contains("Shared Protocol"));
3787
3788        config.normalize();
3789
3790        let hat = config.hats.get("builder").unwrap();
3791        // After normalize: extra_instructions drained, instructions includes the fragment
3792        assert!(hat.extra_instructions.is_empty());
3793        assert!(hat.instructions.contains("## BUILDER MODE"));
3794        assert!(hat.instructions.contains("### Shared Protocol"));
3795        assert!(hat.instructions.contains("Follow this protocol."));
3796    }
3797
3798    #[test]
3799    fn test_extra_instructions_empty_by_default() {
3800        let yaml = r#"
3801hats:
3802  simple:
3803    name: "Simple"
3804    triggers: ["start"]
3805    instructions: "Do the thing."
3806"#;
3807        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3808        let hat = config.hats.get("simple").unwrap();
3809        assert!(hat.extra_instructions.is_empty());
3810    }
3811
3812    // === Per-Hat Scratchpad Configuration Tests ===
3813
3814    /// AC1: Legacy plain-string config
3815    #[test]
3816    fn test_scratchpad_legacy_plain_string() {
3817        let yaml = r#"
3818core:
3819  scratchpad: ".workspace/plan.md"
3820"#;
3821        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3822        assert_eq!(
3823            config.core.scratchpad,
3824            ScratchpadConfig {
3825                enabled: true,
3826                path: ".workspace/plan.md".to_string()
3827            }
3828        );
3829    }
3830
3831    /// AC2: Structured config with enabled/path
3832    #[test]
3833    fn test_scratchpad_structured_config() {
3834        let yaml = r#"
3835core:
3836  scratchpad:
3837    enabled: true
3838    path: ".ralph/agent/scratchpad.md"
3839"#;
3840        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3841        assert_eq!(
3842            config.core.scratchpad,
3843            ScratchpadConfig {
3844                enabled: true,
3845                path: ".ralph/agent/scratchpad.md".to_string()
3846            }
3847        );
3848    }
3849
3850    /// AC2 variant: Structured config with enabled: false
3851    #[test]
3852    fn test_scratchpad_structured_disabled() {
3853        let yaml = r"
3854core:
3855  scratchpad:
3856    enabled: false
3857";
3858        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3859        assert!(!config.core.scratchpad.enabled);
3860        assert_eq!(config.core.scratchpad.path, ".ralph/agent/scratchpad.md");
3861    }
3862
3863    /// AC5/AC7: Default config unchanged
3864    #[test]
3865    fn test_scratchpad_default_config() {
3866        let config = RalphConfig::default();
3867        assert_eq!(
3868            config.core.scratchpad,
3869            ScratchpadConfig {
3870                enabled: true,
3871                path: ".ralph/agent/scratchpad.md".to_string()
3872            }
3873        );
3874    }
3875
3876    /// AC8: Hat with plain-string scratchpad shorthand
3877    #[test]
3878    fn test_hat_scratchpad_plain_string() {
3879        let yaml = r#"
3880hats:
3881  planner:
3882    name: "Planner"
3883    triggers: ["plan.start"]
3884    scratchpad: ".ralph/agent/planner.md"
3885"#;
3886        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3887        let hat = config.hats.get("planner").unwrap();
3888        assert_eq!(
3889            hat.scratchpad,
3890            Some(ScratchpadConfig {
3891                enabled: true,
3892                path: ".ralph/agent/planner.md".to_string()
3893            })
3894        );
3895    }
3896
3897    /// AC3 (config part): Hat disables scratchpad
3898    #[test]
3899    fn test_hat_scratchpad_disabled() {
3900        let yaml = r#"
3901hats:
3902  validator:
3903    name: "Validator"
3904    triggers: ["validate.start"]
3905    scratchpad:
3906      enabled: false
3907"#;
3908        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3909        let hat = config.hats.get("validator").unwrap();
3910        assert_eq!(
3911            hat.scratchpad,
3912            Some(ScratchpadConfig {
3913                enabled: false,
3914                path: ".ralph/agent/scratchpad.md".to_string()
3915            })
3916        );
3917    }
3918
3919    /// AC4 (config part): Hat with custom path
3920    #[test]
3921    fn test_hat_scratchpad_custom_path() {
3922        let yaml = r#"
3923hats:
3924  builder:
3925    name: "Builder"
3926    triggers: ["build.start"]
3927    scratchpad:
3928      path: ".ralph/agent/builder.md"
3929"#;
3930        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3931        let hat = config.hats.get("builder").unwrap();
3932        assert_eq!(
3933            hat.scratchpad,
3934            Some(ScratchpadConfig {
3935                enabled: true,
3936                path: ".ralph/agent/builder.md".to_string()
3937            })
3938        );
3939    }
3940
3941    /// AC5 (config part): Hat inherits global (no scratchpad key)
3942    #[test]
3943    fn test_hat_scratchpad_inherits_global() {
3944        let yaml = r#"
3945hats:
3946  reviewer:
3947    name: "Reviewer"
3948    triggers: ["review.start"]
3949    instructions: "Review the code."
3950"#;
3951        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3952        let hat = config.hats.get("reviewer").unwrap();
3953        assert!(
3954            hat.scratchpad.is_none(),
3955            "No scratchpad key means None (inherit global)"
3956        );
3957    }
3958
3959    /// Resolution function test
3960    #[test]
3961    fn test_scratchpad_resolve_hat_override() {
3962        let global = ScratchpadConfig {
3963            enabled: true,
3964            path: ".ralph/agent/scratchpad.md".to_string(),
3965        };
3966        let hat_override = ScratchpadConfig {
3967            enabled: true,
3968            path: ".ralph/agent/planner.md".to_string(),
3969        };
3970        let resolved = ScratchpadConfig::resolve(Some(&hat_override), &global);
3971        assert_eq!(resolved, hat_override);
3972    }
3973
3974    #[test]
3975    fn test_scratchpad_resolve_global_fallback() {
3976        let global = ScratchpadConfig {
3977            enabled: true,
3978            path: ".ralph/agent/scratchpad.md".to_string(),
3979        };
3980        let resolved = ScratchpadConfig::resolve(None, &global);
3981        assert_eq!(resolved, global);
3982    }
3983
3984    /// AC9 (config part): Multiple hats with different configs
3985    #[test]
3986    fn test_multiple_hats_different_scratchpad_configs() {
3987        let yaml = r#"
3988core:
3989  scratchpad:
3990    enabled: true
3991    path: ".ralph/agent/scratchpad.md"
3992hats:
3993  planner:
3994    name: "Planner"
3995    triggers: ["plan.start"]
3996    scratchpad:
3997      path: ".ralph/agent/planner.md"
3998  builder:
3999    name: "Builder"
4000    triggers: ["build.start"]
4001  validator:
4002    name: "Validator"
4003    triggers: ["validate.start"]
4004    scratchpad:
4005      enabled: false
4006"#;
4007        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4008
4009        // Planner has custom path
4010        let planner = config.hats.get("planner").unwrap();
4011        let planner_resolved =
4012            ScratchpadConfig::resolve(planner.scratchpad.as_ref(), &config.core.scratchpad);
4013        assert_eq!(planner_resolved.path, ".ralph/agent/planner.md");
4014        assert!(planner_resolved.enabled);
4015
4016        // Builder inherits global
4017        let builder = config.hats.get("builder").unwrap();
4018        let builder_resolved =
4019            ScratchpadConfig::resolve(builder.scratchpad.as_ref(), &config.core.scratchpad);
4020        assert_eq!(builder_resolved.path, ".ralph/agent/scratchpad.md");
4021        assert!(builder_resolved.enabled);
4022
4023        // Validator is disabled
4024        let validator = config.hats.get("validator").unwrap();
4025        let validator_resolved =
4026            ScratchpadConfig::resolve(validator.scratchpad.as_ref(), &config.core.scratchpad);
4027        assert!(!validator_resolved.enabled);
4028    }
4029
4030    /// Edge case: core.scratchpad missing entirely
4031    #[test]
4032    fn test_scratchpad_missing_defaults() {
4033        let yaml = r#"
4034core:
4035  specs_dir: "./specs/"
4036"#;
4037        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4038        assert_eq!(config.core.scratchpad, ScratchpadConfig::default());
4039    }
4040
4041    /// Edge case: hat scratchpad with enabled but no path
4042    #[test]
4043    fn test_hat_scratchpad_enabled_no_path() {
4044        let yaml = r#"
4045hats:
4046  worker:
4047    name: "Worker"
4048    triggers: ["work.start"]
4049    scratchpad:
4050      enabled: true
4051"#;
4052        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4053        let hat = config.hats.get("worker").unwrap();
4054        let sc = hat.scratchpad.as_ref().unwrap();
4055        assert!(sc.enabled);
4056        assert_eq!(sc.path, ".ralph/agent/scratchpad.md");
4057    }
4058
4059    /// Edge case: hat scratchpad with path but no enabled
4060    #[test]
4061    fn test_hat_scratchpad_path_no_enabled() {
4062        let yaml = r#"
4063hats:
4064  worker:
4065    name: "Worker"
4066    triggers: ["work.start"]
4067    scratchpad:
4068      path: ".ralph/agent/worker.md"
4069"#;
4070        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4071        let hat = config.hats.get("worker").unwrap();
4072        let sc = hat.scratchpad.as_ref().unwrap();
4073        assert!(sc.enabled);
4074        assert_eq!(sc.path, ".ralph/agent/worker.md");
4075    }
4076
4077    // ── Wave config tests (Step 2: HatConfig extensions) ──
4078
4079    #[test]
4080    fn test_wave_config_concurrency_and_aggregate_parse() {
4081        let yaml = r#"
4082hats:
4083  reviewer:
4084    name: "Reviewer"
4085    description: "Reviews files in parallel"
4086    triggers: ["review.file"]
4087    publishes: ["review.done"]
4088    instructions: "Review the file."
4089    concurrency: 3
4090  aggregator:
4091    name: "Aggregator"
4092    description: "Aggregates review results"
4093    triggers: ["review.done"]
4094    publishes: ["review.complete"]
4095    instructions: "Aggregate results."
4096    aggregate:
4097      mode: wait_for_all
4098      timeout: 600
4099"#;
4100        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4101
4102        let reviewer = config.hats.get("reviewer").unwrap();
4103        assert_eq!(reviewer.concurrency, 3);
4104        assert!(reviewer.aggregate.is_none());
4105
4106        let aggregator = config.hats.get("aggregator").unwrap();
4107        assert_eq!(aggregator.concurrency, 1); // default
4108        let agg = aggregator.aggregate.as_ref().unwrap();
4109        assert!(matches!(agg.mode, AggregateMode::WaitForAll));
4110        assert_eq!(agg.timeout, 600);
4111    }
4112
4113    #[test]
4114    fn test_wave_config_defaults_without_new_fields() {
4115        // Existing YAML without concurrency/aggregate should parse with defaults
4116        let yaml = r#"
4117hats:
4118  builder:
4119    name: "Builder"
4120    description: "Builds code"
4121    triggers: ["build.task"]
4122    publishes: ["build.done"]
4123    instructions: "Build stuff."
4124"#;
4125        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4126        let hat = config.hats.get("builder").unwrap();
4127        assert_eq!(hat.concurrency, 1);
4128        assert!(hat.aggregate.is_none());
4129    }
4130
4131    #[test]
4132    fn test_wave_config_concurrency_zero_rejected() {
4133        let yaml = r#"
4134hats:
4135  worker:
4136    name: "Worker"
4137    description: "Parallel worker"
4138    triggers: ["work.item"]
4139    publishes: ["work.done"]
4140    instructions: "Do work."
4141    concurrency: 0
4142"#;
4143        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4144        let result = config.validate();
4145
4146        assert!(result.is_err());
4147        let err = result.unwrap_err();
4148        assert!(
4149            matches!(&err, ConfigError::InvalidConcurrency { hat, .. } if hat == "worker"),
4150            "Expected InvalidConcurrency error, got: {:?}",
4151            err
4152        );
4153    }
4154
4155    #[test]
4156    fn test_wave_config_aggregate_on_concurrent_hat_rejected() {
4157        // A hat cannot be both concurrent (concurrency > 1) and an aggregator
4158        let yaml = r#"
4159hats:
4160  hybrid:
4161    name: "Hybrid"
4162    description: "Invalid: both concurrent and aggregator"
4163    triggers: ["work.item"]
4164    publishes: ["work.done"]
4165    instructions: "Invalid config."
4166    concurrency: 3
4167    aggregate:
4168      mode: wait_for_all
4169      timeout: 300
4170"#;
4171        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4172        let result = config.validate();
4173
4174        assert!(result.is_err());
4175        let err = result.unwrap_err();
4176        assert!(
4177            matches!(&err, ConfigError::AggregateOnConcurrentHat { hat, .. } if hat == "hybrid"),
4178            "Expected AggregateOnConcurrentHat error, got: {:?}",
4179            err
4180        );
4181    }
4182
4183    #[test]
4184    fn test_wave_config_aggregate_on_non_concurrent_hat_valid() {
4185        // Aggregate on a hat with concurrency=1 (default) is valid
4186        let yaml = r#"
4187hats:
4188  aggregator:
4189    name: "Aggregator"
4190    description: "Collects results"
4191    triggers: ["work.done"]
4192    publishes: ["work.complete"]
4193    instructions: "Aggregate."
4194    aggregate:
4195      mode: wait_for_all
4196      timeout: 300
4197"#;
4198        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4199        let result = config.validate();
4200
4201        assert!(
4202            result.is_ok(),
4203            "Aggregate on non-concurrent hat should be valid: {:?}",
4204            result.unwrap_err()
4205        );
4206    }
4207}