Skip to main content

batty_cli/team/
init.rs

1//! Team initialization, template management, and run export.
2
3use 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
13/// Returns `~/.batty/templates/`.
14pub 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/// Overrides collected from the interactive init wizard.
20#[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
40/// Scaffold `.batty/team_config/` with default team.yaml and prompt templates.
41pub 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
51/// Scaffold with optional interactive overrides applied to the template YAML.
52pub 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    // Install prompt .md files matching the template's roles
104    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    // Initialize kanban-md board in the team config directory
194    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
344/// Apply interactive overrides to template YAML content via text replacement.
345fn 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
607/// Export the current team config as a reusable template.
608pub 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        // kanban-md creates board/ directory; fallback creates kanban.md
877        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        // Research-specific .md files installed
1059        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        // Generic files NOT installed
1067        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        // Software-specific .md files installed
1081        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        // Batty-specific .md files installed
1098        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}