Skip to main content

batty_cli/team/
config.rs

1//! Team configuration parsed from `.batty/team_config/team.yaml`.
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, bail};
7use serde::Deserialize;
8
9use super::DEFAULT_EVENT_LOG_MAX_BYTES;
10use super::TEAM_CONFIG_DIR;
11use crate::agent;
12
13#[derive(Debug, Clone, Deserialize)]
14pub struct TeamConfig {
15    pub name: String,
16    /// Team-level default agent backend. Individual roles can override this
17    /// with their own `agent` field. Resolution order:
18    /// role-level agent > team-level agent > "claude" (hardcoded default).
19    #[serde(default)]
20    pub agent: Option<String>,
21    #[serde(default = "default_workflow_mode")]
22    pub workflow_mode: WorkflowMode,
23    #[serde(default)]
24    pub board: BoardConfig,
25    #[serde(default)]
26    pub standup: StandupConfig,
27    #[serde(default)]
28    pub automation: AutomationConfig,
29    #[serde(default)]
30    pub automation_sender: Option<String>,
31    /// External senders (e.g. email-router, slack-bridge) that are allowed to
32    /// message any role even though they are not team members.
33    #[serde(default)]
34    pub external_senders: Vec<String>,
35    #[serde(default = "default_orchestrator_pane")]
36    pub orchestrator_pane: bool,
37    #[serde(default)]
38    pub orchestrator_position: OrchestratorPosition,
39    #[serde(default)]
40    pub layout: Option<LayoutConfig>,
41    #[serde(default)]
42    pub workflow_policy: WorkflowPolicy,
43    #[serde(default)]
44    pub cost: CostConfig,
45    #[serde(default = "default_event_log_max_bytes")]
46    pub event_log_max_bytes: u64,
47    #[serde(default = "default_retro_min_duration_secs")]
48    pub retro_min_duration_secs: u64,
49    pub roles: Vec<RoleDef>,
50}
51
52#[derive(Debug, Clone, Deserialize, Default)]
53pub struct CostConfig {
54    #[serde(default)]
55    pub models: HashMap<String, ModelPricing>,
56}
57
58#[derive(Debug, Clone, Deserialize)]
59pub struct ModelPricing {
60    pub input_usd_per_mtok: f64,
61    #[serde(default)]
62    pub cached_input_usd_per_mtok: f64,
63    #[serde(default)]
64    pub cache_creation_input_usd_per_mtok: Option<f64>,
65    #[serde(default)]
66    pub cache_creation_5m_input_usd_per_mtok: Option<f64>,
67    #[serde(default)]
68    pub cache_creation_1h_input_usd_per_mtok: Option<f64>,
69    #[serde(default)]
70    pub cache_read_input_usd_per_mtok: f64,
71    pub output_usd_per_mtok: f64,
72    #[serde(default)]
73    pub reasoning_output_usd_per_mtok: Option<f64>,
74}
75
76#[derive(Debug, Clone, Deserialize)]
77#[allow(dead_code)]
78pub struct WorkflowPolicy {
79    #[serde(default)]
80    pub wip_limit_per_engineer: Option<u32>,
81    #[serde(default)]
82    pub wip_limit_per_reviewer: Option<u32>,
83    #[serde(default = "default_pipeline_starvation_threshold")]
84    pub pipeline_starvation_threshold: Option<usize>,
85    #[serde(default = "default_escalation_threshold_secs")]
86    pub escalation_threshold_secs: u64,
87    #[serde(default = "default_review_nudge_threshold_secs")]
88    pub review_nudge_threshold_secs: u64,
89    #[serde(default = "default_review_timeout_secs")]
90    pub review_timeout_secs: u64,
91    #[serde(default)]
92    pub review_timeout_overrides: HashMap<String, ReviewTimeoutOverride>,
93    #[serde(default)]
94    pub auto_archive_done_after_secs: Option<u64>,
95    #[serde(default)]
96    pub capability_overrides: HashMap<String, Vec<String>>,
97    #[serde(default = "default_stall_threshold_secs")]
98    pub stall_threshold_secs: u64,
99    #[serde(default = "default_max_stall_restarts")]
100    pub max_stall_restarts: u32,
101    #[serde(default = "default_health_check_interval_secs")]
102    pub health_check_interval_secs: u64,
103    #[serde(default = "default_uncommitted_warn_threshold")]
104    pub uncommitted_warn_threshold: usize,
105    #[serde(default)]
106    pub auto_merge: AutoMergePolicy,
107}
108
109/// Per-priority override for review timeout thresholds.
110/// When a task's priority matches a key in `review_timeout_overrides`,
111/// these values replace the global defaults.
112#[derive(Debug, Clone, Deserialize)]
113pub struct ReviewTimeoutOverride {
114    /// Nudge threshold override (seconds). Falls back to global if absent.
115    pub review_nudge_threshold_secs: Option<u64>,
116    /// Escalation threshold override (seconds). Falls back to global if absent.
117    pub review_timeout_secs: Option<u64>,
118}
119
120impl Default for WorkflowPolicy {
121    fn default() -> Self {
122        Self {
123            wip_limit_per_engineer: None,
124            wip_limit_per_reviewer: None,
125            pipeline_starvation_threshold: default_pipeline_starvation_threshold(),
126            escalation_threshold_secs: default_escalation_threshold_secs(),
127            review_nudge_threshold_secs: default_review_nudge_threshold_secs(),
128            review_timeout_secs: default_review_timeout_secs(),
129            review_timeout_overrides: HashMap::new(),
130            auto_archive_done_after_secs: None,
131            capability_overrides: HashMap::new(),
132            stall_threshold_secs: default_stall_threshold_secs(),
133            max_stall_restarts: default_max_stall_restarts(),
134            health_check_interval_secs: default_health_check_interval_secs(),
135            uncommitted_warn_threshold: default_uncommitted_warn_threshold(),
136            auto_merge: AutoMergePolicy::default(),
137        }
138    }
139}
140
141fn default_sensitive_paths() -> Vec<String> {
142    vec![
143        "Cargo.toml".to_string(),
144        "team.yaml".to_string(),
145        ".env".to_string(),
146    ]
147}
148
149#[derive(Debug, Clone, Deserialize)]
150pub struct AutoMergePolicy {
151    #[serde(default)]
152    pub enabled: bool,
153    #[serde(default = "default_max_diff_lines")]
154    pub max_diff_lines: usize,
155    #[serde(default = "default_max_files_changed")]
156    pub max_files_changed: usize,
157    #[serde(default = "default_max_modules_touched")]
158    pub max_modules_touched: usize,
159    #[serde(default = "default_sensitive_paths")]
160    pub sensitive_paths: Vec<String>,
161    #[serde(default = "default_confidence_threshold")]
162    pub confidence_threshold: f64,
163    #[serde(default = "default_require_tests_pass")]
164    pub require_tests_pass: bool,
165}
166
167fn default_max_diff_lines() -> usize {
168    200
169}
170fn default_max_files_changed() -> usize {
171    5
172}
173fn default_max_modules_touched() -> usize {
174    2
175}
176fn default_confidence_threshold() -> f64 {
177    0.8
178}
179fn default_require_tests_pass() -> bool {
180    true
181}
182
183impl Default for AutoMergePolicy {
184    fn default() -> Self {
185        Self {
186            enabled: false,
187            max_diff_lines: default_max_diff_lines(),
188            max_files_changed: default_max_files_changed(),
189            max_modules_touched: default_max_modules_touched(),
190            sensitive_paths: default_sensitive_paths(),
191            confidence_threshold: default_confidence_threshold(),
192            require_tests_pass: default_require_tests_pass(),
193        }
194    }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
198#[serde(rename_all = "snake_case")]
199pub enum WorkflowMode {
200    #[default]
201    Legacy,
202    Hybrid,
203    WorkflowFirst,
204}
205
206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
207#[serde(rename_all = "snake_case")]
208pub enum OrchestratorPosition {
209    #[default]
210    Bottom,
211    Left,
212}
213
214impl WorkflowMode {
215    #[cfg_attr(not(test), allow(dead_code))]
216    pub fn legacy_runtime_enabled(self) -> bool {
217        matches!(self, Self::Legacy | Self::Hybrid)
218    }
219
220    #[cfg_attr(not(test), allow(dead_code))]
221    pub fn workflow_state_primary(self) -> bool {
222        matches!(self, Self::WorkflowFirst)
223    }
224
225    pub fn enables_runtime_surface(self) -> bool {
226        matches!(self, Self::Hybrid | Self::WorkflowFirst)
227    }
228
229    pub fn as_str(self) -> &'static str {
230        match self {
231            Self::Legacy => "legacy",
232            Self::Hybrid => "hybrid",
233            Self::WorkflowFirst => "workflow_first",
234        }
235    }
236}
237
238#[derive(Debug, Clone, Deserialize)]
239pub struct BoardConfig {
240    #[serde(default = "default_rotation_threshold")]
241    pub rotation_threshold: u32,
242    #[serde(default = "default_board_auto_dispatch")]
243    pub auto_dispatch: bool,
244    #[serde(default = "default_dispatch_stabilization_delay_secs")]
245    pub dispatch_stabilization_delay_secs: u64,
246    #[serde(default = "default_dispatch_dedup_window_secs")]
247    pub dispatch_dedup_window_secs: u64,
248    #[serde(default = "default_dispatch_manual_cooldown_secs")]
249    pub dispatch_manual_cooldown_secs: u64,
250}
251
252impl Default for BoardConfig {
253    fn default() -> Self {
254        Self {
255            rotation_threshold: default_rotation_threshold(),
256            auto_dispatch: default_board_auto_dispatch(),
257            dispatch_stabilization_delay_secs: default_dispatch_stabilization_delay_secs(),
258            dispatch_dedup_window_secs: default_dispatch_dedup_window_secs(),
259            dispatch_manual_cooldown_secs: default_dispatch_manual_cooldown_secs(),
260        }
261    }
262}
263
264#[derive(Debug, Clone, Deserialize)]
265pub struct StandupConfig {
266    #[serde(default = "default_standup_interval")]
267    pub interval_secs: u64,
268    #[serde(default = "default_output_lines")]
269    pub output_lines: u32,
270}
271
272impl Default for StandupConfig {
273    fn default() -> Self {
274        Self {
275            interval_secs: default_standup_interval(),
276            output_lines: default_output_lines(),
277        }
278    }
279}
280
281#[derive(Debug, Clone, Deserialize)]
282pub struct AutomationConfig {
283    #[serde(default = "default_enabled")]
284    pub timeout_nudges: bool,
285    #[serde(default = "default_enabled")]
286    pub standups: bool,
287    #[serde(default = "default_enabled")]
288    pub failure_pattern_detection: bool,
289    #[serde(default = "default_enabled")]
290    pub triage_interventions: bool,
291    #[serde(default = "default_enabled")]
292    pub review_interventions: bool,
293    #[serde(default = "default_enabled")]
294    pub owned_task_interventions: bool,
295    #[serde(default = "default_enabled")]
296    pub manager_dispatch_interventions: bool,
297    #[serde(default = "default_enabled")]
298    pub architect_utilization_interventions: bool,
299    #[serde(default)]
300    pub replenishment_threshold: Option<usize>,
301    #[serde(default = "default_intervention_idle_grace_secs")]
302    pub intervention_idle_grace_secs: u64,
303    #[serde(default = "default_intervention_cooldown_secs")]
304    pub intervention_cooldown_secs: u64,
305    #[serde(default = "default_utilization_recovery_interval_secs")]
306    pub utilization_recovery_interval_secs: u64,
307    #[serde(default = "default_enabled")]
308    pub commit_before_reset: bool,
309}
310
311impl Default for AutomationConfig {
312    fn default() -> Self {
313        Self {
314            timeout_nudges: default_enabled(),
315            standups: default_enabled(),
316            failure_pattern_detection: default_enabled(),
317            triage_interventions: default_enabled(),
318            review_interventions: default_enabled(),
319            owned_task_interventions: default_enabled(),
320            manager_dispatch_interventions: default_enabled(),
321            architect_utilization_interventions: default_enabled(),
322            replenishment_threshold: None,
323            intervention_idle_grace_secs: default_intervention_idle_grace_secs(),
324            intervention_cooldown_secs: default_intervention_cooldown_secs(),
325            utilization_recovery_interval_secs: default_utilization_recovery_interval_secs(),
326            commit_before_reset: default_enabled(),
327        }
328    }
329}
330
331#[derive(Debug, Clone, Deserialize)]
332pub struct LayoutConfig {
333    pub zones: Vec<ZoneDef>,
334}
335
336#[derive(Debug, Clone, Deserialize)]
337pub struct ZoneDef {
338    pub name: String,
339    pub width_pct: u32,
340    #[serde(default)]
341    pub split: Option<SplitDef>,
342}
343
344#[derive(Debug, Clone, Deserialize)]
345pub struct SplitDef {
346    pub horizontal: u32,
347}
348
349#[derive(Debug, Clone, Deserialize)]
350pub struct RoleDef {
351    pub name: String,
352    pub role_type: RoleType,
353    #[serde(default)]
354    pub agent: Option<String>,
355    #[serde(default = "default_instances")]
356    pub instances: u32,
357    #[serde(default)]
358    pub prompt: Option<String>,
359    #[serde(default)]
360    pub talks_to: Vec<String>,
361    #[serde(default)]
362    pub channel: Option<String>,
363    #[serde(default)]
364    pub channel_config: Option<ChannelConfig>,
365    #[serde(default)]
366    pub nudge_interval_secs: Option<u64>,
367    #[serde(default)]
368    pub receives_standup: Option<bool>,
369    #[serde(default)]
370    pub standup_interval_secs: Option<u64>,
371    #[serde(default)]
372    #[allow(dead_code)] // Parsed for future ownership semantics but not yet enforced.
373    pub owns: Vec<String>,
374    #[serde(default)]
375    pub use_worktrees: bool,
376}
377
378#[derive(Debug, Clone, Deserialize)]
379pub struct ChannelConfig {
380    pub target: String,
381    pub provider: String,
382    /// Telegram bot token for native API (optional; falls back to provider CLI).
383    /// Can also be set via `BATTY_TELEGRAM_BOT_TOKEN` env var.
384    #[serde(default)]
385    pub bot_token: Option<String>,
386    /// Telegram user IDs allowed to send messages (access control).
387    #[serde(default)]
388    pub allowed_user_ids: Vec<i64>,
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub enum PlanningDirectiveFile {
393    ReplenishmentContext,
394    ReviewPolicy,
395    EscalationPolicy,
396}
397
398impl PlanningDirectiveFile {
399    pub fn file_name(self) -> &'static str {
400        match self {
401            Self::ReplenishmentContext => "replenishment_context.md",
402            Self::ReviewPolicy => "review_policy.md",
403            Self::EscalationPolicy => "escalation_policy.md",
404        }
405    }
406
407    pub fn path_for(self, project_root: &Path) -> PathBuf {
408        project_root
409            .join(".batty")
410            .join(TEAM_CONFIG_DIR)
411            .join(self.file_name())
412    }
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
416#[serde(rename_all = "lowercase")]
417pub enum RoleType {
418    User,
419    Architect,
420    Manager,
421    Engineer,
422}
423
424fn default_rotation_threshold() -> u32 {
425    20
426}
427
428fn default_workflow_mode() -> WorkflowMode {
429    WorkflowMode::Legacy
430}
431
432fn default_board_auto_dispatch() -> bool {
433    true
434}
435
436fn default_dispatch_stabilization_delay_secs() -> u64 {
437    30
438}
439
440fn default_dispatch_dedup_window_secs() -> u64 {
441    60
442}
443
444fn default_dispatch_manual_cooldown_secs() -> u64 {
445    30
446}
447
448fn default_standup_interval() -> u64 {
449    300
450}
451
452fn default_pipeline_starvation_threshold() -> Option<usize> {
453    Some(1)
454}
455
456fn default_output_lines() -> u32 {
457    30
458}
459
460fn default_instances() -> u32 {
461    1
462}
463
464fn default_escalation_threshold_secs() -> u64 {
465    3600
466}
467
468fn default_review_nudge_threshold_secs() -> u64 {
469    1800
470}
471
472fn default_review_timeout_secs() -> u64 {
473    7200
474}
475
476fn default_stall_threshold_secs() -> u64 {
477    300
478}
479
480fn default_max_stall_restarts() -> u32 {
481    2
482}
483
484fn default_health_check_interval_secs() -> u64 {
485    60
486}
487
488fn default_uncommitted_warn_threshold() -> usize {
489    200
490}
491
492fn default_enabled() -> bool {
493    true
494}
495
496fn default_orchestrator_pane() -> bool {
497    true
498}
499
500fn default_intervention_idle_grace_secs() -> u64 {
501    60
502}
503
504fn default_intervention_cooldown_secs() -> u64 {
505    120
506}
507
508fn default_utilization_recovery_interval_secs() -> u64 {
509    1200
510}
511
512fn default_event_log_max_bytes() -> u64 {
513    DEFAULT_EVENT_LOG_MAX_BYTES
514}
515
516fn default_retro_min_duration_secs() -> u64 {
517    60
518}
519
520impl TeamConfig {
521    pub fn orchestrator_enabled(&self) -> bool {
522        self.workflow_mode.enables_runtime_surface() && self.orchestrator_pane
523    }
524
525    /// Resolve the effective agent for a role.
526    ///
527    /// Resolution order: role-level agent > team-level agent > "claude".
528    pub fn resolve_agent(&self, role: &RoleDef) -> Option<String> {
529        if role.role_type == RoleType::User {
530            return None;
531        }
532        Some(
533            role.agent
534                .clone()
535                .or_else(|| self.agent.clone())
536                .unwrap_or_else(|| "claude".to_string()),
537        )
538    }
539
540    /// Check if a role is allowed to send messages to another role.
541    ///
542    /// Uses `talks_to` if configured. If `talks_to` is empty for a role,
543    /// falls back to the default hierarchy:
544    /// - User ↔ Architect
545    /// - Architect ↔ Manager
546    /// - Manager ↔ Engineer
547    ///
548    /// The `from` and `to` are role definition names (not member instance names).
549    /// "human" is always allowed to talk to any role.
550    pub fn can_talk(&self, from_role: &str, to_role: &str) -> bool {
551        // human (CLI user) can always send to anyone
552        if from_role == "human" {
553            return true;
554        }
555        // daemon-generated messages (standups, nudges) always allowed
556        if from_role == "daemon" {
557            return true;
558        }
559        // external senders (e.g. email-router, slack-bridge) can send to anyone
560        if self.external_senders.iter().any(|s| s == from_role) {
561            return true;
562        }
563
564        let from_def = self.roles.iter().find(|r| r.name == from_role);
565        let Some(from_def) = from_def else {
566            return false;
567        };
568
569        // If talks_to is explicitly configured, use it
570        if !from_def.talks_to.is_empty() {
571            return from_def.talks_to.iter().any(|t| t == to_role);
572        }
573
574        // Default hierarchy: user↔architect, architect↔manager, manager↔engineer
575        let to_def = self.roles.iter().find(|r| r.name == to_role);
576        let Some(to_def) = to_def else {
577            return false;
578        };
579
580        matches!(
581            (from_def.role_type, to_def.role_type),
582            (RoleType::User, RoleType::Architect)
583                | (RoleType::Architect, RoleType::User)
584                | (RoleType::Architect, RoleType::Manager)
585                | (RoleType::Manager, RoleType::Architect)
586                | (RoleType::Manager, RoleType::Engineer)
587                | (RoleType::Engineer, RoleType::Manager)
588        )
589    }
590
591    /// Load team config from a YAML file.
592    pub fn load(path: &Path) -> Result<Self> {
593        let content = std::fs::read_to_string(path)
594            .with_context(|| format!("failed to read {}", path.display()))?;
595        let config: TeamConfig = serde_yaml::from_str(&content)
596            .with_context(|| format!("failed to parse {}", path.display()))?;
597        Ok(config)
598    }
599
600    /// Validate the team config. Returns an error if invalid.
601    pub fn validate(&self) -> Result<()> {
602        if self.name.is_empty() {
603            bail!("team name cannot be empty");
604        }
605
606        if self.roles.is_empty() {
607            bail!("team must have at least one role");
608        }
609
610        let valid_agents = agent::KNOWN_AGENT_NAMES.join(", ");
611
612        // Validate team-level agent if specified.
613        if let Some(team_agent) = self.agent.as_deref() {
614            if agent::adapter_from_name(team_agent).is_none() {
615                bail!(
616                    "unknown team-level agent '{}'; valid agents: {}",
617                    team_agent,
618                    valid_agents
619                );
620            }
621        }
622
623        let mut role_names: HashSet<&str> = HashSet::new();
624        for role in &self.roles {
625            if role.name.is_empty() {
626                bail!("role has empty name — every role requires a non-empty 'name' field");
627            }
628
629            if !role_names.insert(&role.name) {
630                bail!("duplicate role name: '{}'", role.name);
631            }
632
633            // Non-user roles need an agent — either their own or the team default.
634            if role.role_type != RoleType::User && role.agent.is_none() && self.agent.is_none() {
635                bail!(
636                    "role '{}' has no agent configured — \
637                     set a role-level 'agent' field or a team-level 'agent' default; \
638                     valid agents: {}",
639                    role.name,
640                    valid_agents
641                );
642            }
643
644            if role.role_type == RoleType::User && role.agent.is_some() {
645                bail!(
646                    "role '{}' is a user but has an agent configured; users use channels instead",
647                    role.name
648                );
649            }
650
651            if role.instances == 0 {
652                bail!("role '{}' has zero instances", role.name);
653            }
654
655            if let Some(agent_name) = role.agent.as_deref()
656                && agent::adapter_from_name(agent_name).is_none()
657            {
658                bail!(
659                    "role '{}' uses unknown agent '{}'; valid agents: {}",
660                    role.name,
661                    agent_name,
662                    valid_agents
663                );
664            }
665        }
666
667        // Validate talks_to references exist
668        let all_role_names: Vec<&str> = role_names.iter().copied().collect();
669        for role in &self.roles {
670            for target in &role.talks_to {
671                if !role_names.contains(target.as_str()) {
672                    bail!(
673                        "role '{}' references unknown role '{}' in talks_to; \
674                         defined roles: {}",
675                        role.name,
676                        target,
677                        all_role_names.join(", ")
678                    );
679                }
680            }
681        }
682
683        if let Some(sender) = &self.automation_sender
684            && !role_names.contains(sender.as_str())
685            && sender != "human"
686        {
687            bail!(
688                "automation_sender references unknown role '{}'; \
689                 defined roles: {}",
690                sender,
691                all_role_names.join(", ")
692            );
693        }
694
695        // Validate layout zones if present
696        if let Some(layout) = &self.layout {
697            let total_pct: u32 = layout.zones.iter().map(|z| z.width_pct).sum();
698            if total_pct > 100 {
699                bail!("layout zone widths sum to {}%, exceeds 100%", total_pct);
700            }
701        }
702
703        Ok(())
704    }
705
706    /// Run all validation checks, collecting results for each check.
707    /// Returns a list of (check_name, passed, detail) tuples.
708    pub fn validate_verbose(&self) -> Vec<ValidationCheck> {
709        let mut checks = Vec::new();
710
711        // 1. Team name
712        let name_ok = !self.name.is_empty();
713        checks.push(ValidationCheck {
714            name: "team_name".to_string(),
715            passed: name_ok,
716            detail: if name_ok {
717                format!("team name: '{}'", self.name)
718            } else {
719                "team name is empty".to_string()
720            },
721        });
722
723        // 2. Roles present
724        let roles_ok = !self.roles.is_empty();
725        checks.push(ValidationCheck {
726            name: "roles_present".to_string(),
727            passed: roles_ok,
728            detail: if roles_ok {
729                format!("{} role(s) defined", self.roles.len())
730            } else {
731                "no roles defined".to_string()
732            },
733        });
734
735        if !roles_ok {
736            return checks;
737        }
738
739        // 3. Team-level agent
740        let team_agent_ok = match self.agent.as_deref() {
741            Some(name) => agent::adapter_from_name(name).is_some(),
742            None => true,
743        };
744        checks.push(ValidationCheck {
745            name: "team_agent".to_string(),
746            passed: team_agent_ok,
747            detail: match self.agent.as_deref() {
748                Some(name) if team_agent_ok => format!("team agent: '{name}'"),
749                Some(name) => format!("unknown team agent: '{name}'"),
750                None => "no team-level agent (roles must set their own)".to_string(),
751            },
752        });
753
754        // 4. Per-role checks
755        let mut role_names: HashSet<&str> = HashSet::new();
756        for role in &self.roles {
757            let unique = role_names.insert(&role.name);
758            checks.push(ValidationCheck {
759                name: format!("role_unique:{}", role.name),
760                passed: unique,
761                detail: if unique {
762                    format!("role '{}' is unique", role.name)
763                } else {
764                    format!("duplicate role name: '{}'", role.name)
765                },
766            });
767
768            let has_agent =
769                role.role_type == RoleType::User || role.agent.is_some() || self.agent.is_some();
770            checks.push(ValidationCheck {
771                name: format!("role_agent:{}", role.name),
772                passed: has_agent,
773                detail: if has_agent {
774                    let effective = role
775                        .agent
776                        .as_deref()
777                        .or(self.agent.as_deref())
778                        .unwrap_or("(user)");
779                    format!("role '{}' agent: {effective}", role.name)
780                } else {
781                    format!("role '{}' has no agent", role.name)
782                },
783            });
784
785            if let Some(agent_name) = role.agent.as_deref() {
786                let valid = agent::adapter_from_name(agent_name).is_some();
787                checks.push(ValidationCheck {
788                    name: format!("role_agent_valid:{}", role.name),
789                    passed: valid,
790                    detail: if valid {
791                        format!("role '{}' agent '{}' is valid", role.name, agent_name)
792                    } else {
793                        format!("role '{}' uses unknown agent '{}'", role.name, agent_name)
794                    },
795                });
796            }
797
798            let instances_ok = role.instances > 0;
799            checks.push(ValidationCheck {
800                name: format!("role_instances:{}", role.name),
801                passed: instances_ok,
802                detail: format!("role '{}' instances: {}", role.name, role.instances),
803            });
804        }
805
806        // 5. talks_to references
807        for role in &self.roles {
808            for target in &role.talks_to {
809                let valid = role_names.contains(target.as_str());
810                checks.push(ValidationCheck {
811                    name: format!("talks_to:{}→{}", role.name, target),
812                    passed: valid,
813                    detail: if valid {
814                        format!("role '{}' → '{}' is valid", role.name, target)
815                    } else {
816                        format!(
817                            "role '{}' references unknown role '{}' in talks_to",
818                            role.name, target
819                        )
820                    },
821                });
822            }
823        }
824
825        // 6. automation_sender
826        if let Some(sender) = &self.automation_sender {
827            let valid = role_names.contains(sender.as_str()) || sender == "human";
828            checks.push(ValidationCheck {
829                name: "automation_sender".to_string(),
830                passed: valid,
831                detail: if valid {
832                    format!("automation_sender '{sender}' is valid")
833                } else {
834                    format!("automation_sender references unknown role '{sender}'")
835                },
836            });
837        }
838
839        // 7. Layout zones
840        if let Some(layout) = &self.layout {
841            let total_pct: u32 = layout.zones.iter().map(|z| z.width_pct).sum();
842            let valid = total_pct <= 100;
843            checks.push(ValidationCheck {
844                name: "layout_zones".to_string(),
845                passed: valid,
846                detail: if valid {
847                    format!("layout zones sum to {total_pct}%")
848                } else {
849                    format!("layout zones sum to {total_pct}%, exceeds 100%")
850                },
851            });
852        }
853
854        checks
855    }
856}
857
858/// A single validation check result.
859#[derive(Debug, Clone)]
860pub struct ValidationCheck {
861    pub name: String,
862    pub passed: bool,
863    pub detail: String,
864}
865
866pub fn load_planning_directive(
867    project_root: &Path,
868    directive: PlanningDirectiveFile,
869    max_chars: usize,
870) -> Result<Option<String>> {
871    let path = directive.path_for(project_root);
872    match std::fs::read_to_string(&path) {
873        Ok(content) => {
874            let trimmed = content.trim();
875            if trimmed.is_empty() {
876                return Ok(None);
877            }
878
879            let total_chars = trimmed.chars().count();
880            let truncated = trimmed.chars().take(max_chars).collect::<String>();
881            if total_chars > max_chars {
882                Ok(Some(format!(
883                    "{truncated}\n\n[truncated to {max_chars} chars from {}]",
884                    directive.file_name()
885                )))
886            } else {
887                Ok(Some(truncated))
888            }
889        }
890        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
891        Err(error) => Err(error)
892            .with_context(|| format!("failed to read planning directive {}", path.display())),
893    }
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    fn minimal_yaml() -> &'static str {
901        r#"
902name: test-team
903roles:
904  - name: architect
905    role_type: architect
906    agent: claude
907    instances: 1
908    talks_to: [manager]
909  - name: manager
910    role_type: manager
911    agent: claude
912    instances: 1
913    talks_to: [architect, engineer]
914  - name: engineer
915    role_type: engineer
916    agent: codex
917    instances: 3
918    talks_to: [manager]
919"#
920    }
921
922    #[test]
923    fn parse_minimal_config() {
924        let config: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
925        assert_eq!(config.name, "test-team");
926        assert_eq!(config.workflow_mode, WorkflowMode::Legacy);
927        assert_eq!(config.roles.len(), 3);
928        assert_eq!(config.roles[0].role_type, RoleType::Architect);
929        assert_eq!(config.roles[2].instances, 3);
930        assert_eq!(config.workflow_mode, WorkflowMode::Legacy);
931        assert!(config.orchestrator_pane);
932        assert_eq!(config.event_log_max_bytes, DEFAULT_EVENT_LOG_MAX_BYTES);
933    }
934
935    #[test]
936    fn parse_config_with_user_role() {
937        let yaml = r#"
938name: test-team
939roles:
940  - name: human
941    role_type: user
942    channel: telegram
943    channel_config:
944      target: "12345"
945      provider: openclaw
946    talks_to: [architect]
947  - name: architect
948    role_type: architect
949    agent: claude
950    instances: 1
951    talks_to: [human]
952"#;
953        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
954        assert_eq!(config.roles[0].role_type, RoleType::User);
955        assert_eq!(config.roles[0].channel.as_deref(), Some("telegram"));
956        assert_eq!(
957            config.roles[0].channel_config.as_ref().unwrap().provider,
958            "openclaw"
959        );
960        assert_eq!(config.workflow_mode, WorkflowMode::Legacy);
961        assert!(config.orchestrator_pane);
962    }
963
964    #[test]
965    fn planning_directive_path_uses_team_config_directory() {
966        let root = Path::new("/tmp/project");
967
968        assert_eq!(
969            PlanningDirectiveFile::ReviewPolicy.path_for(root),
970            PathBuf::from("/tmp/project/.batty/team_config/review_policy.md")
971        );
972    }
973
974    #[test]
975    fn load_planning_directive_returns_none_when_missing() {
976        let tmp = tempfile::tempdir().unwrap();
977
978        let loaded =
979            load_planning_directive(tmp.path(), PlanningDirectiveFile::ReviewPolicy, 120).unwrap();
980
981        assert_eq!(loaded, None);
982    }
983
984    #[test]
985    fn load_planning_directive_truncates_long_content() {
986        let tmp = tempfile::tempdir().unwrap();
987        let config_dir = tmp.path().join(".batty").join("team_config");
988        std::fs::create_dir_all(&config_dir).unwrap();
989        std::fs::write(
990            config_dir.join("review_policy.md"),
991            "abcdefghijklmnopqrstuvwxyz",
992        )
993        .unwrap();
994
995        let loaded = load_planning_directive(tmp.path(), PlanningDirectiveFile::ReviewPolicy, 10)
996            .unwrap()
997            .unwrap();
998
999        assert!(loaded.starts_with("abcdefghij"));
1000        assert!(loaded.contains("[truncated to 10 chars"));
1001    }
1002
1003    #[test]
1004    fn parse_full_config_with_layout() {
1005        let yaml = r#"
1006name: mafia-solver
1007board:
1008  rotation_threshold: 20
1009  auto_dispatch: false
1010workflow_mode: hybrid
1011orchestrator_pane: false
1012standup:
1013  interval_secs: 1200
1014  output_lines: 30
1015automation:
1016  timeout_nudges: true
1017  standups: true
1018  triage_interventions: true
1019  review_interventions: true
1020  owned_task_interventions: true
1021  manager_dispatch_interventions: true
1022  architect_utilization_interventions: true
1023  intervention_idle_grace_secs: 60
1024layout:
1025  zones:
1026    - name: architect
1027      width_pct: 15
1028    - name: managers
1029      width_pct: 25
1030      split: { horizontal: 3 }
1031    - name: engineers
1032      width_pct: 60
1033      split: { horizontal: 15 }
1034roles:
1035  - name: architect
1036    role_type: architect
1037    agent: claude
1038    instances: 1
1039    prompt: architect.md
1040    talks_to: [manager]
1041    nudge_interval_secs: 1800
1042    owns: ["planning/**", "docs/**"]
1043  - name: manager
1044    role_type: manager
1045    agent: claude
1046    instances: 3
1047    prompt: manager.md
1048    talks_to: [architect, engineer]
1049  - name: engineer
1050    role_type: engineer
1051    agent: codex
1052    instances: 5
1053    prompt: engineer.md
1054    talks_to: [manager]
1055    use_worktrees: true
1056"#;
1057        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1058        assert_eq!(config.name, "mafia-solver");
1059        assert_eq!(config.board.rotation_threshold, 20);
1060        assert!(!config.board.auto_dispatch);
1061        assert_eq!(config.workflow_mode, WorkflowMode::Hybrid);
1062        assert!(!config.orchestrator_pane);
1063        assert_eq!(config.standup.interval_secs, 1200);
1064        let layout = config.layout.as_ref().unwrap();
1065        assert_eq!(layout.zones.len(), 3);
1066        assert_eq!(layout.zones[0].width_pct, 15);
1067        assert_eq!(layout.zones[2].split.as_ref().unwrap().horizontal, 15);
1068        assert_eq!(config.event_log_max_bytes, DEFAULT_EVENT_LOG_MAX_BYTES);
1069    }
1070
1071    #[test]
1072    fn defaults_applied() {
1073        let yaml = r#"
1074name: minimal
1075roles:
1076  - name: worker
1077    role_type: engineer
1078    agent: codex
1079"#;
1080        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1081        assert_eq!(config.workflow_mode, WorkflowMode::Legacy);
1082        assert_eq!(config.board.rotation_threshold, 20);
1083        assert!(config.board.auto_dispatch);
1084        assert_eq!(config.standup.interval_secs, 300);
1085        assert_eq!(config.standup.output_lines, 30);
1086        assert!(config.automation.timeout_nudges);
1087        assert!(config.automation.standups);
1088        assert!(config.automation.failure_pattern_detection);
1089        assert!(config.automation.triage_interventions);
1090        assert_eq!(config.automation.intervention_idle_grace_secs, 60);
1091        assert!(config.cost.models.is_empty());
1092        assert_eq!(config.roles[0].instances, 1);
1093        assert_eq!(config.workflow_mode, WorkflowMode::Legacy);
1094        assert!(config.orchestrator_pane);
1095        assert_eq!(config.event_log_max_bytes, DEFAULT_EVENT_LOG_MAX_BYTES);
1096    }
1097
1098    #[test]
1099    fn validate_accepts_kiro_agent() {
1100        let yaml = r#"
1101name: test-team
1102roles:
1103  - name: architect
1104    role_type: architect
1105    agent: kiro
1106"#;
1107        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1108        assert!(config.validate().is_ok());
1109    }
1110
1111    #[test]
1112    fn validate_rejects_unknown_agent() {
1113        let yaml = r#"
1114name: test-team
1115roles:
1116  - name: architect
1117    role_type: architect
1118    agent: mystery
1119"#;
1120        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1121        let error = config.validate().unwrap_err().to_string();
1122        assert!(error.contains("unknown agent 'mystery'"));
1123        assert!(error.contains("valid agents:"));
1124    }
1125
1126    #[test]
1127    fn parse_event_log_max_bytes_override() {
1128        let yaml = r#"
1129name: test
1130event_log_max_bytes: 2048
1131roles:
1132  - name: worker
1133    role_type: engineer
1134    agent: codex
1135"#;
1136        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1137        assert_eq!(config.event_log_max_bytes, 2048);
1138    }
1139
1140    #[test]
1141    fn parse_cost_config() {
1142        let yaml = r#"
1143name: test-team
1144cost:
1145  models:
1146    gpt-5.4:
1147      input_usd_per_mtok: 2.5
1148      cached_input_usd_per_mtok: 0.25
1149      output_usd_per_mtok: 15.0
1150    claude-opus-4-6:
1151      input_usd_per_mtok: 15.0
1152      cache_creation_5m_input_usd_per_mtok: 18.75
1153      cache_creation_1h_input_usd_per_mtok: 30.0
1154      cache_read_input_usd_per_mtok: 1.5
1155      output_usd_per_mtok: 75.0
1156roles:
1157  - name: architect
1158    role_type: architect
1159    agent: claude
1160"#;
1161        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1162        let gpt = config.cost.models.get("gpt-5.4").unwrap();
1163        assert_eq!(gpt.input_usd_per_mtok, 2.5);
1164        assert_eq!(gpt.cached_input_usd_per_mtok, 0.25);
1165        assert_eq!(gpt.output_usd_per_mtok, 15.0);
1166
1167        let claude = config.cost.models.get("claude-opus-4-6").unwrap();
1168        assert_eq!(claude.input_usd_per_mtok, 15.0);
1169        assert_eq!(claude.cache_creation_5m_input_usd_per_mtok, Some(18.75));
1170        assert_eq!(claude.cache_creation_1h_input_usd_per_mtok, Some(30.0));
1171        assert_eq!(claude.cache_read_input_usd_per_mtok, 1.5);
1172        assert_eq!(claude.output_usd_per_mtok, 75.0);
1173    }
1174
1175    #[test]
1176    fn parse_workflow_mode_legacy_when_absent() {
1177        let config: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
1178
1179        assert_eq!(config.workflow_mode, WorkflowMode::Legacy);
1180        assert!(config.workflow_mode.legacy_runtime_enabled());
1181        assert!(!config.workflow_mode.workflow_state_primary());
1182    }
1183
1184    #[test]
1185    fn parse_workflow_mode_hybrid_from_yaml() {
1186        let yaml = format!("workflow_mode: hybrid\n{}", minimal_yaml());
1187        let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
1188
1189        assert_eq!(config.workflow_mode, WorkflowMode::Hybrid);
1190        assert!(config.workflow_mode.legacy_runtime_enabled());
1191        assert!(!config.workflow_mode.workflow_state_primary());
1192    }
1193
1194    #[test]
1195    fn parse_workflow_mode_workflow_first_from_yaml() {
1196        let yaml = format!("workflow_mode: workflow_first\n{}", minimal_yaml());
1197        let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
1198
1199        assert_eq!(config.workflow_mode, WorkflowMode::WorkflowFirst);
1200        assert!(!config.workflow_mode.legacy_runtime_enabled());
1201        assert!(config.workflow_mode.workflow_state_primary());
1202    }
1203
1204    #[test]
1205    fn parse_explicit_automation_config() {
1206        let yaml = r#"
1207name: test
1208automation:
1209  timeout_nudges: false
1210  standups: true
1211  failure_pattern_detection: false
1212  triage_interventions: true
1213  review_interventions: false
1214  owned_task_interventions: true
1215  manager_dispatch_interventions: false
1216  architect_utilization_interventions: true
1217  intervention_idle_grace_secs: 90
1218roles:
1219  - name: worker
1220    role_type: engineer
1221    agent: codex
1222"#;
1223        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1224        assert!(!config.automation.timeout_nudges);
1225        assert!(config.automation.standups);
1226        assert!(!config.automation.failure_pattern_detection);
1227        assert!(config.automation.triage_interventions);
1228        assert!(!config.automation.review_interventions);
1229        assert!(config.automation.owned_task_interventions);
1230        assert!(!config.automation.manager_dispatch_interventions);
1231        assert!(config.automation.architect_utilization_interventions);
1232        assert_eq!(config.automation.intervention_idle_grace_secs, 90);
1233    }
1234
1235    #[test]
1236    fn parse_workflow_mode_variants() {
1237        let legacy: TeamConfig = serde_yaml::from_str(
1238            r#"
1239name: test
1240workflow_mode: legacy
1241roles:
1242  - name: worker
1243    role_type: engineer
1244    agent: codex
1245"#,
1246        )
1247        .unwrap();
1248        assert_eq!(legacy.workflow_mode, WorkflowMode::Legacy);
1249
1250        let hybrid: TeamConfig = serde_yaml::from_str(
1251            r#"
1252name: test
1253workflow_mode: hybrid
1254roles:
1255  - name: worker
1256    role_type: engineer
1257    agent: codex
1258"#,
1259        )
1260        .unwrap();
1261        assert_eq!(hybrid.workflow_mode, WorkflowMode::Hybrid);
1262
1263        let workflow_first: TeamConfig = serde_yaml::from_str(
1264            r#"
1265name: test
1266workflow_mode: workflow_first
1267roles:
1268  - name: worker
1269    role_type: engineer
1270    agent: codex
1271"#,
1272        )
1273        .unwrap();
1274        assert_eq!(workflow_first.workflow_mode, WorkflowMode::WorkflowFirst);
1275    }
1276
1277    #[test]
1278    fn orchestrator_enabled_respects_mode_and_pane_flag() {
1279        let legacy: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
1280        assert!(!legacy.orchestrator_enabled());
1281
1282        let hybrid_enabled: TeamConfig = serde_yaml::from_str(&format!(
1283            "workflow_mode: hybrid\norchestrator_pane: true\n{}",
1284            minimal_yaml()
1285        ))
1286        .unwrap();
1287        assert!(hybrid_enabled.orchestrator_enabled());
1288
1289        let hybrid_disabled: TeamConfig = serde_yaml::from_str(&format!(
1290            "workflow_mode: hybrid\norchestrator_pane: false\n{}",
1291            minimal_yaml()
1292        ))
1293        .unwrap();
1294        assert!(!hybrid_disabled.orchestrator_enabled());
1295
1296        let workflow_first_enabled: TeamConfig = serde_yaml::from_str(&format!(
1297            "workflow_mode: workflow_first\norchestrator_pane: true\n{}",
1298            minimal_yaml()
1299        ))
1300        .unwrap();
1301        assert!(workflow_first_enabled.orchestrator_enabled());
1302    }
1303
1304    #[test]
1305    fn validate_rejects_empty_name() {
1306        let yaml = r#"
1307name: ""
1308roles:
1309  - name: worker
1310    role_type: engineer
1311    agent: codex
1312"#;
1313        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1314        let err = config.validate().unwrap_err().to_string();
1315        assert!(err.contains("empty"));
1316    }
1317
1318    #[test]
1319    fn validate_rejects_duplicate_role_names() {
1320        let yaml = r#"
1321name: test
1322roles:
1323  - name: worker
1324    role_type: engineer
1325    agent: codex
1326  - name: worker
1327    role_type: engineer
1328    agent: codex
1329"#;
1330        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1331        let err = config.validate().unwrap_err().to_string();
1332        assert!(err.contains("duplicate"));
1333    }
1334
1335    #[test]
1336    fn validate_rejects_non_user_without_agent() {
1337        let yaml = r#"
1338name: test
1339roles:
1340  - name: worker
1341    role_type: engineer
1342"#;
1343        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1344        let err = config.validate().unwrap_err().to_string();
1345        assert!(err.contains("no agent"));
1346    }
1347
1348    #[test]
1349    fn validate_rejects_user_with_agent() {
1350        let yaml = r#"
1351name: test
1352roles:
1353  - name: human
1354    role_type: user
1355    agent: claude
1356"#;
1357        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1358        let err = config.validate().unwrap_err().to_string();
1359        assert!(err.contains("user") && err.contains("agent"));
1360    }
1361
1362    #[test]
1363    fn validate_rejects_unknown_talks_to() {
1364        let yaml = r#"
1365name: test
1366roles:
1367  - name: worker
1368    role_type: engineer
1369    agent: codex
1370    talks_to: [nonexistent]
1371"#;
1372        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1373        let err = config.validate().unwrap_err().to_string();
1374        assert!(err.contains("unknown role"));
1375    }
1376
1377    #[test]
1378    fn validate_rejects_unknown_automation_sender() {
1379        let yaml = r#"
1380name: test
1381automation_sender: nonexistent
1382roles:
1383  - name: architect
1384    role_type: architect
1385    agent: claude
1386"#;
1387        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1388        let err = config.validate().unwrap_err().to_string();
1389        assert!(err.contains("automation_sender"));
1390        assert!(err.contains("unknown role"));
1391    }
1392
1393    #[test]
1394    fn validate_accepts_known_automation_sender() {
1395        let yaml = r#"
1396name: test
1397automation_sender: human
1398roles:
1399  - name: human
1400    role_type: user
1401    channel: telegram
1402    channel_config:
1403      target: "12345"
1404      provider: openclaw
1405  - name: architect
1406    role_type: architect
1407    agent: claude
1408"#;
1409        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1410        config.validate().unwrap();
1411    }
1412
1413    #[test]
1414    fn validate_rejects_layout_over_100_pct() {
1415        let yaml = r#"
1416name: test
1417layout:
1418  zones:
1419    - name: a
1420      width_pct: 60
1421    - name: b
1422      width_pct: 50
1423roles:
1424  - name: worker
1425    role_type: engineer
1426    agent: codex
1427"#;
1428        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1429        let err = config.validate().unwrap_err().to_string();
1430        assert!(err.contains("100%"));
1431    }
1432
1433    #[test]
1434    fn validate_accepts_minimal_config() {
1435        let config: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
1436        config.validate().unwrap();
1437    }
1438
1439    #[test]
1440    fn load_from_file() {
1441        let tmp = tempfile::tempdir().unwrap();
1442        let path = tmp.path().join("team.yaml");
1443        std::fs::write(&path, minimal_yaml()).unwrap();
1444        let config = TeamConfig::load(&path).unwrap();
1445        assert_eq!(config.name, "test-team");
1446        assert_eq!(config.workflow_mode, WorkflowMode::Legacy);
1447    }
1448
1449    #[test]
1450    fn can_talk_default_hierarchy() {
1451        let config: TeamConfig = serde_yaml::from_str(
1452            r#"
1453name: test
1454roles:
1455  - name: architect
1456    role_type: architect
1457    agent: claude
1458  - name: manager
1459    role_type: manager
1460    agent: claude
1461  - name: engineer
1462    role_type: engineer
1463    agent: codex
1464"#,
1465        )
1466        .unwrap();
1467
1468        // Default: architect↔manager, manager↔engineer
1469        assert!(config.can_talk("architect", "manager"));
1470        assert!(config.can_talk("manager", "architect"));
1471        assert!(config.can_talk("manager", "engineer"));
1472        assert!(config.can_talk("engineer", "manager"));
1473
1474        // architect↔engineer blocked by default
1475        assert!(!config.can_talk("architect", "engineer"));
1476        assert!(!config.can_talk("engineer", "architect"));
1477
1478        // human can talk to anyone
1479        assert!(config.can_talk("human", "architect"));
1480        assert!(config.can_talk("human", "engineer"));
1481
1482        // daemon can talk to anyone
1483        assert!(config.can_talk("daemon", "engineer"));
1484    }
1485
1486    #[test]
1487    fn can_talk_explicit_talks_to() {
1488        let config: TeamConfig = serde_yaml::from_str(
1489            r#"
1490name: test
1491roles:
1492  - name: architect
1493    role_type: architect
1494    agent: claude
1495    talks_to: [manager, engineer]
1496  - name: manager
1497    role_type: manager
1498    agent: claude
1499    talks_to: [architect, engineer]
1500  - name: engineer
1501    role_type: engineer
1502    agent: codex
1503    talks_to: [manager]
1504"#,
1505        )
1506        .unwrap();
1507
1508        // Explicit: architect→engineer allowed
1509        assert!(config.can_talk("architect", "engineer"));
1510        // But engineer→architect still blocked (not in engineer's talks_to)
1511        assert!(!config.can_talk("engineer", "architect"));
1512    }
1513
1514    #[test]
1515    fn validate_rejects_zero_instances() {
1516        let yaml = r#"
1517name: test
1518roles:
1519  - name: worker
1520    role_type: engineer
1521    agent: codex
1522    instances: 0
1523"#;
1524        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1525        let err = config.validate().unwrap_err().to_string();
1526        assert!(err.contains("zero instances"));
1527    }
1528
1529    #[test]
1530    fn parse_rejects_malformed_yaml_missing_colon() {
1531        let yaml = r#"
1532name test
1533roles:
1534  - name: worker
1535    role_type: engineer
1536    agent: codex
1537"#;
1538
1539        let err = serde_yaml::from_str::<TeamConfig>(yaml)
1540            .unwrap_err()
1541            .to_string();
1542        assert!(!err.is_empty());
1543    }
1544
1545    #[test]
1546    fn parse_rejects_malformed_yaml_bad_indentation() {
1547        let yaml = r#"
1548name: test
1549roles:
1550- name: worker
1551   role_type: engineer
1552   agent: codex
1553"#;
1554
1555        let err = serde_yaml::from_str::<TeamConfig>(yaml)
1556            .unwrap_err()
1557            .to_string();
1558        assert!(!err.is_empty());
1559    }
1560
1561    #[test]
1562    fn parse_rejects_missing_name_field() {
1563        let yaml = r#"
1564roles:
1565  - name: worker
1566    role_type: engineer
1567    agent: codex
1568"#;
1569
1570        let err = serde_yaml::from_str::<TeamConfig>(yaml)
1571            .unwrap_err()
1572            .to_string();
1573        assert!(err.contains("name"));
1574    }
1575
1576    #[test]
1577    fn parse_rejects_missing_roles_field() {
1578        let yaml = r#"
1579name: test
1580"#;
1581
1582        let err = serde_yaml::from_str::<TeamConfig>(yaml)
1583            .unwrap_err()
1584            .to_string();
1585        assert!(err.contains("roles"));
1586    }
1587
1588    #[test]
1589    fn legacy_mode_with_orchestrator_pane_true_disables_orchestrator_surface() {
1590        let yaml = r#"
1591name: test
1592workflow_mode: legacy
1593orchestrator_pane: true
1594roles:
1595  - name: worker
1596    role_type: engineer
1597    agent: codex
1598"#;
1599
1600        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1601        assert_eq!(config.workflow_mode, WorkflowMode::Legacy);
1602        assert!(config.orchestrator_pane);
1603        assert!(!config.orchestrator_enabled());
1604    }
1605
1606    #[test]
1607    fn parse_all_automation_flags_false() {
1608        let yaml = r#"
1609name: test
1610automation:
1611  timeout_nudges: false
1612  standups: false
1613  failure_pattern_detection: false
1614  triage_interventions: false
1615  review_interventions: false
1616  owned_task_interventions: false
1617  manager_dispatch_interventions: false
1618  architect_utilization_interventions: false
1619  replenishment_threshold: 1
1620roles:
1621  - name: worker
1622    role_type: engineer
1623    agent: codex
1624"#;
1625
1626        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1627        assert!(!config.automation.timeout_nudges);
1628        assert!(!config.automation.standups);
1629        assert!(!config.automation.failure_pattern_detection);
1630        assert!(!config.automation.triage_interventions);
1631        assert!(!config.automation.review_interventions);
1632        assert!(!config.automation.owned_task_interventions);
1633        assert!(!config.automation.manager_dispatch_interventions);
1634        assert!(!config.automation.architect_utilization_interventions);
1635        assert_eq!(config.automation.replenishment_threshold, Some(1));
1636    }
1637
1638    #[test]
1639    fn automation_replenishment_threshold_defaults_to_none() {
1640        let config: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
1641        assert_eq!(config.automation.replenishment_threshold, None);
1642    }
1643
1644    #[test]
1645    fn parse_standup_interval_zero() {
1646        let yaml = r#"
1647name: test
1648standup:
1649  interval_secs: 0
1650roles:
1651  - name: worker
1652    role_type: engineer
1653    agent: codex
1654"#;
1655
1656        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1657        assert_eq!(config.standup.interval_secs, 0);
1658    }
1659
1660    #[test]
1661    fn parse_standup_interval_u64_max() {
1662        let yaml = format!(
1663            r#"
1664name: test
1665standup:
1666  interval_secs: {}
1667roles:
1668  - name: worker
1669    role_type: engineer
1670    agent: codex
1671"#,
1672            u64::MAX
1673        );
1674
1675        let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
1676        assert_eq!(config.standup.interval_secs, u64::MAX);
1677    }
1678
1679    #[test]
1680    fn parse_ignores_unknown_top_level_fields_for_forward_compatibility() {
1681        let yaml = r#"
1682name: test
1683future_flag: true
1684future_section:
1685  nested_value: 42
1686roles:
1687  - name: worker
1688    role_type: engineer
1689    agent: codex
1690    extra_role_setting: keep-going
1691"#;
1692
1693        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1694        assert_eq!(config.name, "test");
1695        assert_eq!(config.roles.len(), 1);
1696        config.validate().unwrap();
1697    }
1698
1699    #[test]
1700    fn validate_rejects_duplicate_role_names_with_mixed_role_types() {
1701        let yaml = r#"
1702name: test
1703roles:
1704  - name: lead
1705    role_type: architect
1706    agent: claude
1707  - name: lead
1708    role_type: manager
1709    agent: claude
1710"#;
1711
1712        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1713        let err = config.validate().unwrap_err().to_string();
1714        assert!(err.contains("duplicate role name"));
1715    }
1716
1717    #[test]
1718    fn validate_rejects_talks_to_reference_to_missing_role() {
1719        let yaml = r#"
1720name: test
1721roles:
1722  - name: worker
1723    role_type: engineer
1724    agent: codex
1725    talks_to: [manager]
1726"#;
1727
1728        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1729        let err = config.validate().unwrap_err().to_string();
1730        assert!(err.contains("unknown role 'manager'"));
1731        assert!(err.contains("talks_to"));
1732    }
1733
1734    #[test]
1735    fn external_sender_can_talk_to_any_role() {
1736        let config: TeamConfig = serde_yaml::from_str(
1737            r#"
1738name: test
1739external_senders:
1740  - email-router
1741  - slack-bridge
1742roles:
1743  - name: architect
1744    role_type: architect
1745    agent: claude
1746  - name: manager
1747    role_type: manager
1748    agent: claude
1749  - name: engineer
1750    role_type: engineer
1751    agent: codex
1752"#,
1753        )
1754        .unwrap();
1755
1756        assert!(config.can_talk("email-router", "manager"));
1757        assert!(config.can_talk("email-router", "architect"));
1758        assert!(config.can_talk("email-router", "engineer"));
1759        assert!(config.can_talk("slack-bridge", "manager"));
1760        assert!(config.can_talk("slack-bridge", "engineer"));
1761    }
1762
1763    #[test]
1764    fn unknown_sender_blocked() {
1765        let config: TeamConfig = serde_yaml::from_str(
1766            r#"
1767name: test
1768external_senders:
1769  - email-router
1770roles:
1771  - name: manager
1772    role_type: manager
1773    agent: claude
1774  - name: engineer
1775    role_type: engineer
1776    agent: codex
1777"#,
1778        )
1779        .unwrap();
1780
1781        // "random-sender" is not in external_senders and not a known role
1782        assert!(!config.can_talk("random-sender", "manager"));
1783        assert!(!config.can_talk("random-sender", "engineer"));
1784    }
1785
1786    #[test]
1787    fn parse_review_timeout_overrides_from_yaml() {
1788        let yaml = r#"
1789name: test-team
1790workflow_policy:
1791  review_nudge_threshold_secs: 1800
1792  review_timeout_secs: 7200
1793  review_timeout_overrides:
1794    critical:
1795      review_nudge_threshold_secs: 300
1796      review_timeout_secs: 600
1797    high:
1798      review_timeout_secs: 3600
1799roles:
1800  - name: architect
1801    role_type: architect
1802    agent: claude
1803"#;
1804        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1805        let policy = &config.workflow_policy;
1806
1807        // Global defaults
1808        assert_eq!(policy.review_nudge_threshold_secs, 1800);
1809        assert_eq!(policy.review_timeout_secs, 7200);
1810
1811        // Critical override — both fields set
1812        let critical = policy.review_timeout_overrides.get("critical").unwrap();
1813        assert_eq!(critical.review_nudge_threshold_secs, Some(300));
1814        assert_eq!(critical.review_timeout_secs, Some(600));
1815
1816        // High override — only escalation set, nudge absent
1817        let high = policy.review_timeout_overrides.get("high").unwrap();
1818        assert_eq!(high.review_nudge_threshold_secs, None);
1819        assert_eq!(high.review_timeout_secs, Some(3600));
1820
1821        // No override for medium
1822        assert!(!policy.review_timeout_overrides.contains_key("medium"));
1823    }
1824
1825    #[test]
1826    fn empty_overrides_when_absent_in_yaml() {
1827        let config: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
1828        assert!(config.workflow_policy.review_timeout_overrides.is_empty());
1829    }
1830
1831    // --- Mixed-backend / team-level agent tests ---
1832
1833    #[test]
1834    fn team_level_agent_parsed() {
1835        let yaml = r#"
1836name: test
1837agent: codex
1838roles:
1839  - name: worker
1840    role_type: engineer
1841    instances: 2
1842"#;
1843        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1844        assert_eq!(config.agent.as_deref(), Some("codex"));
1845        assert!(config.validate().is_ok());
1846    }
1847
1848    #[test]
1849    fn team_level_agent_absent_defaults_to_none() {
1850        let config: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
1851        assert!(config.agent.is_none());
1852    }
1853
1854    #[test]
1855    fn resolve_agent_role_overrides_team() {
1856        let yaml = r#"
1857name: test
1858agent: codex
1859roles:
1860  - name: architect
1861    role_type: architect
1862    agent: claude
1863  - name: worker
1864    role_type: engineer
1865    instances: 2
1866"#;
1867        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1868        let architect = &config.roles[0];
1869        let worker = &config.roles[1];
1870        // Role-level agent overrides team-level
1871        assert_eq!(config.resolve_agent(architect).as_deref(), Some("claude"));
1872        // No role-level agent, falls back to team-level
1873        assert_eq!(config.resolve_agent(worker).as_deref(), Some("codex"));
1874    }
1875
1876    #[test]
1877    fn resolve_agent_defaults_to_claude_when_nothing_set() {
1878        let yaml = r#"
1879name: test
1880roles:
1881  - name: worker
1882    role_type: engineer
1883    agent: codex
1884"#;
1885        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1886        // Override the role to have no agent for testing
1887        let mut role = config.roles[0].clone();
1888        role.agent = None;
1889        let mut config_no_team = config.clone();
1890        config_no_team.agent = None;
1891        assert_eq!(
1892            config_no_team.resolve_agent(&role).as_deref(),
1893            Some("claude")
1894        );
1895    }
1896
1897    #[test]
1898    fn resolve_agent_returns_none_for_user() {
1899        let yaml = r#"
1900name: test
1901agent: codex
1902roles:
1903  - name: human
1904    role_type: user
1905  - name: worker
1906    role_type: engineer
1907    instances: 1
1908"#;
1909        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1910        let user = &config.roles[0];
1911        assert!(config.resolve_agent(user).is_none());
1912    }
1913
1914    #[test]
1915    fn validate_team_level_agent_rejects_unknown() {
1916        let yaml = r#"
1917name: test
1918agent: mystery
1919roles:
1920  - name: worker
1921    role_type: engineer
1922    instances: 1
1923"#;
1924        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1925        let err = config.validate().unwrap_err().to_string();
1926        assert!(err.contains("team-level agent"));
1927        assert!(err.contains("mystery"));
1928    }
1929
1930    #[test]
1931    fn validate_accepts_team_level_agent_without_role_agent() {
1932        let yaml = r#"
1933name: test
1934agent: codex
1935roles:
1936  - name: architect
1937    role_type: architect
1938  - name: manager
1939    role_type: manager
1940  - name: engineer
1941    role_type: engineer
1942    instances: 2
1943"#;
1944        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1945        assert!(config.validate().is_ok());
1946    }
1947
1948    #[test]
1949    fn validate_rejects_no_agent_at_any_level() {
1950        let yaml = r#"
1951name: test
1952roles:
1953  - name: worker
1954    role_type: engineer
1955"#;
1956        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1957        let err = config.validate().unwrap_err().to_string();
1958        assert!(err.contains("no agent"));
1959    }
1960
1961    #[test]
1962    fn validate_mixed_backend_team() {
1963        let yaml = r#"
1964name: mixed
1965agent: codex
1966roles:
1967  - name: architect
1968    role_type: architect
1969    agent: claude
1970  - name: manager
1971    role_type: manager
1972    agent: claude
1973  - name: eng-claude
1974    role_type: engineer
1975    agent: claude
1976    instances: 2
1977    talks_to: [manager]
1978  - name: eng-codex
1979    role_type: engineer
1980    instances: 2
1981    talks_to: [manager]
1982"#;
1983        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
1984        assert!(config.validate().is_ok());
1985        // eng-claude has explicit agent
1986        assert_eq!(config.roles[2].agent.as_deref(), Some("claude"));
1987        // eng-codex inherits team default
1988        assert!(config.roles[3].agent.is_none());
1989        assert_eq!(
1990            config.resolve_agent(&config.roles[3]).as_deref(),
1991            Some("codex")
1992        );
1993    }
1994
1995    // --- Edge case tests: missing fields ---
1996
1997    #[test]
1998    fn validate_rejects_empty_roles_list() {
1999        let yaml = r#"
2000name: test
2001roles: []
2002"#;
2003        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2004        let err = config.validate().unwrap_err().to_string();
2005        assert!(err.contains("at least one role"));
2006    }
2007
2008    #[test]
2009    fn validate_rejects_role_with_empty_name() {
2010        let yaml = r#"
2011name: test
2012roles:
2013  - name: ""
2014    role_type: engineer
2015    agent: codex
2016"#;
2017        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2018        let err = config.validate().unwrap_err().to_string();
2019        assert!(err.contains("empty name"));
2020    }
2021
2022    #[test]
2023    fn validate_rejects_two_roles_with_empty_names() {
2024        let yaml = r#"
2025name: test
2026roles:
2027  - name: ""
2028    role_type: engineer
2029    agent: codex
2030  - name: ""
2031    role_type: manager
2032    agent: claude
2033"#;
2034        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2035        let err = config.validate().unwrap_err().to_string();
2036        // Now fails at the first empty name before reaching duplicate check
2037        assert!(err.contains("empty name"));
2038    }
2039
2040    // --- Edge case tests: wrong types in YAML ---
2041
2042    #[test]
2043    fn parse_rejects_string_for_instances() {
2044        let yaml = r#"
2045name: test
2046roles:
2047  - name: worker
2048    role_type: engineer
2049    agent: codex
2050    instances: many
2051"#;
2052        assert!(serde_yaml::from_str::<TeamConfig>(yaml).is_err());
2053    }
2054
2055    #[test]
2056    fn parse_rejects_boolean_for_name() {
2057        let yaml = r#"
2058name: true
2059roles:
2060  - name: worker
2061    role_type: engineer
2062    agent: codex
2063"#;
2064        // YAML coerces true to "true" string — should parse but name is "true"
2065        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2066        assert_eq!(config.name, "true");
2067    }
2068
2069    #[test]
2070    fn parse_null_name_deserializes_as_literal_string() {
2071        let yaml = r#"
2072name: null
2073roles:
2074  - name: worker
2075    role_type: engineer
2076    agent: codex
2077"#;
2078        // serde_yaml 0.9 deserializes YAML null as literal "null" for String fields
2079        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2080        assert_eq!(config.name, "null");
2081    }
2082
2083    #[test]
2084    fn parse_tilde_name_deserializes_as_tilde_string() {
2085        let yaml = r#"
2086name: ~
2087roles:
2088  - name: worker
2089    role_type: engineer
2090    agent: codex
2091"#;
2092        // serde_yaml 0.9 coerces ~ (YAML null) to "~" for non-Option String fields
2093        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2094        assert_eq!(config.name, "~");
2095    }
2096
2097    #[test]
2098    fn parse_rejects_invalid_role_type() {
2099        let yaml = r#"
2100name: test
2101roles:
2102  - name: wizard
2103    role_type: wizard
2104    agent: claude
2105"#;
2106        let err = serde_yaml::from_str::<TeamConfig>(yaml)
2107            .unwrap_err()
2108            .to_string();
2109        assert!(!err.is_empty());
2110    }
2111
2112    #[test]
2113    fn parse_rejects_invalid_workflow_mode() {
2114        let yaml = r#"
2115name: test
2116workflow_mode: turbo
2117roles:
2118  - name: worker
2119    role_type: engineer
2120    agent: codex
2121"#;
2122        assert!(serde_yaml::from_str::<TeamConfig>(yaml).is_err());
2123    }
2124
2125    #[test]
2126    fn parse_rejects_invalid_orchestrator_position() {
2127        let yaml = r#"
2128name: test
2129orchestrator_position: top
2130roles:
2131  - name: worker
2132    role_type: engineer
2133    agent: codex
2134"#;
2135        assert!(serde_yaml::from_str::<TeamConfig>(yaml).is_err());
2136    }
2137
2138    #[test]
2139    fn parse_rejects_negative_instances() {
2140        let yaml = r#"
2141name: test
2142roles:
2143  - name: worker
2144    role_type: engineer
2145    agent: codex
2146    instances: -1
2147"#;
2148        assert!(serde_yaml::from_str::<TeamConfig>(yaml).is_err());
2149    }
2150
2151    #[test]
2152    fn parse_rejects_string_for_interval_secs() {
2153        let yaml = r#"
2154name: test
2155standup:
2156  interval_secs: forever
2157roles:
2158  - name: worker
2159    role_type: engineer
2160    agent: codex
2161"#;
2162        assert!(serde_yaml::from_str::<TeamConfig>(yaml).is_err());
2163    }
2164
2165    // --- Edge case tests: hierarchy and talks_to ---
2166
2167    #[test]
2168    fn can_talk_role_to_self_via_talks_to() {
2169        let config: TeamConfig = serde_yaml::from_str(
2170            r#"
2171name: test
2172roles:
2173  - name: solo
2174    role_type: architect
2175    agent: claude
2176    talks_to: [solo]
2177"#,
2178        )
2179        .unwrap();
2180        // Self-referencing talks_to — allowed by current rules, role can talk to itself
2181        assert!(config.can_talk("solo", "solo"));
2182        config.validate().unwrap(); // Should not crash
2183    }
2184
2185    #[test]
2186    fn can_talk_nonexistent_sender_returns_false() {
2187        let config: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
2188        assert!(!config.can_talk("ghost", "architect"));
2189    }
2190
2191    #[test]
2192    fn can_talk_nonexistent_target_returns_false() {
2193        let config: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
2194        assert!(!config.can_talk("architect", "ghost"));
2195    }
2196
2197    #[test]
2198    fn validate_accepts_single_user_only_team() {
2199        let yaml = r#"
2200name: test
2201roles:
2202  - name: human
2203    role_type: user
2204"#;
2205        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2206        // User roles don't need agents — should be valid
2207        config.validate().unwrap();
2208    }
2209
2210    #[test]
2211    fn validate_accepts_multiple_user_roles() {
2212        let yaml = r#"
2213name: test
2214roles:
2215  - name: alice
2216    role_type: user
2217  - name: bob
2218    role_type: user
2219"#;
2220        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2221        config.validate().unwrap();
2222    }
2223
2224    // --- Edge case tests: boundary values ---
2225
2226    #[test]
2227    fn validate_accepts_large_instance_count() {
2228        let yaml = r#"
2229name: test
2230roles:
2231  - name: army
2232    role_type: engineer
2233    agent: codex
2234    instances: 100
2235"#;
2236        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2237        assert_eq!(config.roles[0].instances, 100);
2238        config.validate().unwrap();
2239    }
2240
2241    #[test]
2242    fn parse_workflow_policy_zero_wip_limits() {
2243        let yaml = r#"
2244name: test
2245workflow_policy:
2246  wip_limit_per_engineer: 0
2247  wip_limit_per_reviewer: 0
2248roles:
2249  - name: worker
2250    role_type: engineer
2251    agent: codex
2252"#;
2253        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2254        assert_eq!(config.workflow_policy.wip_limit_per_engineer, Some(0));
2255        assert_eq!(config.workflow_policy.wip_limit_per_reviewer, Some(0));
2256    }
2257
2258    #[test]
2259    fn parse_workflow_policy_defaults_all_applied() {
2260        let config: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
2261        let p = &config.workflow_policy;
2262        assert!(p.wip_limit_per_engineer.is_none());
2263        assert!(p.wip_limit_per_reviewer.is_none());
2264        assert_eq!(p.pipeline_starvation_threshold, Some(1));
2265        assert_eq!(p.escalation_threshold_secs, 3600);
2266        assert_eq!(p.review_nudge_threshold_secs, 1800);
2267        assert_eq!(p.review_timeout_secs, 7200);
2268        assert!(p.review_timeout_overrides.is_empty());
2269        assert!(p.auto_archive_done_after_secs.is_none());
2270        assert!(p.capability_overrides.is_empty());
2271        assert_eq!(p.stall_threshold_secs, 300);
2272        assert_eq!(p.max_stall_restarts, 2);
2273        assert_eq!(p.health_check_interval_secs, 60);
2274        assert_eq!(p.uncommitted_warn_threshold, 200);
2275    }
2276
2277    #[test]
2278    fn parse_workflow_policy_zero_escalation_threshold() {
2279        let yaml = r#"
2280name: test
2281workflow_policy:
2282  escalation_threshold_secs: 0
2283  stall_threshold_secs: 0
2284  max_stall_restarts: 0
2285  health_check_interval_secs: 0
2286  uncommitted_warn_threshold: 0
2287roles:
2288  - name: worker
2289    role_type: engineer
2290    agent: codex
2291"#;
2292        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2293        assert_eq!(config.workflow_policy.escalation_threshold_secs, 0);
2294        assert_eq!(config.workflow_policy.stall_threshold_secs, 0);
2295        assert_eq!(config.workflow_policy.max_stall_restarts, 0);
2296        assert_eq!(config.workflow_policy.health_check_interval_secs, 0);
2297        assert_eq!(config.workflow_policy.uncommitted_warn_threshold, 0);
2298    }
2299
2300    #[test]
2301    fn validate_layout_zones_exactly_100_pct() {
2302        let yaml = r#"
2303name: test
2304layout:
2305  zones:
2306    - name: left
2307      width_pct: 50
2308    - name: right
2309      width_pct: 50
2310roles:
2311  - name: worker
2312    role_type: engineer
2313    agent: codex
2314"#;
2315        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2316        config.validate().unwrap();
2317    }
2318
2319    #[test]
2320    fn validate_layout_empty_zones_accepted() {
2321        let yaml = r#"
2322name: test
2323layout:
2324  zones: []
2325roles:
2326  - name: worker
2327    role_type: engineer
2328    agent: codex
2329"#;
2330        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2331        config.validate().unwrap();
2332    }
2333
2334    #[test]
2335    fn validate_layout_zone_width_zero() {
2336        let yaml = r#"
2337name: test
2338layout:
2339  zones:
2340    - name: invisible
2341      width_pct: 0
2342    - name: full
2343      width_pct: 100
2344roles:
2345  - name: worker
2346    role_type: engineer
2347    agent: codex
2348"#;
2349        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2350        config.validate().unwrap();
2351    }
2352
2353    // --- Edge case tests: auto-merge policy ---
2354
2355    #[test]
2356    fn parse_auto_merge_policy_defaults() {
2357        let config: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
2358        let am = &config.workflow_policy.auto_merge;
2359        assert!(!am.enabled);
2360        assert_eq!(am.max_diff_lines, 200);
2361        assert_eq!(am.max_files_changed, 5);
2362        assert_eq!(am.max_modules_touched, 2);
2363        assert_eq!(am.confidence_threshold, 0.8);
2364        assert!(am.require_tests_pass);
2365        assert!(!am.sensitive_paths.is_empty());
2366    }
2367
2368    #[test]
2369    fn parse_auto_merge_policy_custom() {
2370        let yaml = r#"
2371name: test
2372workflow_policy:
2373  auto_merge:
2374    enabled: true
2375    max_diff_lines: 50
2376    max_files_changed: 2
2377    max_modules_touched: 1
2378    confidence_threshold: 0.95
2379    require_tests_pass: false
2380    sensitive_paths: ["secrets.yaml"]
2381roles:
2382  - name: worker
2383    role_type: engineer
2384    agent: codex
2385"#;
2386        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2387        let am = &config.workflow_policy.auto_merge;
2388        assert!(am.enabled);
2389        assert_eq!(am.max_diff_lines, 50);
2390        assert_eq!(am.max_files_changed, 2);
2391        assert_eq!(am.max_modules_touched, 1);
2392        assert_eq!(am.confidence_threshold, 0.95);
2393        assert!(!am.require_tests_pass);
2394        assert_eq!(am.sensitive_paths, vec!["secrets.yaml"]);
2395    }
2396
2397    #[test]
2398    fn parse_auto_merge_zero_thresholds() {
2399        let yaml = r#"
2400name: test
2401workflow_policy:
2402  auto_merge:
2403    enabled: true
2404    max_diff_lines: 0
2405    max_files_changed: 0
2406    max_modules_touched: 0
2407    confidence_threshold: 0.0
2408roles:
2409  - name: worker
2410    role_type: engineer
2411    agent: codex
2412"#;
2413        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2414        let am = &config.workflow_policy.auto_merge;
2415        assert_eq!(am.max_diff_lines, 0);
2416        assert_eq!(am.max_files_changed, 0);
2417        assert_eq!(am.max_modules_touched, 0);
2418        assert_eq!(am.confidence_threshold, 0.0);
2419    }
2420
2421    // --- Edge case tests: cost config ---
2422
2423    #[test]
2424    fn parse_cost_config_empty_models_map() {
2425        let yaml = r#"
2426name: test
2427cost:
2428  models: {}
2429roles:
2430  - name: worker
2431    role_type: engineer
2432    agent: codex
2433"#;
2434        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2435        assert!(config.cost.models.is_empty());
2436    }
2437
2438    #[test]
2439    fn parse_cost_config_zero_pricing() {
2440        let yaml = r#"
2441name: test
2442cost:
2443  models:
2444    free-model:
2445      input_usd_per_mtok: 0.0
2446      output_usd_per_mtok: 0.0
2447roles:
2448  - name: worker
2449    role_type: engineer
2450    agent: codex
2451"#;
2452        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2453        let model = config.cost.models.get("free-model").unwrap();
2454        assert_eq!(model.input_usd_per_mtok, 0.0);
2455        assert_eq!(model.output_usd_per_mtok, 0.0);
2456    }
2457
2458    // --- Edge case tests: orchestrator position ---
2459
2460    #[test]
2461    fn parse_orchestrator_position_left() {
2462        let yaml = r#"
2463name: test
2464orchestrator_position: left
2465roles:
2466  - name: worker
2467    role_type: engineer
2468    agent: codex
2469"#;
2470        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2471        assert_eq!(config.orchestrator_position, OrchestratorPosition::Left);
2472    }
2473
2474    #[test]
2475    fn parse_orchestrator_position_defaults_to_bottom() {
2476        let config: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
2477        assert_eq!(config.orchestrator_position, OrchestratorPosition::Bottom);
2478    }
2479
2480    // --- Edge case tests: event log and retro ---
2481
2482    #[test]
2483    fn parse_event_log_max_bytes_zero() {
2484        let yaml = r#"
2485name: test
2486event_log_max_bytes: 0
2487roles:
2488  - name: worker
2489    role_type: engineer
2490    agent: codex
2491"#;
2492        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2493        assert_eq!(config.event_log_max_bytes, 0);
2494    }
2495
2496    #[test]
2497    fn parse_retro_min_duration_zero() {
2498        let yaml = r#"
2499name: test
2500retro_min_duration_secs: 0
2501roles:
2502  - name: worker
2503    role_type: engineer
2504    agent: codex
2505"#;
2506        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2507        assert_eq!(config.retro_min_duration_secs, 0);
2508    }
2509
2510    // --- Edge case tests: planning directives ---
2511
2512    #[test]
2513    fn load_planning_directive_returns_none_for_empty_file() {
2514        let tmp = tempfile::tempdir().unwrap();
2515        let config_dir = tmp.path().join(".batty").join("team_config");
2516        std::fs::create_dir_all(&config_dir).unwrap();
2517        std::fs::write(config_dir.join("review_policy.md"), "").unwrap();
2518
2519        let loaded =
2520            load_planning_directive(tmp.path(), PlanningDirectiveFile::ReviewPolicy, 120).unwrap();
2521        assert_eq!(loaded, None);
2522    }
2523
2524    #[test]
2525    fn load_planning_directive_returns_none_for_whitespace_only() {
2526        let tmp = tempfile::tempdir().unwrap();
2527        let config_dir = tmp.path().join(".batty").join("team_config");
2528        std::fs::create_dir_all(&config_dir).unwrap();
2529        std::fs::write(config_dir.join("review_policy.md"), "   \n  \n  ").unwrap();
2530
2531        let loaded =
2532            load_planning_directive(tmp.path(), PlanningDirectiveFile::ReviewPolicy, 120).unwrap();
2533        assert_eq!(loaded, None);
2534    }
2535
2536    #[test]
2537    fn load_planning_directive_truncation_boundary_exact_length() {
2538        let tmp = tempfile::tempdir().unwrap();
2539        let config_dir = tmp.path().join(".batty").join("team_config");
2540        std::fs::create_dir_all(&config_dir).unwrap();
2541        std::fs::write(config_dir.join("review_policy.md"), "abcde").unwrap();
2542
2543        // Exact length — no truncation
2544        let loaded = load_planning_directive(tmp.path(), PlanningDirectiveFile::ReviewPolicy, 5)
2545            .unwrap()
2546            .unwrap();
2547        assert_eq!(loaded, "abcde");
2548        assert!(!loaded.contains("truncated"));
2549    }
2550
2551    #[test]
2552    fn load_planning_directive_truncation_boundary_one_over() {
2553        let tmp = tempfile::tempdir().unwrap();
2554        let config_dir = tmp.path().join(".batty").join("team_config");
2555        std::fs::create_dir_all(&config_dir).unwrap();
2556        std::fs::write(config_dir.join("review_policy.md"), "abcdef").unwrap();
2557
2558        // One char over — truncated
2559        let loaded = load_planning_directive(tmp.path(), PlanningDirectiveFile::ReviewPolicy, 5)
2560            .unwrap()
2561            .unwrap();
2562        assert!(loaded.starts_with("abcde"));
2563        assert!(loaded.contains("truncated"));
2564    }
2565
2566    // --- Edge case tests: capability overrides ---
2567
2568    #[test]
2569    fn parse_capability_overrides() {
2570        let yaml = r#"
2571name: test
2572workflow_policy:
2573  capability_overrides:
2574    engineer:
2575      - review
2576      - merge
2577roles:
2578  - name: worker
2579    role_type: engineer
2580    agent: codex
2581"#;
2582        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2583        let overrides = &config.workflow_policy.capability_overrides;
2584        assert_eq!(
2585            overrides.get("engineer").unwrap(),
2586            &vec!["review".to_string(), "merge".to_string()]
2587        );
2588    }
2589
2590    #[test]
2591    fn parse_capability_overrides_empty() {
2592        let config: TeamConfig = serde_yaml::from_str(minimal_yaml()).unwrap();
2593        assert!(config.workflow_policy.capability_overrides.is_empty());
2594    }
2595
2596    // --- Edge case tests: board config boundaries ---
2597
2598    #[test]
2599    fn parse_board_config_zero_thresholds() {
2600        let yaml = r#"
2601name: test
2602board:
2603  rotation_threshold: 0
2604  dispatch_stabilization_delay_secs: 0
2605  dispatch_dedup_window_secs: 0
2606  dispatch_manual_cooldown_secs: 0
2607roles:
2608  - name: worker
2609    role_type: engineer
2610    agent: codex
2611"#;
2612        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2613        assert_eq!(config.board.rotation_threshold, 0);
2614        assert_eq!(config.board.dispatch_stabilization_delay_secs, 0);
2615        assert_eq!(config.board.dispatch_dedup_window_secs, 0);
2616        assert_eq!(config.board.dispatch_manual_cooldown_secs, 0);
2617    }
2618
2619    // --- Edge case tests: load from file errors ---
2620
2621    #[test]
2622    fn load_from_nonexistent_file_returns_error() {
2623        let result = TeamConfig::load(Path::new("/nonexistent/path/team.yaml"));
2624        assert!(result.is_err());
2625        assert!(result.unwrap_err().to_string().contains("failed to read"));
2626    }
2627
2628    #[test]
2629    fn load_from_invalid_yaml_file_returns_error() {
2630        let tmp = tempfile::tempdir().unwrap();
2631        let path = tmp.path().join("team.yaml");
2632        std::fs::write(&path, "{{{{not valid yaml}}}}").unwrap();
2633        let result = TeamConfig::load(&path);
2634        assert!(result.is_err());
2635        assert!(result.unwrap_err().to_string().contains("failed to parse"));
2636    }
2637
2638    #[test]
2639    fn load_from_empty_file_returns_error() {
2640        let tmp = tempfile::tempdir().unwrap();
2641        let path = tmp.path().join("team.yaml");
2642        std::fs::write(&path, "").unwrap();
2643        let result = TeamConfig::load(&path);
2644        assert!(result.is_err());
2645    }
2646
2647    // --- Task #291: Config validation improvements ---
2648
2649    #[test]
2650    fn invalid_talks_to_error_shows_defined_roles() {
2651        let yaml = r#"
2652name: test
2653roles:
2654  - name: architect
2655    role_type: architect
2656    agent: claude
2657  - name: engineer
2658    role_type: engineer
2659    agent: codex
2660    talks_to: [nonexistent]
2661"#;
2662        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2663        let err = config.validate().unwrap_err().to_string();
2664        assert!(
2665            err.contains("references unknown role 'nonexistent'"),
2666            "expected unknown role message, got: {err}"
2667        );
2668        assert!(
2669            err.contains("defined roles:"),
2670            "expected defined roles list, got: {err}"
2671        );
2672        assert!(
2673            err.contains("architect"),
2674            "expected architect in defined roles, got: {err}"
2675        );
2676        assert!(
2677            err.contains("engineer"),
2678            "expected engineer in defined roles, got: {err}"
2679        );
2680    }
2681
2682    #[test]
2683    fn missing_field_error_lists_valid_agents() {
2684        let yaml = r#"
2685name: test
2686roles:
2687  - name: worker
2688    role_type: engineer
2689"#;
2690        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2691        let err = config.validate().unwrap_err().to_string();
2692        assert!(
2693            err.contains("no agent configured"),
2694            "expected missing agent message, got: {err}"
2695        );
2696        assert!(
2697            err.contains("valid agents:"),
2698            "expected valid agents list, got: {err}"
2699        );
2700        assert!(
2701            err.contains("claude"),
2702            "expected claude in valid agents, got: {err}"
2703        );
2704    }
2705
2706    #[test]
2707    fn unknown_backend_error_lists_valid_agents() {
2708        let yaml = r#"
2709name: test
2710roles:
2711  - name: worker
2712    role_type: engineer
2713    agent: gpt4
2714"#;
2715        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2716        let err = config.validate().unwrap_err().to_string();
2717        assert!(
2718            err.contains("unknown agent 'gpt4'"),
2719            "expected unknown agent message, got: {err}"
2720        );
2721        assert!(
2722            err.contains("valid agents:"),
2723            "expected valid agents list, got: {err}"
2724        );
2725        assert!(err.contains("claude"), "expected claude listed, got: {err}");
2726        assert!(err.contains("codex"), "expected codex listed, got: {err}");
2727        assert!(err.contains("kiro"), "expected kiro listed, got: {err}");
2728    }
2729
2730    #[test]
2731    fn verbose_shows_checks_all_pass() {
2732        let yaml = r#"
2733name: test-team
2734roles:
2735  - name: architect
2736    role_type: architect
2737    agent: claude
2738  - name: engineer
2739    role_type: engineer
2740    agent: codex
2741    talks_to: [architect]
2742"#;
2743        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2744        let checks = config.validate_verbose();
2745
2746        assert!(!checks.is_empty(), "expected at least one check");
2747        assert!(
2748            checks.iter().all(|c| c.passed),
2749            "expected all checks to pass, failures: {:?}",
2750            checks.iter().filter(|c| !c.passed).collect::<Vec<_>>()
2751        );
2752
2753        // Verify specific checks are present
2754        assert!(
2755            checks.iter().any(|c| c.name == "team_name"),
2756            "expected team_name check"
2757        );
2758        assert!(
2759            checks.iter().any(|c| c.name == "roles_present"),
2760            "expected roles_present check"
2761        );
2762        assert!(
2763            checks.iter().any(|c| c.name == "team_agent"),
2764            "expected team_agent check"
2765        );
2766        assert!(
2767            checks.iter().any(|c| c.name.starts_with("role_unique:")),
2768            "expected role_unique check"
2769        );
2770        assert!(
2771            checks.iter().any(|c| c.name.starts_with("role_agent:")),
2772            "expected role_agent check"
2773        );
2774        assert!(
2775            checks.iter().any(|c| c.name.starts_with("talks_to:")),
2776            "expected talks_to check"
2777        );
2778    }
2779
2780    #[test]
2781    fn verbose_shows_checks_with_failures() {
2782        let yaml = r#"
2783name: test
2784roles:
2785  - name: worker
2786    role_type: engineer
2787    agent: mystery
2788"#;
2789        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
2790        let checks = config.validate_verbose();
2791
2792        let failed: Vec<_> = checks.iter().filter(|c| !c.passed).collect();
2793        assert!(!failed.is_empty(), "expected at least one failing check");
2794        assert!(
2795            failed.iter().any(|c| c.name.contains("role_agent_valid")),
2796            "expected role_agent_valid failure, failures: {:?}",
2797            failed
2798        );
2799        assert!(
2800            failed.iter().any(|c| c.detail.contains("unknown agent")),
2801            "expected unknown agent detail in failure"
2802        );
2803    }
2804
2805    // --- Property-based tests (proptest) ---
2806
2807    mod proptest_tests {
2808        use super::*;
2809        use proptest::prelude::*;
2810
2811        /// Valid agent backend names recognized by adapter_from_name.
2812        const VALID_AGENTS: &[&str] = &[
2813            "claude",
2814            "claude-code",
2815            "codex",
2816            "codex-cli",
2817            "kiro",
2818            "kiro-cli",
2819        ];
2820
2821        /// Valid role type strings for YAML.
2822        const VALID_ROLE_TYPES: &[&str] = &["user", "architect", "manager", "engineer"];
2823
2824        /// Valid workflow mode strings for YAML.
2825        const VALID_WORKFLOW_MODES: &[&str] = &["legacy", "hybrid", "workflow_first"];
2826
2827        /// Valid orchestrator position strings for YAML.
2828        const VALID_ORCH_POSITIONS: &[&str] = &["bottom", "left"];
2829
2830        /// Strategy for a valid agent name.
2831        fn valid_agent() -> impl Strategy<Value = String> {
2832            proptest::sample::select(VALID_AGENTS).prop_map(|s| s.to_string())
2833        }
2834
2835        /// Strategy for a valid role type.
2836        fn valid_role_type() -> impl Strategy<Value = String> {
2837            proptest::sample::select(VALID_ROLE_TYPES).prop_map(|s| s.to_string())
2838        }
2839
2840        /// Strategy for a safe YAML name (alphanumeric + hyphens, non-empty).
2841        fn safe_name() -> impl Strategy<Value = String> {
2842            "[a-z][a-z0-9\\-]{0,15}".prop_map(|s| s.to_string())
2843        }
2844
2845        // 1. Random role count: valid configs with 1-10 roles never panic on parse
2846        proptest! {
2847            #[test]
2848            fn valid_random_role_count_parses_without_panic(
2849                team_name in safe_name(),
2850                role_count in 1usize..=10,
2851            ) {
2852                let mut roles_yaml = String::new();
2853                for i in 0..role_count {
2854                    let role_type = if i == 0 { "architect" } else { "engineer" };
2855                    roles_yaml.push_str(&format!(
2856                        "  - name: role-{i}\n    role_type: {role_type}\n    agent: claude\n"
2857                    ));
2858                }
2859                let yaml = format!("name: {team_name}\nroles:\n{roles_yaml}");
2860                let result = serde_yaml::from_str::<TeamConfig>(&yaml);
2861                prop_assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
2862                let config = result.unwrap();
2863                prop_assert_eq!(config.roles.len(), role_count);
2864            }
2865        }
2866
2867        // 2. Random agent backends: all valid agent names parse successfully
2868        proptest! {
2869            #[test]
2870            fn valid_agent_backend_parses(agent in valid_agent()) {
2871                let yaml = format!(
2872                    "name: test\nroles:\n  - name: worker\n    role_type: engineer\n    agent: {agent}\n"
2873                );
2874                let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
2875                prop_assert_eq!(config.roles[0].agent.as_deref(), Some(agent.as_str()));
2876            }
2877        }
2878
2879        // 3. Random invalid agent names: parse succeeds but validate rejects
2880        proptest! {
2881            #[test]
2882            fn invalid_agent_backend_rejected_by_validate(
2883                agent in "[a-z]{3,10}".prop_filter(
2884                    "must not be a valid agent",
2885                    |s| !VALID_AGENTS.contains(&s.as_str()),
2886                ),
2887            ) {
2888                let yaml = format!(
2889                    "name: test\nroles:\n  - name: worker\n    role_type: engineer\n    agent: {agent}\n"
2890                );
2891                let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
2892                let err = config.validate().unwrap_err().to_string();
2893                prop_assert!(err.contains("unknown agent"), "Error was: {err}");
2894            }
2895        }
2896
2897        // 4. Team-level agent applied to roles without explicit agent
2898        proptest! {
2899            #[test]
2900            fn team_level_agent_applied_to_agentless_roles(
2901                team_agent in valid_agent(),
2902                role_count in 1usize..=5,
2903            ) {
2904                let mut roles_yaml = String::new();
2905                for i in 0..role_count {
2906                    let role_type = if i == 0 { "architect" } else { "engineer" };
2907                    roles_yaml.push_str(&format!(
2908                        "  - name: role-{i}\n    role_type: {role_type}\n"
2909                    ));
2910                }
2911                let yaml = format!("name: test\nagent: {team_agent}\nroles:\n{roles_yaml}");
2912                let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
2913                prop_assert!(config.validate().is_ok());
2914                for role in &config.roles {
2915                    let resolved = config.resolve_agent(role);
2916                    prop_assert_eq!(resolved.as_deref(), Some(team_agent.as_str()));
2917                }
2918            }
2919        }
2920
2921        // 5. Random workflow policy values: never panic on parse
2922        proptest! {
2923            #[test]
2924            fn random_workflow_policy_values_parse(
2925                wip_eng in proptest::option::of(0u32..100),
2926                wip_rev in proptest::option::of(0u32..100),
2927                escalation in 0u64..100_000,
2928                stall in 0u64..100_000,
2929                max_restarts in 0u32..20,
2930                health_interval in 0u64..10_000,
2931                uncommitted_warn in 0usize..1000,
2932            ) {
2933                let mut policy_yaml = String::from("workflow_policy:\n");
2934                if let Some(v) = wip_eng {
2935                    policy_yaml.push_str(&format!("  wip_limit_per_engineer: {v}\n"));
2936                }
2937                if let Some(v) = wip_rev {
2938                    policy_yaml.push_str(&format!("  wip_limit_per_reviewer: {v}\n"));
2939                }
2940                policy_yaml.push_str(&format!("  escalation_threshold_secs: {escalation}\n"));
2941                policy_yaml.push_str(&format!("  stall_threshold_secs: {stall}\n"));
2942                policy_yaml.push_str(&format!("  max_stall_restarts: {max_restarts}\n"));
2943                policy_yaml.push_str(&format!("  health_check_interval_secs: {health_interval}\n"));
2944                policy_yaml.push_str(&format!("  uncommitted_warn_threshold: {uncommitted_warn}\n"));
2945
2946                let yaml = format!(
2947                    "name: test\n{policy_yaml}roles:\n  - name: w\n    role_type: engineer\n    agent: codex\n"
2948                );
2949                let result = serde_yaml::from_str::<TeamConfig>(&yaml);
2950                prop_assert!(result.is_ok(), "Parse failed: {:?}", result.err());
2951                let config = result.unwrap();
2952                prop_assert_eq!(config.workflow_policy.escalation_threshold_secs, escalation);
2953                prop_assert_eq!(config.workflow_policy.stall_threshold_secs, stall);
2954                prop_assert_eq!(config.workflow_policy.max_stall_restarts, max_restarts);
2955            }
2956        }
2957
2958        // 6. Missing optional fields: config parses with defaults
2959        proptest! {
2960            #[test]
2961            fn missing_optional_fields_use_defaults(
2962                team_name in safe_name(),
2963                instances in 1u32..=20,
2964            ) {
2965                // Minimal config: only required fields (name, roles with name+role_type+agent)
2966                let yaml = format!(
2967                    "name: {team_name}\nroles:\n  - name: w\n    role_type: engineer\n    agent: codex\n    instances: {instances}\n"
2968                );
2969                let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
2970                // All optional fields should have defaults
2971                prop_assert_eq!(config.workflow_mode, WorkflowMode::Legacy);
2972                prop_assert!(config.orchestrator_pane);
2973                prop_assert_eq!(config.orchestrator_position, OrchestratorPosition::Bottom);
2974                prop_assert!(config.layout.is_none());
2975                prop_assert!(config.agent.is_none());
2976                prop_assert!(config.automation_sender.is_none());
2977                prop_assert!(config.external_senders.is_empty());
2978                prop_assert_eq!(config.board.rotation_threshold, 20);
2979                prop_assert!(config.board.auto_dispatch);
2980                prop_assert_eq!(config.roles[0].instances, instances);
2981            }
2982        }
2983
2984        // 7. Extra unknown fields: serde ignores them (forward compatibility)
2985        proptest! {
2986            #[test]
2987            fn extra_unknown_fields_ignored(
2988                extra_key in "[a-z_]{3,12}",
2989                extra_val in "[a-z0-9]{1,10}",
2990            ) {
2991                let yaml = format!(
2992                    "name: test\n{extra_key}: {extra_val}\nroles:\n  - name: w\n    role_type: engineer\n    agent: codex\n    {extra_key}: {extra_val}\n"
2993                );
2994                let result = serde_yaml::from_str::<TeamConfig>(&yaml);
2995                prop_assert!(result.is_ok(), "Unknown fields should be ignored: {:?}", result.err());
2996            }
2997        }
2998
2999        // 8. Random workflow mode: all valid modes parse correctly
3000        proptest! {
3001            #[test]
3002            fn valid_workflow_modes_parse(
3003                mode_idx in 0usize..VALID_WORKFLOW_MODES.len(),
3004            ) {
3005                let mode = VALID_WORKFLOW_MODES[mode_idx];
3006                let yaml = format!(
3007                    "name: test\nworkflow_mode: {mode}\nroles:\n  - name: w\n    role_type: engineer\n    agent: codex\n"
3008                );
3009                let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
3010                match mode {
3011                    "legacy" => prop_assert_eq!(config.workflow_mode, WorkflowMode::Legacy),
3012                    "hybrid" => prop_assert_eq!(config.workflow_mode, WorkflowMode::Hybrid),
3013                    "workflow_first" => prop_assert_eq!(config.workflow_mode, WorkflowMode::WorkflowFirst),
3014                    _ => unreachable!(),
3015                }
3016            }
3017        }
3018
3019        // 9. Invalid workflow modes: produce parse errors, not panics
3020        proptest! {
3021            #[test]
3022            fn invalid_workflow_mode_produces_error(
3023                mode in "[a-z]{3,10}".prop_filter(
3024                    "must not be valid",
3025                    |s| !VALID_WORKFLOW_MODES.contains(&s.as_str()),
3026                ),
3027            ) {
3028                let yaml = format!(
3029                    "name: test\nworkflow_mode: {mode}\nroles:\n  - name: w\n    role_type: engineer\n    agent: codex\n"
3030                );
3031                let result = serde_yaml::from_str::<TeamConfig>(&yaml);
3032                prop_assert!(result.is_err(), "Should reject invalid workflow_mode '{mode}'");
3033            }
3034        }
3035
3036        // 10. Random orchestrator position: valid ones parse, invalid error
3037        proptest! {
3038            #[test]
3039            fn valid_orchestrator_positions_parse(
3040                pos_idx in 0usize..VALID_ORCH_POSITIONS.len(),
3041            ) {
3042                let pos = VALID_ORCH_POSITIONS[pos_idx];
3043                let yaml = format!(
3044                    "name: test\norchestrator_position: {pos}\nroles:\n  - name: w\n    role_type: engineer\n    agent: codex\n"
3045                );
3046                let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
3047                match pos {
3048                    "bottom" => prop_assert_eq!(config.orchestrator_position, OrchestratorPosition::Bottom),
3049                    "left" => prop_assert_eq!(config.orchestrator_position, OrchestratorPosition::Left),
3050                    _ => unreachable!(),
3051                }
3052            }
3053        }
3054
3055        // 11. Layout zone widths: parsing never panics, validation catches >100%
3056        proptest! {
3057            #[test]
3058            fn layout_zone_widths_parse_and_validate(
3059                zone_count in 1usize..=6,
3060                width in 0u32..=100,
3061            ) {
3062                let mut zones_yaml = String::new();
3063                for i in 0..zone_count {
3064                    zones_yaml.push_str(&format!(
3065                        "    - name: zone-{i}\n      width_pct: {width}\n"
3066                    ));
3067                }
3068                let yaml = format!(
3069                    "name: test\nlayout:\n  zones:\n{zones_yaml}roles:\n  - name: w\n    role_type: engineer\n    agent: codex\n"
3070                );
3071                let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
3072                let total: u32 = config.layout.as_ref().unwrap().zones.iter().map(|z| z.width_pct).sum();
3073                if total > 100 {
3074                    prop_assert!(config.validate().is_err());
3075                } else {
3076                    prop_assert!(config.validate().is_ok());
3077                }
3078            }
3079        }
3080
3081        // 12. Random automation config booleans: never panic
3082        proptest! {
3083            #[test]
3084            fn random_automation_booleans_parse(
3085                nudges in proptest::bool::ANY,
3086                standups in proptest::bool::ANY,
3087                failure_det in proptest::bool::ANY,
3088                triage in proptest::bool::ANY,
3089                review in proptest::bool::ANY,
3090                owned in proptest::bool::ANY,
3091                dispatch in proptest::bool::ANY,
3092                arch_util in proptest::bool::ANY,
3093            ) {
3094                let yaml = format!(
3095                    "name: test\nautomation:\n  timeout_nudges: {nudges}\n  standups: {standups}\n  failure_pattern_detection: {failure_det}\n  triage_interventions: {triage}\n  review_interventions: {review}\n  owned_task_interventions: {owned}\n  manager_dispatch_interventions: {dispatch}\n  architect_utilization_interventions: {arch_util}\nroles:\n  - name: w\n    role_type: engineer\n    agent: codex\n"
3096                );
3097                let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
3098                prop_assert_eq!(config.automation.timeout_nudges, nudges);
3099                prop_assert_eq!(config.automation.standups, standups);
3100                prop_assert_eq!(config.automation.failure_pattern_detection, failure_det);
3101                prop_assert_eq!(config.automation.triage_interventions, triage);
3102                prop_assert_eq!(config.automation.review_interventions, review);
3103                prop_assert_eq!(config.automation.owned_task_interventions, owned);
3104                prop_assert_eq!(config.automation.manager_dispatch_interventions, dispatch);
3105                prop_assert_eq!(config.automation.architect_utilization_interventions, arch_util);
3106            }
3107        }
3108
3109        // 13. Random standup/board config values: parse without panic
3110        proptest! {
3111            #[test]
3112            fn random_standup_and_board_values_parse(
3113                interval in 0u64..=1_000_000,
3114                output_lines in 0u32..=500,
3115                rotation in 0u32..=1000,
3116                auto_dispatch in proptest::bool::ANY,
3117            ) {
3118                let yaml = format!(
3119                    "name: test\nstandup:\n  interval_secs: {interval}\n  output_lines: {output_lines}\nboard:\n  rotation_threshold: {rotation}\n  auto_dispatch: {auto_dispatch}\nroles:\n  - name: w\n    role_type: engineer\n    agent: codex\n"
3120                );
3121                let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
3122                prop_assert_eq!(config.standup.interval_secs, interval);
3123                prop_assert_eq!(config.standup.output_lines, output_lines);
3124                prop_assert_eq!(config.board.rotation_threshold, rotation);
3125                prop_assert_eq!(config.board.auto_dispatch, auto_dispatch);
3126            }
3127        }
3128
3129        // 14. Random role type: all valid types parse
3130        proptest! {
3131            #[test]
3132            fn all_role_types_parse(role_type in valid_role_type()) {
3133                let agent_line = if role_type == "user" { "" } else { "    agent: claude\n" };
3134                let yaml = format!(
3135                    "name: test\nroles:\n  - name: r\n    role_type: {role_type}\n{agent_line}"
3136                );
3137                let config: TeamConfig = serde_yaml::from_str(&yaml).unwrap();
3138                prop_assert_eq!(config.roles.len(), 1);
3139            }
3140        }
3141
3142        // 15. Auto-merge policy random values: never panic
3143        proptest! {
3144            #[test]
3145            fn random_auto_merge_policy_parses(
3146                enabled in proptest::bool::ANY,
3147                max_diff in 0usize..10_000,
3148                max_files in 0usize..100,
3149                max_modules in 0usize..50,
3150                confidence in 0.0f64..=1.0,
3151                require_tests in proptest::bool::ANY,
3152            ) {
3153                let yaml = format!(
3154                    "name: test\nworkflow_policy:\n  auto_merge:\n    enabled: {enabled}\n    max_diff_lines: {max_diff}\n    max_files_changed: {max_files}\n    max_modules_touched: {max_modules}\n    confidence_threshold: {confidence}\n    require_tests_pass: {require_tests}\nroles:\n  - name: w\n    role_type: engineer\n    agent: codex\n"
3155                );
3156                let result = serde_yaml::from_str::<TeamConfig>(&yaml);
3157                prop_assert!(result.is_ok(), "Parse failed: {:?}", result.err());
3158                let config = result.unwrap();
3159                prop_assert_eq!(config.workflow_policy.auto_merge.enabled, enabled);
3160                prop_assert_eq!(config.workflow_policy.auto_merge.max_diff_lines, max_diff);
3161                prop_assert_eq!(config.workflow_policy.auto_merge.max_files_changed, max_files);
3162                prop_assert_eq!(config.workflow_policy.auto_merge.require_tests_pass, require_tests);
3163            }
3164        }
3165
3166        // 16. Completely random YAML strings: never panic (errors OK)
3167        proptest! {
3168            #[test]
3169            fn arbitrary_yaml_never_panics(yaml in "\\PC{0,200}") {
3170                // Parsing arbitrary bytes should either succeed or return Err, never panic
3171                let _ = serde_yaml::from_str::<TeamConfig>(&yaml);
3172            }
3173        }
3174    }
3175}