1mod 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#[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 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 pub fn can_talk(&self, from_role: &str, to_role: &str) -> bool {
78 if from_role == "human" {
80 return true;
81 }
82 if from_role == "daemon" {
84 return true;
85 }
86 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 !from_def.talks_to.is_empty() {
98 return from_def.talks_to.iter().any(|t| t == to_role);
99 }
100
101 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 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 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 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 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 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 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 pub fn validate_verbose(&self) -> Vec<ValidationCheck> {
236 let mut checks = Vec::new();
237
238 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 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 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 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 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 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 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 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 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 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;