Skip to main content

batty_cli/team/
mod.rs

1//! Team mode — hierarchical agent org chart with daemon-managed communication.
2//!
3//! A YAML-defined team (architect ↔ manager ↔ N engineers) runs in a tmux
4//! session. The daemon monitors panes, routes messages between roles, and
5//! manages agent lifecycles.
6
7pub mod artifact;
8pub mod auto_merge;
9#[cfg(test)]
10mod behavioral_tests;
11pub mod board;
12pub mod board_cmd;
13pub mod board_health;
14pub mod capability;
15pub mod checkpoint;
16pub mod comms;
17pub mod completion;
18pub mod config;
19pub mod cost;
20pub mod daemon;
21pub mod delivery;
22pub mod deps;
23pub mod doctor;
24pub mod errors;
25pub mod estimation;
26pub mod events;
27pub mod failure_patterns;
28pub mod git_cmd;
29pub mod harness;
30pub mod hierarchy;
31pub mod inbox;
32pub mod layout;
33pub mod merge;
34pub mod message;
35pub mod metrics;
36pub mod metrics_cmd;
37pub mod nudge;
38pub mod policy;
39pub mod resolver;
40pub mod retrospective;
41pub mod retry;
42pub mod review;
43pub mod standup;
44pub mod status;
45pub mod task_cmd;
46pub mod task_loop;
47pub mod telegram;
48pub mod telemetry_db;
49#[cfg(test)]
50pub mod test_helpers;
51#[cfg(test)]
52pub mod test_support;
53pub mod validation;
54pub mod watcher;
55pub mod workflow;
56
57use std::collections::HashMap;
58use std::fs::File;
59use std::path::{Path, PathBuf};
60use std::time::{Duration, SystemTime, UNIX_EPOCH};
61
62use anyhow::{Context, Result, bail};
63use serde::{Deserialize, Serialize};
64use tracing::{info, warn};
65
66use crate::tmux;
67
68/// Team config directory name inside `.batty/`.
69pub const TEAM_CONFIG_DIR: &str = "team_config";
70/// Team config filename.
71pub const TEAM_CONFIG_FILE: &str = "team.yaml";
72
73/// Default duration window for load graph rendering, in seconds (1 hour).
74const LOAD_GRAPH_WINDOW_SECONDS: u64 = 3_600;
75const LOAD_GRAPH_WIDTH: usize = 30;
76const INBOX_BODY_PREVIEW_CHARS: usize = 140;
77const TRIAGE_RESULT_FRESHNESS_SECONDS: u64 = 300;
78const LOG_ROTATION_BYTES: u64 = 5 * 1024 * 1024;
79const LOG_ROTATION_KEEP: usize = 3;
80pub(crate) const DEFAULT_EVENT_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024;
81const DAEMON_SHUTDOWN_GRACE_PERIOD: Duration = Duration::from_secs(5);
82const DAEMON_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "snake_case")]
86pub enum AssignmentResultStatus {
87    Delivered,
88    Failed,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92pub struct AssignmentDeliveryResult {
93    pub message_id: String,
94    pub status: AssignmentResultStatus,
95    pub engineer: String,
96    pub task_summary: String,
97    pub branch: Option<String>,
98    pub work_dir: Option<String>,
99    pub detail: String,
100    pub ts: u64,
101}
102
103/// Resolve the team config directory for a project root.
104pub fn team_config_dir(project_root: &Path) -> PathBuf {
105    project_root.join(".batty").join(TEAM_CONFIG_DIR)
106}
107
108/// Resolve the path to team.yaml.
109pub fn team_config_path(project_root: &Path) -> PathBuf {
110    team_config_dir(project_root).join(TEAM_CONFIG_FILE)
111}
112
113pub fn team_events_path(project_root: &Path) -> PathBuf {
114    team_config_dir(project_root).join("events.jsonl")
115}
116
117pub(crate) fn orchestrator_log_path(project_root: &Path) -> PathBuf {
118    project_root.join(".batty").join("orchestrator.log")
119}
120
121pub(crate) fn orchestrator_ansi_log_path(project_root: &Path) -> PathBuf {
122    project_root.join(".batty").join("orchestrator.ansi.log")
123}
124
125/// Returns `~/.batty/templates/`.
126pub fn templates_base_dir() -> Result<PathBuf> {
127    let home = std::env::var("HOME").context("cannot determine home directory")?;
128    Ok(PathBuf::from(home).join(".batty").join("templates"))
129}
130
131#[derive(Debug, Clone, Copy)]
132pub struct TeamLoadSnapshot {
133    pub timestamp: u64,
134    pub total_members: usize,
135    pub working_members: usize,
136    pub load: f64,
137    pub session_running: bool,
138}
139
140fn assignment_results_dir(project_root: &Path) -> PathBuf {
141    project_root.join(".batty").join("assignment_results")
142}
143
144fn assignment_result_path(project_root: &Path, message_id: &str) -> PathBuf {
145    assignment_results_dir(project_root).join(format!("{message_id}.json"))
146}
147
148pub(crate) fn store_assignment_result(
149    project_root: &Path,
150    result: &AssignmentDeliveryResult,
151) -> Result<()> {
152    let path = assignment_result_path(project_root, &result.message_id);
153    if let Some(parent) = path.parent() {
154        std::fs::create_dir_all(parent)?;
155    }
156    let content = serde_json::to_vec_pretty(result)?;
157    std::fs::write(&path, content)
158        .with_context(|| format!("failed to write assignment result {}", path.display()))?;
159    Ok(())
160}
161
162pub fn load_assignment_result(
163    project_root: &Path,
164    message_id: &str,
165) -> Result<Option<AssignmentDeliveryResult>> {
166    let path = assignment_result_path(project_root, message_id);
167    if !path.exists() {
168        return Ok(None);
169    }
170    let data = std::fs::read(&path)
171        .with_context(|| format!("failed to read assignment result {}", path.display()))?;
172    let result = serde_json::from_slice(&data)
173        .with_context(|| format!("failed to parse assignment result {}", path.display()))?;
174    Ok(Some(result))
175}
176
177pub fn wait_for_assignment_result(
178    project_root: &Path,
179    message_id: &str,
180    timeout: Duration,
181) -> Result<Option<AssignmentDeliveryResult>> {
182    let deadline = std::time::Instant::now() + timeout;
183    loop {
184        if let Some(result) = load_assignment_result(project_root, message_id)? {
185            return Ok(Some(result));
186        }
187        if std::time::Instant::now() >= deadline {
188            return Ok(None);
189        }
190        std::thread::sleep(Duration::from_millis(200));
191    }
192}
193
194pub fn format_assignment_result(result: &AssignmentDeliveryResult) -> String {
195    let mut text = match result.status {
196        AssignmentResultStatus::Delivered => {
197            format!(
198                "Assignment delivered: {} -> {}",
199                result.message_id, result.engineer
200            )
201        }
202        AssignmentResultStatus::Failed => {
203            format!(
204                "Assignment failed: {} -> {}",
205                result.message_id, result.engineer
206            )
207        }
208    };
209
210    text.push_str(&format!("\nTask: {}", result.task_summary));
211    if let Some(branch) = result.branch.as_deref() {
212        text.push_str(&format!("\nBranch: {branch}"));
213    }
214    if let Some(work_dir) = result.work_dir.as_deref() {
215        text.push_str(&format!("\nWorktree: {work_dir}"));
216    }
217    if !result.detail.is_empty() {
218        text.push_str(&format!("\nDetail: {}", result.detail));
219    }
220    text
221}
222
223pub(crate) fn now_unix() -> u64 {
224    SystemTime::now()
225        .duration_since(UNIX_EPOCH)
226        .unwrap_or_default()
227        .as_secs()
228}
229
230/// Scaffold `.batty/team_config/` with default team.yaml and prompt templates.
231pub fn init_team(project_root: &Path, template: &str) -> Result<Vec<PathBuf>> {
232    let config_dir = team_config_dir(project_root);
233    std::fs::create_dir_all(&config_dir)
234        .with_context(|| format!("failed to create {}", config_dir.display()))?;
235
236    let mut created = Vec::new();
237
238    let yaml_path = config_dir.join(TEAM_CONFIG_FILE);
239    if yaml_path.exists() {
240        bail!(
241            "team config already exists at {}; remove it first or edit directly",
242            yaml_path.display()
243        );
244    }
245
246    let yaml_content = match template {
247        "solo" => include_str!("templates/team_solo.yaml"),
248        "pair" => include_str!("templates/team_pair.yaml"),
249        "squad" => include_str!("templates/team_squad.yaml"),
250        "large" => include_str!("templates/team_large.yaml"),
251        "research" => include_str!("templates/team_research.yaml"),
252        "software" => include_str!("templates/team_software.yaml"),
253        "batty" => include_str!("templates/team_batty.yaml"),
254        _ => include_str!("templates/team_simple.yaml"),
255    };
256    std::fs::write(&yaml_path, yaml_content)
257        .with_context(|| format!("failed to write {}", yaml_path.display()))?;
258    created.push(yaml_path);
259
260    // Install prompt .md files matching the template's roles
261    let prompt_files: &[(&str, &str)] = match template {
262        "research" => &[
263            (
264                "research_lead.md",
265                include_str!("templates/research_lead.md"),
266            ),
267            ("sub_lead.md", include_str!("templates/sub_lead.md")),
268            ("researcher.md", include_str!("templates/researcher.md")),
269        ],
270        "software" => &[
271            ("tech_lead.md", include_str!("templates/tech_lead.md")),
272            ("eng_manager.md", include_str!("templates/eng_manager.md")),
273            ("developer.md", include_str!("templates/developer.md")),
274        ],
275        "batty" => &[
276            (
277                "batty_architect.md",
278                include_str!("templates/batty_architect.md"),
279            ),
280            (
281                "batty_manager.md",
282                include_str!("templates/batty_manager.md"),
283            ),
284            (
285                "batty_engineer.md",
286                include_str!("templates/batty_engineer.md"),
287            ),
288        ],
289        _ => &[
290            ("architect.md", include_str!("templates/architect.md")),
291            ("manager.md", include_str!("templates/manager.md")),
292            ("engineer.md", include_str!("templates/engineer.md")),
293        ],
294    };
295
296    for (name, content) in prompt_files {
297        let path = config_dir.join(name);
298        if !path.exists() {
299            std::fs::write(&path, content)
300                .with_context(|| format!("failed to write {}", path.display()))?;
301            created.push(path);
302        }
303    }
304
305    let directive_files = [
306        (
307            "replenishment_context.md",
308            include_str!("templates/replenishment_context.md"),
309        ),
310        (
311            "review_policy.md",
312            include_str!("templates/review_policy.md"),
313        ),
314        (
315            "escalation_policy.md",
316            include_str!("templates/escalation_policy.md"),
317        ),
318    ];
319    for (name, content) in directive_files {
320        let path = config_dir.join(name);
321        if !path.exists() {
322            std::fs::write(&path, content)
323                .with_context(|| format!("failed to write {}", path.display()))?;
324            created.push(path);
325        }
326    }
327
328    // Initialize kanban-md board in the team config directory
329    let board_dir = config_dir.join("board");
330    if !board_dir.exists() {
331        let output = std::process::Command::new("kanban-md")
332            .args(["init", "--dir", &board_dir.to_string_lossy()])
333            .output();
334        match output {
335            Ok(out) if out.status.success() => {
336                created.push(board_dir);
337            }
338            Ok(out) => {
339                let stderr = String::from_utf8_lossy(&out.stderr);
340                warn!("kanban-md init failed: {stderr}; falling back to plain kanban.md");
341                let kanban_path = config_dir.join("kanban.md");
342                std::fs::write(
343                    &kanban_path,
344                    "# Kanban Board\n\n## Backlog\n\n## In Progress\n\n## Done\n",
345                )?;
346                created.push(kanban_path);
347            }
348            Err(_) => {
349                warn!("kanban-md not found; falling back to plain kanban.md");
350                let kanban_path = config_dir.join("kanban.md");
351                std::fs::write(
352                    &kanban_path,
353                    "# Kanban Board\n\n## Backlog\n\n## In Progress\n\n## Done\n",
354                )?;
355                created.push(kanban_path);
356            }
357        }
358    }
359
360    info!(dir = %config_dir.display(), files = created.len(), "scaffolded team config");
361    Ok(created)
362}
363
364pub fn list_available_templates() -> Result<Vec<String>> {
365    let templates_dir = templates_base_dir()?;
366    if !templates_dir.is_dir() {
367        bail!(
368            "no templates directory found at {}",
369            templates_dir.display()
370        );
371    }
372
373    let mut templates = Vec::new();
374    for entry in std::fs::read_dir(&templates_dir)
375        .with_context(|| format!("failed to read {}", templates_dir.display()))?
376    {
377        let entry = entry?;
378        if entry.path().is_dir() {
379            templates.push(entry.file_name().to_string_lossy().into_owned());
380        }
381    }
382    templates.sort();
383    Ok(templates)
384}
385
386fn copy_template_dir(src: &Path, dst: &Path, created: &mut Vec<PathBuf>) -> Result<()> {
387    std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
388    for entry in
389        std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
390    {
391        let entry = entry?;
392        let src_path = entry.path();
393        let dst_path = dst.join(entry.file_name());
394        if src_path.is_dir() {
395            copy_template_dir(&src_path, &dst_path, created)?;
396        } else {
397            std::fs::copy(&src_path, &dst_path).with_context(|| {
398                format!(
399                    "failed to copy template file from {} to {}",
400                    src_path.display(),
401                    dst_path.display()
402                )
403            })?;
404            created.push(dst_path);
405        }
406    }
407    Ok(())
408}
409
410pub fn init_from_template(project_root: &Path, template_name: &str) -> Result<Vec<PathBuf>> {
411    let templates_dir = templates_base_dir()?;
412    if !templates_dir.is_dir() {
413        bail!(
414            "no templates directory found at {}",
415            templates_dir.display()
416        );
417    }
418
419    let available = list_available_templates()?;
420    if !available.iter().any(|name| name == template_name) {
421        let available_display = if available.is_empty() {
422            "(none)".to_string()
423        } else {
424            available.join(", ")
425        };
426        bail!(
427            "template '{}' not found in {}; available templates: {}",
428            template_name,
429            templates_dir.display(),
430            available_display
431        );
432    }
433
434    let config_dir = team_config_dir(project_root);
435    let yaml_path = config_dir.join(TEAM_CONFIG_FILE);
436    if yaml_path.exists() {
437        bail!(
438            "team config already exists at {}; remove it first or edit directly",
439            yaml_path.display()
440        );
441    }
442
443    let source_dir = templates_dir.join(template_name);
444    let mut created = Vec::new();
445    copy_template_dir(&source_dir, &config_dir, &mut created)?;
446    info!(
447        template = template_name,
448        source = %source_dir.display(),
449        dest = %config_dir.display(),
450        files = created.len(),
451        "copied team config from user template"
452    );
453    Ok(created)
454}
455
456/// Export the current team config as a reusable template.
457pub fn export_template(project_root: &Path, name: &str) -> Result<usize> {
458    let config_dir = team_config_dir(project_root);
459    let team_yaml = config_dir.join(TEAM_CONFIG_FILE);
460    if !team_yaml.is_file() {
461        bail!("team config missing at {}", team_yaml.display());
462    }
463
464    let template_dir = templates_base_dir()?.join(name);
465    if template_dir.exists() {
466        eprintln!(
467            "warning: overwriting existing template at {}",
468            template_dir.display()
469        );
470    }
471    std::fs::create_dir_all(&template_dir)
472        .with_context(|| format!("failed to create {}", template_dir.display()))?;
473
474    let mut copied = 0usize;
475    copy_template_file(&team_yaml, &template_dir.join(TEAM_CONFIG_FILE))?;
476    copied += 1;
477
478    let mut prompt_paths = std::fs::read_dir(&config_dir)?
479        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
480        .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
481        .collect::<Vec<_>>();
482    prompt_paths.sort();
483
484    for source in prompt_paths {
485        let file_name = source
486            .file_name()
487            .context("template source missing file name")?;
488        copy_template_file(&source, &template_dir.join(file_name))?;
489        copied += 1;
490    }
491
492    Ok(copied)
493}
494
495pub fn export_run(project_root: &Path) -> Result<PathBuf> {
496    let team_yaml = team_config_path(project_root);
497    if !team_yaml.is_file() {
498        bail!("team config missing at {}", team_yaml.display());
499    }
500
501    let export_dir = create_run_export_dir(project_root)?;
502    copy_template_file(&team_yaml, &export_dir.join(TEAM_CONFIG_FILE))?;
503
504    copy_dir_if_exists(
505        &team_config_dir(project_root).join("board").join("tasks"),
506        &export_dir.join("board").join("tasks"),
507    )?;
508    copy_file_if_exists(
509        &team_events_path(project_root),
510        &export_dir.join("events.jsonl"),
511    )?;
512    copy_file_if_exists(
513        &daemon_log_path(project_root),
514        &export_dir.join("daemon.log"),
515    )?;
516    copy_file_if_exists(
517        &orchestrator_log_path(project_root),
518        &export_dir.join("orchestrator.log"),
519    )?;
520    copy_dir_if_exists(
521        &project_root.join(".batty").join("retrospectives"),
522        &export_dir.join("retrospectives"),
523    )?;
524    copy_file_if_exists(
525        &project_root.join(".batty").join("test_timing.jsonl"),
526        &export_dir.join("test_timing.jsonl"),
527    )?;
528
529    Ok(export_dir)
530}
531
532fn copy_template_file(source: &Path, destination: &Path) -> Result<()> {
533    if let Some(parent) = destination.parent() {
534        std::fs::create_dir_all(parent)
535            .with_context(|| format!("failed to create {}", parent.display()))?;
536    }
537    std::fs::copy(source, destination).with_context(|| {
538        format!(
539            "failed to copy {} to {}",
540            source.display(),
541            destination.display()
542        )
543    })?;
544    Ok(())
545}
546
547/// Path to the daemon PID file.
548fn daemon_pid_path(project_root: &Path) -> PathBuf {
549    project_root.join(".batty").join("daemon.pid")
550}
551
552fn exports_dir(project_root: &Path) -> PathBuf {
553    project_root.join(".batty").join("exports")
554}
555
556fn create_run_export_dir(project_root: &Path) -> Result<PathBuf> {
557    let base = exports_dir(project_root);
558    std::fs::create_dir_all(&base)
559        .with_context(|| format!("failed to create {}", base.display()))?;
560
561    let timestamp = now_unix();
562    let primary = base.join(timestamp.to_string());
563    if !primary.exists() {
564        std::fs::create_dir(&primary)
565            .with_context(|| format!("failed to create {}", primary.display()))?;
566        return Ok(primary);
567    }
568
569    for suffix in 1.. {
570        let candidate = base.join(format!("{timestamp}-{suffix}"));
571        if candidate.exists() {
572            continue;
573        }
574        std::fs::create_dir(&candidate)
575            .with_context(|| format!("failed to create {}", candidate.display()))?;
576        return Ok(candidate);
577    }
578
579    unreachable!("infinite suffix iterator should always return or continue");
580}
581
582/// Path to the daemon log file.
583fn daemon_log_path(project_root: &Path) -> PathBuf {
584    project_root.join(".batty").join("daemon.log")
585}
586
587fn rotated_log_path(path: &Path, generation: usize) -> PathBuf {
588    PathBuf::from(format!("{}.{}", path.display(), generation))
589}
590
591pub(crate) fn rotate_log_if_needed(path: &Path) -> Result<()> {
592    let len = match std::fs::metadata(path) {
593        Ok(metadata) => metadata.len(),
594        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
595        Err(error) => {
596            return Err(error).with_context(|| format!("failed to stat {}", path.display()));
597        }
598    };
599
600    if len <= LOG_ROTATION_BYTES {
601        return Ok(());
602    }
603
604    let oldest = rotated_log_path(path, LOG_ROTATION_KEEP);
605    if oldest.exists() {
606        std::fs::remove_file(&oldest)
607            .with_context(|| format!("failed to remove {}", oldest.display()))?;
608    }
609
610    for generation in (1..LOG_ROTATION_KEEP).rev() {
611        let source = rotated_log_path(path, generation);
612        if !source.exists() {
613            continue;
614        }
615        let destination = rotated_log_path(path, generation + 1);
616        std::fs::rename(&source, &destination).with_context(|| {
617            format!(
618                "failed to rotate {} to {}",
619                source.display(),
620                destination.display()
621            )
622        })?;
623    }
624
625    let rotated = rotated_log_path(path, 1);
626    std::fs::rename(path, &rotated).with_context(|| {
627        format!(
628            "failed to rotate {} to {}",
629            path.display(),
630            rotated.display()
631        )
632    })?;
633    Ok(())
634}
635
636fn copy_file_if_exists(source: &Path, destination: &Path) -> Result<()> {
637    if source.is_file() {
638        copy_template_file(source, destination)?;
639    }
640    Ok(())
641}
642
643fn copy_dir_if_exists(source: &Path, destination: &Path) -> Result<()> {
644    if source.is_dir() {
645        let mut created = Vec::new();
646        copy_template_dir(source, destination, &mut created)?;
647    }
648    Ok(())
649}
650
651pub(crate) fn open_log_for_append(path: &Path) -> Result<File> {
652    if let Some(parent) = path.parent() {
653        std::fs::create_dir_all(parent)?;
654    }
655    rotate_log_if_needed(path)?;
656    File::options()
657        .append(true)
658        .create(true)
659        .open(path)
660        .with_context(|| format!("failed to open log file: {}", path.display()))
661}
662
663fn daemon_spawn_args(root_str: &str, resume: bool) -> Vec<String> {
664    let mut args = vec![
665        "-v".to_string(),
666        "daemon".to_string(),
667        "--project-root".to_string(),
668        root_str.to_string(),
669    ];
670    if resume {
671        args.push("--resume".to_string());
672    }
673    args
674}
675
676pub(crate) fn daemon_state_path(project_root: &Path) -> PathBuf {
677    project_root.join(".batty").join("daemon-state.json")
678}
679
680fn workflow_mode_declared(config_path: &Path) -> Result<bool> {
681    let content = std::fs::read_to_string(config_path)
682        .with_context(|| format!("failed to read {}", config_path.display()))?;
683    let value: serde_yaml::Value = serde_yaml::from_str(&content)
684        .with_context(|| format!("failed to parse {}", config_path.display()))?;
685    let Some(mapping) = value.as_mapping() else {
686        return Ok(false);
687    };
688
689    Ok(mapping.contains_key(serde_yaml::Value::String("workflow_mode".to_string())))
690}
691
692fn migration_validation_notes(
693    team_config: &config::TeamConfig,
694    workflow_mode_is_explicit: bool,
695) -> Vec<String> {
696    if !workflow_mode_is_explicit {
697        return vec![
698            "Migration: workflow_mode omitted; defaulting to legacy so existing teams and boards run unchanged.".to_string(),
699        ];
700    }
701
702    match team_config.workflow_mode {
703        config::WorkflowMode::Legacy => vec![
704            "Migration: legacy mode selected; Batty keeps current runtime behavior and treats workflow metadata as optional.".to_string(),
705        ],
706        config::WorkflowMode::Hybrid => vec![
707            "Migration: hybrid mode selected; workflow adoption is incremental and legacy runtime behavior remains available.".to_string(),
708        ],
709        config::WorkflowMode::WorkflowFirst => vec![
710            "Migration: workflow_first mode selected; complete board metadata and orchestrator rollout before treating workflow state as primary truth.".to_string(),
711        ],
712    }
713}
714
715/// Spawn the daemon as a detached background process.
716///
717/// The daemon runs in its own process group with stdio redirected to a log
718/// file, so it survives terminal closure. PID is saved to `.batty/daemon.pid`.
719fn spawn_daemon(project_root: &Path, resume: bool) -> Result<u32> {
720    use std::process::{Command, Stdio};
721
722    let log_path = daemon_log_path(project_root);
723    let pid_path = daemon_pid_path(project_root);
724
725    // Ensure .batty/ exists
726    if let Some(parent) = log_path.parent() {
727        std::fs::create_dir_all(parent)?;
728    }
729
730    let log_file = open_log_for_append(&log_path)?;
731    let log_err = log_file
732        .try_clone()
733        .context("failed to clone log file handle")?;
734
735    let exe = std::env::current_exe().context("failed to resolve current executable")?;
736    let root_str = project_root
737        .canonicalize()
738        .unwrap_or_else(|_| project_root.to_path_buf())
739        .to_string_lossy()
740        .to_string();
741
742    let mut cmd = Command::new(exe);
743    let args = daemon_spawn_args(&root_str, resume);
744    cmd.args(&args)
745        .stdin(Stdio::null())
746        .stdout(log_file)
747        .stderr(log_err);
748
749    // Detach into a new process group so it survives terminal closure
750    #[cfg(unix)]
751    {
752        use std::os::unix::process::CommandExt;
753        cmd.process_group(0);
754    }
755
756    let mut child = cmd.spawn().context("failed to spawn daemon process")?;
757    let pid = child.id();
758
759    // Give the child a moment to start up and verify it didn't exit immediately
760    // (e.g. due to an unrecognized subcommand in an outdated binary).
761    std::thread::sleep(std::time::Duration::from_millis(500));
762    match child.try_wait() {
763        Ok(Some(status)) => {
764            let _ = std::fs::remove_file(&pid_path);
765            // Read the last few lines of the daemon log for the actual error
766            let tail = std::fs::read_to_string(&log_path)
767                .ok()
768                .and_then(|s| {
769                    let lines: Vec<&str> = s.lines().collect();
770                    let start = lines.len().saturating_sub(5);
771                    let tail = lines[start..].join("\n");
772                    if tail.trim().is_empty() { None } else { Some(tail) }
773                });
774            match tail {
775                Some(detail) => bail!(
776                    "daemon process exited immediately with {status}\n\n\
777                     {detail}\n\n\
778                     see full log: {log}",
779                    log = log_path.display(),
780                ),
781                None => bail!(
782                    "daemon process exited immediately with {status}; \
783                     see {log} for details",
784                    log = log_path.display(),
785                ),
786            }
787        }
788        Ok(None) => {} // still running — good
789        Err(e) => {
790            warn!(pid, error = %e, "failed to check daemon process status");
791        }
792    }
793
794    std::fs::write(&pid_path, pid.to_string())
795        .with_context(|| format!("failed to write PID file: {}", pid_path.display()))?;
796
797    info!(pid, log = %log_path.display(), "daemon spawned");
798    Ok(pid)
799}
800
801/// Kill the daemon process if it's running.
802fn read_daemon_pid(project_root: &Path) -> Option<u32> {
803    let pid_path = daemon_pid_path(project_root);
804    let pid_str = std::fs::read_to_string(pid_path).ok()?;
805    pid_str.trim().parse::<u32>().ok()
806}
807
808#[cfg(unix)]
809fn send_unix_signal(pid: u32, signal: libc::c_int) -> bool {
810    let status = unsafe { libc::kill(pid as libc::pid_t, signal) };
811    if status == 0 {
812        true
813    } else {
814        let error = std::io::Error::last_os_error();
815        warn!(pid, signal, error = %error, "failed to signal daemon");
816        false
817    }
818}
819
820#[cfg(not(unix))]
821fn send_unix_signal(_pid: u32, _signal: i32) -> bool {
822    false
823}
824
825#[cfg(unix)]
826fn daemon_process_exists(pid: u32) -> bool {
827    let status = unsafe { libc::kill(pid as libc::pid_t, 0) };
828    if status == 0 {
829        true
830    } else {
831        !matches!(
832            std::io::Error::last_os_error().raw_os_error(),
833            Some(libc::ESRCH)
834        )
835    }
836}
837
838#[cfg(not(unix))]
839fn daemon_process_exists(_pid: u32) -> bool {
840    false
841}
842
843fn wait_for_graceful_daemon_shutdown(
844    project_root: &Path,
845    pid: u32,
846    previous_saved_at: Option<u64>,
847    timeout: Duration,
848) -> bool {
849    let deadline = std::time::Instant::now() + timeout;
850    loop {
851        let clean_snapshot = daemon_state_indicates_clean_shutdown(project_root, previous_saved_at);
852        if clean_snapshot {
853            let _ = std::fs::remove_file(daemon_pid_path(project_root));
854            return true;
855        }
856        let running = daemon_process_exists(pid);
857        if !running {
858            let _ = std::fs::remove_file(daemon_pid_path(project_root));
859            return false;
860        }
861        if std::time::Instant::now() >= deadline {
862            return false;
863        }
864        std::thread::sleep(DAEMON_SHUTDOWN_POLL_INTERVAL);
865    }
866}
867
868fn request_graceful_daemon_shutdown(project_root: &Path, timeout: Duration) -> bool {
869    let Some(pid) = read_daemon_pid(project_root) else {
870        return true;
871    };
872
873    let previous_saved_at = read_daemon_state_probe(project_root).and_then(|state| state.saved_at);
874    #[cfg(unix)]
875    {
876        if !send_unix_signal(pid, libc::SIGTERM) {
877            return false;
878        }
879        info!(pid, "sent SIGTERM to daemon");
880    }
881    #[cfg(not(unix))]
882    {
883        warn!(
884            pid,
885            "graceful daemon shutdown is not supported on this platform"
886        );
887        return false;
888    }
889
890    wait_for_graceful_daemon_shutdown(project_root, pid, previous_saved_at, timeout)
891}
892
893fn force_kill_daemon(project_root: &Path) {
894    let Some(pid) = read_daemon_pid(project_root) else {
895        return;
896    };
897
898    #[cfg(unix)]
899    {
900        if send_unix_signal(pid, libc::SIGKILL) {
901            info!(pid, "sent SIGKILL to daemon");
902        }
903    }
904    #[cfg(not(unix))]
905    {
906        warn!(pid, "cannot force-kill daemon on this platform");
907    }
908
909    let _ = std::fs::remove_file(daemon_pid_path(project_root));
910}
911
912/// Start a team session: load config, resolve hierarchy, create tmux layout,
913/// spawn the daemon as a background process, and optionally attach.
914///
915/// Returns the tmux session name.
916pub fn start_team(project_root: &Path, attach: bool) -> Result<String> {
917    let config_path = team_config_path(project_root);
918    if !config_path.exists() {
919        bail!(
920            "no team config found at {}; run `batty init` first",
921            config_path.display()
922        );
923    }
924
925    let team_config = config::TeamConfig::load(&config_path)?;
926    team_config.validate()?;
927
928    let members = hierarchy::resolve_hierarchy(&team_config)?;
929    let session = format!("batty-{}", team_config.name);
930
931    if tmux::session_exists(&session) {
932        bail!("session '{session}' already exists; use `batty attach` or `batty stop` first");
933    }
934
935    layout::build_layout(
936        &session,
937        &members,
938        &team_config.layout,
939        project_root,
940        team_config.workflow_mode,
941        team_config.orchestrator_enabled(),
942        team_config.orchestrator_position,
943    )?;
944
945    // Initialize Maildir inboxes for all members
946    let inboxes = inbox::inboxes_root(project_root);
947    for member in &members {
948        inbox::init_inbox(&inboxes, &member.name)?;
949    }
950
951    // Check for resume marker (left by a prior `batty stop`)
952    let marker = resume_marker_path(project_root);
953    let resume = marker.exists() || should_resume_from_daemon_state(project_root);
954    if resume {
955        if marker.exists() {
956            // Consume the marker — it's a one-shot flag
957            std::fs::remove_file(&marker).ok();
958        }
959        info!("resuming agent sessions from previous run");
960    }
961
962    info!(session = %session, members = members.len(), resume, "team session started");
963
964    // Spawn daemon as a detached background process
965    let pid = spawn_daemon(project_root, resume)?;
966    info!(pid, "daemon process launched");
967
968    // Give daemon a moment to start spawning agents
969    std::thread::sleep(std::time::Duration::from_secs(2));
970
971    if attach {
972        tmux::attach(&session)?;
973    }
974
975    Ok(session)
976}
977
978/// Run the daemon loop directly (called by the hidden `batty daemon` subcommand).
979///
980/// This is the entry point for the daemonized background process.
981pub fn run_daemon(project_root: &Path, resume: bool) -> Result<()> {
982    let config_path = team_config_path(project_root);
983    if !config_path.exists() {
984        bail!(
985            "no team config found at {}; run `batty init` first",
986            config_path.display()
987        );
988    }
989
990    let team_config = config::TeamConfig::load(&config_path)?;
991    let members = hierarchy::resolve_hierarchy(&team_config)?;
992    let session = format!("batty-{}", team_config.name);
993
994    // Wait for tmux session to be ready (start_team creates it before spawning us)
995    for _ in 0..30 {
996        if tmux::session_exists(&session) {
997            break;
998        }
999        std::thread::sleep(std::time::Duration::from_millis(200));
1000    }
1001
1002    if !tmux::session_exists(&session) {
1003        bail!("tmux session '{session}' not found — did `batty start` create it?");
1004    }
1005
1006    // Reconstruct pane_map from tmux pane options
1007    let mut pane_map = std::collections::HashMap::new();
1008    for member in &members {
1009        // Query tmux for the pane ID tagged with this member's role
1010        if let Some(pane_id) = find_pane_for_member(&session, &member.name) {
1011            pane_map.insert(member.name.clone(), pane_id);
1012        }
1013    }
1014
1015    let daemon_config = daemon::DaemonConfig {
1016        project_root: project_root.to_path_buf(),
1017        team_config,
1018        session,
1019        members,
1020        pane_map,
1021    };
1022
1023    let events_path = project_root
1024        .join(".batty")
1025        .join("team_config")
1026        .join("events.jsonl");
1027
1028    let mut d = daemon::TeamDaemon::new(daemon_config)?;
1029
1030    // Wrap in catch_unwind so panics are logged to events before exit
1031    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| d.run(resume)));
1032
1033    match result {
1034        Ok(Ok(())) => Ok(()),
1035        Ok(Err(e)) => {
1036            eprintln!("daemon exited with error: {e:#}");
1037            // Try to log the error event
1038            if let Ok(mut sink) = events::EventSink::new(&events_path) {
1039                let _ = sink.emit(events::TeamEvent::daemon_stopped_with_reason(
1040                    &format!("error: {e:#}"),
1041                    0,
1042                ));
1043            }
1044            Err(e)
1045        }
1046        Err(panic_payload) => {
1047            let reason = match panic_payload.downcast_ref::<&str>() {
1048                Some(s) => s.to_string(),
1049                None => match panic_payload.downcast_ref::<String>() {
1050                    Some(s) => s.clone(),
1051                    None => "unknown panic".to_string(),
1052                },
1053            };
1054            eprintln!("daemon panicked: {reason}");
1055            // Log panic event
1056            if let Ok(mut sink) = events::EventSink::new(&events_path) {
1057                let _ = sink.emit(events::TeamEvent::daemon_panic(&reason));
1058            }
1059            std::panic::resume_unwind(panic_payload);
1060        }
1061    }
1062}
1063
1064/// Find the tmux pane ID tagged with `@batty_role=<member_name>` in a session.
1065fn find_pane_for_member(session: &str, member_name: &str) -> Option<String> {
1066    let output = std::process::Command::new("tmux")
1067        .args([
1068            "list-panes",
1069            "-t",
1070            session,
1071            "-F",
1072            "#{pane_id} #{@batty_role}",
1073        ])
1074        .output()
1075        .ok()?;
1076
1077    if !output.status.success() {
1078        return None;
1079    }
1080
1081    let stdout = String::from_utf8_lossy(&output.stdout);
1082    for line in stdout.lines() {
1083        let parts: Vec<&str> = line.splitn(2, ' ').collect();
1084        if parts.len() == 2 && parts[1] == member_name {
1085            return Some(parts[0].to_string());
1086        }
1087    }
1088    None
1089}
1090
1091/// Path to the resume marker file. Presence indicates agents have prior sessions.
1092fn resume_marker_path(project_root: &Path) -> PathBuf {
1093    project_root.join(".batty").join("resume")
1094}
1095
1096#[derive(Debug, Deserialize)]
1097struct DaemonStateResumeProbe {
1098    #[serde(default)]
1099    clean_shutdown: bool,
1100    #[serde(default)]
1101    saved_at: Option<u64>,
1102}
1103
1104fn read_daemon_state_probe(project_root: &Path) -> Option<DaemonStateResumeProbe> {
1105    let path = daemon_state_path(project_root);
1106    let content = std::fs::read_to_string(&path).ok()?;
1107
1108    match serde_json::from_str::<DaemonStateResumeProbe>(&content) {
1109        Ok(state) => Some(state),
1110        Err(error) => {
1111            warn!(
1112                path = %path.display(),
1113                error = %error,
1114                "failed to parse daemon state while probing for resume"
1115            );
1116            None
1117        }
1118    }
1119}
1120
1121fn daemon_state_indicates_clean_shutdown(
1122    project_root: &Path,
1123    previous_saved_at: Option<u64>,
1124) -> bool {
1125    let Some(state) = read_daemon_state_probe(project_root) else {
1126        return false;
1127    };
1128
1129    state.clean_shutdown
1130        && match (state.saved_at, previous_saved_at) {
1131            (Some(saved_at), Some(previous_saved_at)) => saved_at > previous_saved_at,
1132            (Some(_), None) => true,
1133            (None, Some(_)) => false,
1134            (None, None) => true,
1135        }
1136}
1137
1138fn should_resume_from_daemon_state(project_root: &Path) -> bool {
1139    read_daemon_state_probe(project_root)
1140        .map(|state| !state.clean_shutdown)
1141        .unwrap_or(false)
1142}
1143
1144/// Path to the pause marker file. Presence pauses nudges and standups.
1145pub fn pause_marker_path(project_root: &Path) -> PathBuf {
1146    project_root.join(".batty").join("paused")
1147}
1148
1149/// Create the pause marker file, pausing nudges and standups.
1150pub fn pause_team(project_root: &Path) -> Result<()> {
1151    let marker = pause_marker_path(project_root);
1152    if marker.exists() {
1153        bail!("Team is already paused.");
1154    }
1155    if let Some(parent) = marker.parent() {
1156        std::fs::create_dir_all(parent).ok();
1157    }
1158    std::fs::write(&marker, "").context("failed to write pause marker")?;
1159    info!("paused nudges and standups");
1160    Ok(())
1161}
1162
1163/// Remove the pause marker file, resuming nudges and standups.
1164pub fn resume_team(project_root: &Path) -> Result<()> {
1165    let marker = pause_marker_path(project_root);
1166    if !marker.exists() {
1167        bail!("Team is not paused.");
1168    }
1169    std::fs::remove_file(&marker).context("failed to remove pause marker")?;
1170    info!("resumed nudges and standups");
1171    Ok(())
1172}
1173
1174/// Path to the nudge-disabled marker for a given intervention.
1175pub fn nudge_disabled_marker_path(project_root: &Path, intervention: &str) -> PathBuf {
1176    project_root
1177        .join(".batty")
1178        .join(format!("nudge_{intervention}_disabled"))
1179}
1180
1181/// Create a nudge-disabled marker file, disabling the intervention at runtime.
1182pub fn disable_nudge(project_root: &Path, intervention: &str) -> Result<()> {
1183    let marker = nudge_disabled_marker_path(project_root, intervention);
1184    if marker.exists() {
1185        bail!("Intervention '{intervention}' is already disabled.");
1186    }
1187    if let Some(parent) = marker.parent() {
1188        std::fs::create_dir_all(parent).ok();
1189    }
1190    std::fs::write(&marker, "").context("failed to write nudge disabled marker")?;
1191    info!(intervention, "disabled intervention");
1192    Ok(())
1193}
1194
1195/// Remove a nudge-disabled marker file, re-enabling the intervention.
1196pub fn enable_nudge(project_root: &Path, intervention: &str) -> Result<()> {
1197    let marker = nudge_disabled_marker_path(project_root, intervention);
1198    if !marker.exists() {
1199        bail!("Intervention '{intervention}' is not disabled.");
1200    }
1201    std::fs::remove_file(&marker).context("failed to remove nudge disabled marker")?;
1202    info!(intervention, "enabled intervention");
1203    Ok(())
1204}
1205
1206/// Print a table showing config, runtime, and effective state for each intervention.
1207pub fn nudge_status(project_root: &Path) -> Result<()> {
1208    use crate::cli::NudgeIntervention;
1209
1210    let config_path = team_config_path(project_root);
1211    let automation = if config_path.exists() {
1212        let team_config = config::TeamConfig::load(&config_path)?;
1213        Some(team_config.automation)
1214    } else {
1215        None
1216    };
1217
1218    println!(
1219        "{:<16} {:<10} {:<10} {:<10}",
1220        "INTERVENTION", "CONFIG", "RUNTIME", "EFFECTIVE"
1221    );
1222
1223    for intervention in NudgeIntervention::ALL {
1224        let name = intervention.marker_name();
1225        let config_enabled = automation
1226            .as_ref()
1227            .map(|a| match intervention {
1228                NudgeIntervention::Replenish => true, // no dedicated config flag
1229                NudgeIntervention::Triage => a.triage_interventions,
1230                NudgeIntervention::Review => a.review_interventions,
1231                NudgeIntervention::Dispatch => a.manager_dispatch_interventions,
1232                NudgeIntervention::Utilization => a.architect_utilization_interventions,
1233                NudgeIntervention::OwnedTask => a.owned_task_interventions,
1234            })
1235            .unwrap_or(true);
1236
1237        let runtime_disabled = nudge_disabled_marker_path(project_root, name).exists();
1238        let runtime_str = if runtime_disabled {
1239            "disabled"
1240        } else {
1241            "enabled"
1242        };
1243        let config_str = if config_enabled {
1244            "enabled"
1245        } else {
1246            "disabled"
1247        };
1248        let effective = config_enabled && !runtime_disabled;
1249        let effective_str = if effective { "enabled" } else { "DISABLED" };
1250
1251        println!(
1252            "{:<16} {:<10} {:<10} {:<10}",
1253            name, config_str, runtime_str, effective_str
1254        );
1255    }
1256
1257    Ok(())
1258}
1259
1260/// Stop a running team session and clean up any orphaned `batty-` sessions.
1261/// Summary statistics for a completed session.
1262#[derive(Debug, Clone, PartialEq, Eq)]
1263pub(crate) struct SessionSummary {
1264    pub tasks_completed: u32,
1265    pub tasks_merged: u32,
1266    pub runtime_secs: u64,
1267}
1268
1269impl SessionSummary {
1270    pub fn display(&self) -> String {
1271        format!(
1272            "Session summary: {} tasks completed, {} merged, runtime {}",
1273            self.tasks_completed,
1274            self.tasks_merged,
1275            format_runtime(self.runtime_secs),
1276        )
1277    }
1278}
1279
1280fn format_runtime(secs: u64) -> String {
1281    if secs < 60 {
1282        format!("{secs}s")
1283    } else if secs < 3600 {
1284        format!("{}m", secs / 60)
1285    } else {
1286        let hours = secs / 3600;
1287        let mins = (secs % 3600) / 60;
1288        if mins == 0 {
1289            format!("{hours}h")
1290        } else {
1291            format!("{hours}h {mins}m")
1292        }
1293    }
1294}
1295
1296/// Compute session summary from the event log.
1297///
1298/// Finds the most recent `daemon_started` event and counts completions and
1299/// merges that occurred after it. Runtime is calculated from the daemon start
1300/// timestamp to now.
1301pub(crate) fn compute_session_summary(project_root: &Path) -> Option<SessionSummary> {
1302    let events_path = team_events_path(project_root);
1303    let all_events = events::read_events(&events_path).ok()?;
1304
1305    // Find the most recent daemon_started event.
1306    let session_start = all_events
1307        .iter()
1308        .rev()
1309        .find(|e| e.event == "daemon_started")?;
1310    let start_ts = session_start.ts;
1311    let now_ts = now_unix();
1312
1313    let session_events: Vec<_> = all_events.iter().filter(|e| e.ts >= start_ts).collect();
1314
1315    let tasks_completed = session_events
1316        .iter()
1317        .filter(|e| e.event == "task_completed")
1318        .count() as u32;
1319
1320    let tasks_merged = session_events
1321        .iter()
1322        .filter(|e| e.event == "task_auto_merged" || e.event == "task_manual_merged")
1323        .count() as u32;
1324
1325    let runtime_secs = now_ts.saturating_sub(start_ts);
1326
1327    Some(SessionSummary {
1328        tasks_completed,
1329        tasks_merged,
1330        runtime_secs,
1331    })
1332}
1333
1334pub fn stop_team(project_root: &Path) -> Result<()> {
1335    // Compute session summary before shutting down (events log is still available).
1336    let summary = compute_session_summary(project_root);
1337
1338    // Write resume marker before tearing down — agents have sessions to continue
1339    let marker = resume_marker_path(project_root);
1340    if let Some(parent) = marker.parent() {
1341        std::fs::create_dir_all(parent).ok();
1342    }
1343    std::fs::write(&marker, "").ok();
1344
1345    // Ask the daemon to persist a final clean snapshot before the tmux session is torn down.
1346    if !request_graceful_daemon_shutdown(project_root, DAEMON_SHUTDOWN_GRACE_PERIOD) {
1347        warn!("daemon did not stop gracefully; forcing shutdown");
1348        force_kill_daemon(project_root);
1349    }
1350
1351    let config_path = team_config_path(project_root);
1352    let primary_session = if config_path.exists() {
1353        let team_config = config::TeamConfig::load(&config_path)?;
1354        Some(format!("batty-{}", team_config.name))
1355    } else {
1356        None
1357    };
1358
1359    // Kill only the session belonging to this project
1360    match &primary_session {
1361        Some(session) if tmux::session_exists(session) => {
1362            tmux::kill_session(session)?;
1363            info!(session = %session, "team session stopped");
1364        }
1365        Some(session) => {
1366            info!(session = %session, "no running session to stop");
1367        }
1368        None => {
1369            bail!("no team config found at {}", config_path.display());
1370        }
1371    }
1372
1373    // Print session summary after teardown.
1374    if let Some(summary) = summary {
1375        println!();
1376        println!("{}", summary.display());
1377    }
1378
1379    Ok(())
1380}
1381
1382/// Attach to a running team session.
1383///
1384/// First tries the team config in the project root. If not found, looks for
1385/// any running `batty-*` tmux session and attaches to it.
1386pub fn attach_team(project_root: &Path) -> Result<()> {
1387    let config_path = team_config_path(project_root);
1388
1389    let session = if config_path.exists() {
1390        let team_config = config::TeamConfig::load(&config_path)?;
1391        format!("batty-{}", team_config.name)
1392    } else {
1393        // No local config — find any running batty session
1394        let mut sessions = tmux::list_sessions_with_prefix("batty-");
1395        match sessions.len() {
1396            0 => bail!("no team config found and no batty sessions running"),
1397            1 => sessions.swap_remove(0),
1398            _ => {
1399                let list = sessions.join(", ");
1400                bail!(
1401                    "no team config found and multiple batty sessions running: {list}\n\
1402                     Run from the project directory, or use: tmux attach -t <session>"
1403                );
1404            }
1405        }
1406    };
1407
1408    if !tmux::session_exists(&session) {
1409        bail!("no running session '{session}'; run `batty start` first");
1410    }
1411
1412    tmux::attach(&session)
1413}
1414
1415/// Show team status.
1416pub fn team_status(project_root: &Path, json: bool) -> Result<()> {
1417    let config_path = team_config_path(project_root);
1418    if !config_path.exists() {
1419        bail!("no team config found at {}", config_path.display());
1420    }
1421
1422    let team_config = config::TeamConfig::load(&config_path)?;
1423    let members = hierarchy::resolve_hierarchy(&team_config)?;
1424    let session = format!("batty-{}", team_config.name);
1425    let session_running = tmux::session_exists(&session);
1426    let runtime_statuses = if session_running {
1427        match status::list_runtime_member_statuses(&session) {
1428            Ok(statuses) => statuses,
1429            Err(error) => {
1430                warn!(session = %session, error = %error, "failed to read live runtime statuses");
1431                std::collections::HashMap::new()
1432            }
1433        }
1434    } else {
1435        std::collections::HashMap::new()
1436    };
1437    let pending_inbox_counts = status::pending_inbox_counts(project_root, &members);
1438    let triage_backlog_counts = status::triage_backlog_counts(project_root, &members);
1439    let owned_task_buckets = status::owned_task_buckets(project_root, &members);
1440    let agent_health = status::agent_health_by_member(project_root, &members);
1441    let paused = pause_marker_path(project_root).exists();
1442    let mut rows = status::build_team_status_rows(
1443        &members,
1444        session_running,
1445        &runtime_statuses,
1446        &pending_inbox_counts,
1447        &triage_backlog_counts,
1448        &owned_task_buckets,
1449        &agent_health,
1450    );
1451
1452    // Populate ETA estimates for members with active tasks.
1453    let active_task_elapsed: Vec<(u32, u64)> = rows
1454        .iter()
1455        .filter(|row| !row.active_owned_tasks.is_empty())
1456        .flat_map(|row| {
1457            let elapsed = row.health.task_elapsed_secs.unwrap_or(0);
1458            row.active_owned_tasks
1459                .iter()
1460                .map(move |&task_id| (task_id, elapsed))
1461        })
1462        .collect();
1463    let etas = estimation::compute_etas(project_root, &active_task_elapsed);
1464    for row in &mut rows {
1465        if let Some(&task_id) = row.active_owned_tasks.first() {
1466            if let Some(eta) = etas.get(&task_id) {
1467                row.eta = eta.clone();
1468            }
1469        }
1470    }
1471
1472    let workflow_metrics = status::workflow_metrics_section(project_root, &members);
1473    let (active_tasks, review_queue) = match status::board_status_task_queues(project_root) {
1474        Ok(queues) => queues,
1475        Err(error) => {
1476            warn!(error = %error, "failed to load board task queues for status json");
1477            (Vec::new(), Vec::new())
1478        }
1479    };
1480
1481    if json {
1482        let report = status::build_team_status_json_report(status::TeamStatusJsonReportInput {
1483            team: team_config.name.clone(),
1484            session: session.clone(),
1485            session_running,
1486            paused,
1487            workflow_metrics: workflow_metrics
1488                .as_ref()
1489                .map(|(_, metrics)| metrics.clone()),
1490            active_tasks,
1491            review_queue,
1492            members: rows,
1493        });
1494        println!("{}", serde_json::to_string_pretty(&report)?);
1495    } else {
1496        println!("Team: {}", team_config.name);
1497        println!(
1498            "Session: {} ({})",
1499            session,
1500            if session_running {
1501                "running"
1502            } else {
1503                "stopped"
1504            }
1505        );
1506        println!();
1507        println!(
1508            "{:<20} {:<12} {:<10} {:<12} {:>5} {:>6} {:<14} {:<14} {:<16} {:<18} {:<24} {:<20}",
1509            "MEMBER",
1510            "ROLE",
1511            "AGENT",
1512            "STATE",
1513            "INBOX",
1514            "TRIAGE",
1515            "ACTIVE",
1516            "REVIEW",
1517            "ETA",
1518            "HEALTH",
1519            "SIGNAL",
1520            "REPORTS TO"
1521        );
1522        println!("{}", "-".repeat(195));
1523        for row in &rows {
1524            println!(
1525                "{:<20} {:<12} {:<10} {:<12} {:>5} {:>6} {:<14} {:<14} {:<16} {:<18} {:<24} {:<20}",
1526                row.name,
1527                row.role,
1528                row.agent.as_deref().unwrap_or("-"),
1529                row.state,
1530                row.pending_inbox,
1531                row.triage_backlog,
1532                status::format_owned_tasks_summary(&row.active_owned_tasks),
1533                status::format_owned_tasks_summary(&row.review_owned_tasks),
1534                row.eta,
1535                row.health_summary,
1536                row.signal.as_deref().unwrap_or("-"),
1537                row.reports_to.as_deref().unwrap_or("-"),
1538            );
1539        }
1540        if let Some((formatted, _)) = workflow_metrics {
1541            println!();
1542            println!("{formatted}");
1543        }
1544    }
1545
1546    Ok(())
1547}
1548
1549/// Show an estimated team load value from live state, store it, and show recent load trends.
1550pub fn show_load(project_root: &Path) -> Result<()> {
1551    let current = capture_team_load(project_root)?;
1552    if let Err(error) = log_team_load_snapshot(project_root, &current) {
1553        warn!(error = %error, "failed to append load snapshot to team event log");
1554    }
1555
1556    let mut history = read_team_load_history(project_root)?;
1557    history.push(current);
1558    history.sort_by_key(|snapshot| snapshot.timestamp);
1559
1560    println!(
1561        "Current load: {:.1}% ({} / {} members working)",
1562        current.load * 100.0,
1563        current.working_members,
1564        current.total_members.max(1)
1565    );
1566    println!(
1567        "Session: {}",
1568        if current.session_running {
1569            "running"
1570        } else {
1571            "stopped"
1572        }
1573    );
1574
1575    if let Some(avg) = average_load(&history, current.timestamp, 10 * 60) {
1576        println!("10m avg: {:.1}%", avg * 100.0);
1577    } else {
1578        println!("10m avg: n/a");
1579    }
1580    println!(
1581        "30m avg: {}",
1582        average_load(&history, current.timestamp, 30 * 60)
1583            .map(|avg| format!("{:.1}%", avg * 100.0))
1584            .unwrap_or_else(|| "n/a".to_string())
1585    );
1586    println!(
1587        "60m avg: {}",
1588        average_load(&history, current.timestamp, 60 * 60)
1589            .map(|avg| format!("{:.1}%", avg * 100.0))
1590            .unwrap_or_else(|| "n/a".to_string())
1591    );
1592
1593    println!("Load graph (1h):");
1594    println!("{}", render_load_graph(&history, current.timestamp));
1595    Ok(())
1596}
1597
1598fn capture_team_load(project_root: &Path) -> Result<TeamLoadSnapshot> {
1599    let config_path = team_config_path(project_root);
1600    if !config_path.exists() {
1601        bail!("no team config found at {}", config_path.display());
1602    }
1603
1604    let team_config = config::TeamConfig::load(&config_path)?;
1605    let members = hierarchy::resolve_hierarchy(&team_config)?;
1606    let session = format!("batty-{}", team_config.name);
1607    let session_running = tmux::session_exists(&session);
1608    let runtime_statuses = if session_running {
1609        match status::list_runtime_member_statuses(&session) {
1610            Ok(statuses) => statuses,
1611            Err(error) => {
1612                warn!(session = %session, error = %error, "failed to read runtime statuses for load sampling");
1613                std::collections::HashMap::new()
1614            }
1615        }
1616    } else {
1617        std::collections::HashMap::new()
1618    };
1619
1620    let triage_backlog_counts = status::triage_backlog_counts(project_root, &members);
1621    let owned_task_buckets = status::owned_task_buckets(project_root, &members);
1622    let rows = status::build_team_status_rows(
1623        &members,
1624        session_running,
1625        &runtime_statuses,
1626        &Default::default(),
1627        &triage_backlog_counts,
1628        &owned_task_buckets,
1629        &Default::default(),
1630    );
1631    let mut total_members = 0usize;
1632    let mut working_members = 0usize;
1633
1634    for row in &rows {
1635        if row.role_type == "User" {
1636            continue;
1637        }
1638        total_members += 1;
1639        if counts_as_active_load(row) {
1640            working_members += 1;
1641        }
1642    }
1643
1644    let load = if total_members == 0 {
1645        0.0
1646    } else {
1647        working_members as f64 / total_members as f64
1648    };
1649
1650    Ok(TeamLoadSnapshot {
1651        timestamp: now_unix(),
1652        total_members,
1653        working_members: working_members.min(total_members),
1654        load,
1655        session_running,
1656    })
1657}
1658
1659fn counts_as_active_load(row: &status::TeamStatusRow) -> bool {
1660    matches!(row.state.as_str(), "working" | "triaging" | "reviewing")
1661}
1662
1663fn log_team_load_snapshot(project_root: &Path, snapshot: &TeamLoadSnapshot) -> Result<()> {
1664    let events_path = team_events_path(project_root);
1665    let mut sink = events::EventSink::new(&events_path)?;
1666    let event = events::TeamEvent::load_snapshot(
1667        snapshot.working_members as u32,
1668        snapshot.total_members as u32,
1669        snapshot.session_running,
1670    );
1671    sink.emit(event)?;
1672    Ok(())
1673}
1674
1675fn read_team_load_history(project_root: &Path) -> Result<Vec<TeamLoadSnapshot>> {
1676    let events_path = team_events_path(project_root);
1677    let events = events::read_events(&events_path)?;
1678    let mut history = Vec::new();
1679    for event in events {
1680        if event.event != "load_snapshot" {
1681            continue;
1682        }
1683        let Some(load) = event.load else {
1684            continue;
1685        };
1686        let Some(working_members) = event.working_members else {
1687            continue;
1688        };
1689        let Some(total_members) = event.total_members else {
1690            continue;
1691        };
1692
1693        history.push(TeamLoadSnapshot {
1694            timestamp: event.ts,
1695            total_members: total_members as usize,
1696            working_members: working_members as usize,
1697            load,
1698            session_running: event.session_running.unwrap_or(false),
1699        });
1700    }
1701    Ok(history)
1702}
1703
1704fn average_load(samples: &[TeamLoadSnapshot], now: u64, window_seconds: u64) -> Option<f64> {
1705    let cutoff = now.saturating_sub(window_seconds);
1706    let mut values = Vec::new();
1707    for sample in samples {
1708        if sample.timestamp >= cutoff && sample.timestamp <= now {
1709            values.push(sample.load);
1710        }
1711    }
1712    if values.is_empty() {
1713        return None;
1714    }
1715    let sum: f64 = values.iter().copied().sum();
1716    Some(sum / values.len() as f64)
1717}
1718
1719fn render_load_graph(samples: &[TeamLoadSnapshot], now: u64) -> String {
1720    if samples.is_empty() {
1721        return "(no historical load data yet)".to_string();
1722    }
1723
1724    let bucket_size = (LOAD_GRAPH_WINDOW_SECONDS / LOAD_GRAPH_WIDTH as u64).max(1);
1725    let window_start = now.saturating_sub(LOAD_GRAPH_WINDOW_SECONDS);
1726    let mut history = String::new();
1727    let mut previous = 0.0;
1728    for index in 0..LOAD_GRAPH_WIDTH {
1729        let bucket_start = window_start + (index as u64 * bucket_size);
1730        let bucket_end = if index + 1 == LOAD_GRAPH_WIDTH {
1731            now + 1
1732        } else {
1733            bucket_start + bucket_size
1734        };
1735
1736        let mut sum = 0.0;
1737        let mut count = 0usize;
1738        for sample in samples {
1739            if sample.timestamp >= bucket_start && sample.timestamp < bucket_end {
1740                sum += sample.load;
1741                count += 1;
1742            }
1743        }
1744
1745        let value = if count == 0 {
1746            previous
1747        } else {
1748            sum / count as f64
1749        };
1750        previous = value;
1751        history.push(load_point_char(value));
1752    }
1753
1754    history
1755}
1756
1757fn load_point_char(value: f64) -> char {
1758    let clamped = value.clamp(0.0, 1.0);
1759    match (clamped * 5.0).round() as usize {
1760        0 => ' ',
1761        1 => '.',
1762        2 => ':',
1763        3 => '=',
1764        4 => '#',
1765        _ => '@',
1766    }
1767}
1768
1769/// Validate team config without launching.
1770pub fn validate_team(project_root: &Path, verbose: bool) -> Result<()> {
1771    let config_path = team_config_path(project_root);
1772    if !config_path.exists() {
1773        bail!("no team config found at {}", config_path.display());
1774    }
1775
1776    let team_config = config::TeamConfig::load(&config_path)?;
1777
1778    if verbose {
1779        let checks = team_config.validate_verbose();
1780        let mut any_failed = false;
1781        for check in &checks {
1782            let status = if check.passed { "PASS" } else { "FAIL" };
1783            println!("[{status}] {}: {}", check.name, check.detail);
1784            if !check.passed {
1785                any_failed = true;
1786            }
1787        }
1788        if any_failed {
1789            bail!("validation failed — see FAIL checks above");
1790        }
1791    } else {
1792        team_config.validate()?;
1793    }
1794
1795    let workflow_mode_is_explicit = workflow_mode_declared(&config_path)?;
1796
1797    let members = hierarchy::resolve_hierarchy(&team_config)?;
1798
1799    println!("Config: {}", config_path.display());
1800    println!("Team: {}", team_config.name);
1801    println!(
1802        "Workflow mode: {}",
1803        match team_config.workflow_mode {
1804            config::WorkflowMode::Legacy => "legacy",
1805            config::WorkflowMode::Hybrid => "hybrid",
1806            config::WorkflowMode::WorkflowFirst => "workflow_first",
1807        }
1808    );
1809    println!("Roles: {}", team_config.roles.len());
1810    println!("Total members: {}", members.len());
1811    for note in migration_validation_notes(&team_config, workflow_mode_is_explicit) {
1812        println!("{note}");
1813    }
1814    println!("Valid.");
1815    Ok(())
1816}
1817
1818/// Resolve a member instance name (e.g. "eng-1-2") to its role definition name
1819/// (e.g. "engineer"). Returns the name itself if no config is available.
1820fn resolve_role_name(project_root: &Path, member_name: &str) -> String {
1821    // "human" is not a member instance — it's the CLI user
1822    if matches!(member_name, "human" | "daemon") {
1823        return member_name.to_string();
1824    }
1825    let config_path = team_config_path(project_root);
1826    if let Ok(team_config) = config::TeamConfig::load(&config_path) {
1827        if let Ok(members) = hierarchy::resolve_hierarchy(&team_config) {
1828            if let Some(m) = members.iter().find(|m| m.name == member_name) {
1829                return m.role_name.clone();
1830            }
1831        }
1832    }
1833    // Fallback: the name might already be a role name
1834    member_name.to_string()
1835}
1836
1837/// Resolve a caller-facing role/member name to a concrete member instance.
1838///
1839/// Examples:
1840/// - exact member names pass through unchanged (`sam-designer-1-1`)
1841/// - unique role aliases resolve to their single member instance (`sam-designer`)
1842/// - ambiguous aliases error and require an explicit member name
1843fn resolve_member_name(project_root: &Path, member_name: &str) -> Result<String> {
1844    if matches!(member_name, "human" | "daemon") {
1845        return Ok(member_name.to_string());
1846    }
1847
1848    let config_path = team_config_path(project_root);
1849    if let Ok(team_config) = config::TeamConfig::load(&config_path) {
1850        if let Ok(members) = hierarchy::resolve_hierarchy(&team_config) {
1851            if let Some(member) = members.iter().find(|m| m.name == member_name) {
1852                return Ok(member.name.clone());
1853            }
1854
1855            let matches: Vec<String> = members
1856                .iter()
1857                .filter(|m| m.role_name == member_name)
1858                .map(|m| m.name.clone())
1859                .collect();
1860
1861            return match matches.len() {
1862                0 => Ok(member_name.to_string()),
1863                1 => Ok(matches[0].clone()),
1864                _ => bail!(
1865                    "'{member_name}' matches multiple members: {}. Use the explicit member name.",
1866                    matches.join(", ")
1867                ),
1868            };
1869        }
1870    }
1871
1872    Ok(member_name.to_string())
1873}
1874
1875/// Send a message to a role via their Maildir inbox.
1876///
1877/// The sender is auto-detected from the `@batty_role` tmux pane option
1878/// (set during layout). Falls back to "human" if not in a batty pane.
1879/// Enforces communication routing rules from team config.
1880pub fn send_message(project_root: &Path, role: &str, msg: &str) -> Result<()> {
1881    let from = detect_sender().unwrap_or_else(|| "human".to_string());
1882    let recipient = resolve_member_name(project_root, role)?;
1883
1884    // Enforce routing: check talks_to rules
1885    let config_path = team_config_path(project_root);
1886    if config_path.exists() {
1887        if let Ok(team_config) = config::TeamConfig::load(&config_path) {
1888            let from_role = resolve_role_name(project_root, &from);
1889            let to_role = resolve_role_name(project_root, &recipient);
1890            if !team_config.can_talk(&from_role, &to_role) {
1891                bail!(
1892                    "{from} ({from_role}) is not allowed to message {recipient} ({to_role}). \
1893                     Check talks_to in team.yaml."
1894                );
1895            }
1896        }
1897    }
1898
1899    let root = inbox::inboxes_root(project_root);
1900    let inbox_msg = inbox::InboxMessage::new_send(&from, &recipient, msg);
1901    let id = inbox::deliver_to_inbox(&root, &inbox_msg)?;
1902    if let Err(error) = completion::ingest_completion_message(project_root, msg) {
1903        warn!(from, to = %recipient, error = %error, "failed to ingest completion packet");
1904    }
1905    info!(to = %recipient, id = %id, "message delivered to inbox");
1906    Ok(())
1907}
1908
1909/// Detect who is calling `batty send` by reading the `@batty_role` option
1910/// from the current tmux pane.
1911fn detect_sender() -> Option<String> {
1912    let pane_id = std::env::var("TMUX_PANE").ok()?;
1913    let output = std::process::Command::new("tmux")
1914        .args(["show-options", "-p", "-t", &pane_id, "-v", "@batty_role"])
1915        .output()
1916        .ok()?;
1917    if output.status.success() {
1918        let role = String::from_utf8_lossy(&output.stdout).trim().to_string();
1919        if !role.is_empty() { Some(role) } else { None }
1920    } else {
1921        None
1922    }
1923}
1924
1925/// Assign a task to an engineer via their Maildir inbox.
1926pub fn assign_task(project_root: &Path, engineer: &str, task: &str) -> Result<String> {
1927    let from = detect_sender().unwrap_or_else(|| "human".to_string());
1928    let recipient = resolve_member_name(project_root, engineer)?;
1929
1930    let config_path = team_config_path(project_root);
1931    if config_path.exists() {
1932        if let Ok(team_config) = config::TeamConfig::load(&config_path) {
1933            let from_role = resolve_role_name(project_root, &from);
1934            let to_role = resolve_role_name(project_root, &recipient);
1935            if !team_config.can_talk(&from_role, &to_role) {
1936                bail!(
1937                    "{from} ({from_role}) is not allowed to assign {recipient} ({to_role}). \
1938                     Check talks_to in team.yaml."
1939                );
1940            }
1941        }
1942    }
1943
1944    let root = inbox::inboxes_root(project_root);
1945    let msg = inbox::InboxMessage::new_assign(&from, &recipient, task);
1946    let id = inbox::deliver_to_inbox(&root, &msg)?;
1947    info!(from, engineer = %recipient, task, id = %id, "assignment delivered to inbox");
1948    Ok(id)
1949}
1950
1951/// List inbox messages for a member.
1952pub fn list_inbox(project_root: &Path, member: &str, limit: Option<usize>) -> Result<()> {
1953    let member = resolve_member_name(project_root, member)?;
1954    let root = inbox::inboxes_root(project_root);
1955    let messages = inbox::all_messages(&root, &member)?;
1956    print!("{}", format_inbox_listing(&member, &messages, limit));
1957    Ok(())
1958}
1959
1960fn format_inbox_listing(
1961    member: &str,
1962    messages: &[(inbox::InboxMessage, bool)],
1963    limit: Option<usize>,
1964) -> String {
1965    if messages.is_empty() {
1966        return format!("No messages for {member}.\n");
1967    }
1968
1969    let start = match limit {
1970        Some(0) => messages.len(),
1971        Some(n) => messages.len().saturating_sub(n),
1972        None => 0,
1973    };
1974    let shown = &messages[start..];
1975    let refs = inbox_message_refs(messages);
1976    let shown_refs = &refs[start..];
1977
1978    let mut out = String::new();
1979    if shown.len() < messages.len() {
1980        out.push_str(&format!(
1981            "Showing {} of {} messages for {member}. Use `-n <N>` or `--all` to see more.\n",
1982            shown.len(),
1983            messages.len()
1984        ));
1985    }
1986    out.push_str(&format!(
1987        "{:<10} {:<12} {:<12} {:<14} BODY\n",
1988        "STATUS", "FROM", "TYPE", "REF"
1989    ));
1990    out.push_str(&format!("{}\n", "-".repeat(96)));
1991    for ((msg, delivered), msg_ref) in shown.iter().zip(shown_refs.iter()) {
1992        let status = if *delivered { "delivered" } else { "pending" };
1993        let body_short = truncate_chars(&msg.body, INBOX_BODY_PREVIEW_CHARS);
1994        out.push_str(&format!(
1995            "{:<10} {:<12} {:<12} {:<14} {}\n",
1996            status,
1997            msg.from,
1998            format!("{:?}", msg.msg_type).to_lowercase(),
1999            msg_ref,
2000            body_short,
2001        ));
2002    }
2003    out
2004}
2005
2006fn inbox_message_refs(messages: &[(inbox::InboxMessage, bool)]) -> Vec<String> {
2007    let mut totals = HashMap::new();
2008    for (msg, _) in messages {
2009        *totals.entry(msg.timestamp).or_insert(0usize) += 1;
2010    }
2011
2012    let mut seen = HashMap::new();
2013    messages
2014        .iter()
2015        .map(|(msg, _)| {
2016            let ordinal = seen.entry(msg.timestamp).or_insert(0usize);
2017            *ordinal += 1;
2018            if totals.get(&msg.timestamp).copied().unwrap_or(0) <= 1 {
2019                msg.timestamp.to_string()
2020            } else {
2021                format!("{}-{}", msg.timestamp, ordinal)
2022            }
2023        })
2024        .collect()
2025}
2026
2027fn resolve_inbox_message_indices(
2028    messages: &[(inbox::InboxMessage, bool)],
2029    selector: &str,
2030) -> Vec<usize> {
2031    let refs = inbox_message_refs(messages);
2032    messages
2033        .iter()
2034        .enumerate()
2035        .filter_map(|(idx, (msg, _))| {
2036            if msg.id == selector || msg.id.starts_with(selector) || refs[idx] == selector {
2037                Some(idx)
2038            } else {
2039                None
2040            }
2041        })
2042        .collect()
2043}
2044
2045fn truncate_chars(input: &str, max_chars: usize) -> String {
2046    if input.chars().count() <= max_chars {
2047        return input.to_string();
2048    }
2049    let mut truncated: String = input.chars().take(max_chars).collect();
2050    truncated.push_str("...");
2051    truncated
2052}
2053
2054/// Read a specific message from a member's inbox by ID, ID prefix, or REF.
2055pub fn read_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
2056    let member = resolve_member_name(project_root, member)?;
2057    let root = inbox::inboxes_root(project_root);
2058    let messages = inbox::all_messages(&root, &member)?;
2059
2060    let matching = resolve_inbox_message_indices(&messages, id);
2061
2062    match matching.len() {
2063        0 => bail!("no message matching '{id}' in {member}'s inbox"),
2064        1 => {
2065            let (msg, delivered) = &messages[matching[0]];
2066            let status = if *delivered { "delivered" } else { "pending" };
2067            println!("ID:     {}", msg.id);
2068            println!("From:   {}", msg.from);
2069            println!("To:     {}", msg.to);
2070            println!("Type:   {:?}", msg.msg_type);
2071            println!("Status: {status}");
2072            println!("Time:   {}", msg.timestamp);
2073            println!();
2074            println!("{}", msg.body);
2075        }
2076        n => {
2077            bail!(
2078                "'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
2079            );
2080        }
2081    }
2082
2083    Ok(())
2084}
2085
2086/// Acknowledge (mark delivered) a message in a member's inbox by ID, prefix, or REF.
2087pub fn ack_message(project_root: &Path, member: &str, id: &str) -> Result<()> {
2088    let member = resolve_member_name(project_root, member)?;
2089    let root = inbox::inboxes_root(project_root);
2090    let messages = inbox::all_messages(&root, &member)?;
2091    let matching = resolve_inbox_message_indices(&messages, id);
2092    let resolved_id = match matching.len() {
2093        0 => bail!("no message matching '{id}' in {member}'s inbox"),
2094        1 => messages[matching[0]].0.id.clone(),
2095        n => bail!(
2096            "'{id}' matches {n} messages — use a longer prefix or the REF column from `batty inbox`"
2097        ),
2098    };
2099    inbox::mark_delivered(&root, &member, &resolved_id)?;
2100    info!(member, id = %resolved_id, "message acknowledged");
2101    Ok(())
2102}
2103
2104/// Purge delivered messages from one inbox or all inboxes.
2105pub fn purge_inbox(
2106    project_root: &Path,
2107    member: Option<&str>,
2108    all_roles: bool,
2109    before: Option<u64>,
2110    purge_all: bool,
2111) -> Result<inbox::InboxPurgeSummary> {
2112    if !purge_all && before.is_none() {
2113        bail!("use `--all` or `--before <unix-timestamp>` with `batty inbox purge`");
2114    }
2115
2116    let root = inbox::inboxes_root(project_root);
2117    if all_roles {
2118        return inbox::purge_delivered_messages_for_all(&root, before, purge_all);
2119    }
2120
2121    let member = member.context("member is required unless using `--all-roles`")?;
2122    let member = resolve_member_name(project_root, member)?;
2123    let messages = inbox::purge_delivered_messages(&root, &member, before, purge_all)?;
2124    Ok(inbox::InboxPurgeSummary { roles: 1, messages })
2125}
2126
2127/// Merge an engineer's worktree branch.
2128pub fn merge_worktree(project_root: &Path, engineer: &str) -> Result<()> {
2129    let engineer = resolve_member_name(project_root, engineer)?;
2130    match merge::merge_engineer_branch(project_root, &engineer)? {
2131        merge::MergeOutcome::Success => Ok(()),
2132        merge::MergeOutcome::RebaseConflict(stderr) => {
2133            bail!("merge blocked by rebase conflict: {stderr}")
2134        }
2135        merge::MergeOutcome::MergeFailure(stderr) => bail!("merge failed: {stderr}"),
2136    }
2137}
2138
2139/// Run the interactive Telegram setup wizard.
2140pub fn setup_telegram(project_root: &Path) -> Result<()> {
2141    telegram::setup_telegram(project_root)
2142}
2143
2144#[cfg(test)]
2145mod tests {
2146    use super::status;
2147    use super::*;
2148    use crate::team::config::RoleType;
2149    use serial_test::serial;
2150    use std::ffi::OsString;
2151
2152    struct HomeGuard {
2153        original_home: Option<OsString>,
2154    }
2155
2156    impl HomeGuard {
2157        fn set(path: &Path) -> Self {
2158            let original_home = std::env::var_os("HOME");
2159            unsafe {
2160                std::env::set_var("HOME", path);
2161            }
2162            Self { original_home }
2163        }
2164    }
2165
2166    impl Drop for HomeGuard {
2167        fn drop(&mut self) {
2168            match &self.original_home {
2169                Some(home) => unsafe {
2170                    std::env::set_var("HOME", home);
2171                },
2172                None => unsafe {
2173                    std::env::remove_var("HOME");
2174                },
2175            }
2176        }
2177    }
2178
2179    struct EnvVarGuard {
2180        key: &'static str,
2181        original: Option<String>,
2182    }
2183
2184    impl EnvVarGuard {
2185        fn unset(key: &'static str) -> Self {
2186            let original = std::env::var(key).ok();
2187            unsafe {
2188                std::env::remove_var(key);
2189            }
2190            Self { key, original }
2191        }
2192    }
2193
2194    impl Drop for EnvVarGuard {
2195        fn drop(&mut self) {
2196            match self.original.as_deref() {
2197                Some(value) => unsafe {
2198                    std::env::set_var(self.key, value);
2199                },
2200                None => unsafe {
2201                    std::env::remove_var(self.key);
2202                },
2203            }
2204        }
2205    }
2206
2207    #[test]
2208    fn team_config_dir_is_under_batty() {
2209        let root = Path::new("/tmp/project");
2210        assert_eq!(
2211            team_config_dir(root),
2212            PathBuf::from("/tmp/project/.batty/team_config")
2213        );
2214    }
2215
2216    #[test]
2217    fn team_config_path_points_to_yaml() {
2218        let root = Path::new("/tmp/project");
2219        assert_eq!(
2220            team_config_path(root),
2221            PathBuf::from("/tmp/project/.batty/team_config/team.yaml")
2222        );
2223    }
2224
2225    #[test]
2226    fn init_team_creates_scaffolding() {
2227        let tmp = tempfile::tempdir().unwrap();
2228        let created = init_team(tmp.path(), "simple").unwrap();
2229        assert!(!created.is_empty());
2230        assert!(team_config_path(tmp.path()).exists());
2231        assert!(team_config_dir(tmp.path()).join("architect.md").exists());
2232        assert!(team_config_dir(tmp.path()).join("manager.md").exists());
2233        assert!(team_config_dir(tmp.path()).join("engineer.md").exists());
2234        assert!(
2235            team_config_dir(tmp.path())
2236                .join("replenishment_context.md")
2237                .exists()
2238        );
2239        assert!(
2240            team_config_dir(tmp.path())
2241                .join("review_policy.md")
2242                .exists()
2243        );
2244        assert!(
2245            team_config_dir(tmp.path())
2246                .join("escalation_policy.md")
2247                .exists()
2248        );
2249        // kanban-md creates board/ directory; fallback creates kanban.md
2250        let config = team_config_dir(tmp.path());
2251        assert!(config.join("board").is_dir() || config.join("kanban.md").exists());
2252    }
2253
2254    #[test]
2255    fn init_team_refuses_if_exists() {
2256        let tmp = tempfile::tempdir().unwrap();
2257        init_team(tmp.path(), "simple").unwrap();
2258        let result = init_team(tmp.path(), "simple");
2259        assert!(result.is_err());
2260        assert!(result.unwrap_err().to_string().contains("already exists"));
2261    }
2262
2263    #[test]
2264    #[serial]
2265    fn init_from_template_copies_files() {
2266        let project = tempfile::tempdir().unwrap();
2267        let home = tempfile::tempdir().unwrap();
2268        let _home_guard = HomeGuard::set(home.path());
2269
2270        let template_dir = home.path().join(".batty").join("templates").join("custom");
2271        std::fs::create_dir_all(template_dir.join("board")).unwrap();
2272        std::fs::write(template_dir.join("team.yaml"), "name: custom\nroles: []\n").unwrap();
2273        std::fs::write(template_dir.join("architect.md"), "# Architect\n").unwrap();
2274        std::fs::write(template_dir.join("board").join("task.md"), "task\n").unwrap();
2275
2276        let created = init_from_template(project.path(), "custom").unwrap();
2277
2278        assert!(!created.is_empty());
2279        assert_eq!(
2280            std::fs::read_to_string(team_config_path(project.path())).unwrap(),
2281            "name: custom\nroles: []\n"
2282        );
2283        assert!(
2284            team_config_dir(project.path())
2285                .join("architect.md")
2286                .exists()
2287        );
2288        assert!(
2289            team_config_dir(project.path())
2290                .join("board")
2291                .join("task.md")
2292                .exists()
2293        );
2294    }
2295
2296    #[test]
2297    #[serial]
2298    fn init_from_template_missing_template_errors_with_available_list() {
2299        let project = tempfile::tempdir().unwrap();
2300        let home = tempfile::tempdir().unwrap();
2301        let _home_guard = HomeGuard::set(home.path());
2302
2303        let templates_root = home.path().join(".batty").join("templates");
2304        std::fs::create_dir_all(templates_root.join("alpha")).unwrap();
2305        std::fs::create_dir_all(templates_root.join("beta")).unwrap();
2306
2307        let error = init_from_template(project.path(), "missing").unwrap_err();
2308        let message = error.to_string();
2309        assert!(message.contains("template 'missing' not found"));
2310        assert!(message.contains("alpha"));
2311        assert!(message.contains("beta"));
2312    }
2313
2314    #[test]
2315    #[serial]
2316    fn init_from_template_errors_when_templates_dir_is_missing() {
2317        let project = tempfile::tempdir().unwrap();
2318        let home = tempfile::tempdir().unwrap();
2319        let _home_guard = HomeGuard::set(home.path());
2320
2321        let error = init_from_template(project.path(), "missing").unwrap_err();
2322        assert!(error.to_string().contains("no templates directory found"));
2323    }
2324
2325    #[test]
2326    fn init_team_large_template() {
2327        let tmp = tempfile::tempdir().unwrap();
2328        let created = init_team(tmp.path(), "large").unwrap();
2329        assert!(!created.is_empty());
2330        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2331        assert!(content.contains("instances: 3") || content.contains("instances: 5"));
2332    }
2333
2334    #[test]
2335    fn init_team_solo_template() {
2336        let tmp = tempfile::tempdir().unwrap();
2337        let created = init_team(tmp.path(), "solo").unwrap();
2338        assert!(!created.is_empty());
2339        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2340        assert!(content.contains("role_type: engineer"));
2341        assert!(!content.contains("role_type: manager"));
2342    }
2343
2344    #[test]
2345    fn init_team_pair_template() {
2346        let tmp = tempfile::tempdir().unwrap();
2347        let created = init_team(tmp.path(), "pair").unwrap();
2348        assert!(!created.is_empty());
2349        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2350        assert!(content.contains("role_type: architect"));
2351        assert!(content.contains("role_type: engineer"));
2352        assert!(!content.contains("role_type: manager"));
2353    }
2354
2355    #[test]
2356    fn init_team_squad_template() {
2357        let tmp = tempfile::tempdir().unwrap();
2358        let created = init_team(tmp.path(), "squad").unwrap();
2359        assert!(!created.is_empty());
2360        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2361        assert!(content.contains("instances: 5"));
2362        assert!(content.contains("layout:"));
2363    }
2364
2365    #[test]
2366    fn init_team_research_template() {
2367        let tmp = tempfile::tempdir().unwrap();
2368        let created = init_team(tmp.path(), "research").unwrap();
2369        assert!(!created.is_empty());
2370        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2371        assert!(content.contains("principal"));
2372        assert!(content.contains("sub-lead"));
2373        assert!(content.contains("researcher"));
2374        // Research-specific .md files installed
2375        assert!(
2376            team_config_dir(tmp.path())
2377                .join("research_lead.md")
2378                .exists()
2379        );
2380        assert!(team_config_dir(tmp.path()).join("sub_lead.md").exists());
2381        assert!(team_config_dir(tmp.path()).join("researcher.md").exists());
2382        // Generic files NOT installed
2383        assert!(!team_config_dir(tmp.path()).join("architect.md").exists());
2384    }
2385
2386    #[test]
2387    fn init_team_software_template() {
2388        let tmp = tempfile::tempdir().unwrap();
2389        let created = init_team(tmp.path(), "software").unwrap();
2390        assert!(!created.is_empty());
2391        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2392        assert!(content.contains("tech-lead"));
2393        assert!(content.contains("backend-mgr"));
2394        assert!(content.contains("frontend-mgr"));
2395        assert!(content.contains("developer"));
2396        // Software-specific .md files installed
2397        assert!(team_config_dir(tmp.path()).join("tech_lead.md").exists());
2398        assert!(team_config_dir(tmp.path()).join("eng_manager.md").exists());
2399        assert!(team_config_dir(tmp.path()).join("developer.md").exists());
2400    }
2401
2402    #[test]
2403    fn init_team_batty_template() {
2404        let tmp = tempfile::tempdir().unwrap();
2405        let created = init_team(tmp.path(), "batty").unwrap();
2406        assert!(!created.is_empty());
2407        let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
2408        assert!(content.contains("batty-dev"));
2409        assert!(content.contains("role_type: architect"));
2410        assert!(content.contains("role_type: manager"));
2411        assert!(content.contains("instances: 4"));
2412        assert!(content.contains("batty_architect.md"));
2413        // Batty-specific .md files installed
2414        assert!(
2415            team_config_dir(tmp.path())
2416                .join("batty_architect.md")
2417                .exists()
2418        );
2419        assert!(
2420            team_config_dir(tmp.path())
2421                .join("batty_manager.md")
2422                .exists()
2423        );
2424        assert!(
2425            team_config_dir(tmp.path())
2426                .join("batty_engineer.md")
2427                .exists()
2428        );
2429        assert!(
2430            team_config_dir(tmp.path())
2431                .join("review_policy.md")
2432                .exists()
2433        );
2434    }
2435
2436    #[test]
2437    #[serial]
2438    fn export_template_creates_directory_and_copies_files() {
2439        let tmp = tempfile::tempdir().unwrap();
2440        let _home = HomeGuard::set(tmp.path());
2441        let project_root = tmp.path().join("project");
2442        let config_dir = team_config_dir(&project_root);
2443        std::fs::create_dir_all(&config_dir).unwrap();
2444        std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
2445        std::fs::write(config_dir.join("architect.md"), "architect prompt\n").unwrap();
2446
2447        let copied = export_template(&project_root, "demo-template").unwrap();
2448        let template_dir = templates_base_dir().unwrap().join("demo-template");
2449
2450        assert_eq!(copied, 2);
2451        assert_eq!(
2452            std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
2453            "name: demo\n"
2454        );
2455        assert_eq!(
2456            std::fs::read_to_string(template_dir.join("architect.md")).unwrap(),
2457            "architect prompt\n"
2458        );
2459    }
2460
2461    #[test]
2462    #[serial]
2463    fn export_template_overwrites_existing() {
2464        let tmp = tempfile::tempdir().unwrap();
2465        let _home = HomeGuard::set(tmp.path());
2466        let project_root = tmp.path().join("project");
2467        let config_dir = team_config_dir(&project_root);
2468        std::fs::create_dir_all(&config_dir).unwrap();
2469        std::fs::write(config_dir.join("team.yaml"), "name: first\n").unwrap();
2470        std::fs::write(config_dir.join("manager.md"), "v1\n").unwrap();
2471
2472        export_template(&project_root, "demo-template").unwrap();
2473
2474        std::fs::write(config_dir.join("team.yaml"), "name: second\n").unwrap();
2475        std::fs::write(config_dir.join("manager.md"), "v2\n").unwrap();
2476
2477        let copied = export_template(&project_root, "demo-template").unwrap();
2478        let template_dir = templates_base_dir().unwrap().join("demo-template");
2479
2480        assert_eq!(copied, 2);
2481        assert_eq!(
2482            std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
2483            "name: second\n"
2484        );
2485        assert_eq!(
2486            std::fs::read_to_string(template_dir.join("manager.md")).unwrap(),
2487            "v2\n"
2488        );
2489    }
2490
2491    #[test]
2492    #[serial]
2493    fn export_template_missing_team_yaml_errors() {
2494        let tmp = tempfile::tempdir().unwrap();
2495        let _home = HomeGuard::set(tmp.path());
2496        let project_root = tmp.path().join("project");
2497        std::fs::create_dir_all(team_config_dir(&project_root)).unwrap();
2498
2499        let error = export_template(&project_root, "demo-template").unwrap_err();
2500
2501        assert!(error.to_string().contains("team config missing"));
2502    }
2503
2504    #[test]
2505    fn export_run_copies_requested_run_state_only() {
2506        let tmp = tempfile::tempdir().unwrap();
2507        let project_root = tmp.path().join("project");
2508        let config_dir = team_config_dir(&project_root);
2509        let tasks_dir = config_dir.join("board").join("tasks");
2510        let retrospectives_dir = project_root.join(".batty").join("retrospectives");
2511        let worktree_dir = project_root
2512            .join(".batty")
2513            .join("worktrees")
2514            .join("eng-1-1")
2515            .join(".codex")
2516            .join("sessions");
2517        std::fs::create_dir_all(&tasks_dir).unwrap();
2518        std::fs::create_dir_all(&retrospectives_dir).unwrap();
2519        std::fs::create_dir_all(&worktree_dir).unwrap();
2520
2521        std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
2522        std::fs::write(tasks_dir.join("001-task.md"), "---\nid: 1\n---\n").unwrap();
2523        std::fs::write(
2524            team_events_path(&project_root),
2525            "{\"event\":\"daemon_started\"}\n",
2526        )
2527        .unwrap();
2528        std::fs::write(daemon_log_path(&project_root), "daemon-log\n").unwrap();
2529        std::fs::write(orchestrator_log_path(&project_root), "orchestrator-log\n").unwrap();
2530        std::fs::write(retrospectives_dir.join("retro.md"), "# Retro\n").unwrap();
2531        std::fs::write(
2532            project_root.join(".batty").join("test_timing.jsonl"),
2533            "{\"task_id\":1}\n",
2534        )
2535        .unwrap();
2536        std::fs::write(worktree_dir.join("session.jsonl"), "secret\n").unwrap();
2537
2538        let export_dir = export_run(&project_root).unwrap();
2539
2540        assert_eq!(
2541            std::fs::read_to_string(export_dir.join("team.yaml")).unwrap(),
2542            "name: demo\n"
2543        );
2544        assert_eq!(
2545            std::fs::read_to_string(export_dir.join("board").join("tasks").join("001-task.md"))
2546                .unwrap(),
2547            "---\nid: 1\n---\n"
2548        );
2549        assert_eq!(
2550            std::fs::read_to_string(export_dir.join("events.jsonl")).unwrap(),
2551            "{\"event\":\"daemon_started\"}\n"
2552        );
2553        assert_eq!(
2554            std::fs::read_to_string(export_dir.join("daemon.log")).unwrap(),
2555            "daemon-log\n"
2556        );
2557        assert_eq!(
2558            std::fs::read_to_string(export_dir.join("orchestrator.log")).unwrap(),
2559            "orchestrator-log\n"
2560        );
2561        assert_eq!(
2562            std::fs::read_to_string(export_dir.join("retrospectives").join("retro.md")).unwrap(),
2563            "# Retro\n"
2564        );
2565        assert_eq!(
2566            std::fs::read_to_string(export_dir.join("test_timing.jsonl")).unwrap(),
2567            "{\"task_id\":1}\n"
2568        );
2569        assert!(!export_dir.join("worktrees").exists());
2570        assert!(!export_dir.join(".codex").exists());
2571        assert!(!export_dir.join("sessions").exists());
2572    }
2573
2574    #[test]
2575    fn export_run_skips_missing_optional_paths() {
2576        let tmp = tempfile::tempdir().unwrap();
2577        let project_root = tmp.path().join("project");
2578        let config_dir = team_config_dir(&project_root);
2579        std::fs::create_dir_all(&config_dir).unwrap();
2580        std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
2581
2582        let export_dir = export_run(&project_root).unwrap();
2583
2584        assert!(export_dir.join("team.yaml").is_file());
2585        assert!(!export_dir.join("board").exists());
2586        assert!(!export_dir.join("events.jsonl").exists());
2587        assert!(!export_dir.join("daemon.log").exists());
2588        assert!(!export_dir.join("orchestrator.log").exists());
2589        assert!(!export_dir.join("retrospectives").exists());
2590        assert!(!export_dir.join("test_timing.jsonl").exists());
2591    }
2592
2593    #[test]
2594    fn export_run_missing_team_yaml_errors() {
2595        let tmp = tempfile::tempdir().unwrap();
2596        let project_root = tmp.path().join("project");
2597        std::fs::create_dir_all(team_config_dir(&project_root)).unwrap();
2598
2599        let error = export_run(&project_root).unwrap_err();
2600
2601        assert!(error.to_string().contains("team config missing"));
2602    }
2603
2604    #[test]
2605    fn nudge_disable_creates_marker_and_enable_removes_it() {
2606        let tmp = tempfile::tempdir().unwrap();
2607        std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
2608
2609        let marker = nudge_disabled_marker_path(tmp.path(), "triage");
2610        assert!(!marker.exists());
2611
2612        disable_nudge(tmp.path(), "triage").unwrap();
2613        assert!(marker.exists());
2614
2615        // Double-disable should fail
2616        assert!(disable_nudge(tmp.path(), "triage").is_err());
2617
2618        enable_nudge(tmp.path(), "triage").unwrap();
2619        assert!(!marker.exists());
2620
2621        // Double-enable should fail
2622        assert!(enable_nudge(tmp.path(), "triage").is_err());
2623    }
2624
2625    #[test]
2626    fn nudge_marker_path_uses_intervention_name() {
2627        let root = std::path::Path::new("/tmp/test-project");
2628        assert_eq!(
2629            nudge_disabled_marker_path(root, "replenish"),
2630            root.join(".batty").join("nudge_replenish_disabled")
2631        );
2632        assert_eq!(
2633            nudge_disabled_marker_path(root, "owned-task"),
2634            root.join(".batty").join("nudge_owned-task_disabled")
2635        );
2636    }
2637
2638    #[test]
2639    fn nudge_multiple_interventions_independent() {
2640        let tmp = tempfile::tempdir().unwrap();
2641        std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
2642
2643        disable_nudge(tmp.path(), "triage").unwrap();
2644        disable_nudge(tmp.path(), "review").unwrap();
2645
2646        assert!(nudge_disabled_marker_path(tmp.path(), "triage").exists());
2647        assert!(nudge_disabled_marker_path(tmp.path(), "review").exists());
2648        assert!(!nudge_disabled_marker_path(tmp.path(), "dispatch").exists());
2649
2650        enable_nudge(tmp.path(), "triage").unwrap();
2651        assert!(!nudge_disabled_marker_path(tmp.path(), "triage").exists());
2652        assert!(nudge_disabled_marker_path(tmp.path(), "review").exists());
2653    }
2654
2655    #[test]
2656    fn pause_creates_marker_and_resume_removes_it() {
2657        let tmp = tempfile::tempdir().unwrap();
2658        std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
2659
2660        assert!(!pause_marker_path(tmp.path()).exists());
2661        pause_team(tmp.path()).unwrap();
2662        assert!(pause_marker_path(tmp.path()).exists());
2663
2664        // Double-pause should fail
2665        assert!(pause_team(tmp.path()).is_err());
2666
2667        resume_team(tmp.path()).unwrap();
2668        assert!(!pause_marker_path(tmp.path()).exists());
2669
2670        // Double-resume should fail
2671        assert!(resume_team(tmp.path()).is_err());
2672    }
2673
2674    #[test]
2675    fn daemon_state_probe_requests_resume_after_unclean_shutdown() {
2676        let tmp = tempfile::tempdir().unwrap();
2677        let path = daemon_state_path(tmp.path());
2678        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2679        std::fs::write(&path, r#"{"clean_shutdown":false}"#).unwrap();
2680
2681        assert!(should_resume_from_daemon_state(tmp.path()));
2682    }
2683
2684    #[test]
2685    fn daemon_state_probe_ignores_clean_shutdown() {
2686        let tmp = tempfile::tempdir().unwrap();
2687        let path = daemon_state_path(tmp.path());
2688        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2689        std::fs::write(&path, r#"{"clean_shutdown":true}"#).unwrap();
2690
2691        assert!(!should_resume_from_daemon_state(tmp.path()));
2692    }
2693
2694    #[cfg(unix)]
2695    fn write_daemon_script(script_path: &Path, body: &str) {
2696        std::fs::write(script_path, body).unwrap();
2697        use std::os::unix::fs::PermissionsExt;
2698        std::fs::set_permissions(script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
2699    }
2700
2701    #[cfg(unix)]
2702    #[test]
2703    #[serial]
2704    fn graceful_daemon_shutdown_waits_for_clean_snapshot() {
2705        let tmp = tempfile::tempdir().unwrap();
2706        let state_path = daemon_state_path(tmp.path());
2707        let state_dir = state_path.parent().unwrap();
2708        std::fs::create_dir_all(state_dir).unwrap();
2709        std::fs::write(&state_path, r#"{"clean_shutdown":false,"saved_at":1}"#).unwrap();
2710
2711        let state_path_for_thread = state_path.clone();
2712        let state_dir_for_thread = state_dir.to_path_buf();
2713        let writer = std::thread::spawn(move || {
2714            std::thread::sleep(Duration::from_millis(200));
2715            std::fs::create_dir_all(&state_dir_for_thread).unwrap();
2716            std::fs::write(
2717                &state_path_for_thread,
2718                r#"{"clean_shutdown":true,"saved_at":2}"#,
2719            )
2720            .unwrap();
2721        });
2722
2723        assert!(wait_for_graceful_daemon_shutdown(
2724            tmp.path(),
2725            std::process::id(),
2726            Some(1),
2727            Duration::from_secs(2)
2728        ));
2729
2730        writer.join().unwrap();
2731        assert!(daemon_state_indicates_clean_shutdown(tmp.path(), Some(1)));
2732    }
2733
2734    #[cfg(unix)]
2735    #[test]
2736    #[serial]
2737    fn graceful_daemon_shutdown_times_out_before_force_kill_fallback() {
2738        let tmp = tempfile::tempdir().unwrap();
2739        let script_path = tmp.path().join("stubborn-daemon.sh");
2740        write_daemon_script(
2741            &script_path,
2742            "#!/bin/sh\ntrap '' TERM\nwhile :; do :; done\n",
2743        );
2744
2745        let mut child = std::process::Command::new(&script_path).spawn().unwrap();
2746        std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
2747        std::fs::write(daemon_pid_path(tmp.path()), child.id().to_string()).unwrap();
2748        std::thread::sleep(Duration::from_millis(200));
2749
2750        assert!(!request_graceful_daemon_shutdown(
2751            tmp.path(),
2752            Duration::from_millis(300)
2753        ));
2754        assert!(daemon_process_exists(child.id()));
2755
2756        force_kill_daemon(tmp.path());
2757        let _ = child.wait().unwrap();
2758        assert!(!daemon_pid_path(tmp.path()).exists());
2759    }
2760
2761    #[test]
2762    fn test_rotate_log_shifts_files() {
2763        let tmp = tempfile::tempdir().unwrap();
2764        let log_path = daemon_log_path(tmp.path());
2765        std::fs::create_dir_all(log_path.parent().unwrap()).unwrap();
2766        std::fs::write(&log_path, b"current").unwrap();
2767        std::fs::write(rotated_log_path(&log_path, 1), b"older-1").unwrap();
2768        std::fs::write(rotated_log_path(&log_path, 2), b"older-2").unwrap();
2769        std::fs::OpenOptions::new()
2770            .write(true)
2771            .open(&log_path)
2772            .unwrap()
2773            .set_len(LOG_ROTATION_BYTES + 1)
2774            .unwrap();
2775
2776        rotate_log_if_needed(&log_path).unwrap();
2777
2778        assert!(!log_path.exists());
2779        assert_eq!(
2780            std::fs::read(rotated_log_path(&log_path, 1)).unwrap().len() as u64,
2781            LOG_ROTATION_BYTES + 1
2782        );
2783        assert_eq!(
2784            std::fs::read_to_string(rotated_log_path(&log_path, 2)).unwrap(),
2785            "older-1"
2786        );
2787        assert_eq!(
2788            std::fs::read_to_string(rotated_log_path(&log_path, 3)).unwrap(),
2789            "older-2"
2790        );
2791    }
2792
2793    #[test]
2794    fn test_rotate_log_keeps_max_3() {
2795        let tmp = tempfile::tempdir().unwrap();
2796        let log_path = orchestrator_log_path(tmp.path());
2797        std::fs::create_dir_all(log_path.parent().unwrap()).unwrap();
2798        std::fs::write(&log_path, b"current").unwrap();
2799        std::fs::write(rotated_log_path(&log_path, 1), b"older-1").unwrap();
2800        std::fs::write(rotated_log_path(&log_path, 2), b"older-2").unwrap();
2801        std::fs::write(rotated_log_path(&log_path, 3), b"older-3").unwrap();
2802        std::fs::OpenOptions::new()
2803            .write(true)
2804            .open(&log_path)
2805            .unwrap()
2806            .set_len(LOG_ROTATION_BYTES + 1)
2807            .unwrap();
2808
2809        rotate_log_if_needed(&log_path).unwrap();
2810
2811        assert_eq!(
2812            std::fs::read(rotated_log_path(&log_path, 1)).unwrap().len() as u64,
2813            LOG_ROTATION_BYTES + 1
2814        );
2815        assert_eq!(
2816            std::fs::read_to_string(rotated_log_path(&log_path, 2)).unwrap(),
2817            "older-1"
2818        );
2819        assert_eq!(
2820            std::fs::read_to_string(rotated_log_path(&log_path, 3)).unwrap(),
2821            "older-2"
2822        );
2823        assert!(!rotated_log_path(&log_path, 4).exists());
2824    }
2825
2826    #[test]
2827    fn test_rotate_log_noop_under_threshold() {
2828        let tmp = tempfile::tempdir().unwrap();
2829        let log_path = daemon_log_path(tmp.path());
2830        std::fs::create_dir_all(log_path.parent().unwrap()).unwrap();
2831        std::fs::write(&log_path, b"small-log").unwrap();
2832
2833        rotate_log_if_needed(&log_path).unwrap();
2834
2835        assert_eq!(std::fs::read_to_string(&log_path).unwrap(), "small-log");
2836        assert!(!rotated_log_path(&log_path, 1).exists());
2837    }
2838
2839    #[test]
2840    fn test_daemon_log_append_mode() {
2841        let tmp = tempfile::tempdir().unwrap();
2842        let log_path = daemon_log_path(tmp.path());
2843
2844        {
2845            let mut file = open_log_for_append(&log_path).unwrap();
2846            use std::io::Write;
2847            writeln!(file, "first").unwrap();
2848        }
2849
2850        {
2851            let mut file = open_log_for_append(&log_path).unwrap();
2852            use std::io::Write;
2853            writeln!(file, "second").unwrap();
2854        }
2855
2856        assert_eq!(
2857            std::fs::read_to_string(&log_path).unwrap(),
2858            "first\nsecond\n"
2859        );
2860    }
2861
2862    #[test]
2863    fn daemon_spawn_args_include_verbose_and_resume() {
2864        assert_eq!(
2865            daemon_spawn_args("/tmp/project", false),
2866            vec![
2867                "-v".to_string(),
2868                "daemon".to_string(),
2869                "--project-root".to_string(),
2870                "/tmp/project".to_string()
2871            ]
2872        );
2873        assert_eq!(
2874            daemon_spawn_args("/tmp/project", true),
2875            vec![
2876                "-v".to_string(),
2877                "daemon".to_string(),
2878                "--project-root".to_string(),
2879                "/tmp/project".to_string(),
2880                "--resume".to_string()
2881            ]
2882        );
2883    }
2884
2885    #[test]
2886    fn send_message_delivers_to_inbox() {
2887        let tmp = tempfile::tempdir().unwrap();
2888        send_message(tmp.path(), "architect", "hello").unwrap();
2889
2890        let root = inbox::inboxes_root(tmp.path());
2891        let pending = inbox::pending_messages(&root, "architect").unwrap();
2892        assert_eq!(pending.len(), 1);
2893        // detect_sender() returns the tmux pane role if running inside a batty
2894        // session, or "human" otherwise. Accept either.
2895        let expected_from = detect_sender().unwrap_or_else(|| "human".to_string());
2896        assert_eq!(pending[0].from, expected_from);
2897        assert_eq!(pending[0].to, "architect");
2898        assert_eq!(pending[0].body, "hello");
2899    }
2900
2901    #[test]
2902    fn send_message_ingests_completion_packet_into_workflow_metadata() {
2903        let tmp = tempfile::tempdir().unwrap();
2904        let tasks_dir = team_config_dir(tmp.path()).join("board").join("tasks");
2905        std::fs::create_dir_all(&tasks_dir).unwrap();
2906        let task_path = tasks_dir.join("027-completion-packets.md");
2907        std::fs::write(
2908            &task_path,
2909            "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: human\nclass: standard\n---\n\nTask body.\n",
2910        )
2911        .unwrap();
2912
2913        send_message(
2914            tmp.path(),
2915            "architect",
2916            r#"Done.
2917
2918## Completion Packet
2919
2920```json
2921{"task_id":27,"branch":"eng-1-4/task-27","worktree_path":".batty/worktrees/eng-1-4","commit":"abc1234","changed_paths":["src/team/completion.rs"],"tests_run":true,"tests_passed":true,"artifacts":["docs/workflow.md"],"outcome":"ready_for_review"}
2922```"#,
2923        )
2924        .unwrap();
2925
2926        let metadata = board::read_workflow_metadata(&task_path).unwrap();
2927        assert_eq!(metadata.branch.as_deref(), Some("eng-1-4/task-27"));
2928        assert_eq!(
2929            metadata.worktree_path.as_deref(),
2930            Some(".batty/worktrees/eng-1-4")
2931        );
2932        assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
2933        assert_eq!(metadata.tests_run, Some(true));
2934        assert_eq!(metadata.tests_passed, Some(true));
2935        assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
2936        assert!(metadata.review_blockers.is_empty());
2937    }
2938
2939    #[test]
2940    fn assign_task_delivers_to_inbox() {
2941        let tmp = tempfile::tempdir().unwrap();
2942        let id = assign_task(tmp.path(), "eng-1-1", "fix bug").unwrap();
2943        assert!(!id.is_empty());
2944
2945        let root = inbox::inboxes_root(tmp.path());
2946        let pending = inbox::pending_messages(&root, "eng-1-1").unwrap();
2947        assert_eq!(pending.len(), 1);
2948        let expected_from = detect_sender().unwrap_or_else(|| "human".to_string());
2949        assert_eq!(pending[0].from, expected_from);
2950        assert_eq!(pending[0].to, "eng-1-1");
2951        assert_eq!(pending[0].body, "fix bug");
2952        assert_eq!(pending[0].msg_type, inbox::MessageType::Assign);
2953    }
2954
2955    fn write_team_config(project_root: &Path, yaml: &str) {
2956        std::fs::create_dir_all(team_config_dir(project_root)).unwrap();
2957        std::fs::write(team_config_path(project_root), yaml).unwrap();
2958    }
2959
2960    #[test]
2961    fn workflow_mode_declared_detects_absent_field() {
2962        let tmp = tempfile::tempdir().unwrap();
2963        write_team_config(
2964            tmp.path(),
2965            r#"
2966name: test
2967roles:
2968  - name: engineer
2969    role_type: engineer
2970    agent: codex
2971"#,
2972        );
2973
2974        assert!(!workflow_mode_declared(&team_config_path(tmp.path())).unwrap());
2975    }
2976
2977    #[test]
2978    fn workflow_mode_declared_detects_present_field() {
2979        let tmp = tempfile::tempdir().unwrap();
2980        write_team_config(
2981            tmp.path(),
2982            r#"
2983name: test
2984workflow_mode: hybrid
2985roles:
2986  - name: engineer
2987    role_type: engineer
2988    agent: codex
2989"#,
2990        );
2991
2992        assert!(workflow_mode_declared(&team_config_path(tmp.path())).unwrap());
2993    }
2994
2995    #[test]
2996    fn migration_validation_notes_explain_legacy_default_for_older_configs() {
2997        let config =
2998            config::TeamConfig::load(Path::new("src/team/templates/team_pair.yaml")).unwrap();
2999        let notes = migration_validation_notes(&config, false);
3000
3001        assert_eq!(notes.len(), 1);
3002        assert!(notes[0].contains("workflow_mode omitted"));
3003        assert!(notes[0].contains("run unchanged"));
3004    }
3005
3006    #[test]
3007    fn migration_validation_notes_warn_about_workflow_first_partial_rollout() {
3008        let config: config::TeamConfig = serde_yaml::from_str(
3009            r#"
3010name: test
3011workflow_mode: workflow_first
3012roles:
3013  - name: engineer
3014    role_type: engineer
3015    agent: codex
3016"#,
3017        )
3018        .unwrap();
3019        let notes = migration_validation_notes(&config, true);
3020
3021        assert_eq!(notes.len(), 1);
3022        assert!(notes[0].contains("workflow_first mode selected"));
3023        assert!(notes[0].contains("primary truth"));
3024    }
3025
3026    #[test]
3027    fn resolve_member_name_maps_unique_role_alias_to_instance() {
3028        let tmp = tempfile::tempdir().unwrap();
3029        write_team_config(
3030            tmp.path(),
3031            r#"
3032name: test
3033roles:
3034  - name: human
3035    role_type: user
3036    talks_to:
3037      - sam-designer
3038  - name: jordan-pm
3039    role_type: manager
3040    agent: claude
3041    instances: 1
3042  - name: sam-designer
3043    role_type: engineer
3044    agent: codex
3045    instances: 1
3046    talks_to:
3047      - jordan-pm
3048"#,
3049        );
3050
3051        assert_eq!(
3052            resolve_member_name(tmp.path(), "sam-designer").unwrap(),
3053            "sam-designer-1-1"
3054        );
3055        assert_eq!(
3056            resolve_member_name(tmp.path(), "sam-designer-1-1").unwrap(),
3057            "sam-designer-1-1"
3058        );
3059    }
3060
3061    #[test]
3062    fn resolve_member_name_rejects_ambiguous_role_alias() {
3063        let tmp = tempfile::tempdir().unwrap();
3064        write_team_config(
3065            tmp.path(),
3066            r#"
3067name: test
3068roles:
3069  - name: jordan-pm
3070    role_type: manager
3071    agent: claude
3072    instances: 2
3073  - name: sam-designer
3074    role_type: engineer
3075    agent: codex
3076    instances: 1
3077    talks_to:
3078      - jordan-pm
3079"#,
3080        );
3081
3082        let error = resolve_member_name(tmp.path(), "sam-designer")
3083            .unwrap_err()
3084            .to_string();
3085        assert!(error.contains("matches multiple members"));
3086        assert!(error.contains("sam-designer-1-1"));
3087        assert!(error.contains("sam-designer-2-1"));
3088    }
3089
3090    #[test]
3091    #[serial]
3092    fn send_message_delivers_to_unique_instance_inbox() {
3093        let tmp = tempfile::tempdir().unwrap();
3094        let _tmux_pane = EnvVarGuard::unset("TMUX_PANE");
3095        write_team_config(
3096            tmp.path(),
3097            r#"
3098name: test
3099roles:
3100  - name: human
3101    role_type: user
3102    talks_to:
3103      - sam-designer
3104  - name: jordan-pm
3105    role_type: manager
3106    agent: claude
3107    instances: 1
3108  - name: sam-designer
3109    role_type: engineer
3110    agent: codex
3111    instances: 1
3112    talks_to:
3113      - jordan-pm
3114"#,
3115        );
3116
3117        let original_tmux_pane = std::env::var_os("TMUX_PANE");
3118        unsafe {
3119            std::env::remove_var("TMUX_PANE");
3120        }
3121        let send_result = send_message(tmp.path(), "sam-designer", "hello");
3122        match original_tmux_pane {
3123            Some(value) => unsafe {
3124                std::env::set_var("TMUX_PANE", value);
3125            },
3126            None => unsafe {
3127                std::env::remove_var("TMUX_PANE");
3128            },
3129        }
3130        send_result.unwrap();
3131
3132        let root = inbox::inboxes_root(tmp.path());
3133        assert!(
3134            inbox::pending_messages(&root, "sam-designer")
3135                .unwrap()
3136                .is_empty()
3137        );
3138
3139        let pending = inbox::pending_messages(&root, "sam-designer-1-1").unwrap();
3140        assert_eq!(pending.len(), 1);
3141        assert_eq!(pending[0].to, "sam-designer-1-1");
3142        assert_eq!(pending[0].body, "hello");
3143    }
3144
3145    #[test]
3146    fn truncate_chars_handles_unicode_boundaries() {
3147        let body = "Task #109 confirmed complete on main. I’m available for next assignment.";
3148        let truncated = truncate_chars(body, 40);
3149        assert!(truncated.ends_with("..."));
3150        assert!(truncated.starts_with("Task #109 confirmed complete on main."));
3151    }
3152
3153    #[test]
3154    fn format_inbox_listing_shows_most_recent_messages_by_default_limit() {
3155        let messages: Vec<_> = (0..25)
3156            .map(|idx| {
3157                (
3158                    inbox::InboxMessage {
3159                        id: format!("msg{idx:05}"),
3160                        from: "architect".to_string(),
3161                        to: "black-lead".to_string(),
3162                        body: format!("message {idx}"),
3163                        msg_type: inbox::MessageType::Send,
3164                        timestamp: idx,
3165                    },
3166                    true,
3167                )
3168            })
3169            .collect();
3170
3171        let rendered = format_inbox_listing("black-lead", &messages, Some(20));
3172        assert!(rendered.contains("Showing 20 of 25 messages for black-lead."));
3173        assert!(!rendered.contains("message 0"));
3174        assert!(rendered.contains("message 5"));
3175        assert!(rendered.contains("message 24"));
3176        assert!(!rendered.contains("msg00005"));
3177        assert!(!rendered.contains("msg00024"));
3178    }
3179
3180    #[test]
3181    fn format_inbox_listing_allows_showing_all_messages() {
3182        let messages: Vec<_> = (0..3)
3183            .map(|idx| {
3184                (
3185                    inbox::InboxMessage {
3186                        id: format!("msg{idx:05}"),
3187                        from: "architect".to_string(),
3188                        to: "black-lead".to_string(),
3189                        body: format!("message {idx}"),
3190                        msg_type: inbox::MessageType::Send,
3191                        timestamp: idx,
3192                    },
3193                    idx % 2 == 0,
3194                )
3195            })
3196            .collect();
3197
3198        let rendered = format_inbox_listing("black-lead", &messages, None);
3199        assert!(!rendered.contains("Showing 20"));
3200        assert!(rendered.contains("REF"));
3201        assert!(rendered.contains("BODY"));
3202        assert!(rendered.contains("message 0"));
3203        assert!(rendered.contains("message 1"));
3204        assert!(rendered.contains("message 2"));
3205        assert!(!rendered.contains("msg00000"));
3206        assert!(!rendered.contains("msg00001"));
3207        assert!(!rendered.contains("msg00002"));
3208    }
3209
3210    #[test]
3211    fn format_inbox_listing_hides_internal_message_ids() {
3212        let messages = vec![(
3213            inbox::InboxMessage {
3214                id: "1773930387654321.M123456P7890Q42.example".to_string(),
3215                from: "architect".to_string(),
3216                to: "black-lead".to_string(),
3217                body: "message body".to_string(),
3218                msg_type: inbox::MessageType::Send,
3219                timestamp: 1_773_930_725,
3220            },
3221            true,
3222        )];
3223
3224        let rendered = format_inbox_listing("black-lead", &messages, None);
3225        assert!(rendered.contains("1773930725"));
3226        assert!(!rendered.contains("1773930387654321.M123456P7890Q42.example"));
3227        assert!(!rendered.contains("ID BODY"));
3228    }
3229
3230    #[test]
3231    fn inbox_message_refs_use_timestamp_when_unique() {
3232        let messages = vec![(
3233            inbox::InboxMessage {
3234                id: "msg-1".to_string(),
3235                from: "architect".to_string(),
3236                to: "black-lead".to_string(),
3237                body: "message body".to_string(),
3238                msg_type: inbox::MessageType::Send,
3239                timestamp: 1_773_930_725,
3240            },
3241            true,
3242        )];
3243
3244        let refs = inbox_message_refs(&messages);
3245        assert_eq!(refs, vec!["1773930725".to_string()]);
3246        assert_eq!(
3247            resolve_inbox_message_indices(&messages, "1773930725"),
3248            vec![0]
3249        );
3250    }
3251
3252    #[test]
3253    fn inbox_message_refs_suffix_same_second_collisions() {
3254        let messages = vec![
3255            (
3256                inbox::InboxMessage {
3257                    id: "msg-1".to_string(),
3258                    from: "architect".to_string(),
3259                    to: "black-lead".to_string(),
3260                    body: "first".to_string(),
3261                    msg_type: inbox::MessageType::Send,
3262                    timestamp: 1_773_930_725,
3263                },
3264                true,
3265            ),
3266            (
3267                inbox::InboxMessage {
3268                    id: "msg-2".to_string(),
3269                    from: "architect".to_string(),
3270                    to: "black-lead".to_string(),
3271                    body: "second".to_string(),
3272                    msg_type: inbox::MessageType::Send,
3273                    timestamp: 1_773_930_725,
3274                },
3275                true,
3276            ),
3277        ];
3278
3279        let refs = inbox_message_refs(&messages);
3280        assert_eq!(
3281            refs,
3282            vec!["1773930725-1".to_string(), "1773930725-2".to_string()]
3283        );
3284        assert!(resolve_inbox_message_indices(&messages, "1773930725").is_empty());
3285        assert_eq!(
3286            resolve_inbox_message_indices(&messages, "1773930725-1"),
3287            vec![0]
3288        );
3289        assert_eq!(
3290            resolve_inbox_message_indices(&messages, "1773930725-2"),
3291            vec![1]
3292        );
3293    }
3294
3295    #[test]
3296    fn assignment_result_round_trip_and_format() {
3297        let tmp = tempfile::tempdir().unwrap();
3298        let result = AssignmentDeliveryResult {
3299            message_id: "msg-1".to_string(),
3300            status: AssignmentResultStatus::Delivered,
3301            engineer: "eng-1-1".to_string(),
3302            task_summary: "Say Hello".to_string(),
3303            branch: Some("eng-1-1/task-1".to_string()),
3304            work_dir: Some("/tmp/worktree".to_string()),
3305            detail: "assignment launched".to_string(),
3306            ts: now_unix(),
3307        };
3308
3309        store_assignment_result(tmp.path(), &result).unwrap();
3310        let loaded = load_assignment_result(tmp.path(), "msg-1")
3311            .unwrap()
3312            .unwrap();
3313        assert_eq!(loaded, result);
3314
3315        let formatted = format_assignment_result(&loaded);
3316        assert!(formatted.contains("Assignment delivered: msg-1 -> eng-1-1"));
3317        assert!(formatted.contains("Branch: eng-1-1/task-1"));
3318        assert!(formatted.contains("Worktree: /tmp/worktree"));
3319    }
3320
3321    #[test]
3322    fn wait_for_assignment_result_returns_none_when_missing() {
3323        let tmp = tempfile::tempdir().unwrap();
3324        let result =
3325            wait_for_assignment_result(tmp.path(), "missing", Duration::from_millis(10)).unwrap();
3326        assert!(result.is_none());
3327    }
3328
3329    fn make_member(name: &str, role_name: &str, role_type: RoleType) -> hierarchy::MemberInstance {
3330        hierarchy::MemberInstance {
3331            name: name.to_string(),
3332            role_name: role_name.to_string(),
3333            role_type,
3334            agent: Some("codex".to_string()),
3335            prompt: None,
3336            reports_to: None,
3337            use_worktrees: false,
3338        }
3339    }
3340
3341    #[test]
3342    fn strip_tmux_style_removes_formatting_sequences() {
3343        let raw = "#[fg=yellow]idle#[default] #[fg=magenta]nudge 1:05#[default]";
3344        assert_eq!(status::strip_tmux_style(raw), "idle nudge 1:05");
3345    }
3346
3347    #[test]
3348    fn summarize_runtime_member_status_extracts_state_and_signal() {
3349        let summary = status::summarize_runtime_member_status(
3350            "#[fg=cyan]working#[default] #[fg=blue]standup 4:12#[default]",
3351            false,
3352        );
3353
3354        assert_eq!(summary.state, "working");
3355        assert_eq!(summary.signal.as_deref(), Some("standup"));
3356        assert_eq!(summary.label.as_deref(), Some("working standup 4:12"));
3357    }
3358
3359    #[test]
3360    fn summarize_runtime_member_status_marks_nudge_and_standup_together() {
3361        let summary = status::summarize_runtime_member_status(
3362            "#[fg=yellow]idle#[default] #[fg=magenta]nudge now#[default] #[fg=blue]standup 0:10#[default]",
3363            false,
3364        );
3365
3366        assert_eq!(summary.state, "idle");
3367        assert_eq!(
3368            summary.signal.as_deref(),
3369            Some("waiting for nudge, standup")
3370        );
3371    }
3372
3373    #[test]
3374    fn summarize_runtime_member_status_distinguishes_sent_nudge() {
3375        let summary = status::summarize_runtime_member_status(
3376            "#[fg=yellow]idle#[default] #[fg=magenta]nudge sent#[default]",
3377            false,
3378        );
3379
3380        assert_eq!(summary.state, "idle");
3381        assert_eq!(summary.signal.as_deref(), Some("nudged"));
3382        assert_eq!(summary.label.as_deref(), Some("idle nudge sent"));
3383    }
3384
3385    #[test]
3386    fn summarize_runtime_member_status_tracks_paused_automation() {
3387        let summary = status::summarize_runtime_member_status(
3388            "#[fg=cyan]working#[default] #[fg=244]nudge paused#[default] #[fg=244]standup paused#[default]",
3389            false,
3390        );
3391
3392        assert_eq!(summary.state, "working");
3393        assert_eq!(
3394            summary.signal.as_deref(),
3395            Some("nudge paused, standup paused")
3396        );
3397        assert_eq!(
3398            summary.label.as_deref(),
3399            Some("working nudge paused standup paused")
3400        );
3401    }
3402
3403    #[test]
3404    fn build_team_status_rows_defaults_by_session_state() {
3405        let architect = make_member("architect", "architect", RoleType::Architect);
3406        let human = hierarchy::MemberInstance {
3407            name: "human".to_string(),
3408            role_name: "human".to_string(),
3409            role_type: RoleType::User,
3410            agent: None,
3411            prompt: None,
3412            reports_to: None,
3413            use_worktrees: false,
3414        };
3415
3416        let pending = std::collections::HashMap::from([
3417            (architect.name.clone(), 3usize),
3418            (human.name.clone(), 1usize),
3419        ]);
3420        let triage = std::collections::HashMap::from([(architect.name.clone(), 2usize)]);
3421        let owned = std::collections::HashMap::from([(
3422            architect.name.clone(),
3423            status::OwnedTaskBuckets {
3424                active: vec![191u32],
3425                review: vec![193u32],
3426            },
3427        )]);
3428        let rows = status::build_team_status_rows(
3429            &[architect.clone(), human.clone()],
3430            false,
3431            &Default::default(),
3432            &pending,
3433            &triage,
3434            &owned,
3435            &Default::default(),
3436        );
3437        assert_eq!(rows[0].state, "stopped");
3438        assert_eq!(rows[0].pending_inbox, 3);
3439        assert_eq!(rows[0].triage_backlog, 2);
3440        assert_eq!(rows[0].active_owned_tasks, vec![191]);
3441        assert_eq!(rows[0].review_owned_tasks, vec![193]);
3442        assert_eq!(rows[0].health_summary, "-");
3443        assert_eq!(rows[1].state, "user");
3444        assert_eq!(rows[1].pending_inbox, 1);
3445        assert_eq!(rows[1].triage_backlog, 0);
3446        assert!(rows[1].active_owned_tasks.is_empty());
3447        assert!(rows[1].review_owned_tasks.is_empty());
3448
3449        let runtime = std::collections::HashMap::from([(
3450            architect.name.clone(),
3451            status::RuntimeMemberStatus {
3452                state: "idle".to_string(),
3453                signal: Some("standup".to_string()),
3454                label: Some("idle standup 2:00".to_string()),
3455            },
3456        )]);
3457        let rows = status::build_team_status_rows(
3458            &[architect],
3459            true,
3460            &runtime,
3461            &pending,
3462            &triage,
3463            &owned,
3464            &Default::default(),
3465        );
3466        assert_eq!(rows[0].state, "reviewing");
3467        assert_eq!(rows[0].pending_inbox, 3);
3468        assert_eq!(rows[0].triage_backlog, 2);
3469        assert_eq!(rows[0].active_owned_tasks, vec![191]);
3470        assert_eq!(rows[0].review_owned_tasks, vec![193]);
3471        assert_eq!(
3472            rows[0].signal.as_deref(),
3473            Some("standup, needs triage (2), needs review (1)")
3474        );
3475        assert_eq!(rows[0].runtime_label.as_deref(), Some("idle standup 2:00"));
3476    }
3477
3478    #[test]
3479    fn delivered_direct_report_triage_count_only_counts_results_newer_than_lead_response() {
3480        let tmp = tempfile::tempdir().unwrap();
3481        let root = inbox::inboxes_root(tmp.path());
3482        inbox::init_inbox(&root, "lead").unwrap();
3483        inbox::init_inbox(&root, "eng-1").unwrap();
3484        inbox::init_inbox(&root, "eng-2").unwrap();
3485
3486        let mut old_result = inbox::InboxMessage::new_send("eng-1", "lead", "old result");
3487        old_result.timestamp = 10;
3488        let old_result_id = inbox::deliver_to_inbox(&root, &old_result).unwrap();
3489        inbox::mark_delivered(&root, "lead", &old_result_id).unwrap();
3490
3491        let mut lead_reply = inbox::InboxMessage::new_send("lead", "eng-1", "next task");
3492        lead_reply.timestamp = 20;
3493        let lead_reply_id = inbox::deliver_to_inbox(&root, &lead_reply).unwrap();
3494        inbox::mark_delivered(&root, "eng-1", &lead_reply_id).unwrap();
3495
3496        let mut new_result = inbox::InboxMessage::new_send("eng-1", "lead", "new result");
3497        new_result.timestamp = 30;
3498        let new_result_id = inbox::deliver_to_inbox(&root, &new_result).unwrap();
3499        inbox::mark_delivered(&root, "lead", &new_result_id).unwrap();
3500
3501        let mut other_result = inbox::InboxMessage::new_send("eng-2", "lead", "parallel result");
3502        other_result.timestamp = 40;
3503        let other_result_id = inbox::deliver_to_inbox(&root, &other_result).unwrap();
3504        inbox::mark_delivered(&root, "lead", &other_result_id).unwrap();
3505
3506        let triage_state = status::delivered_direct_report_triage_state_at(
3507            &root,
3508            "lead",
3509            &["eng-1".to_string(), "eng-2".to_string()],
3510            100,
3511        )
3512        .unwrap();
3513        assert_eq!(triage_state.count, 2);
3514        assert_eq!(triage_state.newest_result_ts, 40);
3515    }
3516
3517    #[test]
3518    fn delivered_direct_report_triage_count_excludes_stale_delivered_results() {
3519        let tmp = tempfile::tempdir().unwrap();
3520        let root = inbox::inboxes_root(tmp.path());
3521        inbox::init_inbox(&root, "lead").unwrap();
3522        inbox::init_inbox(&root, "eng-1").unwrap();
3523
3524        let mut stale_result = inbox::InboxMessage::new_send("eng-1", "lead", "stale result");
3525        stale_result.timestamp = 10;
3526        let stale_result_id = inbox::deliver_to_inbox(&root, &stale_result).unwrap();
3527        inbox::mark_delivered(&root, "lead", &stale_result_id).unwrap();
3528
3529        let triage_state = status::delivered_direct_report_triage_state_at(
3530            &root,
3531            "lead",
3532            &["eng-1".to_string()],
3533            10 + TRIAGE_RESULT_FRESHNESS_SECONDS + 1,
3534        )
3535        .unwrap();
3536
3537        assert_eq!(triage_state.count, 0);
3538        assert_eq!(triage_state.newest_result_ts, 0);
3539    }
3540
3541    #[test]
3542    fn delivered_direct_report_triage_count_keeps_fresh_delivered_results() {
3543        let tmp = tempfile::tempdir().unwrap();
3544        let root = inbox::inboxes_root(tmp.path());
3545        inbox::init_inbox(&root, "lead").unwrap();
3546        inbox::init_inbox(&root, "eng-1").unwrap();
3547
3548        let mut fresh_result = inbox::InboxMessage::new_send("eng-1", "lead", "fresh result");
3549        fresh_result.timestamp = 100;
3550        let fresh_result_id = inbox::deliver_to_inbox(&root, &fresh_result).unwrap();
3551        inbox::mark_delivered(&root, "lead", &fresh_result_id).unwrap();
3552
3553        let triage_state = status::delivered_direct_report_triage_state_at(
3554            &root,
3555            "lead",
3556            &["eng-1".to_string()],
3557            150,
3558        )
3559        .unwrap();
3560
3561        assert_eq!(triage_state.count, 1);
3562        assert_eq!(triage_state.newest_result_ts, 100);
3563    }
3564
3565    #[test]
3566    fn delivered_direct_report_triage_count_excludes_acked_results() {
3567        let tmp = tempfile::tempdir().unwrap();
3568        let root = inbox::inboxes_root(tmp.path());
3569        inbox::init_inbox(&root, "lead").unwrap();
3570        inbox::init_inbox(&root, "eng-1").unwrap();
3571
3572        let mut result = inbox::InboxMessage::new_send("eng-1", "lead", "task complete");
3573        result.timestamp = 100;
3574        let result_id = inbox::deliver_to_inbox(&root, &result).unwrap();
3575        inbox::mark_delivered(&root, "lead", &result_id).unwrap();
3576
3577        let mut lead_reply = inbox::InboxMessage::new_send("lead", "eng-1", "acknowledged");
3578        lead_reply.timestamp = 110;
3579        let lead_reply_id = inbox::deliver_to_inbox(&root, &lead_reply).unwrap();
3580        inbox::mark_delivered(&root, "eng-1", &lead_reply_id).unwrap();
3581
3582        let triage_state = status::delivered_direct_report_triage_state_at(
3583            &root,
3584            "lead",
3585            &["eng-1".to_string()],
3586            150,
3587        )
3588        .unwrap();
3589
3590        assert_eq!(triage_state.count, 0);
3591        assert_eq!(triage_state.newest_result_ts, 0);
3592    }
3593
3594    #[test]
3595    fn counts_as_active_load_treats_triaging_as_working() {
3596        let triaging = status::TeamStatusRow {
3597            name: "lead".to_string(),
3598            role: "lead".to_string(),
3599            role_type: "Manager".to_string(),
3600            agent: Some("codex".to_string()),
3601            reports_to: Some("architect".to_string()),
3602            state: "triaging".to_string(),
3603            pending_inbox: 0,
3604            triage_backlog: 2,
3605            active_owned_tasks: vec![191],
3606            review_owned_tasks: vec![193],
3607            signal: Some("needs triage (2)".to_string()),
3608            runtime_label: Some("idle".to_string()),
3609            health: status::AgentHealthSummary::default(),
3610            health_summary: "-".to_string(),
3611            eta: "-".to_string(),
3612        };
3613        let reviewing = status::TeamStatusRow {
3614            state: "reviewing".to_string(),
3615            triage_backlog: 0,
3616            signal: Some("needs review (1)".to_string()),
3617            runtime_label: Some("idle".to_string()),
3618            ..triaging.clone()
3619        };
3620        let idle = status::TeamStatusRow {
3621            state: "idle".to_string(),
3622            triage_backlog: 0,
3623            signal: None,
3624            runtime_label: Some("idle".to_string()),
3625            ..triaging.clone()
3626        };
3627
3628        assert!(counts_as_active_load(&triaging));
3629        assert!(counts_as_active_load(&reviewing));
3630        assert!(!counts_as_active_load(&idle));
3631    }
3632
3633    #[test]
3634    fn format_owned_tasks_summary_compacts_multiple_ids() {
3635        assert_eq!(status::format_owned_tasks_summary(&[]), "-");
3636        assert_eq!(status::format_owned_tasks_summary(&[191]), "#191");
3637        assert_eq!(status::format_owned_tasks_summary(&[191, 192]), "#191,#192");
3638        assert_eq!(
3639            status::format_owned_tasks_summary(&[191, 192, 193]),
3640            "#191,#192,+1"
3641        );
3642    }
3643
3644    #[test]
3645    fn owned_task_buckets_split_active_and_review_claims() {
3646        let tmp = tempfile::tempdir().unwrap();
3647        let members = vec![
3648            make_member("lead", "lead", RoleType::Manager),
3649            hierarchy::MemberInstance {
3650                name: "eng-1".to_string(),
3651                role_name: "eng".to_string(),
3652                role_type: RoleType::Engineer,
3653                agent: Some("codex".to_string()),
3654                prompt: None,
3655                reports_to: Some("lead".to_string()),
3656                use_worktrees: false,
3657            },
3658        ];
3659        std::fs::create_dir_all(
3660            tmp.path()
3661                .join(".batty")
3662                .join("team_config")
3663                .join("board")
3664                .join("tasks"),
3665        )
3666        .unwrap();
3667        std::fs::write(
3668            tmp.path()
3669                .join(".batty")
3670                .join("team_config")
3671                .join("board")
3672                .join("tasks")
3673                .join("191-active.md"),
3674            "---\nid: 191\ntitle: Active\nstatus: in-progress\npriority: high\nclaimed_by: eng-1\nclass: standard\n---\n",
3675        )
3676        .unwrap();
3677        std::fs::write(
3678            tmp.path()
3679                .join(".batty")
3680                .join("team_config")
3681                .join("board")
3682                .join("tasks")
3683                .join("193-review.md"),
3684            "---\nid: 193\ntitle: Review\nstatus: review\npriority: high\nclaimed_by: eng-1\nclass: standard\n---\n",
3685        )
3686        .unwrap();
3687
3688        let owned = status::owned_task_buckets(tmp.path(), &members);
3689        let buckets = owned.get("eng-1").unwrap();
3690        assert_eq!(buckets.active, vec![191]);
3691        assert!(buckets.review.is_empty());
3692        let review_buckets = owned.get("lead").unwrap();
3693        assert!(review_buckets.active.is_empty());
3694        assert_eq!(review_buckets.review, vec![193]);
3695    }
3696
3697    #[test]
3698    fn workflow_metrics_enabled_detects_supported_modes() {
3699        let tmp = tempfile::tempdir().unwrap();
3700        let config_path = tmp.path().join("team.yaml");
3701
3702        std::fs::write(
3703            &config_path,
3704            "name: test\nworkflow_mode: hybrid\nroles: []\n",
3705        )
3706        .unwrap();
3707        assert!(status::workflow_metrics_enabled(&config_path));
3708
3709        std::fs::write(
3710            &config_path,
3711            "name: test\nworkflow_mode: workflow_first\nroles: []\n",
3712        )
3713        .unwrap();
3714        assert!(status::workflow_metrics_enabled(&config_path));
3715
3716        std::fs::write(&config_path, "name: test\nroles: []\n").unwrap();
3717        assert!(!status::workflow_metrics_enabled(&config_path));
3718    }
3719
3720    #[test]
3721    fn team_status_metrics_section_renders_when_workflow_mode_enabled() {
3722        let tmp = tempfile::tempdir().unwrap();
3723        let team_dir = tmp.path().join(".batty").join("team_config");
3724        let board_dir = team_dir.join("board");
3725        let tasks_dir = board_dir.join("tasks");
3726        std::fs::create_dir_all(&tasks_dir).unwrap();
3727        std::fs::write(
3728            team_dir.join("team.yaml"),
3729            "name: test\nworkflow_mode: hybrid\nroles:\n  - name: engineer\n    role_type: engineer\n    agent: codex\n",
3730        )
3731        .unwrap();
3732        std::fs::write(
3733            tasks_dir.join("031-runnable.md"),
3734            "---\nid: 31\ntitle: Runnable\nstatus: todo\npriority: medium\nclass: standard\n---\n\nTask body.\n",
3735        )
3736        .unwrap();
3737
3738        let members = vec![make_member("eng-1-1", "engineer", RoleType::Engineer)];
3739        let section = status::workflow_metrics_section(tmp.path(), &members).unwrap();
3740
3741        assert!(section.0.contains("Workflow Metrics"));
3742        assert_eq!(section.1.runnable_count, 1);
3743        assert_eq!(section.1.idle_with_runnable, vec!["eng-1-1"]);
3744    }
3745
3746    #[test]
3747    #[serial]
3748    #[cfg_attr(not(feature = "integration"), ignore)]
3749    fn list_runtime_member_statuses_reads_tmux_role_and_status_options() {
3750        let session = "batty-test-team-status-runtime";
3751        let _ = crate::tmux::kill_session(session);
3752
3753        crate::tmux::create_session(session, "sleep", &["20".to_string()], "/tmp").unwrap();
3754        let pane_id = crate::tmux::pane_id(session).unwrap();
3755
3756        let role_output = std::process::Command::new("tmux")
3757            .args(["set-option", "-p", "-t", &pane_id, "@batty_role", "eng-1"])
3758            .output()
3759            .unwrap();
3760        assert!(role_output.status.success());
3761
3762        let status_output = std::process::Command::new("tmux")
3763            .args([
3764                "set-option",
3765                "-p",
3766                "-t",
3767                &pane_id,
3768                "@batty_status",
3769                "#[fg=yellow]idle#[default] #[fg=magenta]nudge 0:30#[default]",
3770            ])
3771            .output()
3772            .unwrap();
3773        assert!(status_output.status.success());
3774
3775        let statuses = status::list_runtime_member_statuses(session).unwrap();
3776        let eng = statuses.get("eng-1").unwrap();
3777        assert_eq!(eng.state, "idle");
3778        assert_eq!(eng.signal.as_deref(), Some("waiting for nudge"));
3779        assert_eq!(eng.label.as_deref(), Some("idle nudge 0:30"));
3780
3781        crate::tmux::kill_session(session).unwrap();
3782    }
3783
3784    #[test]
3785    fn average_load_ignores_points_older_than_window() {
3786        let now = 10_000u64;
3787        let samples = vec![
3788            TeamLoadSnapshot {
3789                timestamp: now - 3_000,
3790                total_members: 10,
3791                working_members: 0,
3792                load: 0.8,
3793                session_running: true,
3794            },
3795            TeamLoadSnapshot {
3796                timestamp: now - 10,
3797                total_members: 10,
3798                working_members: 0,
3799                load: 0.4,
3800                session_running: true,
3801            },
3802            TeamLoadSnapshot {
3803                timestamp: now - 20,
3804                total_members: 10,
3805                working_members: 0,
3806                load: 0.6,
3807                session_running: true,
3808            },
3809        ];
3810
3811        let avg_60s = average_load(&samples, now, 60).unwrap();
3812        assert!((avg_60s - 0.5).abs() < 0.0001);
3813        assert!(average_load(&samples, now, 5).is_none());
3814    }
3815
3816    #[test]
3817    fn render_load_graph_returns_expected_width() {
3818        let now = 10_000u64;
3819        let samples = vec![
3820            TeamLoadSnapshot {
3821                timestamp: now - 3_600,
3822                total_members: 10,
3823                working_members: 2,
3824                load: 0.2,
3825                session_running: true,
3826            },
3827            TeamLoadSnapshot {
3828                timestamp: now - 1_800,
3829                total_members: 10,
3830                working_members: 5,
3831                load: 0.5,
3832                session_running: true,
3833            },
3834            TeamLoadSnapshot {
3835                timestamp: now - 900,
3836                total_members: 10,
3837                working_members: 10,
3838                load: 1.0,
3839                session_running: true,
3840            },
3841            TeamLoadSnapshot {
3842                timestamp: now - 600,
3843                total_members: 10,
3844                working_members: 0,
3845                load: 0.0,
3846                session_running: true,
3847            },
3848        ];
3849
3850        let graph = render_load_graph(&samples, now);
3851        assert_eq!(graph.len(), LOAD_GRAPH_WIDTH);
3852        assert!(graph.chars().all(|c| " .:=#@".contains(c)));
3853    }
3854
3855    /// Count unwrap()/expect() calls in production code (before `#[cfg(test)] mod tests`).
3856    fn production_unwrap_expect_count(source: &str) -> usize {
3857        // Split at the test module boundary, not individual #[cfg(test)] items
3858        let prod = if let Some(pos) = source.find("\n#[cfg(test)]\nmod tests") {
3859            &source[..pos]
3860        } else {
3861            source
3862        };
3863        prod.lines()
3864            .filter(|line| {
3865                let trimmed = line.trim();
3866                // Skip lines that are themselves cfg(test)-gated items
3867                !trimmed.starts_with("#[cfg(test)]")
3868                    && (trimmed.contains(".unwrap(") || trimmed.contains(".expect("))
3869            })
3870            .count()
3871    }
3872
3873    #[test]
3874    fn production_mod_has_no_unwrap_or_expect_calls() {
3875        let src = include_str!("mod.rs");
3876        assert_eq!(
3877            production_unwrap_expect_count(src),
3878            0,
3879            "production mod.rs should avoid unwrap/expect"
3880        );
3881    }
3882
3883    // --- Session summary tests ---
3884
3885    #[test]
3886    fn session_summary_counts_completions_correctly() {
3887        let tmp = tempfile::tempdir().unwrap();
3888        let events_dir = tmp.path().join(".batty").join("team_config");
3889        std::fs::create_dir_all(&events_dir).unwrap();
3890
3891        let now = now_unix();
3892        let events = [
3893            format!(r#"{{"event":"daemon_started","ts":{}}}"#, now - 3600),
3894            format!(
3895                r#"{{"event":"task_completed","role":"eng-1","task":"10","ts":{}}}"#,
3896                now - 3000
3897            ),
3898            format!(
3899                r#"{{"event":"task_completed","role":"eng-2","task":"11","ts":{}}}"#,
3900                now - 2000
3901            ),
3902            format!(
3903                r#"{{"event":"task_auto_merged","role":"eng-1","task":"10","ts":{}}}"#,
3904                now - 2900
3905            ),
3906            format!(
3907                r#"{{"event":"task_manual_merged","role":"eng-2","task":"11","ts":{}}}"#,
3908                now - 1900
3909            ),
3910            format!(
3911                r#"{{"event":"task_completed","role":"eng-1","task":"12","ts":{}}}"#,
3912                now - 1000
3913            ),
3914        ];
3915        std::fs::write(events_dir.join("events.jsonl"), events.join("\n")).unwrap();
3916
3917        let summary = compute_session_summary(tmp.path()).unwrap();
3918        assert_eq!(summary.tasks_completed, 3);
3919        assert_eq!(summary.tasks_merged, 2);
3920        assert!(summary.runtime_secs >= 3599 && summary.runtime_secs <= 3601);
3921    }
3922
3923    #[test]
3924    fn session_summary_calculates_runtime() {
3925        let tmp = tempfile::tempdir().unwrap();
3926        let events_dir = tmp.path().join(".batty").join("team_config");
3927        std::fs::create_dir_all(&events_dir).unwrap();
3928
3929        let now = now_unix();
3930        let events = [format!(
3931            r#"{{"event":"daemon_started","ts":{}}}"#,
3932            now - 7200
3933        )];
3934        std::fs::write(events_dir.join("events.jsonl"), events.join("\n")).unwrap();
3935
3936        let summary = compute_session_summary(tmp.path()).unwrap();
3937        assert_eq!(summary.tasks_completed, 0);
3938        assert_eq!(summary.tasks_merged, 0);
3939        assert!(summary.runtime_secs >= 7199 && summary.runtime_secs <= 7201);
3940    }
3941
3942    #[test]
3943    fn session_summary_handles_empty_session() {
3944        let tmp = tempfile::tempdir().unwrap();
3945        let events_dir = tmp.path().join(".batty").join("team_config");
3946        std::fs::create_dir_all(&events_dir).unwrap();
3947
3948        // No daemon_started event — summary returns None.
3949        std::fs::write(events_dir.join("events.jsonl"), "").unwrap();
3950        assert!(compute_session_summary(tmp.path()).is_none());
3951    }
3952
3953    #[test]
3954    fn session_summary_handles_missing_events_file() {
3955        let tmp = tempfile::tempdir().unwrap();
3956        // No events.jsonl at all.
3957        assert!(compute_session_summary(tmp.path()).is_none());
3958    }
3959
3960    #[test]
3961    fn session_summary_display_format() {
3962        let summary = SessionSummary {
3963            tasks_completed: 5,
3964            tasks_merged: 4,
3965            runtime_secs: 8100, // 2h 15m
3966        };
3967        assert_eq!(
3968            summary.display(),
3969            "Session summary: 5 tasks completed, 4 merged, runtime 2h 15m"
3970        );
3971    }
3972
3973    #[test]
3974    fn format_runtime_seconds() {
3975        assert_eq!(format_runtime(45), "45s");
3976    }
3977
3978    #[test]
3979    fn format_runtime_minutes() {
3980        assert_eq!(format_runtime(300), "5m");
3981    }
3982
3983    #[test]
3984    fn format_runtime_hours_and_minutes() {
3985        assert_eq!(format_runtime(5400), "1h 30m");
3986    }
3987
3988    #[test]
3989    fn format_runtime_exact_hours() {
3990        assert_eq!(format_runtime(7200), "2h");
3991    }
3992
3993    #[test]
3994    fn session_summary_uses_latest_daemon_started() {
3995        let tmp = tempfile::tempdir().unwrap();
3996        let events_dir = tmp.path().join(".batty").join("team_config");
3997        std::fs::create_dir_all(&events_dir).unwrap();
3998
3999        let now = now_unix();
4000        // First session had 2 completions, second session has 1.
4001        let events = [
4002            format!(r#"{{"event":"daemon_started","ts":{}}}"#, now - 7200),
4003            format!(
4004                r#"{{"event":"task_completed","role":"eng-1","task":"1","ts":{}}}"#,
4005                now - 6000
4006            ),
4007            format!(
4008                r#"{{"event":"task_completed","role":"eng-1","task":"2","ts":{}}}"#,
4009                now - 5000
4010            ),
4011            format!(r#"{{"event":"daemon_started","ts":{}}}"#, now - 1800),
4012            format!(
4013                r#"{{"event":"task_completed","role":"eng-1","task":"3","ts":{}}}"#,
4014                now - 1000
4015            ),
4016        ];
4017        std::fs::write(events_dir.join("events.jsonl"), events.join("\n")).unwrap();
4018
4019        let summary = compute_session_summary(tmp.path()).unwrap();
4020        // Should only count events from the latest daemon_started.
4021        assert_eq!(summary.tasks_completed, 1);
4022        assert!(summary.runtime_secs >= 1799 && summary.runtime_secs <= 1801);
4023    }
4024}