Skip to main content

batty_cli/team/config/
mod.rs

1//! Team configuration parsed from `.batty/team_config/team.yaml`.
2
3mod types;
4
5pub use types::*;
6
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result, bail};
11
12use super::TEAM_CONFIG_DIR;
13use crate::agent;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum PlanningDirectiveFile {
17    ReplenishmentContext,
18    ReviewPolicy,
19    EscalationPolicy,
20}
21
22impl PlanningDirectiveFile {
23    pub fn file_name(self) -> &'static str {
24        match self {
25            Self::ReplenishmentContext => "replenishment_context.md",
26            Self::ReviewPolicy => "review_policy.md",
27            Self::EscalationPolicy => "escalation_policy.md",
28        }
29    }
30
31    pub fn path_for(self, project_root: &Path) -> PathBuf {
32        project_root
33            .join(".batty")
34            .join(TEAM_CONFIG_DIR)
35            .join(self.file_name())
36    }
37}
38
39/// A single validation check result.
40#[derive(Debug, Clone)]
41pub struct ValidationCheck {
42    pub name: String,
43    pub passed: bool,
44    pub detail: String,
45}
46
47impl TeamConfig {
48    pub fn resolve_claude_auth(&self, role: &RoleDef) -> ClaudeAuth {
49        ClaudeAuth {
50            mode: role.auth_mode.unwrap_or_default(),
51            env: role.auth_env.clone(),
52        }
53    }
54
55    pub fn role_def(&self, role_name: &str) -> Option<&RoleDef> {
56        self.roles.iter().find(|role| role.name == role_name)
57    }
58
59    pub fn role_barrier_group(&self, role_name: &str) -> Option<&str> {
60        self.role_def(role_name)
61            .and_then(|role| role.barrier_group.as_deref())
62    }
63
64    pub fn orchestrator_enabled(&self) -> bool {
65        self.workflow_mode.enables_runtime_surface() && self.orchestrator_pane
66    }
67
68    /// Resolve the effective agent for a role.
69    ///
70    /// Resolution order: role-level agent > team-level agent > "claude".
71    pub fn resolve_agent(&self, role: &RoleDef) -> Option<String> {
72        if role.role_type == RoleType::User {
73            return None;
74        }
75        Some(
76            role.agent
77                .clone()
78                .or_else(|| self.agent.clone())
79                .unwrap_or_else(|| "claude".to_string()),
80        )
81    }
82
83    /// Check if a role is allowed to send messages to another role.
84    ///
85    /// Uses `talks_to` if configured. If `talks_to` is empty for a role,
86    /// falls back to the default hierarchy:
87    /// - User <-> Architect
88    /// - Architect <-> Manager
89    /// - Manager <-> Engineer
90    ///
91    /// The `from` and `to` are role definition names (not member instance names).
92    /// "human" is always allowed to talk to any role.
93    pub fn can_talk(&self, from_role: &str, to_role: &str) -> bool {
94        // human (CLI user) can always send to anyone
95        if from_role == "human" {
96            return true;
97        }
98        // daemon-generated messages (standups, nudges) always allowed
99        if from_role == "daemon" {
100            return true;
101        }
102        // external senders (e.g. email-router, slack-bridge) can send to anyone
103        if self.external_senders.iter().any(|s| s == from_role) {
104            return true;
105        }
106
107        let from_def = self.roles.iter().find(|r| r.name == from_role);
108        let Some(from_def) = from_def else {
109            return false;
110        };
111
112        // If talks_to is explicitly configured, use it
113        if !from_def.talks_to.is_empty() {
114            return from_def.talks_to.iter().any(|t| t == to_role);
115        }
116
117        // Default hierarchy: user<->architect, architect<->manager, manager<->engineer
118        let to_def = self.roles.iter().find(|r| r.name == to_role);
119        let Some(to_def) = to_def else {
120            return false;
121        };
122
123        matches!(
124            (from_def.role_type, to_def.role_type),
125            (RoleType::User, RoleType::Architect)
126                | (RoleType::Architect, RoleType::User)
127                | (RoleType::Architect, RoleType::Manager)
128                | (RoleType::Manager, RoleType::Architect)
129                | (RoleType::Manager, RoleType::Engineer)
130                | (RoleType::Engineer, RoleType::Manager)
131        )
132    }
133
134    /// Load team config from a YAML file.
135    pub fn load(path: &Path) -> Result<Self> {
136        let content = std::fs::read_to_string(path)
137            .with_context(|| format!("failed to read {}", path.display()))?;
138        let config: TeamConfig = serde_yaml::from_str(&content)
139            .with_context(|| format!("failed to parse {}", path.display()))?;
140        Ok(config)
141    }
142
143    /// Validate the team config. Returns an error if invalid.
144    pub fn validate(&self) -> Result<()> {
145        if self.name.is_empty() {
146            bail!("team name cannot be empty");
147        }
148
149        if self.roles.is_empty() {
150            bail!("team must have at least one role");
151        }
152
153        let valid_agents = agent::KNOWN_AGENT_NAMES.join(", ");
154
155        // Validate team-level agent if specified.
156        if let Some(team_agent) = self.agent.as_deref() {
157            if agent::adapter_from_name(team_agent).is_none() {
158                bail!(
159                    "unknown team-level agent '{}'; valid agents: {}",
160                    team_agent,
161                    valid_agents
162                );
163            }
164        }
165
166        let mut role_names: HashSet<&str> = HashSet::new();
167        for role in &self.roles {
168            if role.name.is_empty() {
169                bail!("role has empty name — every role requires a non-empty 'name' field");
170            }
171
172            if !role_names.insert(&role.name) {
173                bail!("duplicate role name: '{}'", role.name);
174            }
175
176            // Non-user roles need an agent — either their own or the team default.
177            if role.role_type != RoleType::User && role.agent.is_none() && self.agent.is_none() {
178                bail!(
179                    "role '{}' has no agent configured — \
180                     set a role-level 'agent' field or a team-level 'agent' default; \
181                     valid agents: {}",
182                    role.name,
183                    valid_agents
184                );
185            }
186
187            if role.role_type == RoleType::User && role.agent.is_some() {
188                bail!(
189                    "role '{}' is a user but has an agent configured; users use channels instead",
190                    role.name
191                );
192            }
193
194            if role.instances == 0 {
195                bail!("role '{}' has zero instances", role.name);
196            }
197
198            if let Some(agent_name) = role.agent.as_deref()
199                && agent::adapter_from_name(agent_name).is_none()
200            {
201                bail!(
202                    "role '{}' uses unknown agent '{}'; valid agents: {}",
203                    role.name,
204                    agent_name,
205                    valid_agents
206                );
207            }
208
209            for (instance_name, override_cfg) in &role.instance_overrides {
210                if let Some(agent_name) = override_cfg.agent.as_deref()
211                    && !super::multi_provider::is_known_instance_override_backend(agent_name)
212                {
213                    bail!(
214                        "role '{}' instance override '{}' uses unknown agent '{}'; valid agents: {}",
215                        role.name,
216                        instance_name,
217                        agent_name,
218                        valid_agents
219                    );
220                }
221            }
222
223            let effective_agent = role.agent.as_deref().or(self.agent.as_deref());
224            if (role.auth_mode.is_some() || !role.auth_env.is_empty())
225                && !matches!(effective_agent, Some("claude" | "claude-code"))
226            {
227                bail!(
228                    "role '{}' configures Claude auth but effective agent is not claude",
229                    role.name
230                );
231            }
232            if role.auth_mode != Some(ClaudeAuthMode::Custom) && !role.auth_env.is_empty() {
233                bail!(
234                    "role '{}' sets auth_env but auth_mode is not 'custom'",
235                    role.name
236                );
237            }
238            for env_name in &role.auth_env {
239                if !is_valid_env_name(env_name) {
240                    bail!(
241                        "role '{}' has invalid auth_env entry '{}'; expected shell env name",
242                        role.name,
243                        env_name
244                    );
245                }
246            }
247        }
248
249        if self.workflow_policy.clean_room_mode {
250            if self.workflow_policy.handoff_directory.trim().is_empty() {
251                bail!("workflow_policy.handoff_directory cannot be empty in clean_room_mode");
252            }
253
254            for group_name in self.workflow_policy.barrier_groups.keys() {
255                if group_name.trim().is_empty() {
256                    bail!("workflow_policy.barrier_groups cannot contain an empty group name");
257                }
258            }
259
260            for (group_name, roles) in &self.workflow_policy.barrier_groups {
261                for role_name in roles {
262                    if !role_names.contains(role_name.as_str()) {
263                        bail!(
264                            "workflow_policy.barrier_groups['{}'] references unknown role '{}'",
265                            group_name,
266                            role_name
267                        );
268                    }
269                }
270            }
271
272            for role in &self.roles {
273                if let Some(group) = role.barrier_group.as_deref()
274                    && !self.workflow_policy.barrier_groups.is_empty()
275                    && !self.workflow_policy.barrier_groups.contains_key(group)
276                {
277                    bail!(
278                        "role '{}' references unknown barrier_group '{}'",
279                        role.name,
280                        group
281                    );
282                }
283            }
284        }
285
286        // Validate talks_to references exist
287        let all_role_names: Vec<&str> = role_names.iter().copied().collect();
288        for role in &self.roles {
289            for target in &role.talks_to {
290                if !role_names.contains(target.as_str()) {
291                    bail!(
292                        "role '{}' references unknown role '{}' in talks_to; \
293                         defined roles: {}",
294                        role.name,
295                        target,
296                        all_role_names.join(", ")
297                    );
298                }
299            }
300        }
301
302        if let Some(sender) = &self.automation_sender
303            && !role_names.contains(sender.as_str())
304            && sender != "human"
305        {
306            bail!(
307                "automation_sender references unknown role '{}'; \
308                 defined roles: {}",
309                sender,
310                all_role_names.join(", ")
311            );
312        }
313
314        // Validate layout zones if present
315        if let Some(layout) = &self.layout {
316            let total_pct: u32 = layout.zones.iter().map(|z| z.width_pct).sum();
317            if total_pct > 100 {
318                bail!("layout zone widths sum to {}%, exceeds 100%", total_pct);
319            }
320        }
321
322        Ok(())
323    }
324
325    /// Run all validation checks, collecting results for each check.
326    /// Returns a list of (check_name, passed, detail) tuples.
327    pub fn validate_verbose(&self) -> Vec<ValidationCheck> {
328        let mut checks = Vec::new();
329
330        // 1. Team name
331        let name_ok = !self.name.is_empty();
332        checks.push(ValidationCheck {
333            name: "team_name".to_string(),
334            passed: name_ok,
335            detail: if name_ok {
336                format!("team name: '{}'", self.name)
337            } else {
338                "team name is empty".to_string()
339            },
340        });
341
342        // 2. Roles present
343        let roles_ok = !self.roles.is_empty();
344        checks.push(ValidationCheck {
345            name: "roles_present".to_string(),
346            passed: roles_ok,
347            detail: if roles_ok {
348                format!("{} role(s) defined", self.roles.len())
349            } else {
350                "no roles defined".to_string()
351            },
352        });
353
354        if !roles_ok {
355            return checks;
356        }
357
358        // 3. Team-level agent
359        let team_agent_ok = match self.agent.as_deref() {
360            Some(name) => agent::adapter_from_name(name).is_some(),
361            None => true,
362        };
363        checks.push(ValidationCheck {
364            name: "team_agent".to_string(),
365            passed: team_agent_ok,
366            detail: match self.agent.as_deref() {
367                Some(name) if team_agent_ok => format!("team agent: '{name}'"),
368                Some(name) => format!("unknown team agent: '{name}'"),
369                None => "no team-level agent (roles must set their own)".to_string(),
370            },
371        });
372
373        // 4. Per-role checks
374        let mut role_names: HashSet<&str> = HashSet::new();
375        for role in &self.roles {
376            let unique = role_names.insert(&role.name);
377            checks.push(ValidationCheck {
378                name: format!("role_unique:{}", role.name),
379                passed: unique,
380                detail: if unique {
381                    format!("role '{}' is unique", role.name)
382                } else {
383                    format!("duplicate role name: '{}'", role.name)
384                },
385            });
386
387            let has_agent =
388                role.role_type == RoleType::User || role.agent.is_some() || self.agent.is_some();
389            checks.push(ValidationCheck {
390                name: format!("role_agent:{}", role.name),
391                passed: has_agent,
392                detail: if has_agent {
393                    let effective = role
394                        .agent
395                        .as_deref()
396                        .or(self.agent.as_deref())
397                        .unwrap_or("(user)");
398                    format!("role '{}' agent: {effective}", role.name)
399                } else {
400                    format!("role '{}' has no agent", role.name)
401                },
402            });
403
404            if let Some(agent_name) = role.agent.as_deref() {
405                let valid = agent::adapter_from_name(agent_name).is_some();
406                checks.push(ValidationCheck {
407                    name: format!("role_agent_valid:{}", role.name),
408                    passed: valid,
409                    detail: if valid {
410                        format!("role '{}' agent '{}' is valid", role.name, agent_name)
411                    } else {
412                        format!("role '{}' uses unknown agent '{}'", role.name, agent_name)
413                    },
414                });
415            }
416
417            for (instance_name, override_cfg) in &role.instance_overrides {
418                if let Some(agent_name) = override_cfg.agent.as_deref() {
419                    let valid =
420                        super::multi_provider::is_known_instance_override_backend(agent_name);
421                    checks.push(ValidationCheck {
422                        name: format!("role_instance_agent_valid:{}:{}", role.name, instance_name),
423                        passed: valid,
424                        detail: if valid {
425                            format!(
426                                "role '{}' instance override '{}' agent '{}' is valid",
427                                role.name, instance_name, agent_name
428                            )
429                        } else {
430                            format!(
431                                "role '{}' instance override '{}' uses unknown agent '{}'",
432                                role.name, instance_name, agent_name
433                            )
434                        },
435                    });
436                }
437            }
438
439            let instances_ok = role.instances > 0;
440            checks.push(ValidationCheck {
441                name: format!("role_instances:{}", role.name),
442                passed: instances_ok,
443                detail: format!("role '{}' instances: {}", role.name, role.instances),
444            });
445        }
446
447        // 5. talks_to references
448        for role in &self.roles {
449            for target in &role.talks_to {
450                let valid = role_names.contains(target.as_str());
451                checks.push(ValidationCheck {
452                    name: format!("talks_to:{}→{}", role.name, target),
453                    passed: valid,
454                    detail: if valid {
455                        format!("role '{}' → '{}' is valid", role.name, target)
456                    } else {
457                        format!(
458                            "role '{}' references unknown role '{}' in talks_to",
459                            role.name, target
460                        )
461                    },
462                });
463            }
464        }
465
466        // 6. automation_sender
467        if let Some(sender) = &self.automation_sender {
468            let valid = role_names.contains(sender.as_str()) || sender == "human";
469            checks.push(ValidationCheck {
470                name: "automation_sender".to_string(),
471                passed: valid,
472                detail: if valid {
473                    format!("automation_sender '{sender}' is valid")
474                } else {
475                    format!("automation_sender references unknown role '{sender}'")
476                },
477            });
478        }
479
480        // 7. Layout zones
481        if let Some(layout) = &self.layout {
482            let total_pct: u32 = layout.zones.iter().map(|z| z.width_pct).sum();
483            let valid = total_pct <= 100;
484            checks.push(ValidationCheck {
485                name: "layout_zones".to_string(),
486                passed: valid,
487                detail: if valid {
488                    format!("layout zones sum to {total_pct}%")
489                } else {
490                    format!("layout zones sum to {total_pct}%, exceeds 100%")
491                },
492            });
493        }
494
495        // 8. Backend health checks (warnings, not failures)
496        for (agent_name, health) in self.backend_health_results() {
497            let healthy = health.is_healthy();
498            checks.push(ValidationCheck {
499                name: format!("backend_health:{agent_name}"),
500                passed: healthy,
501                detail: if healthy {
502                    format!("backend '{agent_name}' is available")
503                } else {
504                    format!(
505                        "backend '{agent_name}' binary not found on PATH (status: {})",
506                        health.as_str()
507                    )
508                },
509            });
510        }
511
512        checks
513    }
514
515    /// Collect unique configured backends and their health status.
516    pub fn backend_health_results(&self) -> Vec<(String, agent::BackendHealth)> {
517        let mut seen = HashSet::new();
518        let mut results = Vec::new();
519        for role in &self.roles {
520            if let Some(agent_name) = self.resolve_agent(role) {
521                if seen.insert(agent_name.clone()) {
522                    let health = agent::health_check_by_name(&agent_name)
523                        .unwrap_or(agent::BackendHealth::Unreachable);
524                    results.push((agent_name, health));
525                }
526            }
527        }
528        results
529    }
530
531    /// Return warning messages for any unhealthy backends.
532    pub fn check_backend_health(&self) -> Vec<String> {
533        self.backend_health_results()
534            .into_iter()
535            .filter(|(_, health)| !health.is_healthy())
536            .map(|(name, health)| {
537                format!(
538                    "backend '{name}' binary not found on PATH (status: {})",
539                    health.as_str()
540                )
541            })
542            .collect()
543    }
544}
545
546#[derive(Debug, Clone, PartialEq, Eq)]
547pub struct ClaudeAuth {
548    pub mode: ClaudeAuthMode,
549    pub env: Vec<String>,
550}
551
552fn is_valid_env_name(value: &str) -> bool {
553    let mut chars = value.chars();
554    match chars.next() {
555        Some(first) if first == '_' || first.is_ascii_alphabetic() => {}
556        _ => return false,
557    }
558    chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
559}
560
561pub fn load_planning_directive(
562    project_root: &Path,
563    directive: PlanningDirectiveFile,
564    max_chars: usize,
565) -> Result<Option<String>> {
566    let path = directive.path_for(project_root);
567    match std::fs::read_to_string(&path) {
568        Ok(content) => {
569            let trimmed = content.trim();
570            if trimmed.is_empty() {
571                return Ok(None);
572            }
573
574            let total_chars = trimmed.chars().count();
575            let truncated = trimmed.chars().take(max_chars).collect::<String>();
576            if total_chars > max_chars {
577                Ok(Some(format!(
578                    "{truncated}\n\n[truncated to {max_chars} chars from {}]",
579                    directive.file_name()
580                )))
581            } else {
582                Ok(Some(truncated))
583            }
584        }
585        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
586        Err(error) => Err(error)
587            .with_context(|| format!("failed to read planning directive {}", path.display())),
588    }
589}
590
591#[cfg(test)]
592mod tests;
593#[cfg(test)]
594mod tests_advanced;
595#[cfg(test)]
596mod tests_proptest;