1use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use tracing::{info, warn};
7
8use super::{
9 TEAM_CONFIG_FILE, daemon_log_path, now_unix, orchestrator_log_path, team_config_dir,
10 team_config_path, team_events_path,
11};
12
13pub fn templates_base_dir() -> Result<PathBuf> {
15 let home = std::env::var("HOME").context("cannot determine home directory")?;
16 Ok(PathBuf::from(home).join(".batty").join("templates"))
17}
18
19#[derive(Debug, Default)]
21pub struct InitOverrides {
22 pub orchestrator_pane: Option<bool>,
23 pub auto_dispatch: Option<bool>,
24 pub use_worktrees: Option<bool>,
25 pub timeout_nudges: Option<bool>,
26 pub standups: Option<bool>,
27 pub triage_interventions: Option<bool>,
28 pub review_interventions: Option<bool>,
29 pub owned_task_interventions: Option<bool>,
30 pub manager_dispatch_interventions: Option<bool>,
31 pub architect_utilization_interventions: Option<bool>,
32 pub auto_merge_enabled: Option<bool>,
33 pub standup_interval_secs: Option<u64>,
34 pub nudge_interval_secs: Option<u64>,
35 pub stall_threshold_secs: Option<u64>,
36 pub review_nudge_threshold_secs: Option<u64>,
37 pub review_timeout_secs: Option<u64>,
38}
39
40pub fn init_team(
42 project_root: &Path,
43 template: &str,
44 project_name: Option<&str>,
45 agent: Option<&str>,
46 force: bool,
47) -> Result<Vec<PathBuf>> {
48 init_team_with_overrides(project_root, template, project_name, agent, force, None)
49}
50
51pub fn init_team_with_overrides(
53 project_root: &Path,
54 template: &str,
55 project_name: Option<&str>,
56 agent: Option<&str>,
57 force: bool,
58 overrides: Option<&InitOverrides>,
59) -> Result<Vec<PathBuf>> {
60 let config_dir = team_config_dir(project_root);
61 std::fs::create_dir_all(&config_dir)
62 .with_context(|| format!("failed to create {}", config_dir.display()))?;
63
64 let mut created = Vec::new();
65
66 let yaml_path = config_dir.join(TEAM_CONFIG_FILE);
67 if yaml_path.exists() && !force {
68 bail!(
69 "team config already exists at {}; remove it first or use --force",
70 yaml_path.display()
71 );
72 }
73
74 let yaml_content = match template {
75 "solo" => include_str!("templates/team_solo.yaml"),
76 "pair" => include_str!("templates/team_pair.yaml"),
77 "squad" => include_str!("templates/team_squad.yaml"),
78 "large" => include_str!("templates/team_large.yaml"),
79 "research" => include_str!("templates/team_research.yaml"),
80 "software" => include_str!("templates/team_software.yaml"),
81 "batty" => include_str!("templates/team_batty.yaml"),
82 _ => include_str!("templates/team_simple.yaml"),
83 };
84 let mut yaml_content = yaml_content.to_string();
85 if let Some(name) = project_name {
86 if let Some(end) = yaml_content.find('\n') {
87 yaml_content = format!("name: {name}{}", &yaml_content[end..]);
88 }
89 }
90 if let Some(agent_name) = agent {
91 yaml_content = yaml_content
92 .replace("agent: claude", &format!("agent: {agent_name}"))
93 .replace("agent: codex", &format!("agent: {agent_name}"));
94 }
95 if let Some(ov) = overrides {
96 yaml_content = apply_init_overrides(&yaml_content, ov);
97 }
98 std::fs::write(&yaml_path, &yaml_content)
99 .with_context(|| format!("failed to write {}", yaml_path.display()))?;
100 created.push(yaml_path);
101
102 let prompt_files: &[(&str, &str)] = match template {
104 "research" => &[
105 (
106 "research_lead.md",
107 include_str!("templates/research_lead.md"),
108 ),
109 ("sub_lead.md", include_str!("templates/sub_lead.md")),
110 ("researcher.md", include_str!("templates/researcher.md")),
111 ],
112 "software" => &[
113 ("tech_lead.md", include_str!("templates/tech_lead.md")),
114 ("eng_manager.md", include_str!("templates/eng_manager.md")),
115 ("developer.md", include_str!("templates/developer.md")),
116 ],
117 "batty" => &[
118 (
119 "batty_architect.md",
120 include_str!("templates/batty_architect.md"),
121 ),
122 (
123 "batty_manager.md",
124 include_str!("templates/batty_manager.md"),
125 ),
126 (
127 "batty_engineer.md",
128 include_str!("templates/batty_engineer.md"),
129 ),
130 ],
131 _ => &[
132 ("architect.md", include_str!("templates/architect.md")),
133 ("manager.md", include_str!("templates/manager.md")),
134 ("engineer.md", include_str!("templates/engineer.md")),
135 ],
136 };
137
138 for (name, content) in prompt_files {
139 let path = config_dir.join(name);
140 if force || !path.exists() {
141 std::fs::write(&path, content)
142 .with_context(|| format!("failed to write {}", path.display()))?;
143 created.push(path);
144 }
145 }
146
147 let directive_files = [
148 (
149 "replenishment_context.md",
150 include_str!("templates/replenishment_context.md"),
151 ),
152 (
153 "review_policy.md",
154 include_str!("templates/review_policy.md"),
155 ),
156 (
157 "escalation_policy.md",
158 include_str!("templates/escalation_policy.md"),
159 ),
160 ];
161 for (name, content) in directive_files {
162 let path = config_dir.join(name);
163 if force || !path.exists() {
164 std::fs::write(&path, content)
165 .with_context(|| format!("failed to write {}", path.display()))?;
166 created.push(path);
167 }
168 }
169
170 let board_dir = config_dir.join("board");
172 if !board_dir.exists() {
173 let output = std::process::Command::new("kanban-md")
174 .args(["init", "--dir", &board_dir.to_string_lossy()])
175 .output();
176 match output {
177 Ok(out) if out.status.success() => {
178 created.push(board_dir);
179 }
180 Ok(out) => {
181 let stderr = String::from_utf8_lossy(&out.stderr);
182 warn!("kanban-md init failed: {stderr}; falling back to plain kanban.md");
183 let kanban_path = config_dir.join("kanban.md");
184 std::fs::write(
185 &kanban_path,
186 "# Kanban Board\n\n## Backlog\n\n## In Progress\n\n## Done\n",
187 )?;
188 created.push(kanban_path);
189 }
190 Err(_) => {
191 warn!("kanban-md not found; falling back to plain kanban.md");
192 let kanban_path = config_dir.join("kanban.md");
193 std::fs::write(
194 &kanban_path,
195 "# Kanban Board\n\n## Backlog\n\n## In Progress\n\n## Done\n",
196 )?;
197 created.push(kanban_path);
198 }
199 }
200 }
201
202 info!(dir = %config_dir.display(), files = created.len(), "scaffolded team config");
203 Ok(created)
204}
205
206fn apply_init_overrides(yaml: &str, ov: &InitOverrides) -> String {
208 let mut doc: serde_yaml::Value = match serde_yaml::from_str(yaml) {
209 Ok(v) => v,
210 Err(_) => return yaml.to_string(),
211 };
212 let map = match doc.as_mapping_mut() {
213 Some(m) => m,
214 None => return yaml.to_string(),
215 };
216
217 fn set_bool(map: &mut serde_yaml::Mapping, section: &str, key: &str, val: Option<bool>) {
218 if let Some(v) = val {
219 let sec = map
220 .entry(serde_yaml::Value::String(section.to_string()))
221 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
222 if let Some(m) = sec.as_mapping_mut() {
223 m.insert(
224 serde_yaml::Value::String(key.to_string()),
225 serde_yaml::Value::Bool(v),
226 );
227 }
228 }
229 }
230
231 fn set_u64(map: &mut serde_yaml::Mapping, section: &str, key: &str, val: Option<u64>) {
232 if let Some(v) = val {
233 let sec = map
234 .entry(serde_yaml::Value::String(section.to_string()))
235 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
236 if let Some(m) = sec.as_mapping_mut() {
237 m.insert(
238 serde_yaml::Value::String(key.to_string()),
239 serde_yaml::Value::Number(serde_yaml::Number::from(v)),
240 );
241 }
242 }
243 }
244
245 if let Some(v) = ov.orchestrator_pane {
246 map.insert(
247 serde_yaml::Value::String("orchestrator_pane".to_string()),
248 serde_yaml::Value::Bool(v),
249 );
250 }
251
252 set_bool(map, "board", "auto_dispatch", ov.auto_dispatch);
253 set_u64(map, "standup", "interval_secs", ov.standup_interval_secs);
254 set_bool(map, "automation", "timeout_nudges", ov.timeout_nudges);
255 set_bool(map, "automation", "standups", ov.standups);
256 set_bool(
257 map,
258 "automation",
259 "triage_interventions",
260 ov.triage_interventions,
261 );
262 set_bool(
263 map,
264 "automation",
265 "review_interventions",
266 ov.review_interventions,
267 );
268 set_bool(
269 map,
270 "automation",
271 "owned_task_interventions",
272 ov.owned_task_interventions,
273 );
274 set_bool(
275 map,
276 "automation",
277 "manager_dispatch_interventions",
278 ov.manager_dispatch_interventions,
279 );
280 set_bool(
281 map,
282 "automation",
283 "architect_utilization_interventions",
284 ov.architect_utilization_interventions,
285 );
286 set_u64(
287 map,
288 "workflow_policy",
289 "stall_threshold_secs",
290 ov.stall_threshold_secs,
291 );
292 set_u64(
293 map,
294 "workflow_policy",
295 "review_nudge_threshold_secs",
296 ov.review_nudge_threshold_secs,
297 );
298 set_u64(
299 map,
300 "workflow_policy",
301 "review_timeout_secs",
302 ov.review_timeout_secs,
303 );
304
305 if let Some(v) = ov.auto_merge_enabled {
306 let sec = map
307 .entry(serde_yaml::Value::String("workflow_policy".to_string()))
308 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
309 if let Some(wp) = sec.as_mapping_mut() {
310 let am = wp
311 .entry(serde_yaml::Value::String("auto_merge".to_string()))
312 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
313 if let Some(m) = am.as_mapping_mut() {
314 m.insert(
315 serde_yaml::Value::String("enabled".to_string()),
316 serde_yaml::Value::Bool(v),
317 );
318 }
319 }
320 }
321
322 if ov.use_worktrees.is_some() || ov.nudge_interval_secs.is_some() {
323 if let Some(roles) = map
324 .get_mut(serde_yaml::Value::String("roles".to_string()))
325 .and_then(|v| v.as_sequence_mut())
326 {
327 for role in roles.iter_mut() {
328 if let Some(m) = role.as_mapping_mut() {
329 let role_type = m
330 .get(serde_yaml::Value::String("role_type".to_string()))
331 .and_then(|v| v.as_str())
332 .map(str::to_owned);
333
334 if role_type.as_deref() == Some("engineer") {
335 if let Some(v) = ov.use_worktrees {
336 m.insert(
337 serde_yaml::Value::String("use_worktrees".to_string()),
338 serde_yaml::Value::Bool(v),
339 );
340 }
341 }
342 if role_type.as_deref() == Some("architect") {
343 if let Some(v) = ov.nudge_interval_secs {
344 m.insert(
345 serde_yaml::Value::String("nudge_interval_secs".to_string()),
346 serde_yaml::Value::Number(serde_yaml::Number::from(v)),
347 );
348 }
349 }
350 }
351 }
352 }
353 }
354
355 serde_yaml::to_string(&doc).unwrap_or_else(|_| yaml.to_string())
356}
357
358pub fn list_available_templates() -> Result<Vec<String>> {
359 let templates_dir = templates_base_dir()?;
360 if !templates_dir.is_dir() {
361 bail!(
362 "no templates directory found at {}",
363 templates_dir.display()
364 );
365 }
366
367 let mut templates = Vec::new();
368 for entry in std::fs::read_dir(&templates_dir)
369 .with_context(|| format!("failed to read {}", templates_dir.display()))?
370 {
371 let entry = entry?;
372 if entry.path().is_dir() {
373 templates.push(entry.file_name().to_string_lossy().into_owned());
374 }
375 }
376 templates.sort();
377 Ok(templates)
378}
379
380fn copy_template_dir(src: &Path, dst: &Path, created: &mut Vec<PathBuf>) -> Result<()> {
381 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
382 for entry in
383 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
384 {
385 let entry = entry?;
386 let src_path = entry.path();
387 let dst_path = dst.join(entry.file_name());
388 if src_path.is_dir() {
389 copy_template_dir(&src_path, &dst_path, created)?;
390 } else {
391 std::fs::copy(&src_path, &dst_path).with_context(|| {
392 format!(
393 "failed to copy template file from {} to {}",
394 src_path.display(),
395 dst_path.display()
396 )
397 })?;
398 created.push(dst_path);
399 }
400 }
401 Ok(())
402}
403
404pub fn init_from_template(project_root: &Path, template_name: &str) -> Result<Vec<PathBuf>> {
405 let templates_dir = templates_base_dir()?;
406 if !templates_dir.is_dir() {
407 bail!(
408 "no templates directory found at {}",
409 templates_dir.display()
410 );
411 }
412
413 let available = list_available_templates()?;
414 if !available.iter().any(|name| name == template_name) {
415 let available_display = if available.is_empty() {
416 "(none)".to_string()
417 } else {
418 available.join(", ")
419 };
420 bail!(
421 "template '{}' not found in {}; available templates: {}",
422 template_name,
423 templates_dir.display(),
424 available_display
425 );
426 }
427
428 let config_dir = team_config_dir(project_root);
429 let yaml_path = config_dir.join(TEAM_CONFIG_FILE);
430 if yaml_path.exists() {
431 bail!(
432 "team config already exists at {}; remove it first or edit directly",
433 yaml_path.display()
434 );
435 }
436
437 let source_dir = templates_dir.join(template_name);
438 let mut created = Vec::new();
439 copy_template_dir(&source_dir, &config_dir, &mut created)?;
440 info!(
441 template = template_name,
442 source = %source_dir.display(),
443 dest = %config_dir.display(),
444 files = created.len(),
445 "copied team config from user template"
446 );
447 Ok(created)
448}
449
450pub fn export_template(project_root: &Path, name: &str) -> Result<usize> {
452 let config_dir = team_config_dir(project_root);
453 let team_yaml = config_dir.join(TEAM_CONFIG_FILE);
454 if !team_yaml.is_file() {
455 bail!("team config missing at {}", team_yaml.display());
456 }
457
458 let template_dir = templates_base_dir()?.join(name);
459 if template_dir.exists() {
460 eprintln!(
461 "warning: overwriting existing template at {}",
462 template_dir.display()
463 );
464 }
465 std::fs::create_dir_all(&template_dir)
466 .with_context(|| format!("failed to create {}", template_dir.display()))?;
467
468 let mut copied = 0usize;
469 copy_template_file(&team_yaml, &template_dir.join(TEAM_CONFIG_FILE))?;
470 copied += 1;
471
472 let mut prompt_paths = std::fs::read_dir(&config_dir)?
473 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
474 .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
475 .collect::<Vec<_>>();
476 prompt_paths.sort();
477
478 for source in prompt_paths {
479 let file_name = source
480 .file_name()
481 .context("template source missing file name")?;
482 copy_template_file(&source, &template_dir.join(file_name))?;
483 copied += 1;
484 }
485
486 Ok(copied)
487}
488
489pub fn export_run(project_root: &Path) -> Result<PathBuf> {
490 let team_yaml = team_config_path(project_root);
491 if !team_yaml.is_file() {
492 bail!("team config missing at {}", team_yaml.display());
493 }
494
495 let export_dir = create_run_export_dir(project_root)?;
496 copy_template_file(&team_yaml, &export_dir.join(TEAM_CONFIG_FILE))?;
497
498 copy_dir_if_exists(
499 &team_config_dir(project_root).join("board").join("tasks"),
500 &export_dir.join("board").join("tasks"),
501 )?;
502 copy_file_if_exists(
503 &team_events_path(project_root),
504 &export_dir.join("events.jsonl"),
505 )?;
506 copy_file_if_exists(
507 &daemon_log_path(project_root),
508 &export_dir.join("daemon.log"),
509 )?;
510 copy_file_if_exists(
511 &orchestrator_log_path(project_root),
512 &export_dir.join("orchestrator.log"),
513 )?;
514 copy_dir_if_exists(
515 &project_root.join(".batty").join("retrospectives"),
516 &export_dir.join("retrospectives"),
517 )?;
518 copy_file_if_exists(
519 &project_root.join(".batty").join("test_timing.jsonl"),
520 &export_dir.join("test_timing.jsonl"),
521 )?;
522
523 Ok(export_dir)
524}
525
526fn copy_template_file(source: &Path, destination: &Path) -> Result<()> {
527 if let Some(parent) = destination.parent() {
528 std::fs::create_dir_all(parent)
529 .with_context(|| format!("failed to create {}", parent.display()))?;
530 }
531 std::fs::copy(source, destination).with_context(|| {
532 format!(
533 "failed to copy {} to {}",
534 source.display(),
535 destination.display()
536 )
537 })?;
538 Ok(())
539}
540
541fn exports_dir(project_root: &Path) -> PathBuf {
542 project_root.join(".batty").join("exports")
543}
544
545fn create_run_export_dir(project_root: &Path) -> Result<PathBuf> {
546 let base = exports_dir(project_root);
547 std::fs::create_dir_all(&base)
548 .with_context(|| format!("failed to create {}", base.display()))?;
549
550 let timestamp = now_unix();
551 let primary = base.join(timestamp.to_string());
552 if !primary.exists() {
553 std::fs::create_dir(&primary)
554 .with_context(|| format!("failed to create {}", primary.display()))?;
555 return Ok(primary);
556 }
557
558 for suffix in 1.. {
559 let candidate = base.join(format!("{timestamp}-{suffix}"));
560 if candidate.exists() {
561 continue;
562 }
563 std::fs::create_dir(&candidate)
564 .with_context(|| format!("failed to create {}", candidate.display()))?;
565 return Ok(candidate);
566 }
567
568 unreachable!("infinite suffix iterator should always return or continue");
569}
570
571fn copy_file_if_exists(source: &Path, destination: &Path) -> Result<()> {
572 if source.is_file() {
573 copy_template_file(source, destination)?;
574 }
575 Ok(())
576}
577
578fn copy_dir_if_exists(source: &Path, destination: &Path) -> Result<()> {
579 if source.is_dir() {
580 let mut created = Vec::new();
581 copy_template_dir(source, destination, &mut created)?;
582 }
583 Ok(())
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use serial_test::serial;
590 use std::ffi::OsString;
591
592 use crate::team::{
593 daemon_log_path, orchestrator_log_path, team_config_dir, team_config_path, team_events_path,
594 };
595
596 struct HomeGuard {
597 original_home: Option<OsString>,
598 }
599
600 impl HomeGuard {
601 fn set(path: &Path) -> Self {
602 let original_home = std::env::var_os("HOME");
603 unsafe {
604 std::env::set_var("HOME", path);
605 }
606 Self { original_home }
607 }
608 }
609
610 impl Drop for HomeGuard {
611 fn drop(&mut self) {
612 match &self.original_home {
613 Some(home) => unsafe {
614 std::env::set_var("HOME", home);
615 },
616 None => unsafe {
617 std::env::remove_var("HOME");
618 },
619 }
620 }
621 }
622
623 #[test]
624 fn init_team_creates_scaffolding() {
625 let tmp = tempfile::tempdir().unwrap();
626 let created = init_team(tmp.path(), "simple", None, None, false).unwrap();
627 assert!(!created.is_empty());
628 assert!(team_config_path(tmp.path()).exists());
629 assert!(team_config_dir(tmp.path()).join("architect.md").exists());
630 assert!(team_config_dir(tmp.path()).join("manager.md").exists());
631 assert!(team_config_dir(tmp.path()).join("engineer.md").exists());
632 assert!(
633 team_config_dir(tmp.path())
634 .join("replenishment_context.md")
635 .exists()
636 );
637 assert!(
638 team_config_dir(tmp.path())
639 .join("review_policy.md")
640 .exists()
641 );
642 assert!(
643 team_config_dir(tmp.path())
644 .join("escalation_policy.md")
645 .exists()
646 );
647 let config = team_config_dir(tmp.path());
649 assert!(config.join("board").is_dir() || config.join("kanban.md").exists());
650 let team_yaml = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
651 assert!(team_yaml.contains("auto_respawn_on_crash: true"));
652 }
653
654 #[test]
655 fn init_team_refuses_if_exists() {
656 let tmp = tempfile::tempdir().unwrap();
657 init_team(tmp.path(), "simple", None, None, false).unwrap();
658 let result = init_team(tmp.path(), "simple", None, None, false);
659 assert!(result.is_err());
660 assert!(result.unwrap_err().to_string().contains("already exists"));
661 }
662
663 #[test]
664 #[serial]
665 fn init_from_template_copies_files() {
666 let project = tempfile::tempdir().unwrap();
667 let home = tempfile::tempdir().unwrap();
668 let _home_guard = HomeGuard::set(home.path());
669
670 let template_dir = home.path().join(".batty").join("templates").join("custom");
671 std::fs::create_dir_all(template_dir.join("board")).unwrap();
672 std::fs::write(template_dir.join("team.yaml"), "name: custom\nroles: []\n").unwrap();
673 std::fs::write(template_dir.join("architect.md"), "# Architect\n").unwrap();
674 std::fs::write(template_dir.join("board").join("task.md"), "task\n").unwrap();
675
676 let created = init_from_template(project.path(), "custom").unwrap();
677
678 assert!(!created.is_empty());
679 assert_eq!(
680 std::fs::read_to_string(team_config_path(project.path())).unwrap(),
681 "name: custom\nroles: []\n"
682 );
683 assert!(
684 team_config_dir(project.path())
685 .join("architect.md")
686 .exists()
687 );
688 assert!(
689 team_config_dir(project.path())
690 .join("board")
691 .join("task.md")
692 .exists()
693 );
694 }
695
696 #[test]
697 #[serial]
698 fn init_from_template_missing_template_errors_with_available_list() {
699 let project = tempfile::tempdir().unwrap();
700 let home = tempfile::tempdir().unwrap();
701 let _home_guard = HomeGuard::set(home.path());
702
703 let templates_root = home.path().join(".batty").join("templates");
704 std::fs::create_dir_all(templates_root.join("alpha")).unwrap();
705 std::fs::create_dir_all(templates_root.join("beta")).unwrap();
706
707 let error = init_from_template(project.path(), "missing").unwrap_err();
708 let message = error.to_string();
709 assert!(message.contains("template 'missing' not found"));
710 assert!(message.contains("alpha"));
711 assert!(message.contains("beta"));
712 }
713
714 #[test]
715 #[serial]
716 fn init_from_template_errors_when_templates_dir_is_missing() {
717 let project = tempfile::tempdir().unwrap();
718 let home = tempfile::tempdir().unwrap();
719 let _home_guard = HomeGuard::set(home.path());
720
721 let error = init_from_template(project.path(), "missing").unwrap_err();
722 assert!(error.to_string().contains("no templates directory found"));
723 }
724
725 #[test]
726 fn init_team_large_template() {
727 let tmp = tempfile::tempdir().unwrap();
728 let created = init_team(tmp.path(), "large", None, None, false).unwrap();
729 assert!(!created.is_empty());
730 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
731 assert!(content.contains("instances: 3") || content.contains("instances: 5"));
732 }
733
734 #[test]
735 fn init_team_solo_template() {
736 let tmp = tempfile::tempdir().unwrap();
737 let created = init_team(tmp.path(), "solo", None, None, false).unwrap();
738 assert!(!created.is_empty());
739 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
740 assert!(content.contains("role_type: engineer"));
741 assert!(!content.contains("role_type: manager"));
742 }
743
744 #[test]
745 fn init_team_pair_template() {
746 let tmp = tempfile::tempdir().unwrap();
747 let created = init_team(tmp.path(), "pair", None, None, false).unwrap();
748 assert!(!created.is_empty());
749 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
750 assert!(content.contains("role_type: architect"));
751 assert!(content.contains("role_type: engineer"));
752 assert!(!content.contains("role_type: manager"));
753 }
754
755 #[test]
756 fn init_team_squad_template() {
757 let tmp = tempfile::tempdir().unwrap();
758 let created = init_team(tmp.path(), "squad", None, None, false).unwrap();
759 assert!(!created.is_empty());
760 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
761 assert!(content.contains("instances: 5"));
762 assert!(content.contains("layout:"));
763 }
764
765 #[test]
766 fn init_team_research_template() {
767 let tmp = tempfile::tempdir().unwrap();
768 let created = init_team(tmp.path(), "research", None, None, false).unwrap();
769 assert!(!created.is_empty());
770 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
771 assert!(content.contains("principal"));
772 assert!(content.contains("sub-lead"));
773 assert!(content.contains("researcher"));
774 assert!(
776 team_config_dir(tmp.path())
777 .join("research_lead.md")
778 .exists()
779 );
780 assert!(team_config_dir(tmp.path()).join("sub_lead.md").exists());
781 assert!(team_config_dir(tmp.path()).join("researcher.md").exists());
782 assert!(!team_config_dir(tmp.path()).join("architect.md").exists());
784 }
785
786 #[test]
787 fn init_team_software_template() {
788 let tmp = tempfile::tempdir().unwrap();
789 let created = init_team(tmp.path(), "software", None, None, false).unwrap();
790 assert!(!created.is_empty());
791 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
792 assert!(content.contains("tech-lead"));
793 assert!(content.contains("backend-mgr"));
794 assert!(content.contains("frontend-mgr"));
795 assert!(content.contains("developer"));
796 assert!(team_config_dir(tmp.path()).join("tech_lead.md").exists());
798 assert!(team_config_dir(tmp.path()).join("eng_manager.md").exists());
799 assert!(team_config_dir(tmp.path()).join("developer.md").exists());
800 }
801
802 #[test]
803 fn init_team_batty_template() {
804 let tmp = tempfile::tempdir().unwrap();
805 let created = init_team(tmp.path(), "batty", None, None, false).unwrap();
806 assert!(!created.is_empty());
807 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
808 assert!(content.contains("batty-dev"));
809 assert!(content.contains("role_type: architect"));
810 assert!(content.contains("role_type: manager"));
811 assert!(content.contains("instances: 4"));
812 assert!(content.contains("batty_architect.md"));
813 assert!(
815 team_config_dir(tmp.path())
816 .join("batty_architect.md")
817 .exists()
818 );
819 assert!(
820 team_config_dir(tmp.path())
821 .join("batty_manager.md")
822 .exists()
823 );
824 assert!(
825 team_config_dir(tmp.path())
826 .join("batty_engineer.md")
827 .exists()
828 );
829 assert!(
830 team_config_dir(tmp.path())
831 .join("review_policy.md")
832 .exists()
833 );
834 }
835
836 #[test]
837 fn init_with_agent_codex_sets_backend() {
838 let tmp = tempfile::tempdir().unwrap();
839 let _created = init_team(tmp.path(), "simple", None, Some("codex"), false).unwrap();
840 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
841 assert!(
842 content.contains("agent: codex"),
843 "all agent fields should be codex"
844 );
845 assert!(
846 !content.contains("agent: claude"),
847 "no claude agents should remain"
848 );
849 }
850
851 #[test]
852 fn init_with_agent_kiro_sets_backend() {
853 let tmp = tempfile::tempdir().unwrap();
854 let _created = init_team(tmp.path(), "pair", None, Some("kiro"), false).unwrap();
855 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
856 assert!(
857 content.contains("agent: kiro"),
858 "all agent fields should be kiro"
859 );
860 assert!(
861 !content.contains("agent: claude"),
862 "no claude agents should remain"
863 );
864 assert!(
865 !content.contains("agent: codex"),
866 "no codex agents should remain"
867 );
868 }
869
870 #[test]
871 fn init_default_agent_is_claude() {
872 let tmp = tempfile::tempdir().unwrap();
873 let _created = init_team(tmp.path(), "simple", None, None, false).unwrap();
874 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
875 assert!(
876 content.contains("agent: claude"),
877 "default agent should be claude"
878 );
879 }
880
881 #[test]
882 #[serial]
883 fn export_template_creates_directory_and_copies_files() {
884 let tmp = tempfile::tempdir().unwrap();
885 let _home = HomeGuard::set(tmp.path());
886 let project_root = tmp.path().join("project");
887 let config_dir = team_config_dir(&project_root);
888 std::fs::create_dir_all(&config_dir).unwrap();
889 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
890 std::fs::write(config_dir.join("architect.md"), "architect prompt\n").unwrap();
891
892 let copied = export_template(&project_root, "demo-template").unwrap();
893 let template_dir = templates_base_dir().unwrap().join("demo-template");
894
895 assert_eq!(copied, 2);
896 assert_eq!(
897 std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
898 "name: demo\n"
899 );
900 assert_eq!(
901 std::fs::read_to_string(template_dir.join("architect.md")).unwrap(),
902 "architect prompt\n"
903 );
904 }
905
906 #[test]
907 #[serial]
908 fn export_template_overwrites_existing() {
909 let tmp = tempfile::tempdir().unwrap();
910 let _home = HomeGuard::set(tmp.path());
911 let project_root = tmp.path().join("project");
912 let config_dir = team_config_dir(&project_root);
913 std::fs::create_dir_all(&config_dir).unwrap();
914 std::fs::write(config_dir.join("team.yaml"), "name: first\n").unwrap();
915 std::fs::write(config_dir.join("manager.md"), "v1\n").unwrap();
916
917 export_template(&project_root, "demo-template").unwrap();
918
919 std::fs::write(config_dir.join("team.yaml"), "name: second\n").unwrap();
920 std::fs::write(config_dir.join("manager.md"), "v2\n").unwrap();
921
922 let copied = export_template(&project_root, "demo-template").unwrap();
923 let template_dir = templates_base_dir().unwrap().join("demo-template");
924
925 assert_eq!(copied, 2);
926 assert_eq!(
927 std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
928 "name: second\n"
929 );
930 assert_eq!(
931 std::fs::read_to_string(template_dir.join("manager.md")).unwrap(),
932 "v2\n"
933 );
934 }
935
936 #[test]
937 #[serial]
938 fn export_template_missing_team_yaml_errors() {
939 let tmp = tempfile::tempdir().unwrap();
940 let _home = HomeGuard::set(tmp.path());
941 let project_root = tmp.path().join("project");
942 std::fs::create_dir_all(team_config_dir(&project_root)).unwrap();
943
944 let error = export_template(&project_root, "demo-template").unwrap_err();
945
946 assert!(error.to_string().contains("team config missing"));
947 }
948
949 #[test]
950 fn export_run_copies_requested_run_state_only() {
951 let tmp = tempfile::tempdir().unwrap();
952 let project_root = tmp.path().join("project");
953 let config_dir = team_config_dir(&project_root);
954 let tasks_dir = config_dir.join("board").join("tasks");
955 let retrospectives_dir = project_root.join(".batty").join("retrospectives");
956 let worktree_dir = project_root
957 .join(".batty")
958 .join("worktrees")
959 .join("eng-1-1")
960 .join(".codex")
961 .join("sessions");
962 std::fs::create_dir_all(&tasks_dir).unwrap();
963 std::fs::create_dir_all(&retrospectives_dir).unwrap();
964 std::fs::create_dir_all(&worktree_dir).unwrap();
965
966 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
967 std::fs::write(tasks_dir.join("001-task.md"), "---\nid: 1\n---\n").unwrap();
968 std::fs::write(
969 team_events_path(&project_root),
970 "{\"event\":\"daemon_started\"}\n",
971 )
972 .unwrap();
973 std::fs::write(daemon_log_path(&project_root), "daemon-log\n").unwrap();
974 std::fs::write(orchestrator_log_path(&project_root), "orchestrator-log\n").unwrap();
975 std::fs::write(retrospectives_dir.join("retro.md"), "# Retro\n").unwrap();
976 std::fs::write(
977 project_root.join(".batty").join("test_timing.jsonl"),
978 "{\"task_id\":1}\n",
979 )
980 .unwrap();
981 std::fs::write(worktree_dir.join("session.jsonl"), "secret\n").unwrap();
982
983 let export_dir = export_run(&project_root).unwrap();
984
985 assert_eq!(
986 std::fs::read_to_string(export_dir.join("team.yaml")).unwrap(),
987 "name: demo\n"
988 );
989 assert_eq!(
990 std::fs::read_to_string(export_dir.join("board").join("tasks").join("001-task.md"))
991 .unwrap(),
992 "---\nid: 1\n---\n"
993 );
994 assert_eq!(
995 std::fs::read_to_string(export_dir.join("events.jsonl")).unwrap(),
996 "{\"event\":\"daemon_started\"}\n"
997 );
998 assert_eq!(
999 std::fs::read_to_string(export_dir.join("daemon.log")).unwrap(),
1000 "daemon-log\n"
1001 );
1002 assert_eq!(
1003 std::fs::read_to_string(export_dir.join("orchestrator.log")).unwrap(),
1004 "orchestrator-log\n"
1005 );
1006 assert_eq!(
1007 std::fs::read_to_string(export_dir.join("retrospectives").join("retro.md")).unwrap(),
1008 "# Retro\n"
1009 );
1010 assert_eq!(
1011 std::fs::read_to_string(export_dir.join("test_timing.jsonl")).unwrap(),
1012 "{\"task_id\":1}\n"
1013 );
1014 assert!(!export_dir.join("worktrees").exists());
1015 assert!(!export_dir.join(".codex").exists());
1016 assert!(!export_dir.join("sessions").exists());
1017 }
1018
1019 #[test]
1020 fn export_run_skips_missing_optional_paths() {
1021 let tmp = tempfile::tempdir().unwrap();
1022 let project_root = tmp.path().join("project");
1023 let config_dir = team_config_dir(&project_root);
1024 std::fs::create_dir_all(&config_dir).unwrap();
1025 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
1026
1027 let export_dir = export_run(&project_root).unwrap();
1028
1029 assert!(export_dir.join("team.yaml").is_file());
1030 assert!(!export_dir.join("board").exists());
1031 assert!(!export_dir.join("events.jsonl").exists());
1032 assert!(!export_dir.join("daemon.log").exists());
1033 assert!(!export_dir.join("orchestrator.log").exists());
1034 assert!(!export_dir.join("retrospectives").exists());
1035 assert!(!export_dir.join("test_timing.jsonl").exists());
1036 }
1037
1038 #[test]
1039 fn export_run_missing_team_yaml_errors() {
1040 let tmp = tempfile::tempdir().unwrap();
1041 let project_root = tmp.path().join("project");
1042 std::fs::create_dir_all(team_config_dir(&project_root)).unwrap();
1043
1044 let error = export_run(&project_root).unwrap_err();
1045
1046 assert!(error.to_string().contains("team config missing"));
1047 }
1048
1049 #[test]
1050 fn apply_init_overrides_sets_fields() {
1051 let yaml = include_str!("templates/team_simple.yaml");
1052 let ov = InitOverrides {
1053 orchestrator_pane: Some(false),
1054 auto_dispatch: Some(true),
1055 use_worktrees: Some(false),
1056 timeout_nudges: Some(false),
1057 standups: Some(false),
1058 auto_merge_enabled: Some(true),
1059 standup_interval_secs: Some(999),
1060 stall_threshold_secs: Some(123),
1061 review_nudge_threshold_secs: Some(456),
1062 review_timeout_secs: Some(789),
1063 nudge_interval_secs: Some(555),
1064 ..Default::default()
1065 };
1066 let result = apply_init_overrides(yaml, &ov);
1067 let doc: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
1068 let map = doc.as_mapping().unwrap();
1069
1070 assert_eq!(
1071 map.get(serde_yaml::Value::String("orchestrator_pane".into()))
1072 .and_then(|v| v.as_bool()),
1073 Some(false)
1074 );
1075
1076 let board = map
1077 .get(serde_yaml::Value::String("board".into()))
1078 .unwrap()
1079 .as_mapping()
1080 .unwrap();
1081 assert_eq!(
1082 board
1083 .get(serde_yaml::Value::String("auto_dispatch".into()))
1084 .and_then(|v| v.as_bool()),
1085 Some(true)
1086 );
1087
1088 let automation = map
1089 .get(serde_yaml::Value::String("automation".into()))
1090 .unwrap()
1091 .as_mapping()
1092 .unwrap();
1093 assert_eq!(
1094 automation
1095 .get(serde_yaml::Value::String("timeout_nudges".into()))
1096 .and_then(|v| v.as_bool()),
1097 Some(false)
1098 );
1099 assert_eq!(
1100 automation
1101 .get(serde_yaml::Value::String("standups".into()))
1102 .and_then(|v| v.as_bool()),
1103 Some(false)
1104 );
1105
1106 let standup = map
1107 .get(serde_yaml::Value::String("standup".into()))
1108 .unwrap()
1109 .as_mapping()
1110 .unwrap();
1111 assert_eq!(
1112 standup
1113 .get(serde_yaml::Value::String("interval_secs".into()))
1114 .and_then(|v| v.as_u64()),
1115 Some(999)
1116 );
1117
1118 let workflow_policy = map
1119 .get(serde_yaml::Value::String("workflow_policy".into()))
1120 .unwrap()
1121 .as_mapping()
1122 .unwrap();
1123 assert_eq!(
1124 workflow_policy
1125 .get(serde_yaml::Value::String("stall_threshold_secs".into()))
1126 .and_then(|v| v.as_u64()),
1127 Some(123)
1128 );
1129 assert_eq!(
1130 workflow_policy
1131 .get(serde_yaml::Value::String(
1132 "review_nudge_threshold_secs".into()
1133 ))
1134 .and_then(|v| v.as_u64()),
1135 Some(456)
1136 );
1137 assert_eq!(
1138 workflow_policy
1139 .get(serde_yaml::Value::String("review_timeout_secs".into()))
1140 .and_then(|v| v.as_u64()),
1141 Some(789)
1142 );
1143
1144 let auto_merge = workflow_policy
1145 .get(serde_yaml::Value::String("auto_merge".into()))
1146 .unwrap()
1147 .as_mapping()
1148 .unwrap();
1149 assert_eq!(
1150 auto_merge
1151 .get(serde_yaml::Value::String("enabled".into()))
1152 .and_then(|v| v.as_bool()),
1153 Some(true)
1154 );
1155 }
1156}