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