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 orchestrator_enabled(&self) -> bool {
49        self.workflow_mode.enables_runtime_surface() && self.orchestrator_pane
50    }
51
52    /// Resolve the effective agent for a role.
53    ///
54    /// Resolution order: role-level agent > team-level agent > "claude".
55    pub fn resolve_agent(&self, role: &RoleDef) -> Option<String> {
56        if role.role_type == RoleType::User {
57            return None;
58        }
59        Some(
60            role.agent
61                .clone()
62                .or_else(|| self.agent.clone())
63                .unwrap_or_else(|| "claude".to_string()),
64        )
65    }
66
67    /// Check if a role is allowed to send messages to another role.
68    ///
69    /// Uses `talks_to` if configured. If `talks_to` is empty for a role,
70    /// falls back to the default hierarchy:
71    /// - User <-> Architect
72    /// - Architect <-> Manager
73    /// - Manager <-> Engineer
74    ///
75    /// The `from` and `to` are role definition names (not member instance names).
76    /// "human" is always allowed to talk to any role.
77    pub fn can_talk(&self, from_role: &str, to_role: &str) -> bool {
78        // human (CLI user) can always send to anyone
79        if from_role == "human" {
80            return true;
81        }
82        // daemon-generated messages (standups, nudges) always allowed
83        if from_role == "daemon" {
84            return true;
85        }
86        // external senders (e.g. email-router, slack-bridge) can send to anyone
87        if self.external_senders.iter().any(|s| s == from_role) {
88            return true;
89        }
90
91        let from_def = self.roles.iter().find(|r| r.name == from_role);
92        let Some(from_def) = from_def else {
93            return false;
94        };
95
96        // If talks_to is explicitly configured, use it
97        if !from_def.talks_to.is_empty() {
98            return from_def.talks_to.iter().any(|t| t == to_role);
99        }
100
101        // Default hierarchy: user<->architect, architect<->manager, manager<->engineer
102        let to_def = self.roles.iter().find(|r| r.name == to_role);
103        let Some(to_def) = to_def else {
104            return false;
105        };
106
107        matches!(
108            (from_def.role_type, to_def.role_type),
109            (RoleType::User, RoleType::Architect)
110                | (RoleType::Architect, RoleType::User)
111                | (RoleType::Architect, RoleType::Manager)
112                | (RoleType::Manager, RoleType::Architect)
113                | (RoleType::Manager, RoleType::Engineer)
114                | (RoleType::Engineer, RoleType::Manager)
115        )
116    }
117
118    /// Load team config from a YAML file.
119    pub fn load(path: &Path) -> Result<Self> {
120        let content = std::fs::read_to_string(path)
121            .with_context(|| format!("failed to read {}", path.display()))?;
122        let config: TeamConfig = serde_yaml::from_str(&content)
123            .with_context(|| format!("failed to parse {}", path.display()))?;
124        Ok(config)
125    }
126
127    /// Validate the team config. Returns an error if invalid.
128    pub fn validate(&self) -> Result<()> {
129        if self.name.is_empty() {
130            bail!("team name cannot be empty");
131        }
132
133        if self.roles.is_empty() {
134            bail!("team must have at least one role");
135        }
136
137        let valid_agents = agent::KNOWN_AGENT_NAMES.join(", ");
138
139        // Validate team-level agent if specified.
140        if let Some(team_agent) = self.agent.as_deref() {
141            if agent::adapter_from_name(team_agent).is_none() {
142                bail!(
143                    "unknown team-level agent '{}'; valid agents: {}",
144                    team_agent,
145                    valid_agents
146                );
147            }
148        }
149
150        let mut role_names: HashSet<&str> = HashSet::new();
151        for role in &self.roles {
152            if role.name.is_empty() {
153                bail!("role has empty name — every role requires a non-empty 'name' field");
154            }
155
156            if !role_names.insert(&role.name) {
157                bail!("duplicate role name: '{}'", role.name);
158            }
159
160            // Non-user roles need an agent — either their own or the team default.
161            if role.role_type != RoleType::User && role.agent.is_none() && self.agent.is_none() {
162                bail!(
163                    "role '{}' has no agent configured — \
164                     set a role-level 'agent' field or a team-level 'agent' default; \
165                     valid agents: {}",
166                    role.name,
167                    valid_agents
168                );
169            }
170
171            if role.role_type == RoleType::User && role.agent.is_some() {
172                bail!(
173                    "role '{}' is a user but has an agent configured; users use channels instead",
174                    role.name
175                );
176            }
177
178            if role.instances == 0 {
179                bail!("role '{}' has zero instances", role.name);
180            }
181
182            if let Some(agent_name) = role.agent.as_deref()
183                && agent::adapter_from_name(agent_name).is_none()
184            {
185                bail!(
186                    "role '{}' uses unknown agent '{}'; valid agents: {}",
187                    role.name,
188                    agent_name,
189                    valid_agents
190                );
191            }
192        }
193
194        // Validate talks_to references exist
195        let all_role_names: Vec<&str> = role_names.iter().copied().collect();
196        for role in &self.roles {
197            for target in &role.talks_to {
198                if !role_names.contains(target.as_str()) {
199                    bail!(
200                        "role '{}' references unknown role '{}' in talks_to; \
201                         defined roles: {}",
202                        role.name,
203                        target,
204                        all_role_names.join(", ")
205                    );
206                }
207            }
208        }
209
210        if let Some(sender) = &self.automation_sender
211            && !role_names.contains(sender.as_str())
212            && sender != "human"
213        {
214            bail!(
215                "automation_sender references unknown role '{}'; \
216                 defined roles: {}",
217                sender,
218                all_role_names.join(", ")
219            );
220        }
221
222        // Validate layout zones if present
223        if let Some(layout) = &self.layout {
224            let total_pct: u32 = layout.zones.iter().map(|z| z.width_pct).sum();
225            if total_pct > 100 {
226                bail!("layout zone widths sum to {}%, exceeds 100%", total_pct);
227            }
228        }
229
230        Ok(())
231    }
232
233    /// Run all validation checks, collecting results for each check.
234    /// Returns a list of (check_name, passed, detail) tuples.
235    pub fn validate_verbose(&self) -> Vec<ValidationCheck> {
236        let mut checks = Vec::new();
237
238        // 1. Team name
239        let name_ok = !self.name.is_empty();
240        checks.push(ValidationCheck {
241            name: "team_name".to_string(),
242            passed: name_ok,
243            detail: if name_ok {
244                format!("team name: '{}'", self.name)
245            } else {
246                "team name is empty".to_string()
247            },
248        });
249
250        // 2. Roles present
251        let roles_ok = !self.roles.is_empty();
252        checks.push(ValidationCheck {
253            name: "roles_present".to_string(),
254            passed: roles_ok,
255            detail: if roles_ok {
256                format!("{} role(s) defined", self.roles.len())
257            } else {
258                "no roles defined".to_string()
259            },
260        });
261
262        if !roles_ok {
263            return checks;
264        }
265
266        // 3. Team-level agent
267        let team_agent_ok = match self.agent.as_deref() {
268            Some(name) => agent::adapter_from_name(name).is_some(),
269            None => true,
270        };
271        checks.push(ValidationCheck {
272            name: "team_agent".to_string(),
273            passed: team_agent_ok,
274            detail: match self.agent.as_deref() {
275                Some(name) if team_agent_ok => format!("team agent: '{name}'"),
276                Some(name) => format!("unknown team agent: '{name}'"),
277                None => "no team-level agent (roles must set their own)".to_string(),
278            },
279        });
280
281        // 4. Per-role checks
282        let mut role_names: HashSet<&str> = HashSet::new();
283        for role in &self.roles {
284            let unique = role_names.insert(&role.name);
285            checks.push(ValidationCheck {
286                name: format!("role_unique:{}", role.name),
287                passed: unique,
288                detail: if unique {
289                    format!("role '{}' is unique", role.name)
290                } else {
291                    format!("duplicate role name: '{}'", role.name)
292                },
293            });
294
295            let has_agent =
296                role.role_type == RoleType::User || role.agent.is_some() || self.agent.is_some();
297            checks.push(ValidationCheck {
298                name: format!("role_agent:{}", role.name),
299                passed: has_agent,
300                detail: if has_agent {
301                    let effective = role
302                        .agent
303                        .as_deref()
304                        .or(self.agent.as_deref())
305                        .unwrap_or("(user)");
306                    format!("role '{}' agent: {effective}", role.name)
307                } else {
308                    format!("role '{}' has no agent", role.name)
309                },
310            });
311
312            if let Some(agent_name) = role.agent.as_deref() {
313                let valid = agent::adapter_from_name(agent_name).is_some();
314                checks.push(ValidationCheck {
315                    name: format!("role_agent_valid:{}", role.name),
316                    passed: valid,
317                    detail: if valid {
318                        format!("role '{}' agent '{}' is valid", role.name, agent_name)
319                    } else {
320                        format!("role '{}' uses unknown agent '{}'", role.name, agent_name)
321                    },
322                });
323            }
324
325            let instances_ok = role.instances > 0;
326            checks.push(ValidationCheck {
327                name: format!("role_instances:{}", role.name),
328                passed: instances_ok,
329                detail: format!("role '{}' instances: {}", role.name, role.instances),
330            });
331        }
332
333        // 5. talks_to references
334        for role in &self.roles {
335            for target in &role.talks_to {
336                let valid = role_names.contains(target.as_str());
337                checks.push(ValidationCheck {
338                    name: format!("talks_to:{}→{}", role.name, target),
339                    passed: valid,
340                    detail: if valid {
341                        format!("role '{}' → '{}' is valid", role.name, target)
342                    } else {
343                        format!(
344                            "role '{}' references unknown role '{}' in talks_to",
345                            role.name, target
346                        )
347                    },
348                });
349            }
350        }
351
352        // 6. automation_sender
353        if let Some(sender) = &self.automation_sender {
354            let valid = role_names.contains(sender.as_str()) || sender == "human";
355            checks.push(ValidationCheck {
356                name: "automation_sender".to_string(),
357                passed: valid,
358                detail: if valid {
359                    format!("automation_sender '{sender}' is valid")
360                } else {
361                    format!("automation_sender references unknown role '{sender}'")
362                },
363            });
364        }
365
366        // 7. Layout zones
367        if let Some(layout) = &self.layout {
368            let total_pct: u32 = layout.zones.iter().map(|z| z.width_pct).sum();
369            let valid = total_pct <= 100;
370            checks.push(ValidationCheck {
371                name: "layout_zones".to_string(),
372                passed: valid,
373                detail: if valid {
374                    format!("layout zones sum to {total_pct}%")
375                } else {
376                    format!("layout zones sum to {total_pct}%, exceeds 100%")
377                },
378            });
379        }
380
381        // 8. Backend health checks (warnings, not failures)
382        for (agent_name, health) in self.backend_health_results() {
383            let healthy = health.is_healthy();
384            checks.push(ValidationCheck {
385                name: format!("backend_health:{agent_name}"),
386                passed: healthy,
387                detail: if healthy {
388                    format!("backend '{agent_name}' is available")
389                } else {
390                    format!(
391                        "backend '{agent_name}' binary not found on PATH (status: {})",
392                        health.as_str()
393                    )
394                },
395            });
396        }
397
398        checks
399    }
400
401    /// Collect unique configured backends and their health status.
402    pub fn backend_health_results(&self) -> Vec<(String, agent::BackendHealth)> {
403        let mut seen = HashSet::new();
404        let mut results = Vec::new();
405        for role in &self.roles {
406            if let Some(agent_name) = self.resolve_agent(role) {
407                if seen.insert(agent_name.clone()) {
408                    let health = agent::health_check_by_name(&agent_name)
409                        .unwrap_or(agent::BackendHealth::Unreachable);
410                    results.push((agent_name, health));
411                }
412            }
413        }
414        results
415    }
416
417    /// Return warning messages for any unhealthy backends.
418    pub fn check_backend_health(&self) -> Vec<String> {
419        self.backend_health_results()
420            .into_iter()
421            .filter(|(_, health)| !health.is_healthy())
422            .map(|(name, health)| {
423                format!(
424                    "backend '{name}' binary not found on PATH (status: {})",
425                    health.as_str()
426                )
427            })
428            .collect()
429    }
430}
431
432pub fn load_planning_directive(
433    project_root: &Path,
434    directive: PlanningDirectiveFile,
435    max_chars: usize,
436) -> Result<Option<String>> {
437    let path = directive.path_for(project_root);
438    match std::fs::read_to_string(&path) {
439        Ok(content) => {
440            let trimmed = content.trim();
441            if trimmed.is_empty() {
442                return Ok(None);
443            }
444
445            let total_chars = trimmed.chars().count();
446            let truncated = trimmed.chars().take(max_chars).collect::<String>();
447            if total_chars > max_chars {
448                Ok(Some(format!(
449                    "{truncated}\n\n[truncated to {max_chars} chars from {}]",
450                    directive.file_name()
451                )))
452            } else {
453                Ok(Some(truncated))
454            }
455        }
456        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
457        Err(error) => Err(error)
458            .with_context(|| format!("failed to read planning directive {}", path.display())),
459    }
460}
461
462#[cfg(test)]
463mod tests;
464#[cfg(test)]
465mod tests_advanced;
466#[cfg(test)]
467mod tests_proptest;