1use 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 #[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 #[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#[derive(Debug, Clone, Deserialize)]
113pub struct ReviewTimeoutOverride {
114 pub review_nudge_threshold_secs: Option<u64>,
116 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)] 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 #[serde(default)]
385 pub bot_token: Option<String>,
386 #[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 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 pub fn can_talk(&self, from_role: &str, to_role: &str) -> bool {
551 if from_role == "human" {
553 return true;
554 }
555 if from_role == "daemon" {
557 return true;
558 }
559 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 !from_def.talks_to.is_empty() {
571 return from_def.talks_to.iter().any(|t| t == to_role);
572 }
573
574 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 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 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 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 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 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 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 pub fn validate_verbose(&self) -> Vec<ValidationCheck> {
709 let mut checks = Vec::new();
710
711 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 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 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 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 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 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 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#[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 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 assert!(!config.can_talk("architect", "engineer"));
1476 assert!(!config.can_talk("engineer", "architect"));
1477
1478 assert!(config.can_talk("human", "architect"));
1480 assert!(config.can_talk("human", "engineer"));
1481
1482 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 assert!(config.can_talk("architect", "engineer"));
1510 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 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 assert_eq!(policy.review_nudge_threshold_secs, 1800);
1809 assert_eq!(policy.review_timeout_secs, 7200);
1810
1811 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 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 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 #[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 assert_eq!(config.resolve_agent(architect).as_deref(), Some("claude"));
1872 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 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 assert_eq!(config.roles[2].agent.as_deref(), Some("claude"));
1987 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 #[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 assert!(err.contains("empty name"));
2038 }
2039
2040 #[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 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 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 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 #[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 assert!(config.can_talk("solo", "solo"));
2182 config.validate().unwrap(); }
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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 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 mod proptest_tests {
2808 use super::*;
2809 use proptest::prelude::*;
2810
2811 const VALID_AGENTS: &[&str] = &[
2813 "claude",
2814 "claude-code",
2815 "codex",
2816 "codex-cli",
2817 "kiro",
2818 "kiro-cli",
2819 ];
2820
2821 const VALID_ROLE_TYPES: &[&str] = &["user", "architect", "manager", "engineer"];
2823
2824 const VALID_WORKFLOW_MODES: &[&str] = &["legacy", "hybrid", "workflow_first"];
2826
2827 const VALID_ORCH_POSITIONS: &[&str] = &["bottom", "left"];
2829
2830 fn valid_agent() -> impl Strategy<Value = String> {
2832 proptest::sample::select(VALID_AGENTS).prop_map(|s| s.to_string())
2833 }
2834
2835 fn valid_role_type() -> impl Strategy<Value = String> {
2837 proptest::sample::select(VALID_ROLE_TYPES).prop_map(|s| s.to_string())
2838 }
2839
2840 fn safe_name() -> impl Strategy<Value = String> {
2842 "[a-z][a-z0-9\\-]{0,15}".prop_map(|s| s.to_string())
2843 }
2844
2845 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 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 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 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 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 proptest! {
2960 #[test]
2961 fn missing_optional_fields_use_defaults(
2962 team_name in safe_name(),
2963 instances in 1u32..=20,
2964 ) {
2965 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 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 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 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 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 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 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 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 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 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 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 proptest! {
3168 #[test]
3169 fn arbitrary_yaml_never_panics(yaml in "\\PC{0,200}") {
3170 let _ = serde_yaml::from_str::<TeamConfig>(&yaml);
3172 }
3173 }
3174 }
3175}