1use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use tracing::{info, warn};
7
8use super::{
9 TEAM_CONFIG_FILE, daemon_log_path, now_unix, orchestrator_log_path, team_config_dir,
10 team_config_path, team_events_path,
11};
12
13pub fn templates_base_dir() -> Result<PathBuf> {
15 let home = std::env::var("HOME").context("cannot determine home directory")?;
16 Ok(PathBuf::from(home).join(".batty").join("templates"))
17}
18
19#[derive(Debug, Default)]
21pub struct InitOverrides {
22 pub orchestrator_pane: Option<bool>,
23 pub auto_dispatch: Option<bool>,
24 pub use_worktrees: Option<bool>,
25 pub timeout_nudges: Option<bool>,
26 pub standups: Option<bool>,
27 pub triage_interventions: Option<bool>,
28 pub review_interventions: Option<bool>,
29 pub owned_task_interventions: Option<bool>,
30 pub manager_dispatch_interventions: Option<bool>,
31 pub architect_utilization_interventions: Option<bool>,
32 pub auto_merge_enabled: Option<bool>,
33 pub standup_interval_secs: Option<u64>,
34 pub nudge_interval_secs: Option<u64>,
35 pub stall_threshold_secs: Option<u64>,
36 pub review_nudge_threshold_secs: Option<u64>,
37 pub review_timeout_secs: Option<u64>,
38}
39
40pub fn init_team(
42 project_root: &Path,
43 template: &str,
44 project_name: Option<&str>,
45 agent: Option<&str>,
46 force: bool,
47) -> Result<Vec<PathBuf>> {
48 init_team_with_overrides(project_root, template, project_name, agent, force, None)
49}
50
51pub fn init_team_with_overrides(
53 project_root: &Path,
54 template: &str,
55 project_name: Option<&str>,
56 agent: Option<&str>,
57 force: bool,
58 overrides: Option<&InitOverrides>,
59) -> Result<Vec<PathBuf>> {
60 let config_dir = team_config_dir(project_root);
61 std::fs::create_dir_all(&config_dir)
62 .with_context(|| format!("failed to create {}", config_dir.display()))?;
63
64 let mut created = Vec::new();
65
66 let yaml_path = config_dir.join(TEAM_CONFIG_FILE);
67 if yaml_path.exists() && !force {
68 bail!(
69 "team config already exists at {}; remove it first or use --force",
70 yaml_path.display()
71 );
72 }
73
74 let yaml_content = match template {
75 "solo" => include_str!("templates/team_solo.yaml"),
76 "pair" => include_str!("templates/team_pair.yaml"),
77 "squad" => include_str!("templates/team_squad.yaml"),
78 "large" => include_str!("templates/team_large.yaml"),
79 "research" => include_str!("templates/team_research.yaml"),
80 "software" => include_str!("templates/team_software.yaml"),
81 "batty" => include_str!("templates/team_batty.yaml"),
82 _ => include_str!("templates/team_simple.yaml"),
83 };
84 let mut yaml_content = yaml_content.to_string();
85 if let Some(name) = project_name {
86 if let Some(end) = yaml_content.find('\n') {
87 yaml_content = format!("name: {name}{}", &yaml_content[end..]);
88 }
89 }
90 if let Some(agent_name) = agent {
91 yaml_content = yaml_content
92 .replace("agent: claude", &format!("agent: {agent_name}"))
93 .replace("agent: codex", &format!("agent: {agent_name}"));
94 }
95 if let Some(ov) = overrides {
96 yaml_content = apply_init_overrides(&yaml_content, ov);
97 }
98 std::fs::write(&yaml_path, &yaml_content)
99 .with_context(|| format!("failed to write {}", yaml_path.display()))?;
100 created.push(yaml_path);
101
102 let prompt_files: &[(&str, &str)] = match template {
104 "research" => &[
105 (
106 "research_lead.md",
107 include_str!("templates/research_lead.md"),
108 ),
109 ("sub_lead.md", include_str!("templates/sub_lead.md")),
110 ("researcher.md", include_str!("templates/researcher.md")),
111 ],
112 "software" => &[
113 ("tech_lead.md", include_str!("templates/tech_lead.md")),
114 ("eng_manager.md", include_str!("templates/eng_manager.md")),
115 ("developer.md", include_str!("templates/developer.md")),
116 ],
117 "batty" => &[
118 (
119 "batty_architect.md",
120 include_str!("templates/batty_architect.md"),
121 ),
122 (
123 "batty_manager.md",
124 include_str!("templates/batty_manager.md"),
125 ),
126 (
127 "batty_engineer.md",
128 include_str!("templates/batty_engineer.md"),
129 ),
130 ],
131 _ => &[
132 ("architect.md", include_str!("templates/architect.md")),
133 ("manager.md", include_str!("templates/manager.md")),
134 ("engineer.md", include_str!("templates/engineer.md")),
135 ],
136 };
137
138 for (name, content) in prompt_files {
139 let path = config_dir.join(name);
140 if force || !path.exists() {
141 std::fs::write(&path, content)
142 .with_context(|| format!("failed to write {}", path.display()))?;
143 created.push(path);
144 }
145 }
146
147 let directive_files = [
148 (
149 "replenishment_context.md",
150 include_str!("templates/replenishment_context.md"),
151 ),
152 (
153 "review_policy.md",
154 include_str!("templates/review_policy.md"),
155 ),
156 (
157 "escalation_policy.md",
158 include_str!("templates/escalation_policy.md"),
159 ),
160 ];
161 for (name, content) in directive_files {
162 let path = config_dir.join(name);
163 if force || !path.exists() {
164 std::fs::write(&path, content)
165 .with_context(|| format!("failed to write {}", path.display()))?;
166 created.push(path);
167 }
168 }
169
170 let board_dir = config_dir.join("board");
172 if !board_dir.exists() {
173 let output = std::process::Command::new("kanban-md")
174 .args(["init", "--dir", &board_dir.to_string_lossy()])
175 .output();
176 match output {
177 Ok(out) if out.status.success() => {
178 created.push(board_dir);
179 }
180 Ok(out) => {
181 let stderr = String::from_utf8_lossy(&out.stderr);
182 warn!("kanban-md init failed: {stderr}; falling back to plain kanban.md");
183 let kanban_path = config_dir.join("kanban.md");
184 std::fs::write(
185 &kanban_path,
186 "# Kanban Board\n\n## Backlog\n\n## In Progress\n\n## Done\n",
187 )?;
188 created.push(kanban_path);
189 }
190 Err(_) => {
191 warn!("kanban-md not found; falling back to plain kanban.md");
192 let kanban_path = config_dir.join("kanban.md");
193 std::fs::write(
194 &kanban_path,
195 "# Kanban Board\n\n## Backlog\n\n## In Progress\n\n## Done\n",
196 )?;
197 created.push(kanban_path);
198 }
199 }
200 }
201
202 info!(dir = %config_dir.display(), files = created.len(), "scaffolded team config");
203 Ok(created)
204}
205
206fn apply_init_overrides(yaml: &str, ov: &InitOverrides) -> String {
208 let mut doc: serde_yaml::Value = match serde_yaml::from_str(yaml) {
209 Ok(v) => v,
210 Err(_) => return yaml.to_string(),
211 };
212 let map = match doc.as_mapping_mut() {
213 Some(m) => m,
214 None => return yaml.to_string(),
215 };
216
217 fn set_bool(map: &mut serde_yaml::Mapping, section: &str, key: &str, val: Option<bool>) {
218 if let Some(v) = val {
219 let sec = map
220 .entry(serde_yaml::Value::String(section.to_string()))
221 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
222 if let Some(m) = sec.as_mapping_mut() {
223 m.insert(
224 serde_yaml::Value::String(key.to_string()),
225 serde_yaml::Value::Bool(v),
226 );
227 }
228 }
229 }
230
231 fn set_u64(map: &mut serde_yaml::Mapping, section: &str, key: &str, val: Option<u64>) {
232 if let Some(v) = val {
233 let sec = map
234 .entry(serde_yaml::Value::String(section.to_string()))
235 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
236 if let Some(m) = sec.as_mapping_mut() {
237 m.insert(
238 serde_yaml::Value::String(key.to_string()),
239 serde_yaml::Value::Number(serde_yaml::Number::from(v)),
240 );
241 }
242 }
243 }
244
245 if let Some(v) = ov.orchestrator_pane {
246 map.insert(
247 serde_yaml::Value::String("orchestrator_pane".to_string()),
248 serde_yaml::Value::Bool(v),
249 );
250 }
251
252 set_bool(map, "board", "auto_dispatch", ov.auto_dispatch);
253 set_u64(map, "standup", "interval_secs", ov.standup_interval_secs);
254 set_bool(map, "automation", "timeout_nudges", ov.timeout_nudges);
255 set_bool(map, "automation", "standups", ov.standups);
256 set_bool(
257 map,
258 "automation",
259 "triage_interventions",
260 ov.triage_interventions,
261 );
262 set_bool(
263 map,
264 "automation",
265 "review_interventions",
266 ov.review_interventions,
267 );
268 set_bool(
269 map,
270 "automation",
271 "owned_task_interventions",
272 ov.owned_task_interventions,
273 );
274 set_bool(
275 map,
276 "automation",
277 "manager_dispatch_interventions",
278 ov.manager_dispatch_interventions,
279 );
280 set_bool(
281 map,
282 "automation",
283 "architect_utilization_interventions",
284 ov.architect_utilization_interventions,
285 );
286 set_u64(
287 map,
288 "workflow_policy",
289 "stall_threshold_secs",
290 ov.stall_threshold_secs,
291 );
292 set_u64(
293 map,
294 "workflow_policy",
295 "review_nudge_threshold_secs",
296 ov.review_nudge_threshold_secs,
297 );
298 set_u64(
299 map,
300 "workflow_policy",
301 "review_timeout_secs",
302 ov.review_timeout_secs,
303 );
304
305 if let Some(v) = ov.auto_merge_enabled {
306 let sec = map
307 .entry(serde_yaml::Value::String("workflow_policy".to_string()))
308 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
309 if let Some(wp) = sec.as_mapping_mut() {
310 let am = wp
311 .entry(serde_yaml::Value::String("auto_merge".to_string()))
312 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
313 if let Some(m) = am.as_mapping_mut() {
314 m.insert(
315 serde_yaml::Value::String("enabled".to_string()),
316 serde_yaml::Value::Bool(v),
317 );
318 }
319 }
320 }
321
322 if ov.use_worktrees.is_some() || ov.nudge_interval_secs.is_some() {
323 if let Some(roles) = map
324 .get_mut(serde_yaml::Value::String("roles".to_string()))
325 .and_then(|v| v.as_sequence_mut())
326 {
327 for role in roles.iter_mut() {
328 if let Some(m) = role.as_mapping_mut() {
329 let role_type = m
330 .get(serde_yaml::Value::String("role_type".to_string()))
331 .and_then(|v| v.as_str())
332 .map(str::to_owned);
333
334 if role_type.as_deref() == Some("engineer") {
335 if let Some(v) = ov.use_worktrees {
336 m.insert(
337 serde_yaml::Value::String("use_worktrees".to_string()),
338 serde_yaml::Value::Bool(v),
339 );
340 }
341 }
342 if role_type.as_deref() == Some("architect") {
343 if let Some(v) = ov.nudge_interval_secs {
344 m.insert(
345 serde_yaml::Value::String("nudge_interval_secs".to_string()),
346 serde_yaml::Value::Number(serde_yaml::Number::from(v)),
347 );
348 }
349 }
350 }
351 }
352 }
353 }
354
355 serde_yaml::to_string(&doc).unwrap_or_else(|_| yaml.to_string())
356}
357
358pub fn list_available_templates() -> Result<Vec<String>> {
359 let templates_dir = templates_base_dir()?;
360 if !templates_dir.is_dir() {
361 bail!(
362 "no templates directory found at {}",
363 templates_dir.display()
364 );
365 }
366
367 let mut templates = Vec::new();
368 for entry in std::fs::read_dir(&templates_dir)
369 .with_context(|| format!("failed to read {}", templates_dir.display()))?
370 {
371 let entry = entry?;
372 if entry.path().is_dir() {
373 templates.push(entry.file_name().to_string_lossy().into_owned());
374 }
375 }
376 templates.sort();
377 Ok(templates)
378}
379
380fn copy_template_dir(src: &Path, dst: &Path, created: &mut Vec<PathBuf>) -> Result<()> {
381 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
382 for entry in
383 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
384 {
385 let entry = entry?;
386 let src_path = entry.path();
387 let dst_path = dst.join(entry.file_name());
388 if src_path.is_dir() {
389 copy_template_dir(&src_path, &dst_path, created)?;
390 } else {
391 std::fs::copy(&src_path, &dst_path).with_context(|| {
392 format!(
393 "failed to copy template file from {} to {}",
394 src_path.display(),
395 dst_path.display()
396 )
397 })?;
398 created.push(dst_path);
399 }
400 }
401 Ok(())
402}
403
404pub fn init_from_template(project_root: &Path, template_name: &str) -> Result<Vec<PathBuf>> {
405 let templates_dir = templates_base_dir()?;
406 if !templates_dir.is_dir() {
407 bail!(
408 "no templates directory found at {}",
409 templates_dir.display()
410 );
411 }
412
413 let available = list_available_templates()?;
414 if !available.iter().any(|name| name == template_name) {
415 let available_display = if available.is_empty() {
416 "(none)".to_string()
417 } else {
418 available.join(", ")
419 };
420 bail!(
421 "template '{}' not found in {}; available templates: {}",
422 template_name,
423 templates_dir.display(),
424 available_display
425 );
426 }
427
428 let config_dir = team_config_dir(project_root);
429 let yaml_path = config_dir.join(TEAM_CONFIG_FILE);
430 if yaml_path.exists() {
431 bail!(
432 "team config already exists at {}; remove it first or edit directly",
433 yaml_path.display()
434 );
435 }
436
437 let source_dir = templates_dir.join(template_name);
438 let mut created = Vec::new();
439 copy_template_dir(&source_dir, &config_dir, &mut created)?;
440 info!(
441 template = template_name,
442 source = %source_dir.display(),
443 dest = %config_dir.display(),
444 files = created.len(),
445 "copied team config from user template"
446 );
447 Ok(created)
448}
449
450pub fn export_template(project_root: &Path, name: &str) -> Result<usize> {
452 let config_dir = team_config_dir(project_root);
453 let team_yaml = config_dir.join(TEAM_CONFIG_FILE);
454 if !team_yaml.is_file() {
455 bail!("team config missing at {}", team_yaml.display());
456 }
457
458 let template_dir = templates_base_dir()?.join(name);
459 if template_dir.exists() {
460 eprintln!(
461 "warning: overwriting existing template at {}",
462 template_dir.display()
463 );
464 }
465 std::fs::create_dir_all(&template_dir)
466 .with_context(|| format!("failed to create {}", template_dir.display()))?;
467
468 let mut copied = 0usize;
469 copy_template_file(&team_yaml, &template_dir.join(TEAM_CONFIG_FILE))?;
470 copied += 1;
471
472 let mut prompt_paths = std::fs::read_dir(&config_dir)?
473 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
474 .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
475 .collect::<Vec<_>>();
476 prompt_paths.sort();
477
478 for source in prompt_paths {
479 let file_name = source
480 .file_name()
481 .context("template source missing file name")?;
482 copy_template_file(&source, &template_dir.join(file_name))?;
483 copied += 1;
484 }
485
486 Ok(copied)
487}
488
489pub fn export_run(project_root: &Path) -> Result<PathBuf> {
490 let team_yaml = team_config_path(project_root);
491 if !team_yaml.is_file() {
492 bail!("team config missing at {}", team_yaml.display());
493 }
494
495 let export_dir = create_run_export_dir(project_root)?;
496 copy_template_file(&team_yaml, &export_dir.join(TEAM_CONFIG_FILE))?;
497
498 copy_dir_if_exists(
499 &team_config_dir(project_root).join("board").join("tasks"),
500 &export_dir.join("board").join("tasks"),
501 )?;
502 copy_file_if_exists(
503 &team_events_path(project_root),
504 &export_dir.join("events.jsonl"),
505 )?;
506 copy_file_if_exists(
507 &daemon_log_path(project_root),
508 &export_dir.join("daemon.log"),
509 )?;
510 copy_file_if_exists(
511 &orchestrator_log_path(project_root),
512 &export_dir.join("orchestrator.log"),
513 )?;
514 copy_dir_if_exists(
515 &project_root.join(".batty").join("retrospectives"),
516 &export_dir.join("retrospectives"),
517 )?;
518 copy_file_if_exists(
519 &project_root.join(".batty").join("test_timing.jsonl"),
520 &export_dir.join("test_timing.jsonl"),
521 )?;
522
523 Ok(export_dir)
524}
525
526fn copy_template_file(source: &Path, destination: &Path) -> Result<()> {
527 if let Some(parent) = destination.parent() {
528 std::fs::create_dir_all(parent)
529 .with_context(|| format!("failed to create {}", parent.display()))?;
530 }
531 std::fs::copy(source, destination).with_context(|| {
532 format!(
533 "failed to copy {} to {}",
534 source.display(),
535 destination.display()
536 )
537 })?;
538 Ok(())
539}
540
541fn exports_dir(project_root: &Path) -> PathBuf {
542 project_root.join(".batty").join("exports")
543}
544
545fn create_run_export_dir(project_root: &Path) -> Result<PathBuf> {
546 let base = exports_dir(project_root);
547 std::fs::create_dir_all(&base)
548 .with_context(|| format!("failed to create {}", base.display()))?;
549
550 let timestamp = now_unix();
551 let primary = base.join(timestamp.to_string());
552 if !primary.exists() {
553 std::fs::create_dir(&primary)
554 .with_context(|| format!("failed to create {}", primary.display()))?;
555 return Ok(primary);
556 }
557
558 for suffix in 1.. {
559 let candidate = base.join(format!("{timestamp}-{suffix}"));
560 if candidate.exists() {
561 continue;
562 }
563 std::fs::create_dir(&candidate)
564 .with_context(|| format!("failed to create {}", candidate.display()))?;
565 return Ok(candidate);
566 }
567
568 unreachable!("infinite suffix iterator should always return or continue");
569}
570
571fn copy_file_if_exists(source: &Path, destination: &Path) -> Result<()> {
572 if source.is_file() {
573 copy_template_file(source, destination)?;
574 }
575 Ok(())
576}
577
578fn copy_dir_if_exists(source: &Path, destination: &Path) -> Result<()> {
579 if source.is_dir() {
580 let mut created = Vec::new();
581 copy_template_dir(source, destination, &mut created)?;
582 }
583 Ok(())
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use serial_test::serial;
590 use std::ffi::OsString;
591
592 use crate::team::{
593 daemon_log_path, orchestrator_log_path, team_config_dir, team_config_path, team_events_path,
594 };
595
596 struct HomeGuard {
597 original_home: Option<OsString>,
598 }
599
600 impl HomeGuard {
601 fn set(path: &Path) -> Self {
602 let original_home = std::env::var_os("HOME");
603 unsafe {
604 std::env::set_var("HOME", path);
605 }
606 Self { original_home }
607 }
608 }
609
610 impl Drop for HomeGuard {
611 fn drop(&mut self) {
612 match &self.original_home {
613 Some(home) => unsafe {
614 std::env::set_var("HOME", home);
615 },
616 None => unsafe {
617 std::env::remove_var("HOME");
618 },
619 }
620 }
621 }
622
623 #[test]
624 fn init_team_creates_scaffolding() {
625 let tmp = tempfile::tempdir().unwrap();
626 let created = init_team(tmp.path(), "simple", None, None, false).unwrap();
627 assert!(!created.is_empty());
628 assert!(team_config_path(tmp.path()).exists());
629 assert!(team_config_dir(tmp.path()).join("architect.md").exists());
630 assert!(team_config_dir(tmp.path()).join("manager.md").exists());
631 assert!(team_config_dir(tmp.path()).join("engineer.md").exists());
632 assert!(
633 team_config_dir(tmp.path())
634 .join("replenishment_context.md")
635 .exists()
636 );
637 assert!(
638 team_config_dir(tmp.path())
639 .join("review_policy.md")
640 .exists()
641 );
642 assert!(
643 team_config_dir(tmp.path())
644 .join("escalation_policy.md")
645 .exists()
646 );
647 let config = team_config_dir(tmp.path());
649 assert!(config.join("board").is_dir() || config.join("kanban.md").exists());
650 }
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 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 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 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 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}