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