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 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 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 pub fn can_talk(&self, from_role: &str, to_role: &str) -> bool {
87 if from_role == "human" {
89 return true;
90 }
91 if from_role == "daemon" {
93 return true;
94 }
95 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 !from_def.talks_to.is_empty() {
107 return from_def.talks_to.iter().any(|t| t == to_role);
108 }
109
110 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 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 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 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 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 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 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 pub fn validate_verbose(&self) -> Vec<ValidationCheck> {
282 let mut checks = Vec::new();
283
284 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 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 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 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 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 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 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 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 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 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;