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    }
651
652    #[test]
653    fn init_team_refuses_if_exists() {
654        let tmp = tempfile::tempdir().unwrap();
655        init_team(tmp.path(), "simple", None, None, false).unwrap();
656        let result = init_team(tmp.path(), "simple", None, None, false);
657        assert!(result.is_err());
658        assert!(result.unwrap_err().to_string().contains("already exists"));
659    }
660
661    #[test]
662    #[serial]
663    fn init_from_template_copies_files() {
664        let project = tempfile::tempdir().unwrap();
665        let home = tempfile::tempdir().unwrap();
666        let _home_guard = HomeGuard::set(home.path());
667
668        let template_dir = home.path().join(".batty").join("templates").join("custom");
669        std::fs::create_dir_all(template_dir.join("board")).unwrap();
670        std::fs::write(template_dir.join("team.yaml"), "name: custom\nroles: []\n").unwrap();
671        std::fs::write(template_dir.join("architect.md"), "# Architect\n").unwrap();
672        std::fs::write(template_dir.join("board").join("task.md"), "task\n").unwrap();
673
674        let created = init_from_template(project.path(), "custom").unwrap();
675
676        assert!(!created.is_empty());
677        assert_eq!(
678            std::fs::read_to_string(team_config_path(project.path())).unwrap(),
679            "name: custom\nroles: []\n"
680        );
681        assert!(
682            team_config_dir(project.path())
683                .join("architect.md")
684                .exists()
685        );
686        assert!(
687            team_config_dir(project.path())
688                .join("board")
689                .join("task.md")
690                .exists()
691        );
692    }
693
694    #[test]
695    #[serial]
696    fn init_from_template_missing_template_errors_with_available_list() {
697        let project = tempfile::tempdir().unwrap();
698        let home = tempfile::tempdir().unwrap();
699        let _home_guard = HomeGuard::set(home.path());
700
701        let templates_root = home.path().join(".batty").join("templates");
702        std::fs::create_dir_all(templates_root.join("alpha")).unwrap();
703        std::fs::create_dir_all(templates_root.join("beta")).unwrap();
704
705        let error = init_from_template(project.path(), "missing").unwrap_err();
706        let message = error.to_string();
707        assert!(message.contains("template 'missing' not found"));
708        assert!(message.contains("alpha"));
709        assert!(message.contains("beta"));
710    }
711
712    #[test]
713    #[serial]
714    fn init_from_template_errors_when_templates_dir_is_missing() {
715        let project = tempfile::tempdir().unwrap();
716        let home = tempfile::tempdir().unwrap();
717        let _home_guard = HomeGuard::set(home.path());
718
719        let error = init_from_template(project.path(), "missing").unwrap_err();
720        assert!(error.to_string().contains("no templates directory found"));
721    }
722
723    #[test]
724    fn init_team_large_template() {
725        let tmp = tempfile::tempdir().unwrap();
726        let created = init_team(tmp.path(), "large", None, None, false).unwrap();
727        assert!(!created.is_empty());
728        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
729        assert!(content.contains("instances: 3") || content.contains("instances: 5"));
730    }
731
732    #[test]
733    fn init_team_solo_template() {
734        let tmp = tempfile::tempdir().unwrap();
735        let created = init_team(tmp.path(), "solo", None, None, false).unwrap();
736        assert!(!created.is_empty());
737        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
738        assert!(content.contains("role_type: engineer"));
739        assert!(!content.contains("role_type: manager"));
740    }
741
742    #[test]
743    fn init_team_pair_template() {
744        let tmp = tempfile::tempdir().unwrap();
745        let created = init_team(tmp.path(), "pair", None, None, false).unwrap();
746        assert!(!created.is_empty());
747        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
748        assert!(content.contains("role_type: architect"));
749        assert!(content.contains("role_type: engineer"));
750        assert!(!content.contains("role_type: manager"));
751    }
752
753    #[test]
754    fn init_team_squad_template() {
755        let tmp = tempfile::tempdir().unwrap();
756        let created = init_team(tmp.path(), "squad", None, None, false).unwrap();
757        assert!(!created.is_empty());
758        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
759        assert!(content.contains("instances: 5"));
760        assert!(content.contains("layout:"));
761    }
762
763    #[test]
764    fn init_team_research_template() {
765        let tmp = tempfile::tempdir().unwrap();
766        let created = init_team(tmp.path(), "research", None, None, false).unwrap();
767        assert!(!created.is_empty());
768        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
769        assert!(content.contains("principal"));
770        assert!(content.contains("sub-lead"));
771        assert!(content.contains("researcher"));
772        // Research-specific .md files installed
773        assert!(
774            team_config_dir(tmp.path())
775                .join("research_lead.md")
776                .exists()
777        );
778        assert!(team_config_dir(tmp.path()).join("sub_lead.md").exists());
779        assert!(team_config_dir(tmp.path()).join("researcher.md").exists());
780        // Generic files NOT installed
781        assert!(!team_config_dir(tmp.path()).join("architect.md").exists());
782    }
783
784    #[test]
785    fn init_team_software_template() {
786        let tmp = tempfile::tempdir().unwrap();
787        let created = init_team(tmp.path(), "software", None, None, false).unwrap();
788        assert!(!created.is_empty());
789        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
790        assert!(content.contains("tech-lead"));
791        assert!(content.contains("backend-mgr"));
792        assert!(content.contains("frontend-mgr"));
793        assert!(content.contains("developer"));
794        // Software-specific .md files installed
795        assert!(team_config_dir(tmp.path()).join("tech_lead.md").exists());
796        assert!(team_config_dir(tmp.path()).join("eng_manager.md").exists());
797        assert!(team_config_dir(tmp.path()).join("developer.md").exists());
798    }
799
800    #[test]
801    fn init_team_batty_template() {
802        let tmp = tempfile::tempdir().unwrap();
803        let created = init_team(tmp.path(), "batty", None, None, false).unwrap();
804        assert!(!created.is_empty());
805        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
806        assert!(content.contains("batty-dev"));
807        assert!(content.contains("role_type: architect"));
808        assert!(content.contains("role_type: manager"));
809        assert!(content.contains("instances: 4"));
810        assert!(content.contains("batty_architect.md"));
811        // Batty-specific .md files installed
812        assert!(
813            team_config_dir(tmp.path())
814                .join("batty_architect.md")
815                .exists()
816        );
817        assert!(
818            team_config_dir(tmp.path())
819                .join("batty_manager.md")
820                .exists()
821        );
822        assert!(
823            team_config_dir(tmp.path())
824                .join("batty_engineer.md")
825                .exists()
826        );
827        assert!(
828            team_config_dir(tmp.path())
829                .join("review_policy.md")
830                .exists()
831        );
832    }
833
834    #[test]
835    fn init_with_agent_codex_sets_backend() {
836        let tmp = tempfile::tempdir().unwrap();
837        let _created = init_team(tmp.path(), "simple", None, Some("codex"), false).unwrap();
838        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
839        assert!(
840            content.contains("agent: codex"),
841            "all agent fields should be codex"
842        );
843        assert!(
844            !content.contains("agent: claude"),
845            "no claude agents should remain"
846        );
847    }
848
849    #[test]
850    fn init_with_agent_kiro_sets_backend() {
851        let tmp = tempfile::tempdir().unwrap();
852        let _created = init_team(tmp.path(), "pair", None, Some("kiro"), false).unwrap();
853        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
854        assert!(
855            content.contains("agent: kiro"),
856            "all agent fields should be kiro"
857        );
858        assert!(
859            !content.contains("agent: claude"),
860            "no claude agents should remain"
861        );
862        assert!(
863            !content.contains("agent: codex"),
864            "no codex agents should remain"
865        );
866    }
867
868    #[test]
869    fn init_default_agent_is_claude() {
870        let tmp = tempfile::tempdir().unwrap();
871        let _created = init_team(tmp.path(), "simple", None, None, false).unwrap();
872        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
873        assert!(
874            content.contains("agent: claude"),
875            "default agent should be claude"
876        );
877    }
878
879    #[test]
880    #[serial]
881    fn export_template_creates_directory_and_copies_files() {
882        let tmp = tempfile::tempdir().unwrap();
883        let _home = HomeGuard::set(tmp.path());
884        let project_root = tmp.path().join("project");
885        let config_dir = team_config_dir(&project_root);
886        std::fs::create_dir_all(&config_dir).unwrap();
887        std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
888        std::fs::write(config_dir.join("architect.md"), "architect prompt\n").unwrap();
889
890        let copied = export_template(&project_root, "demo-template").unwrap();
891        let template_dir = templates_base_dir().unwrap().join("demo-template");
892
893        assert_eq!(copied, 2);
894        assert_eq!(
895            std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
896            "name: demo\n"
897        );
898        assert_eq!(
899            std::fs::read_to_string(template_dir.join("architect.md")).unwrap(),
900            "architect prompt\n"
901        );
902    }
903
904    #[test]
905    #[serial]
906    fn export_template_overwrites_existing() {
907        let tmp = tempfile::tempdir().unwrap();
908        let _home = HomeGuard::set(tmp.path());
909        let project_root = tmp.path().join("project");
910        let config_dir = team_config_dir(&project_root);
911        std::fs::create_dir_all(&config_dir).unwrap();
912        std::fs::write(config_dir.join("team.yaml"), "name: first\n").unwrap();
913        std::fs::write(config_dir.join("manager.md"), "v1\n").unwrap();
914
915        export_template(&project_root, "demo-template").unwrap();
916
917        std::fs::write(config_dir.join("team.yaml"), "name: second\n").unwrap();
918        std::fs::write(config_dir.join("manager.md"), "v2\n").unwrap();
919
920        let copied = export_template(&project_root, "demo-template").unwrap();
921        let template_dir = templates_base_dir().unwrap().join("demo-template");
922
923        assert_eq!(copied, 2);
924        assert_eq!(
925            std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
926            "name: second\n"
927        );
928        assert_eq!(
929            std::fs::read_to_string(template_dir.join("manager.md")).unwrap(),
930            "v2\n"
931        );
932    }
933
934    #[test]
935    #[serial]
936    fn export_template_missing_team_yaml_errors() {
937        let tmp = tempfile::tempdir().unwrap();
938        let _home = HomeGuard::set(tmp.path());
939        let project_root = tmp.path().join("project");
940        std::fs::create_dir_all(team_config_dir(&project_root)).unwrap();
941
942        let error = export_template(&project_root, "demo-template").unwrap_err();
943
944        assert!(error.to_string().contains("team config missing"));
945    }
946
947    #[test]
948    fn export_run_copies_requested_run_state_only() {
949        let tmp = tempfile::tempdir().unwrap();
950        let project_root = tmp.path().join("project");
951        let config_dir = team_config_dir(&project_root);
952        let tasks_dir = config_dir.join("board").join("tasks");
953        let retrospectives_dir = project_root.join(".batty").join("retrospectives");
954        let worktree_dir = project_root
955            .join(".batty")
956            .join("worktrees")
957            .join("eng-1-1")
958            .join(".codex")
959            .join("sessions");
960        std::fs::create_dir_all(&tasks_dir).unwrap();
961        std::fs::create_dir_all(&retrospectives_dir).unwrap();
962        std::fs::create_dir_all(&worktree_dir).unwrap();
963
964        std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
965        std::fs::write(tasks_dir.join("001-task.md"), "---\nid: 1\n---\n").unwrap();
966        std::fs::write(
967            team_events_path(&project_root),
968            "{\"event\":\"daemon_started\"}\n",
969        )
970        .unwrap();
971        std::fs::write(daemon_log_path(&project_root), "daemon-log\n").unwrap();
972        std::fs::write(orchestrator_log_path(&project_root), "orchestrator-log\n").unwrap();
973        std::fs::write(retrospectives_dir.join("retro.md"), "# Retro\n").unwrap();
974        std::fs::write(
975            project_root.join(".batty").join("test_timing.jsonl"),
976            "{\"task_id\":1}\n",
977        )
978        .unwrap();
979        std::fs::write(worktree_dir.join("session.jsonl"), "secret\n").unwrap();
980
981        let export_dir = export_run(&project_root).unwrap();
982
983        assert_eq!(
984            std::fs::read_to_string(export_dir.join("team.yaml")).unwrap(),
985            "name: demo\n"
986        );
987        assert_eq!(
988            std::fs::read_to_string(export_dir.join("board").join("tasks").join("001-task.md"))
989                .unwrap(),
990            "---\nid: 1\n---\n"
991        );
992        assert_eq!(
993            std::fs::read_to_string(export_dir.join("events.jsonl")).unwrap(),
994            "{\"event\":\"daemon_started\"}\n"
995        );
996        assert_eq!(
997            std::fs::read_to_string(export_dir.join("daemon.log")).unwrap(),
998            "daemon-log\n"
999        );
1000        assert_eq!(
1001            std::fs::read_to_string(export_dir.join("orchestrator.log")).unwrap(),
1002            "orchestrator-log\n"
1003        );
1004        assert_eq!(
1005            std::fs::read_to_string(export_dir.join("retrospectives").join("retro.md")).unwrap(),
1006            "# Retro\n"
1007        );
1008        assert_eq!(
1009            std::fs::read_to_string(export_dir.join("test_timing.jsonl")).unwrap(),
1010            "{\"task_id\":1}\n"
1011        );
1012        assert!(!export_dir.join("worktrees").exists());
1013        assert!(!export_dir.join(".codex").exists());
1014        assert!(!export_dir.join("sessions").exists());
1015    }
1016
1017    #[test]
1018    fn export_run_skips_missing_optional_paths() {
1019        let tmp = tempfile::tempdir().unwrap();
1020        let project_root = tmp.path().join("project");
1021        let config_dir = team_config_dir(&project_root);
1022        std::fs::create_dir_all(&config_dir).unwrap();
1023        std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
1024
1025        let export_dir = export_run(&project_root).unwrap();
1026
1027        assert!(export_dir.join("team.yaml").is_file());
1028        assert!(!export_dir.join("board").exists());
1029        assert!(!export_dir.join("events.jsonl").exists());
1030        assert!(!export_dir.join("daemon.log").exists());
1031        assert!(!export_dir.join("orchestrator.log").exists());
1032        assert!(!export_dir.join("retrospectives").exists());
1033        assert!(!export_dir.join("test_timing.jsonl").exists());
1034    }
1035
1036    #[test]
1037    fn export_run_missing_team_yaml_errors() {
1038        let tmp = tempfile::tempdir().unwrap();
1039        let project_root = tmp.path().join("project");
1040        std::fs::create_dir_all(team_config_dir(&project_root)).unwrap();
1041
1042        let error = export_run(&project_root).unwrap_err();
1043
1044        assert!(error.to_string().contains("team config missing"));
1045    }
1046
1047    #[test]
1048    fn apply_init_overrides_sets_fields() {
1049        let yaml = include_str!("templates/team_simple.yaml");
1050        let ov = InitOverrides {
1051            orchestrator_pane: Some(false),
1052            auto_dispatch: Some(true),
1053            use_worktrees: Some(false),
1054            timeout_nudges: Some(false),
1055            standups: Some(false),
1056            auto_merge_enabled: Some(true),
1057            standup_interval_secs: Some(999),
1058            stall_threshold_secs: Some(123),
1059            review_nudge_threshold_secs: Some(456),
1060            review_timeout_secs: Some(789),
1061            nudge_interval_secs: Some(555),
1062            ..Default::default()
1063        };
1064        let result = apply_init_overrides(yaml, &ov);
1065        let doc: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
1066        let map = doc.as_mapping().unwrap();
1067
1068        assert_eq!(
1069            map.get(&serde_yaml::Value::String("orchestrator_pane".into()))
1070                .and_then(|v| v.as_bool()),
1071            Some(false)
1072        );
1073
1074        let board = map
1075            .get(&serde_yaml::Value::String("board".into()))
1076            .unwrap()
1077            .as_mapping()
1078            .unwrap();
1079        assert_eq!(
1080            board
1081                .get(&serde_yaml::Value::String("auto_dispatch".into()))
1082                .and_then(|v| v.as_bool()),
1083            Some(true)
1084        );
1085
1086        let automation = map
1087            .get(&serde_yaml::Value::String("automation".into()))
1088            .unwrap()
1089            .as_mapping()
1090            .unwrap();
1091        assert_eq!(
1092            automation
1093                .get(&serde_yaml::Value::String("timeout_nudges".into()))
1094                .and_then(|v| v.as_bool()),
1095            Some(false)
1096        );
1097        assert_eq!(
1098            automation
1099                .get(&serde_yaml::Value::String("standups".into()))
1100                .and_then(|v| v.as_bool()),
1101            Some(false)
1102        );
1103
1104        let standup = map
1105            .get(&serde_yaml::Value::String("standup".into()))
1106            .unwrap()
1107            .as_mapping()
1108            .unwrap();
1109        assert_eq!(
1110            standup
1111                .get(&serde_yaml::Value::String("interval_secs".into()))
1112                .and_then(|v| v.as_u64()),
1113            Some(999)
1114        );
1115
1116        let workflow_policy = map
1117            .get(&serde_yaml::Value::String("workflow_policy".into()))
1118            .unwrap()
1119            .as_mapping()
1120            .unwrap();
1121        assert_eq!(
1122            workflow_policy
1123                .get(&serde_yaml::Value::String("stall_threshold_secs".into()))
1124                .and_then(|v| v.as_u64()),
1125            Some(123)
1126        );
1127        assert_eq!(
1128            workflow_policy
1129                .get(&serde_yaml::Value::String(
1130                    "review_nudge_threshold_secs".into()
1131                ))
1132                .and_then(|v| v.as_u64()),
1133            Some(456)
1134        );
1135        assert_eq!(
1136            workflow_policy
1137                .get(&serde_yaml::Value::String("review_timeout_secs".into()))
1138                .and_then(|v| v.as_u64()),
1139            Some(789)
1140        );
1141
1142        let auto_merge = workflow_policy
1143            .get(&serde_yaml::Value::String("auto_merge".into()))
1144            .unwrap()
1145            .as_mapping()
1146            .unwrap();
1147        assert_eq!(
1148            auto_merge
1149                .get(&serde_yaml::Value::String("enabled".into()))
1150                .and_then(|v| v.as_bool()),
1151            Some(true)
1152        );
1153    }
1154}