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 "cleanroom" => include_str!("templates/team_cleanroom.yaml"),
82 "batty" => include_str!("templates/team_batty.yaml"),
83 _ => include_str!("templates/team_simple.yaml"),
84 };
85 let mut yaml_content = yaml_content.to_string();
86 if let Some(name) = project_name {
87 if let Some(end) = yaml_content.find('\n') {
88 yaml_content = format!("name: {name}{}", &yaml_content[end..]);
89 }
90 }
91 if let Some(agent_name) = agent {
92 yaml_content = yaml_content
93 .replace("agent: claude", &format!("agent: {agent_name}"))
94 .replace("agent: codex", &format!("agent: {agent_name}"));
95 }
96 if let Some(ov) = overrides {
97 yaml_content = apply_init_overrides(&yaml_content, ov);
98 }
99 std::fs::write(&yaml_path, &yaml_content)
100 .with_context(|| format!("failed to write {}", yaml_path.display()))?;
101 created.push(yaml_path);
102
103 let prompt_files: &[(&str, &str)] = match template {
105 "research" => &[
106 (
107 "research_lead.md",
108 include_str!("templates/research_lead.md"),
109 ),
110 ("sub_lead.md", include_str!("templates/sub_lead.md")),
111 ("researcher.md", include_str!("templates/researcher.md")),
112 ],
113 "software" => &[
114 ("tech_lead.md", include_str!("templates/tech_lead.md")),
115 ("eng_manager.md", include_str!("templates/eng_manager.md")),
116 ("developer.md", include_str!("templates/developer.md")),
117 ],
118 "batty" => &[
119 (
120 "batty_architect.md",
121 include_str!("templates/batty_architect.md"),
122 ),
123 (
124 "batty_manager.md",
125 include_str!("templates/batty_manager.md"),
126 ),
127 (
128 "batty_engineer.md",
129 include_str!("templates/batty_engineer.md"),
130 ),
131 ],
132 "cleanroom" => &[
133 (
134 "batty_decompiler.md",
135 include_str!("templates/batty_decompiler.md"),
136 ),
137 (
138 "batty_spec_writer.md",
139 include_str!("templates/batty_spec_writer.md"),
140 ),
141 (
142 "batty_test_writer.md",
143 include_str!("templates/batty_test_writer.md"),
144 ),
145 (
146 "batty_implementer.md",
147 include_str!("templates/batty_implementer.md"),
148 ),
149 ],
150 _ => &[
151 ("architect.md", include_str!("templates/architect.md")),
152 ("manager.md", include_str!("templates/manager.md")),
153 ("engineer.md", include_str!("templates/engineer.md")),
154 ],
155 };
156
157 for (name, content) in prompt_files {
158 let path = config_dir.join(name);
159 if force || !path.exists() {
160 std::fs::write(&path, content)
161 .with_context(|| format!("failed to write {}", path.display()))?;
162 created.push(path);
163 }
164 }
165
166 let directive_files = [
167 (
168 "replenishment_context.md",
169 include_str!("templates/replenishment_context.md"),
170 ),
171 (
172 "review_policy.md",
173 include_str!("templates/review_policy.md"),
174 ),
175 (
176 "escalation_policy.md",
177 include_str!("templates/escalation_policy.md"),
178 ),
179 ];
180 for (name, content) in directive_files {
181 let path = config_dir.join(name);
182 if force || !path.exists() {
183 std::fs::write(&path, content)
184 .with_context(|| format!("failed to write {}", path.display()))?;
185 created.push(path);
186 }
187 }
188
189 if template == "cleanroom" {
190 scaffold_cleanroom_assets(project_root, force, &mut created)?;
191 }
192
193 let board_dir = config_dir.join("board");
195 if !board_dir.exists() {
196 let output = std::process::Command::new("kanban-md")
197 .args(["init", "--dir", &board_dir.to_string_lossy()])
198 .output();
199 match output {
200 Ok(out) if out.status.success() => {
201 created.push(board_dir);
202 }
203 Ok(out) => {
204 let stderr = String::from_utf8_lossy(&out.stderr);
205 warn!("kanban-md init failed: {stderr}; falling back to plain kanban.md");
206 let kanban_path = config_dir.join("kanban.md");
207 std::fs::write(
208 &kanban_path,
209 "# Kanban Board\n\n## Backlog\n\n## In Progress\n\n## Done\n",
210 )?;
211 created.push(kanban_path);
212 }
213 Err(_) => {
214 warn!("kanban-md not found; falling back to plain kanban.md");
215 let kanban_path = config_dir.join("kanban.md");
216 std::fs::write(
217 &kanban_path,
218 "# Kanban Board\n\n## Backlog\n\n## In Progress\n\n## Done\n",
219 )?;
220 created.push(kanban_path);
221 }
222 }
223 }
224
225 info!(dir = %config_dir.display(), files = created.len(), "scaffolded team config");
226 Ok(created)
227}
228
229fn scaffold_cleanroom_assets(
230 project_root: &Path,
231 force: bool,
232 created: &mut Vec<PathBuf>,
233) -> Result<()> {
234 let analysis_dir = project_root.join("analysis");
235 let implementation_dir = project_root.join("implementation");
236 let planning_dir = project_root.join("planning");
237 let specs_dir = project_root.join("specs");
238 let verification_scripts_dir = project_root.join("scripts").join("verification");
239
240 create_dir_if_missing(&analysis_dir, created)?;
241 create_dir_if_missing(&implementation_dir, created)?;
242 create_dir_if_missing(&planning_dir, created)?;
243 create_dir_if_missing(&specs_dir, created)?;
244 create_dir_if_missing(&verification_scripts_dir, created)?;
245
246 write_scaffold_file(
247 &project_root.join("PARITY.md"),
248 include_str!("templates/cleanroom_PARITY.md"),
249 force,
250 created,
251 )?;
252 write_scaffold_file(
253 &project_root.join("SPEC.md"),
254 include_str!("templates/cleanroom_SPEC.md"),
255 force,
256 created,
257 )?;
258 write_scaffold_file(
259 &planning_dir.join("cleanroom-process.md"),
260 include_str!("templates/cleanroom_process.md"),
261 force,
262 created,
263 )?;
264 write_scaffold_file(
265 &project_root.join(".batty").join("verification.yml"),
266 include_str!("templates/cleanroom_verification.yml"),
267 force,
268 created,
269 )?;
270 write_scaffold_file(
271 &verification_scripts_dir.join("placeholder-baseline.sh"),
272 include_str!("templates/cleanroom_verification_baseline.sh"),
273 force,
274 created,
275 )?;
276 write_scaffold_file(
277 &verification_scripts_dir.join("placeholder-candidate.sh"),
278 include_str!("templates/cleanroom_verification_candidate.sh"),
279 force,
280 created,
281 )?;
282 mark_executable(
283 &verification_scripts_dir.join("placeholder-baseline.sh"),
284 force,
285 )?;
286 mark_executable(
287 &verification_scripts_dir.join("placeholder-candidate.sh"),
288 force,
289 )?;
290 write_scaffold_file(
291 &analysis_dir.join("README.md"),
292 include_str!("templates/cleanroom_analysis.md"),
293 force,
294 created,
295 )?;
296
297 Ok(())
298}
299
300fn create_dir_if_missing(path: &Path, created: &mut Vec<PathBuf>) -> Result<()> {
301 if !path.exists() {
302 std::fs::create_dir_all(path)
303 .with_context(|| format!("failed to create {}", path.display()))?;
304 created.push(path.to_path_buf());
305 }
306 Ok(())
307}
308
309fn write_scaffold_file(
310 path: &Path,
311 content: &str,
312 force: bool,
313 created: &mut Vec<PathBuf>,
314) -> Result<()> {
315 if force || !path.exists() {
316 if let Some(parent) = path.parent() {
317 std::fs::create_dir_all(parent)
318 .with_context(|| format!("failed to create {}", parent.display()))?;
319 }
320 std::fs::write(path, content)
321 .with_context(|| format!("failed to write {}", path.display()))?;
322 created.push(path.to_path_buf());
323 }
324 Ok(())
325}
326
327fn mark_executable(path: &Path, force: bool) -> Result<()> {
328 if force || path.exists() {
329 #[cfg(unix)]
330 {
331 use std::os::unix::fs::PermissionsExt;
332
333 let mut perms = std::fs::metadata(path)
334 .with_context(|| format!("failed to stat {}", path.display()))?
335 .permissions();
336 perms.set_mode(0o755);
337 std::fs::set_permissions(path, perms)
338 .with_context(|| format!("failed to chmod {}", path.display()))?;
339 }
340 }
341 Ok(())
342}
343
344fn apply_init_overrides(yaml: &str, ov: &InitOverrides) -> String {
346 let mut doc: serde_yaml::Value = match serde_yaml::from_str(yaml) {
347 Ok(v) => v,
348 Err(_) => return yaml.to_string(),
349 };
350 let map = match doc.as_mapping_mut() {
351 Some(m) => m,
352 None => return yaml.to_string(),
353 };
354
355 fn set_bool(map: &mut serde_yaml::Mapping, section: &str, key: &str, val: Option<bool>) {
356 if let Some(v) = val {
357 let sec = map
358 .entry(serde_yaml::Value::String(section.to_string()))
359 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
360 if let Some(m) = sec.as_mapping_mut() {
361 m.insert(
362 serde_yaml::Value::String(key.to_string()),
363 serde_yaml::Value::Bool(v),
364 );
365 }
366 }
367 }
368
369 fn set_u64(map: &mut serde_yaml::Mapping, section: &str, key: &str, val: Option<u64>) {
370 if let Some(v) = val {
371 let sec = map
372 .entry(serde_yaml::Value::String(section.to_string()))
373 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
374 if let Some(m) = sec.as_mapping_mut() {
375 m.insert(
376 serde_yaml::Value::String(key.to_string()),
377 serde_yaml::Value::Number(serde_yaml::Number::from(v)),
378 );
379 }
380 }
381 }
382
383 if let Some(v) = ov.orchestrator_pane {
384 map.insert(
385 serde_yaml::Value::String("orchestrator_pane".to_string()),
386 serde_yaml::Value::Bool(v),
387 );
388 }
389
390 set_bool(map, "board", "auto_dispatch", ov.auto_dispatch);
391 set_u64(map, "standup", "interval_secs", ov.standup_interval_secs);
392 set_bool(map, "automation", "timeout_nudges", ov.timeout_nudges);
393 set_bool(map, "automation", "standups", ov.standups);
394 set_bool(
395 map,
396 "automation",
397 "triage_interventions",
398 ov.triage_interventions,
399 );
400 set_bool(
401 map,
402 "automation",
403 "review_interventions",
404 ov.review_interventions,
405 );
406 set_bool(
407 map,
408 "automation",
409 "owned_task_interventions",
410 ov.owned_task_interventions,
411 );
412 set_bool(
413 map,
414 "automation",
415 "manager_dispatch_interventions",
416 ov.manager_dispatch_interventions,
417 );
418 set_bool(
419 map,
420 "automation",
421 "architect_utilization_interventions",
422 ov.architect_utilization_interventions,
423 );
424 set_u64(
425 map,
426 "workflow_policy",
427 "stall_threshold_secs",
428 ov.stall_threshold_secs,
429 );
430 set_u64(
431 map,
432 "workflow_policy",
433 "review_nudge_threshold_secs",
434 ov.review_nudge_threshold_secs,
435 );
436 set_u64(
437 map,
438 "workflow_policy",
439 "review_timeout_secs",
440 ov.review_timeout_secs,
441 );
442
443 if let Some(v) = ov.auto_merge_enabled {
444 let sec = map
445 .entry(serde_yaml::Value::String("workflow_policy".to_string()))
446 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
447 if let Some(wp) = sec.as_mapping_mut() {
448 let am = wp
449 .entry(serde_yaml::Value::String("auto_merge".to_string()))
450 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
451 if let Some(m) = am.as_mapping_mut() {
452 m.insert(
453 serde_yaml::Value::String("enabled".to_string()),
454 serde_yaml::Value::Bool(v),
455 );
456 }
457 }
458 }
459
460 if ov.use_worktrees.is_some() || ov.nudge_interval_secs.is_some() {
461 if let Some(roles) = map
462 .get_mut(serde_yaml::Value::String("roles".to_string()))
463 .and_then(|v| v.as_sequence_mut())
464 {
465 for role in roles.iter_mut() {
466 if let Some(m) = role.as_mapping_mut() {
467 let role_type = m
468 .get(serde_yaml::Value::String("role_type".to_string()))
469 .and_then(|v| v.as_str())
470 .map(str::to_owned);
471
472 if role_type.as_deref() == Some("engineer") {
473 if let Some(v) = ov.use_worktrees {
474 m.insert(
475 serde_yaml::Value::String("use_worktrees".to_string()),
476 serde_yaml::Value::Bool(v),
477 );
478 }
479 }
480 if role_type.as_deref() == Some("architect") {
481 if let Some(v) = ov.nudge_interval_secs {
482 m.insert(
483 serde_yaml::Value::String("nudge_interval_secs".to_string()),
484 serde_yaml::Value::Number(serde_yaml::Number::from(v)),
485 );
486 }
487 }
488 }
489 }
490 }
491 }
492
493 serde_yaml::to_string(&doc).unwrap_or_else(|_| yaml.to_string())
494}
495
496pub fn list_available_templates() -> Result<Vec<String>> {
497 let templates_dir = templates_base_dir()?;
498 if !templates_dir.is_dir() {
499 bail!(
500 "no templates directory found at {}",
501 templates_dir.display()
502 );
503 }
504
505 let mut templates = Vec::new();
506 for entry in std::fs::read_dir(&templates_dir)
507 .with_context(|| format!("failed to read {}", templates_dir.display()))?
508 {
509 let entry = entry?;
510 if entry.path().is_dir() {
511 templates.push(entry.file_name().to_string_lossy().into_owned());
512 }
513 }
514 templates.sort();
515 Ok(templates)
516}
517
518const TEMPLATE_PROJECT_ROOT_DIR: &str = "project_root";
519
520fn copy_template_dir(src: &Path, dst: &Path, created: &mut Vec<PathBuf>) -> Result<()> {
521 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
522 for entry in
523 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
524 {
525 let entry = entry?;
526 let src_path = entry.path();
527 let dst_path = dst.join(entry.file_name());
528 if src_path.is_dir() {
529 copy_template_dir(&src_path, &dst_path, created)?;
530 } else {
531 std::fs::copy(&src_path, &dst_path).with_context(|| {
532 format!(
533 "failed to copy template file from {} to {}",
534 src_path.display(),
535 dst_path.display()
536 )
537 })?;
538 created.push(dst_path);
539 }
540 }
541 Ok(())
542}
543
544pub fn init_from_template(project_root: &Path, template_name: &str) -> Result<Vec<PathBuf>> {
545 let templates_dir = templates_base_dir()?;
546 if !templates_dir.is_dir() {
547 bail!(
548 "no templates directory found at {}",
549 templates_dir.display()
550 );
551 }
552
553 let available = list_available_templates()?;
554 if !available.iter().any(|name| name == template_name) {
555 let available_display = if available.is_empty() {
556 "(none)".to_string()
557 } else {
558 available.join(", ")
559 };
560 bail!(
561 "template '{}' not found in {}; available templates: {}",
562 template_name,
563 templates_dir.display(),
564 available_display
565 );
566 }
567
568 let config_dir = team_config_dir(project_root);
569 let yaml_path = config_dir.join(TEAM_CONFIG_FILE);
570 if yaml_path.exists() {
571 bail!(
572 "team config already exists at {}; remove it first or edit directly",
573 yaml_path.display()
574 );
575 }
576
577 let source_dir = templates_dir.join(template_name);
578 let mut created = Vec::new();
579 std::fs::create_dir_all(&config_dir)
580 .with_context(|| format!("failed to create {}", config_dir.display()))?;
581 for entry in std::fs::read_dir(&source_dir)
582 .with_context(|| format!("failed to read {}", source_dir.display()))?
583 {
584 let entry = entry?;
585 let src_path = entry.path();
586 let file_name = entry.file_name();
587 if file_name == TEMPLATE_PROJECT_ROOT_DIR {
588 copy_template_dir(&src_path, project_root, &mut created)?;
589 } else if src_path.is_dir() {
590 copy_template_dir(&src_path, &config_dir.join(file_name), &mut created)?;
591 } else {
592 let dst_path = config_dir.join(file_name);
593 copy_template_file(&src_path, &dst_path)?;
594 created.push(dst_path);
595 }
596 }
597 info!(
598 template = template_name,
599 source = %source_dir.display(),
600 dest = %config_dir.display(),
601 files = created.len(),
602 "copied team config from user template"
603 );
604 Ok(created)
605}
606
607pub fn export_template(project_root: &Path, name: &str) -> Result<usize> {
609 let config_dir = team_config_dir(project_root);
610 let team_yaml = config_dir.join(TEAM_CONFIG_FILE);
611 if !team_yaml.is_file() {
612 bail!("team config missing at {}", team_yaml.display());
613 }
614
615 let template_dir = templates_base_dir()?.join(name);
616 if template_dir.exists() {
617 eprintln!(
618 "warning: overwriting existing template at {}",
619 template_dir.display()
620 );
621 }
622 std::fs::create_dir_all(&template_dir)
623 .with_context(|| format!("failed to create {}", template_dir.display()))?;
624
625 let mut copied = 0usize;
626 copy_template_file(&team_yaml, &template_dir.join(TEAM_CONFIG_FILE))?;
627 copied += 1;
628
629 let mut prompt_paths = std::fs::read_dir(&config_dir)?
630 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
631 .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
632 .collect::<Vec<_>>();
633 prompt_paths.sort();
634
635 for source in prompt_paths {
636 let file_name = source
637 .file_name()
638 .context("template source missing file name")?;
639 copy_template_file(&source, &template_dir.join(file_name))?;
640 copied += 1;
641 }
642
643 copied += export_project_template_assets(project_root, &template_dir)?;
644
645 Ok(copied)
646}
647
648fn export_project_template_assets(project_root: &Path, template_dir: &Path) -> Result<usize> {
649 let mut copied = 0usize;
650 let export_root = template_dir.join(TEMPLATE_PROJECT_ROOT_DIR);
651
652 let optional_dirs = [
653 (
654 project_root.join("analysis"),
655 export_root.join("analysis"),
656 false,
657 ),
658 (
659 project_root.join("implementation"),
660 export_root.join("implementation"),
661 false,
662 ),
663 (
664 project_root.join("planning"),
665 export_root.join("planning"),
666 true,
667 ),
668 ];
669 for (source, destination, cleanroom_only) in optional_dirs {
670 if cleanroom_only && source.file_name() == Some(std::ffi::OsStr::new("planning")) {
671 let cleanroom_doc = source.join("cleanroom-process.md");
672 if cleanroom_doc.is_file() {
673 copy_template_file(&cleanroom_doc, &destination.join("cleanroom-process.md"))?;
674 copied += 1;
675 }
676 continue;
677 }
678 if source.is_dir() {
679 let mut created = Vec::new();
680 copy_template_dir(&source, &destination, &mut created)?;
681 copied += count_files_in_dir(&source)?;
682 }
683 }
684
685 let optional_files = [
686 (
687 project_root.join("PARITY.md"),
688 export_root.join("PARITY.md"),
689 ),
690 (project_root.join("SPEC.md"), export_root.join("SPEC.md")),
691 ];
692 for (source, destination) in optional_files {
693 if source.is_file() {
694 copy_template_file(&source, &destination)?;
695 copied += 1;
696 }
697 }
698
699 Ok(copied)
700}
701
702pub fn export_run(project_root: &Path) -> Result<PathBuf> {
703 let team_yaml = team_config_path(project_root);
704 if !team_yaml.is_file() {
705 bail!("team config missing at {}", team_yaml.display());
706 }
707
708 let export_dir = create_run_export_dir(project_root)?;
709 copy_template_file(&team_yaml, &export_dir.join(TEAM_CONFIG_FILE))?;
710
711 copy_dir_if_exists(
712 &team_config_dir(project_root).join("board").join("tasks"),
713 &export_dir.join("board").join("tasks"),
714 )?;
715 copy_file_if_exists(
716 &team_events_path(project_root),
717 &export_dir.join("events.jsonl"),
718 )?;
719 copy_file_if_exists(
720 &daemon_log_path(project_root),
721 &export_dir.join("daemon.log"),
722 )?;
723 copy_file_if_exists(
724 &orchestrator_log_path(project_root),
725 &export_dir.join("orchestrator.log"),
726 )?;
727 copy_dir_if_exists(
728 &project_root.join(".batty").join("retrospectives"),
729 &export_dir.join("retrospectives"),
730 )?;
731 copy_file_if_exists(
732 &project_root.join(".batty").join("test_timing.jsonl"),
733 &export_dir.join("test_timing.jsonl"),
734 )?;
735
736 Ok(export_dir)
737}
738
739fn copy_template_file(source: &Path, destination: &Path) -> Result<()> {
740 if let Some(parent) = destination.parent() {
741 std::fs::create_dir_all(parent)
742 .with_context(|| format!("failed to create {}", parent.display()))?;
743 }
744 std::fs::copy(source, destination).with_context(|| {
745 format!(
746 "failed to copy {} to {}",
747 source.display(),
748 destination.display()
749 )
750 })?;
751 Ok(())
752}
753
754fn exports_dir(project_root: &Path) -> PathBuf {
755 project_root.join(".batty").join("exports")
756}
757
758fn create_run_export_dir(project_root: &Path) -> Result<PathBuf> {
759 let base = exports_dir(project_root);
760 std::fs::create_dir_all(&base)
761 .with_context(|| format!("failed to create {}", base.display()))?;
762
763 let timestamp = now_unix();
764 let primary = base.join(timestamp.to_string());
765 if !primary.exists() {
766 std::fs::create_dir(&primary)
767 .with_context(|| format!("failed to create {}", primary.display()))?;
768 return Ok(primary);
769 }
770
771 for suffix in 1.. {
772 let candidate = base.join(format!("{timestamp}-{suffix}"));
773 if candidate.exists() {
774 continue;
775 }
776 std::fs::create_dir(&candidate)
777 .with_context(|| format!("failed to create {}", candidate.display()))?;
778 return Ok(candidate);
779 }
780
781 unreachable!("infinite suffix iterator should always return or continue");
782}
783
784fn copy_file_if_exists(source: &Path, destination: &Path) -> Result<()> {
785 if source.is_file() {
786 copy_template_file(source, destination)?;
787 }
788 Ok(())
789}
790
791fn copy_dir_if_exists(source: &Path, destination: &Path) -> Result<()> {
792 if source.is_dir() {
793 let mut created = Vec::new();
794 copy_template_dir(source, destination, &mut created)?;
795 }
796 Ok(())
797}
798
799fn count_files_in_dir(path: &Path) -> Result<usize> {
800 let mut count = 0usize;
801 for entry in
802 std::fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))?
803 {
804 let entry = entry?;
805 let child = entry.path();
806 if child.is_dir() {
807 count += count_files_in_dir(&child)?;
808 } else {
809 count += 1;
810 }
811 }
812 Ok(count)
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818 use serial_test::serial;
819 use std::ffi::OsString;
820
821 use crate::team::{
822 daemon_log_path, orchestrator_log_path, team_config_dir, team_config_path, team_events_path,
823 };
824
825 struct HomeGuard {
826 original_home: Option<OsString>,
827 }
828
829 impl HomeGuard {
830 fn set(path: &Path) -> Self {
831 let original_home = std::env::var_os("HOME");
832 unsafe {
833 std::env::set_var("HOME", path);
834 }
835 Self { original_home }
836 }
837 }
838
839 impl Drop for HomeGuard {
840 fn drop(&mut self) {
841 match &self.original_home {
842 Some(home) => unsafe {
843 std::env::set_var("HOME", home);
844 },
845 None => unsafe {
846 std::env::remove_var("HOME");
847 },
848 }
849 }
850 }
851
852 #[test]
853 fn init_team_creates_scaffolding() {
854 let tmp = tempfile::tempdir().unwrap();
855 let created = init_team(tmp.path(), "simple", None, None, false).unwrap();
856 assert!(!created.is_empty());
857 assert!(team_config_path(tmp.path()).exists());
858 assert!(team_config_dir(tmp.path()).join("architect.md").exists());
859 assert!(team_config_dir(tmp.path()).join("manager.md").exists());
860 assert!(team_config_dir(tmp.path()).join("engineer.md").exists());
861 assert!(
862 team_config_dir(tmp.path())
863 .join("replenishment_context.md")
864 .exists()
865 );
866 assert!(
867 team_config_dir(tmp.path())
868 .join("review_policy.md")
869 .exists()
870 );
871 assert!(
872 team_config_dir(tmp.path())
873 .join("escalation_policy.md")
874 .exists()
875 );
876 let config = team_config_dir(tmp.path());
878 assert!(config.join("board").is_dir() || config.join("kanban.md").exists());
879 let team_yaml = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
880 assert!(team_yaml.contains("auto_respawn_on_crash: true"));
881 }
882
883 #[test]
884 fn init_team_refuses_if_exists() {
885 let tmp = tempfile::tempdir().unwrap();
886 init_team(tmp.path(), "simple", None, None, false).unwrap();
887 let result = init_team(tmp.path(), "simple", None, None, false);
888 assert!(result.is_err());
889 assert!(result.unwrap_err().to_string().contains("already exists"));
890 }
891
892 #[test]
893 #[serial]
894 fn init_from_template_copies_files() {
895 let project = tempfile::tempdir().unwrap();
896 let home = tempfile::tempdir().unwrap();
897 let _home_guard = HomeGuard::set(home.path());
898
899 let template_dir = home.path().join(".batty").join("templates").join("custom");
900 std::fs::create_dir_all(template_dir.join("board")).unwrap();
901 std::fs::write(template_dir.join("team.yaml"), "name: custom\nroles: []\n").unwrap();
902 std::fs::write(template_dir.join("architect.md"), "# Architect\n").unwrap();
903 std::fs::write(template_dir.join("board").join("task.md"), "task\n").unwrap();
904
905 let created = init_from_template(project.path(), "custom").unwrap();
906
907 assert!(!created.is_empty());
908 assert_eq!(
909 std::fs::read_to_string(team_config_path(project.path())).unwrap(),
910 "name: custom\nroles: []\n"
911 );
912 assert!(
913 team_config_dir(project.path())
914 .join("architect.md")
915 .exists()
916 );
917 assert!(
918 team_config_dir(project.path())
919 .join("board")
920 .join("task.md")
921 .exists()
922 );
923 }
924
925 #[test]
926 #[serial]
927 fn init_from_template_restores_project_root_assets() {
928 let project = tempfile::tempdir().unwrap();
929 let home = tempfile::tempdir().unwrap();
930 let _home_guard = HomeGuard::set(home.path());
931
932 let template_dir = home
933 .path()
934 .join(".batty")
935 .join("templates")
936 .join("cleanroom");
937 let export_root = template_dir.join(TEMPLATE_PROJECT_ROOT_DIR);
938 std::fs::create_dir_all(export_root.join("analysis")).unwrap();
939 std::fs::create_dir_all(export_root.join("implementation")).unwrap();
940 std::fs::create_dir_all(export_root.join("planning")).unwrap();
941 std::fs::write(template_dir.join("team.yaml"), "name: custom\nroles: []\n").unwrap();
942 std::fs::write(template_dir.join("batty_decompiler.md"), "# Decompiler\n").unwrap();
943 std::fs::write(export_root.join("PARITY.md"), "# Parity\n").unwrap();
944 std::fs::write(export_root.join("SPEC.md"), "# Spec\n").unwrap();
945 std::fs::write(
946 export_root.join("planning").join("cleanroom-process.md"),
947 "# Process\n",
948 )
949 .unwrap();
950
951 let created = init_from_template(project.path(), "cleanroom").unwrap();
952
953 assert!(!created.is_empty());
954 assert_eq!(
955 std::fs::read_to_string(team_config_path(project.path())).unwrap(),
956 "name: custom\nroles: []\n"
957 );
958 assert!(
959 team_config_dir(project.path())
960 .join("batty_decompiler.md")
961 .exists()
962 );
963 assert!(project.path().join("analysis").is_dir());
964 assert!(project.path().join("implementation").is_dir());
965 assert_eq!(
966 std::fs::read_to_string(project.path().join("PARITY.md")).unwrap(),
967 "# Parity\n"
968 );
969 assert_eq!(
970 std::fs::read_to_string(project.path().join("SPEC.md")).unwrap(),
971 "# Spec\n"
972 );
973 assert_eq!(
974 std::fs::read_to_string(project.path().join("planning").join("cleanroom-process.md"))
975 .unwrap(),
976 "# Process\n"
977 );
978 }
979
980 #[test]
981 #[serial]
982 fn init_from_template_missing_template_errors_with_available_list() {
983 let project = tempfile::tempdir().unwrap();
984 let home = tempfile::tempdir().unwrap();
985 let _home_guard = HomeGuard::set(home.path());
986
987 let templates_root = home.path().join(".batty").join("templates");
988 std::fs::create_dir_all(templates_root.join("alpha")).unwrap();
989 std::fs::create_dir_all(templates_root.join("beta")).unwrap();
990
991 let error = init_from_template(project.path(), "missing").unwrap_err();
992 let message = error.to_string();
993 assert!(message.contains("template 'missing' not found"));
994 assert!(message.contains("alpha"));
995 assert!(message.contains("beta"));
996 }
997
998 #[test]
999 #[serial]
1000 fn init_from_template_errors_when_templates_dir_is_missing() {
1001 let project = tempfile::tempdir().unwrap();
1002 let home = tempfile::tempdir().unwrap();
1003 let _home_guard = HomeGuard::set(home.path());
1004
1005 let error = init_from_template(project.path(), "missing").unwrap_err();
1006 assert!(error.to_string().contains("no templates directory found"));
1007 }
1008
1009 #[test]
1010 fn init_team_large_template() {
1011 let tmp = tempfile::tempdir().unwrap();
1012 let created = init_team(tmp.path(), "large", None, None, false).unwrap();
1013 assert!(!created.is_empty());
1014 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1015 assert!(content.contains("instances: 3") || content.contains("instances: 5"));
1016 }
1017
1018 #[test]
1019 fn init_team_solo_template() {
1020 let tmp = tempfile::tempdir().unwrap();
1021 let created = init_team(tmp.path(), "solo", None, None, false).unwrap();
1022 assert!(!created.is_empty());
1023 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1024 assert!(content.contains("role_type: engineer"));
1025 assert!(!content.contains("role_type: manager"));
1026 }
1027
1028 #[test]
1029 fn init_team_pair_template() {
1030 let tmp = tempfile::tempdir().unwrap();
1031 let created = init_team(tmp.path(), "pair", None, None, false).unwrap();
1032 assert!(!created.is_empty());
1033 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1034 assert!(content.contains("role_type: architect"));
1035 assert!(content.contains("role_type: engineer"));
1036 assert!(!content.contains("role_type: manager"));
1037 }
1038
1039 #[test]
1040 fn init_team_squad_template() {
1041 let tmp = tempfile::tempdir().unwrap();
1042 let created = init_team(tmp.path(), "squad", None, None, false).unwrap();
1043 assert!(!created.is_empty());
1044 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1045 assert!(content.contains("instances: 5"));
1046 assert!(content.contains("layout:"));
1047 }
1048
1049 #[test]
1050 fn init_team_research_template() {
1051 let tmp = tempfile::tempdir().unwrap();
1052 let created = init_team(tmp.path(), "research", None, None, false).unwrap();
1053 assert!(!created.is_empty());
1054 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1055 assert!(content.contains("principal"));
1056 assert!(content.contains("sub-lead"));
1057 assert!(content.contains("researcher"));
1058 assert!(
1060 team_config_dir(tmp.path())
1061 .join("research_lead.md")
1062 .exists()
1063 );
1064 assert!(team_config_dir(tmp.path()).join("sub_lead.md").exists());
1065 assert!(team_config_dir(tmp.path()).join("researcher.md").exists());
1066 assert!(!team_config_dir(tmp.path()).join("architect.md").exists());
1068 }
1069
1070 #[test]
1071 fn init_team_software_template() {
1072 let tmp = tempfile::tempdir().unwrap();
1073 let created = init_team(tmp.path(), "software", None, None, false).unwrap();
1074 assert!(!created.is_empty());
1075 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1076 assert!(content.contains("tech-lead"));
1077 assert!(content.contains("backend-mgr"));
1078 assert!(content.contains("frontend-mgr"));
1079 assert!(content.contains("developer"));
1080 assert!(team_config_dir(tmp.path()).join("tech_lead.md").exists());
1082 assert!(team_config_dir(tmp.path()).join("eng_manager.md").exists());
1083 assert!(team_config_dir(tmp.path()).join("developer.md").exists());
1084 }
1085
1086 #[test]
1087 fn init_team_batty_template() {
1088 let tmp = tempfile::tempdir().unwrap();
1089 let created = init_team(tmp.path(), "batty", None, None, false).unwrap();
1090 assert!(!created.is_empty());
1091 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1092 assert!(content.contains("batty-dev"));
1093 assert!(content.contains("role_type: architect"));
1094 assert!(content.contains("role_type: manager"));
1095 assert!(content.contains("instances: 4"));
1096 assert!(content.contains("batty_architect.md"));
1097 assert!(
1099 team_config_dir(tmp.path())
1100 .join("batty_architect.md")
1101 .exists()
1102 );
1103 assert!(
1104 team_config_dir(tmp.path())
1105 .join("batty_manager.md")
1106 .exists()
1107 );
1108 assert!(
1109 team_config_dir(tmp.path())
1110 .join("batty_engineer.md")
1111 .exists()
1112 );
1113 assert!(
1114 team_config_dir(tmp.path())
1115 .join("review_policy.md")
1116 .exists()
1117 );
1118 let manager_prompt =
1119 std::fs::read_to_string(team_config_dir(tmp.path()).join("batty_manager.md")).unwrap();
1120 let engineer_prompt =
1121 std::fs::read_to_string(team_config_dir(tmp.path()).join("batty_engineer.md")).unwrap();
1122 assert!(manager_prompt.contains("## Anti-Narration Rules"));
1123 assert!(manager_prompt.contains("Run the control-plane commands directly."));
1124 assert!(engineer_prompt.contains("## Anti-Narration Rules"));
1125 assert!(engineer_prompt.contains("Execute commands directly."));
1126 }
1127
1128 #[test]
1129 fn init_team_cleanroom_template() {
1130 let tmp = tempfile::tempdir().unwrap();
1131 let created = init_team(tmp.path(), "cleanroom", None, None, false).unwrap();
1132 assert!(!created.is_empty());
1133
1134 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1135 assert!(content.contains("decompiler"));
1136 assert!(content.contains("spec-writer"));
1137 assert!(content.contains("test-writer"));
1138 assert!(content.contains("implementer"));
1139 assert!(content.contains("batty_decompiler.md"));
1140
1141 let config_dir = team_config_dir(tmp.path());
1142 assert!(config_dir.join("batty_decompiler.md").exists());
1143 assert!(config_dir.join("batty_spec_writer.md").exists());
1144 assert!(config_dir.join("batty_test_writer.md").exists());
1145 assert!(config_dir.join("batty_implementer.md").exists());
1146 assert!(tmp.path().join("analysis").is_dir());
1147 assert!(tmp.path().join("analysis").join("README.md").exists());
1148 assert!(tmp.path().join("implementation").is_dir());
1149 assert!(tmp.path().join("PARITY.md").exists());
1150 assert!(tmp.path().join("SPEC.md").exists());
1151 assert!(
1152 tmp.path()
1153 .join("planning")
1154 .join("cleanroom-process.md")
1155 .exists()
1156 );
1157 }
1158
1159 #[test]
1160 fn init_team_cleanroom_template_scaffolds_parseable_parity_report() {
1161 let tmp = tempfile::tempdir().unwrap();
1162 init_team(tmp.path(), "cleanroom", None, None, false).unwrap();
1163
1164 let report = crate::team::parity::ParityReport::load(tmp.path()).unwrap();
1165 let summary = report.summary();
1166
1167 assert_eq!(report.metadata.project, "clean-room-project");
1168 assert_eq!(report.metadata.source_platform, "zx-spectrum-z80");
1169 assert_eq!(summary.total_behaviors, 3);
1170 assert_eq!(summary.spec_complete, 0);
1171 assert_eq!(summary.verified_pass, 0);
1172 }
1173
1174 #[test]
1175 fn init_with_agent_codex_sets_backend() {
1176 let tmp = tempfile::tempdir().unwrap();
1177 let _created = init_team(tmp.path(), "simple", None, Some("codex"), false).unwrap();
1178 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1179 assert!(
1180 content.contains("agent: codex"),
1181 "all agent fields should be codex"
1182 );
1183 assert!(
1184 !content.contains("agent: claude"),
1185 "no claude agents should remain"
1186 );
1187 }
1188
1189 #[test]
1190 fn init_with_agent_kiro_sets_backend() {
1191 let tmp = tempfile::tempdir().unwrap();
1192 let _created = init_team(tmp.path(), "pair", None, Some("kiro"), false).unwrap();
1193 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1194 assert!(
1195 content.contains("agent: kiro"),
1196 "all agent fields should be kiro"
1197 );
1198 assert!(
1199 !content.contains("agent: claude"),
1200 "no claude agents should remain"
1201 );
1202 assert!(
1203 !content.contains("agent: codex"),
1204 "no codex agents should remain"
1205 );
1206 }
1207
1208 #[test]
1209 fn init_default_agent_is_claude() {
1210 let tmp = tempfile::tempdir().unwrap();
1211 let _created = init_team(tmp.path(), "simple", None, None, false).unwrap();
1212 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1213 assert!(
1214 content.contains("agent: claude"),
1215 "default agent should be claude"
1216 );
1217 }
1218
1219 #[test]
1220 #[serial]
1221 fn export_template_creates_directory_and_copies_files() {
1222 let tmp = tempfile::tempdir().unwrap();
1223 let _home = HomeGuard::set(tmp.path());
1224 let project_root = tmp.path().join("project");
1225 let config_dir = team_config_dir(&project_root);
1226 std::fs::create_dir_all(&config_dir).unwrap();
1227 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
1228 std::fs::write(config_dir.join("architect.md"), "architect prompt\n").unwrap();
1229
1230 let copied = export_template(&project_root, "demo-template").unwrap();
1231 let template_dir = templates_base_dir().unwrap().join("demo-template");
1232
1233 assert_eq!(copied, 2);
1234 assert_eq!(
1235 std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
1236 "name: demo\n"
1237 );
1238 assert_eq!(
1239 std::fs::read_to_string(template_dir.join("architect.md")).unwrap(),
1240 "architect prompt\n"
1241 );
1242 }
1243
1244 #[test]
1245 #[serial]
1246 fn export_template_overwrites_existing() {
1247 let tmp = tempfile::tempdir().unwrap();
1248 let _home = HomeGuard::set(tmp.path());
1249 let project_root = tmp.path().join("project");
1250 let config_dir = team_config_dir(&project_root);
1251 std::fs::create_dir_all(&config_dir).unwrap();
1252 std::fs::write(config_dir.join("team.yaml"), "name: first\n").unwrap();
1253 std::fs::write(config_dir.join("manager.md"), "v1\n").unwrap();
1254
1255 export_template(&project_root, "demo-template").unwrap();
1256
1257 std::fs::write(config_dir.join("team.yaml"), "name: second\n").unwrap();
1258 std::fs::write(config_dir.join("manager.md"), "v2\n").unwrap();
1259
1260 let copied = export_template(&project_root, "demo-template").unwrap();
1261 let template_dir = templates_base_dir().unwrap().join("demo-template");
1262
1263 assert_eq!(copied, 2);
1264 assert_eq!(
1265 std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
1266 "name: second\n"
1267 );
1268 assert_eq!(
1269 std::fs::read_to_string(template_dir.join("manager.md")).unwrap(),
1270 "v2\n"
1271 );
1272 }
1273
1274 #[test]
1275 #[serial]
1276 fn export_template_missing_team_yaml_errors() {
1277 let tmp = tempfile::tempdir().unwrap();
1278 let _home = HomeGuard::set(tmp.path());
1279 let project_root = tmp.path().join("project");
1280 std::fs::create_dir_all(team_config_dir(&project_root)).unwrap();
1281
1282 let error = export_template(&project_root, "demo-template").unwrap_err();
1283
1284 assert!(error.to_string().contains("team config missing"));
1285 }
1286
1287 #[test]
1288 #[serial]
1289 fn export_template_includes_cleanroom_project_assets() {
1290 let tmp = tempfile::tempdir().unwrap();
1291 let _home = HomeGuard::set(tmp.path());
1292 let project_root = tmp.path().join("project");
1293 let config_dir = team_config_dir(&project_root);
1294 std::fs::create_dir_all(&config_dir).unwrap();
1295 std::fs::create_dir_all(project_root.join("analysis")).unwrap();
1296 std::fs::create_dir_all(project_root.join("implementation")).unwrap();
1297 std::fs::create_dir_all(project_root.join("planning")).unwrap();
1298 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
1299 std::fs::write(config_dir.join("batty_decompiler.md"), "prompt\n").unwrap();
1300 std::fs::write(
1301 project_root.join("analysis").join("README.md"),
1302 "# Analysis\n",
1303 )
1304 .unwrap();
1305 std::fs::write(project_root.join("PARITY.md"), "# Parity\n").unwrap();
1306 std::fs::write(project_root.join("SPEC.md"), "# Spec\n").unwrap();
1307 std::fs::write(
1308 project_root.join("planning").join("cleanroom-process.md"),
1309 "# Process\n",
1310 )
1311 .unwrap();
1312
1313 let copied = export_template(&project_root, "cleanroom-template").unwrap();
1314 let template_dir = templates_base_dir().unwrap().join("cleanroom-template");
1315 let export_root = template_dir.join(TEMPLATE_PROJECT_ROOT_DIR);
1316
1317 assert_eq!(copied, 6);
1318 assert_eq!(
1319 std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
1320 "name: demo\n"
1321 );
1322 assert_eq!(
1323 std::fs::read_to_string(template_dir.join("batty_decompiler.md")).unwrap(),
1324 "prompt\n"
1325 );
1326 assert_eq!(
1327 std::fs::read_to_string(export_root.join("analysis").join("README.md")).unwrap(),
1328 "# Analysis\n"
1329 );
1330 assert_eq!(
1331 std::fs::read_to_string(export_root.join("PARITY.md")).unwrap(),
1332 "# Parity\n"
1333 );
1334 assert_eq!(
1335 std::fs::read_to_string(export_root.join("SPEC.md")).unwrap(),
1336 "# Spec\n"
1337 );
1338 assert_eq!(
1339 std::fs::read_to_string(export_root.join("planning").join("cleanroom-process.md"))
1340 .unwrap(),
1341 "# Process\n"
1342 );
1343 }
1344
1345 #[test]
1346 fn init_team_cleanroom_template_includes_multi_backend_guidance() {
1347 let tmp = tempfile::tempdir().unwrap();
1348 init_team(tmp.path(), "cleanroom", None, None, false).unwrap();
1349
1350 let config_dir = team_config_dir(tmp.path());
1351 let decompiler = std::fs::read_to_string(config_dir.join("batty_decompiler.md")).unwrap();
1352 let spec_writer = std::fs::read_to_string(config_dir.join("batty_spec_writer.md")).unwrap();
1353 let process =
1354 std::fs::read_to_string(tmp.path().join("planning").join("cleanroom-process.md"))
1355 .unwrap();
1356 let analysis_readme =
1357 std::fs::read_to_string(tmp.path().join("analysis").join("README.md")).unwrap();
1358
1359 assert!(decompiler.contains("SkoolKit"));
1360 assert!(decompiler.contains("Ghidra"));
1361 assert!(decompiler.contains("NES"));
1362 assert!(decompiler.contains("Game Boy"));
1363 assert!(decompiler.contains("DOS"));
1364 assert!(decompiler.contains("analysis/README.md"));
1365 assert!(spec_writer.contains("normalized analysis artifact"));
1366 assert!(process.contains("## Backend Selection"));
1367 assert!(analysis_readme.contains("## Normalized Analysis Artifact"));
1368 }
1369
1370 #[test]
1371 fn export_run_copies_requested_run_state_only() {
1372 let tmp = tempfile::tempdir().unwrap();
1373 let project_root = tmp.path().join("project");
1374 let config_dir = team_config_dir(&project_root);
1375 let tasks_dir = config_dir.join("board").join("tasks");
1376 let retrospectives_dir = project_root.join(".batty").join("retrospectives");
1377 let worktree_dir = project_root
1378 .join(".batty")
1379 .join("worktrees")
1380 .join("eng-1-1")
1381 .join(".codex")
1382 .join("sessions");
1383 std::fs::create_dir_all(&tasks_dir).unwrap();
1384 std::fs::create_dir_all(&retrospectives_dir).unwrap();
1385 std::fs::create_dir_all(&worktree_dir).unwrap();
1386
1387 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
1388 std::fs::write(tasks_dir.join("001-task.md"), "---\nid: 1\n---\n").unwrap();
1389 std::fs::write(
1390 team_events_path(&project_root),
1391 "{\"event\":\"daemon_started\"}\n",
1392 )
1393 .unwrap();
1394 std::fs::write(daemon_log_path(&project_root), "daemon-log\n").unwrap();
1395 std::fs::write(orchestrator_log_path(&project_root), "orchestrator-log\n").unwrap();
1396 std::fs::write(retrospectives_dir.join("retro.md"), "# Retro\n").unwrap();
1397 std::fs::write(
1398 project_root.join(".batty").join("test_timing.jsonl"),
1399 "{\"task_id\":1}\n",
1400 )
1401 .unwrap();
1402 std::fs::write(worktree_dir.join("session.jsonl"), "secret\n").unwrap();
1403
1404 let export_dir = export_run(&project_root).unwrap();
1405
1406 assert_eq!(
1407 std::fs::read_to_string(export_dir.join("team.yaml")).unwrap(),
1408 "name: demo\n"
1409 );
1410 assert_eq!(
1411 std::fs::read_to_string(export_dir.join("board").join("tasks").join("001-task.md"))
1412 .unwrap(),
1413 "---\nid: 1\n---\n"
1414 );
1415 assert_eq!(
1416 std::fs::read_to_string(export_dir.join("events.jsonl")).unwrap(),
1417 "{\"event\":\"daemon_started\"}\n"
1418 );
1419 assert_eq!(
1420 std::fs::read_to_string(export_dir.join("daemon.log")).unwrap(),
1421 "daemon-log\n"
1422 );
1423 assert_eq!(
1424 std::fs::read_to_string(export_dir.join("orchestrator.log")).unwrap(),
1425 "orchestrator-log\n"
1426 );
1427 assert_eq!(
1428 std::fs::read_to_string(export_dir.join("retrospectives").join("retro.md")).unwrap(),
1429 "# Retro\n"
1430 );
1431 assert_eq!(
1432 std::fs::read_to_string(export_dir.join("test_timing.jsonl")).unwrap(),
1433 "{\"task_id\":1}\n"
1434 );
1435 assert!(!export_dir.join("worktrees").exists());
1436 assert!(!export_dir.join(".codex").exists());
1437 assert!(!export_dir.join("sessions").exists());
1438 }
1439
1440 #[test]
1441 fn export_run_skips_missing_optional_paths() {
1442 let tmp = tempfile::tempdir().unwrap();
1443 let project_root = tmp.path().join("project");
1444 let config_dir = team_config_dir(&project_root);
1445 std::fs::create_dir_all(&config_dir).unwrap();
1446 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
1447
1448 let export_dir = export_run(&project_root).unwrap();
1449
1450 assert!(export_dir.join("team.yaml").is_file());
1451 assert!(!export_dir.join("board").exists());
1452 assert!(!export_dir.join("events.jsonl").exists());
1453 assert!(!export_dir.join("daemon.log").exists());
1454 assert!(!export_dir.join("orchestrator.log").exists());
1455 assert!(!export_dir.join("retrospectives").exists());
1456 assert!(!export_dir.join("test_timing.jsonl").exists());
1457 }
1458
1459 #[test]
1460 fn export_run_missing_team_yaml_errors() {
1461 let tmp = tempfile::tempdir().unwrap();
1462 let project_root = tmp.path().join("project");
1463 std::fs::create_dir_all(team_config_dir(&project_root)).unwrap();
1464
1465 let error = export_run(&project_root).unwrap_err();
1466
1467 assert!(error.to_string().contains("team config missing"));
1468 }
1469
1470 #[test]
1471 fn apply_init_overrides_sets_fields() {
1472 let yaml = include_str!("templates/team_simple.yaml");
1473 let ov = InitOverrides {
1474 orchestrator_pane: Some(false),
1475 auto_dispatch: Some(true),
1476 use_worktrees: Some(false),
1477 timeout_nudges: Some(false),
1478 standups: Some(false),
1479 auto_merge_enabled: Some(true),
1480 standup_interval_secs: Some(999),
1481 stall_threshold_secs: Some(123),
1482 review_nudge_threshold_secs: Some(456),
1483 review_timeout_secs: Some(789),
1484 nudge_interval_secs: Some(555),
1485 ..Default::default()
1486 };
1487 let result = apply_init_overrides(yaml, &ov);
1488 let doc: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
1489 let map = doc.as_mapping().unwrap();
1490
1491 assert_eq!(
1492 map.get(serde_yaml::Value::String("orchestrator_pane".into()))
1493 .and_then(|v| v.as_bool()),
1494 Some(false)
1495 );
1496
1497 let board = map
1498 .get(serde_yaml::Value::String("board".into()))
1499 .unwrap()
1500 .as_mapping()
1501 .unwrap();
1502 assert_eq!(
1503 board
1504 .get(serde_yaml::Value::String("auto_dispatch".into()))
1505 .and_then(|v| v.as_bool()),
1506 Some(true)
1507 );
1508
1509 let automation = map
1510 .get(serde_yaml::Value::String("automation".into()))
1511 .unwrap()
1512 .as_mapping()
1513 .unwrap();
1514 assert_eq!(
1515 automation
1516 .get(serde_yaml::Value::String("timeout_nudges".into()))
1517 .and_then(|v| v.as_bool()),
1518 Some(false)
1519 );
1520 assert_eq!(
1521 automation
1522 .get(serde_yaml::Value::String("standups".into()))
1523 .and_then(|v| v.as_bool()),
1524 Some(false)
1525 );
1526
1527 let standup = map
1528 .get(serde_yaml::Value::String("standup".into()))
1529 .unwrap()
1530 .as_mapping()
1531 .unwrap();
1532 assert_eq!(
1533 standup
1534 .get(serde_yaml::Value::String("interval_secs".into()))
1535 .and_then(|v| v.as_u64()),
1536 Some(999)
1537 );
1538
1539 let workflow_policy = map
1540 .get(serde_yaml::Value::String("workflow_policy".into()))
1541 .unwrap()
1542 .as_mapping()
1543 .unwrap();
1544 assert_eq!(
1545 workflow_policy
1546 .get(serde_yaml::Value::String("stall_threshold_secs".into()))
1547 .and_then(|v| v.as_u64()),
1548 Some(123)
1549 );
1550 assert_eq!(
1551 workflow_policy
1552 .get(serde_yaml::Value::String(
1553 "review_nudge_threshold_secs".into()
1554 ))
1555 .and_then(|v| v.as_u64()),
1556 Some(456)
1557 );
1558 assert_eq!(
1559 workflow_policy
1560 .get(serde_yaml::Value::String("review_timeout_secs".into()))
1561 .and_then(|v| v.as_u64()),
1562 Some(789)
1563 );
1564
1565 let auto_merge = workflow_policy
1566 .get(serde_yaml::Value::String("auto_merge".into()))
1567 .unwrap()
1568 .as_mapping()
1569 .unwrap();
1570 assert_eq!(
1571 auto_merge
1572 .get(serde_yaml::Value::String("enabled".into()))
1573 .and_then(|v| v.as_bool()),
1574 Some(true)
1575 );
1576 }
1577}