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 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 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 pub fn can_talk(&self, from_role: &str, to_role: &str) -> bool {
94 if from_role == "human" {
96 return true;
97 }
98 if from_role == "daemon" {
100 return true;
101 }
102 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 !from_def.talks_to.is_empty() {
114 return from_def.talks_to.iter().any(|t| t == to_role);
115 }
116
117 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 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 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 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 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 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 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 pub fn validate_verbose(&self) -> Vec<ValidationCheck> {
328 let mut checks = Vec::new();
329
330 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 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 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 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 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 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 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 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 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 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;