Skip to main content

batty_cli/team/
standup.rs

1//! Standup status gathering and delivery helpers.
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use tracing::warn;
10
11use super::config::{PlanningDirectiveFile, RoleType, TeamConfig, load_planning_directive};
12use super::hierarchy::MemberInstance;
13use super::metrics;
14use super::parity::ParityReport;
15use super::telegram::TelegramBot;
16use super::watcher::SessionWatcher;
17use super::{pause_marker_path, team_config_dir};
18use crate::task;
19
20const REVIEW_POLICY_MAX_CHARS: usize = 2_000;
21
22/// Generate a standup report for a specific recipient, showing only their
23/// direct reports.
24#[cfg_attr(not(test), allow(dead_code))]
25pub fn generate_standup_for(
26    recipient: &MemberInstance,
27    members: &[MemberInstance],
28    watchers: &HashMap<String, SessionWatcher>,
29    states: &HashMap<String, MemberState>,
30    output_lines: usize,
31) -> String {
32    generate_board_aware_standup_for(
33        recipient,
34        members,
35        watchers,
36        states,
37        output_lines,
38        None,
39        &HashMap::new(),
40    )
41}
42
43/// Generate a standup report for a specific recipient, optionally enriching the
44/// report with board-derived task ownership and workflow signals.
45pub fn generate_board_aware_standup_for(
46    recipient: &MemberInstance,
47    members: &[MemberInstance],
48    watchers: &HashMap<String, SessionWatcher>,
49    states: &HashMap<String, MemberState>,
50    output_lines: usize,
51    board_dir: Option<&Path>,
52    backend_health: &HashMap<String, crate::agent::BackendHealth>,
53) -> String {
54    let board_context = load_board_context(board_dir, members);
55    let mut report = String::new();
56    report.push_str(&format!("=== STANDUP for {} ===\n", recipient.name));
57
58    // Only include members who report to this recipient
59    let direct_reports: Vec<&MemberInstance> = members
60        .iter()
61        .filter(|m| m.reports_to.as_deref() == Some(&recipient.name))
62        .collect();
63
64    if direct_reports.is_empty() {
65        report.push_str("(no direct reports)\n");
66    } else {
67        for member in &direct_reports {
68            let state = states
69                .get(&member.name)
70                .copied()
71                .unwrap_or(MemberState::Idle);
72            let state_str = match state {
73                MemberState::Idle => "idle",
74                MemberState::Working => "working",
75            };
76
77            report.push_str(&format!("\n[{}] status: {}\n", member.name, state_str));
78
79            if let Some(health) = backend_health.get(&member.name) {
80                if !health.is_healthy() {
81                    report.push_str(&format!(
82                        "  backend: {} (agent may be unable to work)\n",
83                        health.as_str()
84                    ));
85                }
86            }
87
88            if let Some(board_context) = &board_context {
89                let assigned_ids = board_context.assigned_task_ids.get(&member.name);
90                report.push_str(&format!(
91                    "  assigned tasks: {}\n",
92                    format_assigned_task_ids(assigned_ids)
93                ));
94
95                if board_context
96                    .idle_with_runnable
97                    .contains(member.name.as_str())
98                {
99                    report.push_str("  warning: idle while runnable work exists on the board\n");
100                }
101            }
102
103            if let Some(watcher) = watchers.get(&member.name) {
104                let last = watcher.last_lines(output_lines);
105                if !last.trim().is_empty() {
106                    report.push_str("  recent output:\n");
107                    for line in last.lines().take(output_lines) {
108                        report.push_str(&format!("    {line}\n"));
109                    }
110                }
111            }
112        }
113    }
114
115    if let Some(board_context) = &board_context {
116        let idle_reports = direct_reports
117            .iter()
118            .filter(|member| {
119                board_context
120                    .idle_with_runnable
121                    .contains(member.name.as_str())
122            })
123            .map(|member| member.name.as_str())
124            .collect::<Vec<_>>();
125
126        report.push_str("\nWorkflow signals:\n");
127        report.push_str(&format!(
128            "  blocked tasks: {}\n",
129            board_context.metrics.blocked_count
130        ));
131        report.push_str(&format!(
132            "  oldest review age: {}\n",
133            format_age(board_context.metrics.oldest_review_age_secs)
134        ));
135        if !idle_reports.is_empty() {
136            report.push_str(&format!(
137                "  idle with runnable: {}\n",
138                idle_reports.join(", ")
139            ));
140        }
141        if !board_context.aging.stale_in_progress.is_empty()
142            || !board_context.aging.aged_todo.is_empty()
143            || !board_context.aging.stale_review.is_empty()
144        {
145            report.push_str(&format!(
146                "  aging alerts: stale in-progress {} | aged todo {} | stale review {}\n",
147                board_context.aging.stale_in_progress.len(),
148                board_context.aging.aged_todo.len(),
149                board_context.aging.stale_review.len()
150            ));
151        }
152        let total_merges =
153            board_context.metrics.auto_merge_count + board_context.metrics.manual_merge_count;
154        if total_merges > 0 || board_context.metrics.rework_count > 0 {
155            let auto_rate = board_context
156                .metrics
157                .auto_merge_rate
158                .map(|r| format!("{:.0}%", r * 100.0))
159                .unwrap_or_else(|| "-".to_string());
160            report.push_str(&format!(
161                "  review pipeline: auto-merge rate {} | rework {} | nudges {} | escalations {}\n",
162                auto_rate,
163                board_context.metrics.rework_count,
164                board_context.metrics.review_nudge_count,
165                board_context.metrics.review_escalation_count,
166            ));
167        }
168    }
169
170    report.push_str("\n=== END STANDUP ===\n");
171    prepend_review_policy_context(board_dir, report)
172}
173
174fn prepend_review_policy_context(board_dir: Option<&Path>, report: String) -> String {
175    let Some(project_root) = project_root_from_board_dir(board_dir) else {
176        return report;
177    };
178    match load_planning_directive(
179        project_root,
180        PlanningDirectiveFile::ReviewPolicy,
181        REVIEW_POLICY_MAX_CHARS,
182    ) {
183        Ok(Some(policy)) => format!("Review policy context:\n{policy}\n\n{report}"),
184        Ok(None) => report,
185        Err(error) => {
186            warn!(error = %error, "failed to load review policy for standup");
187            report
188        }
189    }
190}
191
192fn project_root_from_board_dir(board_dir: Option<&Path>) -> Option<&Path> {
193    let board_dir = board_dir?;
194    let team_config = board_dir.parent()?;
195    if team_config.file_name()? != "team_config" {
196        return None;
197    }
198    let batty_dir = team_config.parent()?;
199    if batty_dir.file_name()? != ".batty" {
200        return None;
201    }
202    batty_dir.parent()
203}
204
205pub(crate) struct StandupGenerationContext<'a> {
206    pub(crate) project_root: &'a Path,
207    pub(crate) team_config: &'a TeamConfig,
208    pub(crate) members: &'a [MemberInstance],
209    pub(crate) watchers: &'a HashMap<String, SessionWatcher>,
210    pub(crate) states: &'a HashMap<String, MemberState>,
211    #[allow(dead_code)]
212    pub(crate) pane_map: &'a HashMap<String, String>,
213    pub(crate) telegram_bot: Option<&'a TelegramBot>,
214    pub(crate) paused_standups: &'a HashSet<String>,
215    pub(crate) last_standup: &'a mut HashMap<String, Instant>,
216    pub(crate) backend_health: &'a HashMap<String, crate::agent::BackendHealth>,
217}
218
219pub(crate) fn maybe_generate_standup(context: StandupGenerationContext<'_>) -> Result<Vec<String>> {
220    let StandupGenerationContext {
221        project_root,
222        team_config,
223        members,
224        watchers,
225        states,
226        pane_map: _,
227        telegram_bot,
228        paused_standups,
229        last_standup,
230        backend_health,
231    } = context;
232    if !team_config.automation.standups {
233        return Ok(Vec::new());
234    }
235    if pause_marker_path(project_root).exists() {
236        return Ok(Vec::new());
237    }
238    let global_interval = team_config.standup.interval_secs;
239    if global_interval == 0 {
240        return Ok(Vec::new());
241    }
242
243    let mut recipients = Vec::new();
244    for role in &team_config.roles {
245        let receives = role.receives_standup.unwrap_or(matches!(
246            role.role_type,
247            RoleType::Manager | RoleType::Architect
248        ));
249        if !receives {
250            continue;
251        }
252        let interval = Duration::from_secs(role.standup_interval_secs.unwrap_or(global_interval));
253        for member in members {
254            if member.role_name == role.name {
255                recipients.push((member.clone(), interval));
256            }
257        }
258    }
259
260    let mut generated_recipients = Vec::new();
261
262    for (recipient, interval) in &recipients {
263        if paused_standups.contains(&recipient.name) {
264            continue;
265        }
266
267        let last = last_standup.get(&recipient.name).copied();
268        let should_fire = match last {
269            Some(instant) => instant.elapsed() >= *interval,
270            None => true,
271        };
272
273        if last.is_none() {
274            last_standup.insert(recipient.name.clone(), Instant::now());
275            continue;
276        }
277        if !should_fire {
278            continue;
279        }
280
281        let board_dir = team_config_dir(project_root).join("board");
282        let mut report = generate_board_aware_standup_for(
283            recipient,
284            members,
285            watchers,
286            states,
287            team_config.standup.output_lines as usize,
288            Some(&board_dir),
289            backend_health,
290        );
291        if team_config.automation.clean_room_mode {
292            report = append_parity_summary(project_root, report);
293        }
294
295        match recipient.role_type {
296            RoleType::User => {
297                if let Some(bot) = telegram_bot {
298                    let chat_id = team_config
299                        .roles
300                        .iter()
301                        .find(|role| {
302                            role.role_type == RoleType::User && role.name == recipient.role_name
303                        })
304                        .and_then(|role| role.channel_config.as_ref())
305                        .map(|config| config.target.clone());
306
307                    match chat_id {
308                        Some(chat_id) => {
309                            if let Err(error) = bot.send_message(&chat_id, &report) {
310                                warn!(
311                                    member = %recipient.name,
312                                    target = %chat_id,
313                                    error = %error,
314                                    "failed to send standup via telegram"
315                                );
316                            } else {
317                                generated_recipients.push(recipient.name.clone());
318                            }
319                        }
320                        None => warn!(
321                            member = %recipient.name,
322                            "telegram standup delivery skipped: missing target"
323                        ),
324                    }
325                } else {
326                    match write_standup_file(project_root, &report) {
327                        Ok(path) => {
328                            tracing::info!(member = %recipient.name, path = %path.display(), "standup written to file");
329                            generated_recipients.push(recipient.name.clone());
330                        }
331                        Err(error) => warn!(
332                            member = %recipient.name,
333                            error = %error,
334                            "failed to write standup file"
335                        ),
336                    }
337                }
338            }
339            _ => {
340                // Non-telegram, non-file standups: write to file as fallback
341                // (tmux pane injection was removed with the tmux-direct code path)
342                match write_standup_file(project_root, &report) {
343                    Ok(path) => {
344                        tracing::info!(member = %recipient.name, path = %path.display(), "standup written to file (fallback)");
345                        generated_recipients.push(recipient.name.clone());
346                    }
347                    Err(error) => warn!(
348                        member = %recipient.name,
349                        error = %error,
350                        "failed to write standup file"
351                    ),
352                }
353            }
354        }
355
356        last_standup.insert(recipient.name.clone(), Instant::now());
357    }
358
359    if !generated_recipients.is_empty() {
360        tracing::info!("standups generated and delivered");
361    }
362
363    Ok(generated_recipients)
364}
365
366fn append_parity_summary(project_root: &Path, mut report: String) -> String {
367    let Ok(parity) = ParityReport::load(project_root) else {
368        return report;
369    };
370    let summary = parity.summary();
371    let gap_count = parity.gaps().len();
372    report.push_str("\nClean room parity:\n");
373    report.push_str(&format!(
374        "  overall parity: {}%\n",
375        summary.overall_parity_pct
376    ));
377    report.push_str(&format!(
378        "  behaviors tracked: {}\n",
379        summary.total_behaviors
380    ));
381    report.push_str(&format!("  spec complete: {}\n", summary.spec_complete));
382    report.push_str(&format!("  tests complete: {}\n", summary.tests_complete));
383    report.push_str(&format!(
384        "  implementation complete: {}\n",
385        summary.implementation_complete
386    ));
387    report.push_str(&format!("  verified PASS: {}\n", summary.verified_pass));
388    report.push_str(&format!("  parity gaps: {}\n", gap_count));
389    report
390}
391
392pub(crate) fn update_timer_for_state(
393    team_config: &TeamConfig,
394    members: &[MemberInstance],
395    paused_standups: &mut HashSet<String>,
396    last_standup: &mut HashMap<String, Instant>,
397    member_name: &str,
398    new_state: MemberState,
399) {
400    if standup_interval_for_member_name(team_config, members, member_name).is_none() {
401        paused_standups.remove(member_name);
402        last_standup.remove(member_name);
403        return;
404    }
405
406    match new_state {
407        MemberState::Working => {
408            paused_standups.insert(member_name.to_string());
409            last_standup.remove(member_name);
410        }
411        MemberState::Idle => {
412            let was_paused = paused_standups.remove(member_name);
413            if was_paused || !last_standup.contains_key(member_name) {
414                last_standup.insert(member_name.to_string(), Instant::now());
415            }
416        }
417    }
418}
419
420pub(crate) fn standup_interval_for_member_name(
421    team_config: &TeamConfig,
422    members: &[MemberInstance],
423    member_name: &str,
424) -> Option<Duration> {
425    let member = members.iter().find(|member| member.name == member_name)?;
426    let role_def = team_config
427        .roles
428        .iter()
429        .find(|role| role.name == member.role_name);
430
431    let receives = role_def
432        .and_then(|role| role.receives_standup)
433        .unwrap_or(matches!(
434            member.role_type,
435            RoleType::Manager | RoleType::Architect
436        ));
437    if !receives {
438        return None;
439    }
440
441    let interval_secs = role_def
442        .and_then(|role| role.standup_interval_secs)
443        .unwrap_or(team_config.standup.interval_secs);
444    Some(Duration::from_secs(interval_secs))
445}
446
447pub(crate) fn restore_timer_state(
448    last_standup_elapsed_secs: HashMap<String, u64>,
449) -> HashMap<String, Instant> {
450    last_standup_elapsed_secs
451        .into_iter()
452        .map(|(member, elapsed_secs)| {
453            (
454                member,
455                Instant::now()
456                    .checked_sub(Duration::from_secs(elapsed_secs))
457                    .unwrap_or_else(Instant::now),
458            )
459        })
460        .collect()
461}
462
463pub(crate) fn snapshot_timer_state(
464    last_standup: &HashMap<String, Instant>,
465) -> HashMap<String, u64> {
466    last_standup
467        .iter()
468        .map(|(member, instant)| (member.clone(), instant.elapsed().as_secs()))
469        .collect()
470}
471
472/// Write standup text to a timestamped Markdown file under `.batty/standups/`.
473pub fn write_standup_file(project_root: &Path, standup: &str) -> Result<PathBuf> {
474    let standups_dir = project_root.join(".batty").join("standups");
475    std::fs::create_dir_all(&standups_dir)
476        .with_context(|| format!("failed to create {}", standups_dir.display()))?;
477
478    let timestamp = SystemTime::now()
479        .duration_since(UNIX_EPOCH)
480        .context("system clock is before UNIX_EPOCH")?
481        .as_millis();
482    let path = standups_dir.join(format!("{timestamp}.md"));
483
484    std::fs::write(&path, standup)
485        .with_context(|| format!("failed to write {}", path.display()))?;
486    Ok(path)
487}
488
489/// Simple member state enum used by standup reporting.
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
491#[serde(rename_all = "snake_case")]
492pub enum MemberState {
493    Idle,
494    Working,
495}
496
497#[derive(Debug, Clone)]
498struct BoardContext {
499    metrics: metrics::WorkflowMetrics,
500    aging: crate::team::board::TaskAgingReport,
501    assigned_task_ids: HashMap<String, Vec<u32>>,
502    idle_with_runnable: HashSet<String>,
503}
504
505fn load_board_context(
506    board_dir: Option<&Path>,
507    members: &[MemberInstance],
508) -> Option<BoardContext> {
509    let board_dir = board_dir?;
510    let tasks_dir = board_dir.join("tasks");
511    if !tasks_dir.is_dir() {
512        return None;
513    }
514
515    let metrics = metrics::compute_metrics(board_dir, members).ok()?;
516    let thresholds = project_root_from_board_dir(Some(board_dir))
517        .and_then(|project_root| {
518            crate::team::config::TeamConfig::load(&crate::team::team_config_path(project_root)).ok()
519        })
520        .map(|config| crate::team::board::AgingThresholds {
521            stale_in_progress_hours: config.workflow_policy.stale_in_progress_hours,
522            aged_todo_hours: config.workflow_policy.aged_todo_hours,
523            stale_review_hours: config.workflow_policy.stale_review_hours,
524        })
525        .unwrap_or_default();
526    let aging = crate::team::board::compute_task_aging(
527        board_dir,
528        project_root_from_board_dir(Some(board_dir))?,
529        thresholds,
530    )
531    .ok()?;
532    let tasks = task::load_tasks_from_dir(&tasks_dir).ok()?;
533    let mut assigned_task_ids = HashMap::<String, Vec<u32>>::new();
534
535    for task in tasks
536        .into_iter()
537        .filter(|task| !matches!(task.status.as_str(), "done" | "archived"))
538    {
539        let Some(claimed_by) = task.claimed_by else {
540            continue;
541        };
542        assigned_task_ids
543            .entry(claimed_by)
544            .or_default()
545            .push(task.id);
546    }
547
548    for task_ids in assigned_task_ids.values_mut() {
549        task_ids.sort_unstable();
550    }
551
552    Some(BoardContext {
553        aging,
554        idle_with_runnable: metrics.idle_with_runnable.iter().cloned().collect(),
555        metrics,
556        assigned_task_ids,
557    })
558}
559
560fn format_assigned_task_ids(task_ids: Option<&Vec<u32>>) -> String {
561    let Some(task_ids) = task_ids else {
562        return "none".to_string();
563    };
564
565    if task_ids.is_empty() {
566        "none".to_string()
567    } else {
568        task_ids
569            .iter()
570            .map(|task_id| format!("#{task_id}"))
571            .collect::<Vec<_>>()
572            .join(", ")
573    }
574}
575
576fn format_age(age_secs: Option<u64>) -> String {
577    age_secs
578        .map(|secs| format!("{secs}s"))
579        .unwrap_or_else(|| "n/a".to_string())
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use crate::team::config::{
586        AutomationConfig, BoardConfig, OrchestratorPosition, RoleDef, RoleType, StandupConfig,
587        TeamConfig, WorkflowMode, WorkflowPolicy,
588    };
589    use std::path::Path;
590
591    fn make_member(name: &str, role_type: RoleType, reports_to: Option<&str>) -> MemberInstance {
592        MemberInstance {
593            name: name.to_string(),
594            role_name: name.to_string(),
595            role_type,
596            agent: Some("claude".to_string()),
597            model: None,
598            prompt: None,
599            posture: None,
600            model_class: None,
601            provider_overlay: None,
602            reports_to: reports_to.map(|s| s.to_string()),
603            use_worktrees: false,
604        }
605    }
606
607    fn write_task(
608        board_dir: &Path,
609        id: u32,
610        title: &str,
611        status: &str,
612        claimed_by: Option<&str>,
613        blocked: Option<&str>,
614    ) {
615        let tasks_dir = board_dir.join("tasks");
616        std::fs::create_dir_all(&tasks_dir).unwrap();
617        let mut content =
618            format!("---\nid: {id}\ntitle: {title}\nstatus: {status}\npriority: medium\n");
619        if let Some(claimed_by) = claimed_by {
620            content.push_str(&format!("claimed_by: {claimed_by}\n"));
621        }
622        if let Some(blocked) = blocked {
623            content.push_str(&format!("blocked: {blocked}\n"));
624        }
625        content.push_str("class: standard\n---\n\nTask body.\n");
626        std::fs::write(tasks_dir.join(format!("{id:03}-{title}.md")), content).unwrap();
627    }
628
629    #[test]
630    fn standup_shows_only_direct_reports() {
631        let members = vec![
632            make_member("architect", RoleType::Architect, None),
633            make_member("manager", RoleType::Manager, Some("architect")),
634            make_member("eng-1-1", RoleType::Engineer, Some("manager")),
635            make_member("eng-1-2", RoleType::Engineer, Some("manager")),
636        ];
637        let watchers = HashMap::new();
638        let mut states = HashMap::new();
639        states.insert("eng-1-1".to_string(), MemberState::Working);
640        states.insert("eng-1-2".to_string(), MemberState::Idle);
641        states.insert("architect".to_string(), MemberState::Working);
642
643        // Manager standup should only show engineers, not architect
644        let manager = &members[1];
645        let report = generate_standup_for(manager, &members, &watchers, &states, 5);
646        assert!(report.contains("[eng-1-1] status: working"));
647        assert!(report.contains("[eng-1-2] status: idle"));
648        assert!(!report.contains("[architect]"));
649        assert!(report.contains("STANDUP for manager"));
650    }
651
652    #[test]
653    fn standup_architect_sees_manager() {
654        let members = vec![
655            make_member("architect", RoleType::Architect, None),
656            make_member("manager", RoleType::Manager, Some("architect")),
657            make_member("eng-1-1", RoleType::Engineer, Some("manager")),
658        ];
659        let watchers = HashMap::new();
660        let states = HashMap::new();
661
662        let architect = &members[0];
663        let report = generate_standup_for(architect, &members, &watchers, &states, 5);
664        assert!(report.contains("[manager]"));
665        assert!(!report.contains("[eng-1-1]"));
666    }
667
668    #[test]
669    fn standup_no_reports_for_engineer() {
670        let members = vec![
671            make_member("manager", RoleType::Manager, None),
672            make_member("eng-1-1", RoleType::Engineer, Some("manager")),
673        ];
674        let watchers = HashMap::new();
675        let states = HashMap::new();
676
677        let eng = &members[1];
678        let report = generate_standup_for(eng, &members, &watchers, &states, 5);
679        assert!(report.contains("no direct reports"));
680    }
681
682    #[test]
683    fn standup_excludes_user_role() {
684        let members = vec![MemberInstance {
685            name: "human".to_string(),
686            role_name: "human".to_string(),
687            role_type: RoleType::User,
688            agent: None,
689            model: None,
690            prompt: None,
691            posture: None,
692            model_class: None,
693            provider_overlay: None,
694            reports_to: None,
695            use_worktrees: false,
696        }];
697        let report =
698            generate_standup_for(&members[0], &members, &HashMap::new(), &HashMap::new(), 5);
699        assert!(!report.contains("[human]"));
700    }
701
702    #[test]
703    fn test_generate_standup_for_formats_various_member_states() {
704        let members = vec![
705            make_member("manager", RoleType::Manager, None),
706            make_member("eng-idle", RoleType::Engineer, Some("manager")),
707            make_member("eng-working", RoleType::Engineer, Some("manager")),
708        ];
709        let mut states = HashMap::new();
710        states.insert("eng-working".to_string(), MemberState::Working);
711
712        let report = generate_standup_for(&members[0], &members, &HashMap::new(), &states, 5);
713
714        assert!(report.contains("=== STANDUP for manager ==="));
715        assert!(report.contains("[eng-idle] status: idle"));
716        assert!(report.contains("[eng-working] status: working"));
717        assert!(report.contains("=== END STANDUP ==="));
718    }
719
720    #[test]
721    fn test_generate_standup_for_empty_members_returns_no_direct_reports() {
722        let recipient = make_member("manager", RoleType::Manager, None);
723        let report = generate_standup_for(&recipient, &[], &HashMap::new(), &HashMap::new(), 5);
724
725        assert!(report.contains("=== STANDUP for manager ==="));
726        assert!(report.contains("(no direct reports)"));
727        assert!(report.contains("=== END STANDUP ==="));
728    }
729
730    #[test]
731    fn test_generate_standup_for_all_same_status_lists_each_direct_report() {
732        let members = vec![
733            make_member("manager", RoleType::Manager, None),
734            make_member("eng-1", RoleType::Engineer, Some("manager")),
735            make_member("eng-2", RoleType::Engineer, Some("manager")),
736            make_member("eng-3", RoleType::Engineer, Some("manager")),
737        ];
738        let states = HashMap::from([
739            ("eng-1".to_string(), MemberState::Working),
740            ("eng-2".to_string(), MemberState::Working),
741            ("eng-3".to_string(), MemberState::Working),
742        ]);
743
744        let report = generate_standup_for(&members[0], &members, &HashMap::new(), &states, 5);
745
746        assert_eq!(report.matches("status: working").count(), 3);
747        assert!(report.contains("[eng-1] status: working"));
748        assert!(report.contains("[eng-2] status: working"));
749        assert!(report.contains("[eng-3] status: working"));
750    }
751
752    #[test]
753    fn board_aware_standup_appends_task_ids_and_workflow_signals() {
754        let tmp = tempfile::tempdir().unwrap();
755        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
756        write_task(&board_dir, 1, "active", "in-progress", Some("eng-1"), None);
757        write_task(
758            &board_dir,
759            2,
760            "blocked",
761            "blocked",
762            Some("eng-2"),
763            Some("waiting"),
764        );
765        write_task(&board_dir, 3, "review", "review", Some("eng-2"), None);
766        write_task(&board_dir, 4, "runnable", "todo", None, None);
767
768        let members = vec![
769            make_member("manager", RoleType::Manager, None),
770            make_member("eng-1", RoleType::Engineer, Some("manager")),
771            make_member("eng-2", RoleType::Engineer, Some("manager")),
772            make_member("eng-3", RoleType::Engineer, Some("manager")),
773        ];
774        let states = HashMap::from([
775            ("eng-1".to_string(), MemberState::Working),
776            ("eng-2".to_string(), MemberState::Working),
777            ("eng-3".to_string(), MemberState::Idle),
778        ]);
779
780        let report = generate_board_aware_standup_for(
781            &members[0],
782            &members,
783            &HashMap::new(),
784            &states,
785            5,
786            Some(&board_dir),
787            &HashMap::new(),
788        );
789
790        assert!(report.contains("assigned tasks: #1"));
791        assert!(report.contains("assigned tasks: #2, #3"));
792        assert!(report.contains("[eng-3] status: idle"));
793        assert!(report.contains("assigned tasks: none"));
794        assert!(report.contains("warning: idle while runnable work exists on the board"));
795        assert!(report.contains("Workflow signals:"));
796        assert!(report.contains("blocked tasks: 1"));
797        assert!(report.contains("idle with runnable: eng-3"));
798        assert!(report.contains("oldest review age: "));
799        assert!(!report.contains("oldest review age: n/a"));
800    }
801
802    #[test]
803    fn board_aware_standup_falls_back_when_board_is_missing() {
804        let tmp = tempfile::tempdir().unwrap();
805        let missing_board_dir = tmp.path().join("missing-board");
806        let members = vec![
807            make_member("manager", RoleType::Manager, None),
808            make_member("eng-1", RoleType::Engineer, Some("manager")),
809        ];
810        let states = HashMap::from([("eng-1".to_string(), MemberState::Idle)]);
811
812        let report = generate_board_aware_standup_for(
813            &members[0],
814            &members,
815            &HashMap::new(),
816            &states,
817            5,
818            Some(&missing_board_dir),
819            &HashMap::new(),
820        );
821
822        assert!(report.contains("[eng-1] status: idle"));
823        assert!(!report.contains("assigned tasks:"));
824        assert!(!report.contains("Workflow signals:"));
825        assert!(!report.contains("warning: idle while runnable work exists on the board"));
826    }
827
828    #[test]
829    fn board_aware_standup_prepends_review_policy_context() {
830        let tmp = tempfile::tempdir().unwrap();
831        let team_config_dir = tmp.path().join(".batty").join("team_config");
832        let board_dir = team_config_dir.join("board");
833        std::fs::create_dir_all(&board_dir).unwrap();
834        std::fs::write(
835            team_config_dir.join("review_policy.md"),
836            "Approve only after tests pass.",
837        )
838        .unwrap();
839
840        let members = vec![
841            make_member("manager", RoleType::Manager, None),
842            make_member("eng-1", RoleType::Engineer, Some("manager")),
843        ];
844        let states = HashMap::from([("eng-1".to_string(), MemberState::Idle)]);
845
846        let report = generate_board_aware_standup_for(
847            &members[0],
848            &members,
849            &HashMap::new(),
850            &states,
851            5,
852            Some(&board_dir),
853            &HashMap::new(),
854        );
855
856        assert!(report.starts_with("Review policy context:\nApprove only after tests pass."));
857        assert!(report.contains("=== STANDUP for manager ==="));
858    }
859
860    #[test]
861    fn board_aware_standup_reloads_updated_review_policy_contents() {
862        let tmp = tempfile::tempdir().unwrap();
863        let team_config_dir = tmp.path().join(".batty").join("team_config");
864        let board_dir = team_config_dir.join("board");
865        std::fs::create_dir_all(&board_dir).unwrap();
866        let policy_path = team_config_dir.join("review_policy.md");
867        std::fs::write(&policy_path, "Initial policy").unwrap();
868
869        let members = vec![
870            make_member("manager", RoleType::Manager, None),
871            make_member("eng-1", RoleType::Engineer, Some("manager")),
872        ];
873        let states = HashMap::from([("eng-1".to_string(), MemberState::Idle)]);
874
875        let first = generate_board_aware_standup_for(
876            &members[0],
877            &members,
878            &HashMap::new(),
879            &states,
880            5,
881            Some(&board_dir),
882            &HashMap::new(),
883        );
884        std::fs::write(&policy_path, "Updated policy").unwrap();
885        let second = generate_board_aware_standup_for(
886            &members[0],
887            &members,
888            &HashMap::new(),
889            &states,
890            5,
891            Some(&board_dir),
892            &HashMap::new(),
893        );
894
895        assert!(first.contains("Initial policy"));
896        assert!(second.contains("Updated policy"));
897        assert!(!second.contains("Initial policy"));
898    }
899
900    #[test]
901    fn write_standup_file_creates_timestamped_markdown_in_batty_dir() {
902        let tmp = tempfile::tempdir().unwrap();
903        let report = "=== STANDUP for user ===\n[architect] status: working\n";
904        let expected_dir = tmp.path().join(".batty").join("standups");
905
906        let path = write_standup_file(tmp.path(), report).unwrap();
907
908        assert_eq!(path.parent(), Some(expected_dir.as_path()));
909        assert_eq!(path.extension().and_then(|ext| ext.to_str()), Some("md"));
910        assert_eq!(std::fs::read_to_string(&path).unwrap(), report);
911    }
912
913    #[test]
914    fn update_timer_for_state_pauses_while_working_and_restarts_on_idle() {
915        let member = make_member("manager", RoleType::Manager, None);
916        let role = RoleDef {
917            name: "manager".to_string(),
918            role_type: RoleType::Manager,
919            agent: Some("claude".to_string()),
920            auth_mode: None,
921            auth_env: vec![],
922            instances: 1,
923            prompt: None,
924            talks_to: vec![],
925            channel: None,
926            channel_config: None,
927            nudge_interval_secs: None,
928            receives_standup: Some(true),
929            standup_interval_secs: Some(600),
930            owns: Vec::new(),
931            barrier_group: None,
932            use_worktrees: false,
933            ..Default::default()
934        };
935        let team_config = TeamConfig {
936            name: "test".to_string(),
937            agent: None,
938            workflow_mode: WorkflowMode::Legacy,
939            workflow_policy: WorkflowPolicy::default(),
940            board: BoardConfig::default(),
941            standup: StandupConfig::default(),
942            automation: AutomationConfig::default(),
943            automation_sender: None,
944            external_senders: Vec::new(),
945            orchestrator_pane: true,
946            orchestrator_position: OrchestratorPosition::Bottom,
947            layout: None,
948            cost: Default::default(),
949            grafana: Default::default(),
950            use_shim: false,
951            use_sdk_mode: false,
952            auto_respawn_on_crash: false,
953            shim_health_check_interval_secs: 60,
954            shim_health_timeout_secs: 120,
955            shim_shutdown_timeout_secs: 30,
956            shim_working_state_timeout_secs: 1800,
957            pending_queue_max_age_secs: 600,
958            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
959            retro_min_duration_secs: 60,
960            roles: vec![role],
961        };
962        let members = vec![member];
963        let mut paused_standups = HashSet::new();
964        let mut last_standup = HashMap::from([(
965            "manager".to_string(),
966            Instant::now() - Duration::from_secs(120),
967        )]);
968
969        update_timer_for_state(
970            &team_config,
971            &members,
972            &mut paused_standups,
973            &mut last_standup,
974            "manager",
975            MemberState::Working,
976        );
977
978        assert!(paused_standups.contains("manager"));
979        assert!(!last_standup.contains_key("manager"));
980
981        update_timer_for_state(
982            &team_config,
983            &members,
984            &mut paused_standups,
985            &mut last_standup,
986            "manager",
987            MemberState::Idle,
988        );
989
990        assert!(!paused_standups.contains("manager"));
991        assert!(last_standup["manager"].elapsed() < Duration::from_secs(1));
992    }
993
994    #[test]
995    fn maybe_generate_standup_skips_when_global_interval_is_zero() {
996        let tmp = tempfile::tempdir().unwrap();
997        let member = make_member("manager", RoleType::Manager, None);
998        let role = RoleDef {
999            name: "manager".to_string(),
1000            role_type: RoleType::Manager,
1001            agent: Some("claude".to_string()),
1002            auth_mode: None,
1003            auth_env: vec![],
1004            instances: 1,
1005            prompt: None,
1006            talks_to: vec![],
1007            channel: None,
1008            channel_config: None,
1009            nudge_interval_secs: None,
1010            receives_standup: Some(true),
1011            standup_interval_secs: Some(600),
1012            owns: Vec::new(),
1013            barrier_group: None,
1014            use_worktrees: false,
1015            ..Default::default()
1016        };
1017        let team_config = TeamConfig {
1018            name: "test".to_string(),
1019            agent: None,
1020            workflow_mode: WorkflowMode::Legacy,
1021            workflow_policy: WorkflowPolicy::default(),
1022            board: BoardConfig::default(),
1023            standup: StandupConfig {
1024                interval_secs: 0,
1025                output_lines: 30,
1026            },
1027            automation: AutomationConfig::default(),
1028            automation_sender: None,
1029            external_senders: Vec::new(),
1030            orchestrator_pane: false,
1031            orchestrator_position: OrchestratorPosition::Bottom,
1032            layout: None,
1033            cost: Default::default(),
1034            grafana: Default::default(),
1035            use_shim: false,
1036            use_sdk_mode: false,
1037            auto_respawn_on_crash: false,
1038            shim_health_check_interval_secs: 60,
1039            shim_health_timeout_secs: 120,
1040            shim_shutdown_timeout_secs: 30,
1041            shim_working_state_timeout_secs: 1800,
1042            pending_queue_max_age_secs: 600,
1043            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1044            retro_min_duration_secs: 60,
1045            roles: vec![role],
1046        };
1047        let members = vec![member];
1048        let mut last_standup = HashMap::new();
1049
1050        let generated = maybe_generate_standup(StandupGenerationContext {
1051            project_root: tmp.path(),
1052            team_config: &team_config,
1053            members: &members,
1054            watchers: &HashMap::new(),
1055            states: &HashMap::new(),
1056            pane_map: &HashMap::new(),
1057            telegram_bot: None,
1058            paused_standups: &HashSet::new(),
1059            last_standup: &mut last_standup,
1060            backend_health: &HashMap::new(),
1061        })
1062        .unwrap();
1063
1064        assert!(generated.is_empty());
1065        assert!(last_standup.is_empty());
1066    }
1067
1068    #[test]
1069    fn maybe_generate_standup_writes_user_report_to_file_without_telegram_bot() {
1070        let tmp = tempfile::tempdir().unwrap();
1071        let user = MemberInstance {
1072            name: "user".to_string(),
1073            role_name: "user".to_string(),
1074            role_type: RoleType::User,
1075            agent: None,
1076            prompt: None,
1077            reports_to: None,
1078            use_worktrees: false,
1079            ..Default::default()
1080        };
1081        let architect = MemberInstance {
1082            name: "architect".to_string(),
1083            role_name: "architect".to_string(),
1084            role_type: RoleType::Architect,
1085            agent: Some("claude".to_string()),
1086            prompt: None,
1087            reports_to: Some("user".to_string()),
1088            use_worktrees: false,
1089            ..Default::default()
1090        };
1091        let user_role = RoleDef {
1092            name: "user".to_string(),
1093            role_type: RoleType::User,
1094            agent: None,
1095            auth_mode: None,
1096            auth_env: vec![],
1097            instances: 1,
1098            prompt: None,
1099            talks_to: vec!["architect".to_string()],
1100            channel: None,
1101            channel_config: None,
1102            nudge_interval_secs: None,
1103            receives_standup: Some(true),
1104            standup_interval_secs: Some(1),
1105            owns: Vec::new(),
1106            barrier_group: None,
1107            use_worktrees: false,
1108            ..Default::default()
1109        };
1110        let architect_role = RoleDef {
1111            name: "architect".to_string(),
1112            role_type: RoleType::Architect,
1113            agent: Some("claude".to_string()),
1114            auth_mode: None,
1115            auth_env: vec![],
1116            instances: 1,
1117            prompt: None,
1118            talks_to: vec![],
1119            channel: None,
1120            channel_config: None,
1121            nudge_interval_secs: None,
1122            receives_standup: Some(false),
1123            standup_interval_secs: None,
1124            owns: Vec::new(),
1125            barrier_group: None,
1126            use_worktrees: false,
1127            ..Default::default()
1128        };
1129        let team_config = TeamConfig {
1130            name: "test".to_string(),
1131            agent: None,
1132            workflow_mode: WorkflowMode::Legacy,
1133            workflow_policy: WorkflowPolicy::default(),
1134            board: BoardConfig::default(),
1135            standup: StandupConfig {
1136                interval_secs: 1,
1137                output_lines: 30,
1138            },
1139            automation: AutomationConfig::default(),
1140            automation_sender: None,
1141            external_senders: Vec::new(),
1142            orchestrator_pane: false,
1143            orchestrator_position: OrchestratorPosition::Bottom,
1144            layout: None,
1145            cost: Default::default(),
1146            grafana: Default::default(),
1147            use_shim: false,
1148            use_sdk_mode: false,
1149            auto_respawn_on_crash: false,
1150            shim_health_check_interval_secs: 60,
1151            shim_health_timeout_secs: 120,
1152            shim_shutdown_timeout_secs: 30,
1153            shim_working_state_timeout_secs: 1800,
1154            pending_queue_max_age_secs: 600,
1155            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1156            retro_min_duration_secs: 60,
1157            roles: vec![user_role, architect_role],
1158        };
1159        let members = vec![user.clone(), architect];
1160        let states = HashMap::from([("architect".to_string(), MemberState::Working)]);
1161        let mut last_standup =
1162            HashMap::from([(user.name.clone(), Instant::now() - Duration::from_secs(5))]);
1163
1164        let generated = maybe_generate_standup(StandupGenerationContext {
1165            project_root: tmp.path(),
1166            team_config: &team_config,
1167            members: &members,
1168            watchers: &HashMap::new(),
1169            states: &states,
1170            pane_map: &HashMap::new(),
1171            telegram_bot: None,
1172            paused_standups: &HashSet::new(),
1173            last_standup: &mut last_standup,
1174            backend_health: &HashMap::new(),
1175        })
1176        .unwrap();
1177
1178        assert_eq!(generated, vec!["user".to_string()]);
1179
1180        let standups_dir = tmp.path().join(".batty").join("standups");
1181        let entries = std::fs::read_dir(&standups_dir)
1182            .unwrap()
1183            .collect::<std::io::Result<Vec<_>>>()
1184            .unwrap();
1185        assert_eq!(entries.len(), 1);
1186
1187        let report = std::fs::read_to_string(entries[0].path()).unwrap();
1188        assert!(report.contains("=== STANDUP for user ==="));
1189        assert!(report.contains("[architect] status: working"));
1190    }
1191
1192    #[test]
1193    fn append_parity_summary_includes_clean_room_metrics() {
1194        let tmp = tempfile::tempdir().unwrap();
1195        std::fs::write(
1196            tmp.path().join("PARITY.md"),
1197            r#"---
1198project: manic-miner
1199target: original-binary.z80
1200source_platform: zx-spectrum-z80
1201target_language: rust
1202last_verified: 2026-04-05
1203---
1204
1205| Behavior | Spec | Test | Implementation | Verified | Notes |
1206| --- | --- | --- | --- | --- | --- |
1207| Input handling | complete | complete | complete | PASS | matched |
1208| Enemy AI | complete | -- | draft | -- | pending |
1209"#,
1210        )
1211        .unwrap();
1212
1213        let report = append_parity_summary(tmp.path(), "=== STANDUP ===\n".to_string());
1214        assert!(report.contains("Clean room parity:"));
1215        assert!(report.contains("overall parity: 50%"));
1216        assert!(report.contains("behaviors tracked: 2"));
1217        assert!(report.contains("parity gaps: 1"));
1218    }
1219
1220    #[test]
1221    fn standup_includes_backend_health_warning_for_unhealthy_agent() {
1222        let manager = make_member("manager", RoleType::Manager, None);
1223        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1224        let members = vec![manager.clone(), eng.clone()];
1225        let states = HashMap::new();
1226
1227        let mut backend_health = HashMap::new();
1228        backend_health.insert(
1229            "eng-1".to_string(),
1230            crate::agent::BackendHealth::Unreachable,
1231        );
1232
1233        let report = generate_board_aware_standup_for(
1234            &manager,
1235            &members,
1236            &HashMap::new(),
1237            &states,
1238            5,
1239            None,
1240            &backend_health,
1241        );
1242
1243        assert!(
1244            report.contains("backend: unreachable"),
1245            "standup should warn about unhealthy backend: {report}"
1246        );
1247    }
1248
1249    #[test]
1250    fn standup_omits_backend_health_when_healthy() {
1251        let manager = make_member("manager", RoleType::Manager, None);
1252        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1253        let members = vec![manager.clone(), eng.clone()];
1254        let states = HashMap::new();
1255
1256        let mut backend_health = HashMap::new();
1257        backend_health.insert("eng-1".to_string(), crate::agent::BackendHealth::Healthy);
1258
1259        let report = generate_board_aware_standup_for(
1260            &manager,
1261            &members,
1262            &HashMap::new(),
1263            &states,
1264            5,
1265            None,
1266            &backend_health,
1267        );
1268
1269        assert!(
1270            !report.contains("backend:"),
1271            "standup should not mention backend when healthy: {report}"
1272        );
1273    }
1274
1275    // --- New tests for content generation edge cases ---
1276
1277    #[test]
1278    fn standup_shows_degraded_backend_health() {
1279        let manager = make_member("manager", RoleType::Manager, None);
1280        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1281        let members = vec![manager.clone(), eng];
1282        let mut backend_health = HashMap::new();
1283        backend_health.insert("eng-1".to_string(), crate::agent::BackendHealth::Degraded);
1284
1285        let report = generate_board_aware_standup_for(
1286            &manager,
1287            &members,
1288            &HashMap::new(),
1289            &HashMap::new(),
1290            5,
1291            None,
1292            &backend_health,
1293        );
1294
1295        assert!(
1296            report.contains("backend: degraded"),
1297            "standup should warn about degraded backend: {report}"
1298        );
1299    }
1300
1301    #[test]
1302    fn standup_default_state_is_idle() {
1303        let manager = make_member("manager", RoleType::Manager, None);
1304        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1305        let members = vec![manager.clone(), eng];
1306        // No state entry for eng-1 → defaults to Idle
1307        let report = generate_standup_for(&manager, &members, &HashMap::new(), &HashMap::new(), 5);
1308        assert!(report.contains("[eng-1] status: idle"));
1309    }
1310
1311    #[test]
1312    fn board_aware_standup_all_done_tasks_shows_none_assigned() {
1313        let tmp = tempfile::tempdir().unwrap();
1314        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1315        // Only a "done" task — should not show in assigned list
1316        write_task(&board_dir, 10, "finished", "done", Some("eng-1"), None);
1317
1318        let members = vec![
1319            make_member("manager", RoleType::Manager, None),
1320            make_member("eng-1", RoleType::Engineer, Some("manager")),
1321        ];
1322        let states = HashMap::from([("eng-1".to_string(), MemberState::Working)]);
1323
1324        let report = generate_board_aware_standup_for(
1325            &members[0],
1326            &members,
1327            &HashMap::new(),
1328            &states,
1329            5,
1330            Some(&board_dir),
1331            &HashMap::new(),
1332        );
1333
1334        assert!(report.contains("assigned tasks: none"));
1335    }
1336
1337    #[test]
1338    fn board_aware_standup_multiple_tasks_sorted_ascending() {
1339        let tmp = tempfile::tempdir().unwrap();
1340        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1341        write_task(&board_dir, 5, "second", "in-progress", Some("eng-1"), None);
1342        write_task(&board_dir, 2, "first", "in-progress", Some("eng-1"), None);
1343        write_task(&board_dir, 9, "third", "review", Some("eng-1"), None);
1344
1345        let members = vec![
1346            make_member("manager", RoleType::Manager, None),
1347            make_member("eng-1", RoleType::Engineer, Some("manager")),
1348        ];
1349
1350        let report = generate_board_aware_standup_for(
1351            &members[0],
1352            &members,
1353            &HashMap::new(),
1354            &HashMap::new(),
1355            5,
1356            Some(&board_dir),
1357            &HashMap::new(),
1358        );
1359
1360        assert!(report.contains("assigned tasks: #2, #5, #9"));
1361    }
1362
1363    #[test]
1364    fn board_aware_standup_no_idle_warning_when_no_runnable_work() {
1365        let tmp = tempfile::tempdir().unwrap();
1366        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1367        // All tasks done or in-progress — nothing runnable
1368        write_task(&board_dir, 1, "active", "in-progress", Some("eng-1"), None);
1369        write_task(&board_dir, 2, "done-task", "done", Some("eng-2"), None);
1370
1371        let members = vec![
1372            make_member("manager", RoleType::Manager, None),
1373            make_member("eng-1", RoleType::Engineer, Some("manager")),
1374            make_member("eng-2", RoleType::Engineer, Some("manager")),
1375        ];
1376        let states = HashMap::from([("eng-2".to_string(), MemberState::Idle)]);
1377
1378        let report = generate_board_aware_standup_for(
1379            &members[0],
1380            &members,
1381            &HashMap::new(),
1382            &states,
1383            5,
1384            Some(&board_dir),
1385            &HashMap::new(),
1386        );
1387
1388        assert!(!report.contains("warning: idle while runnable work exists"));
1389    }
1390
1391    #[test]
1392    fn board_aware_standup_review_pipeline_metrics_when_present() {
1393        let tmp = tempfile::tempdir().unwrap();
1394        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1395        // One review task triggers oldest review age
1396        write_task(&board_dir, 1, "in-review", "review", Some("eng-1"), None);
1397        // One runnable task
1398        write_task(&board_dir, 2, "ready", "todo", None, None);
1399
1400        let members = vec![
1401            make_member("manager", RoleType::Manager, None),
1402            make_member("eng-1", RoleType::Engineer, Some("manager")),
1403        ];
1404
1405        let report = generate_board_aware_standup_for(
1406            &members[0],
1407            &members,
1408            &HashMap::new(),
1409            &HashMap::new(),
1410            5,
1411            Some(&board_dir),
1412            &HashMap::new(),
1413        );
1414
1415        assert!(report.contains("Workflow signals:"));
1416        assert!(report.contains("blocked tasks: 0"));
1417        assert!(report.contains("oldest review age:"));
1418    }
1419
1420    #[test]
1421    fn format_assigned_task_ids_empty_vec() {
1422        let ids: Vec<u32> = vec![];
1423        assert_eq!(format_assigned_task_ids(Some(&ids)), "none");
1424    }
1425
1426    #[test]
1427    fn format_assigned_task_ids_none() {
1428        assert_eq!(format_assigned_task_ids(None), "none");
1429    }
1430
1431    #[test]
1432    fn format_assigned_task_ids_single() {
1433        let ids = vec![42];
1434        assert_eq!(format_assigned_task_ids(Some(&ids)), "#42");
1435    }
1436
1437    #[test]
1438    fn format_age_with_value() {
1439        assert_eq!(format_age(Some(120)), "120s");
1440    }
1441
1442    #[test]
1443    fn format_age_none() {
1444        assert_eq!(format_age(None), "n/a");
1445    }
1446
1447    #[test]
1448    fn project_root_from_board_dir_valid_path() {
1449        let root = Path::new("/project");
1450        let board_dir = root.join(".batty").join("team_config").join("board");
1451        assert_eq!(project_root_from_board_dir(Some(&board_dir)), Some(root));
1452    }
1453
1454    #[test]
1455    fn project_root_from_board_dir_invalid_structure() {
1456        let bad_path = Path::new("/some/random/path");
1457        assert_eq!(project_root_from_board_dir(Some(bad_path)), None);
1458    }
1459
1460    #[test]
1461    fn project_root_from_board_dir_none() {
1462        assert_eq!(project_root_from_board_dir(None), None);
1463    }
1464
1465    // --- Timer state tests ---
1466
1467    #[test]
1468    fn snapshot_and_restore_timer_roundtrip() {
1469        let mut timers = HashMap::new();
1470        timers.insert(
1471            "manager".to_string(),
1472            Instant::now() - Duration::from_secs(30),
1473        );
1474        timers.insert(
1475            "architect".to_string(),
1476            Instant::now() - Duration::from_secs(120),
1477        );
1478
1479        let snapshot = snapshot_timer_state(&timers);
1480        assert!(snapshot["manager"] >= 30);
1481        assert!(snapshot["architect"] >= 120);
1482
1483        let restored = restore_timer_state(snapshot);
1484        // Restored timers should show elapsed >= the original elapsed
1485        assert!(restored["manager"].elapsed().as_secs() >= 30);
1486        assert!(restored["architect"].elapsed().as_secs() >= 120);
1487    }
1488
1489    #[test]
1490    fn update_timer_for_non_standup_member_clears_state() {
1491        let eng = make_member("eng-1", RoleType::Engineer, None);
1492        let role = RoleDef {
1493            name: "eng-1".to_string(),
1494            role_type: RoleType::Engineer,
1495            agent: Some("claude".to_string()),
1496            auth_mode: None,
1497            auth_env: vec![],
1498            instances: 1,
1499            prompt: None,
1500            talks_to: vec![],
1501            channel: None,
1502            channel_config: None,
1503            nudge_interval_secs: None,
1504            receives_standup: Some(false),
1505            standup_interval_secs: None,
1506            owns: Vec::new(),
1507            barrier_group: None,
1508            use_worktrees: false,
1509            ..Default::default()
1510        };
1511        let team_config = TeamConfig {
1512            name: "test".to_string(),
1513            agent: None,
1514            workflow_mode: WorkflowMode::Legacy,
1515            workflow_policy: WorkflowPolicy::default(),
1516            board: BoardConfig::default(),
1517            standup: StandupConfig::default(),
1518            automation: AutomationConfig::default(),
1519            automation_sender: None,
1520            external_senders: Vec::new(),
1521            orchestrator_pane: false,
1522            orchestrator_position: OrchestratorPosition::Bottom,
1523            layout: None,
1524            cost: Default::default(),
1525            grafana: Default::default(),
1526            use_shim: false,
1527            use_sdk_mode: false,
1528            auto_respawn_on_crash: false,
1529            shim_health_check_interval_secs: 60,
1530            shim_health_timeout_secs: 120,
1531            shim_shutdown_timeout_secs: 30,
1532            shim_working_state_timeout_secs: 1800,
1533            pending_queue_max_age_secs: 600,
1534            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1535            retro_min_duration_secs: 60,
1536            roles: vec![role],
1537        };
1538        let members = vec![eng];
1539        let mut paused = HashSet::from(["eng-1".to_string()]);
1540        let mut last = HashMap::from([("eng-1".to_string(), Instant::now())]);
1541
1542        update_timer_for_state(
1543            &team_config,
1544            &members,
1545            &mut paused,
1546            &mut last,
1547            "eng-1",
1548            MemberState::Idle,
1549        );
1550
1551        assert!(!paused.contains("eng-1"));
1552        assert!(!last.contains_key("eng-1"));
1553    }
1554
1555    #[test]
1556    fn standup_interval_for_manager_uses_role_override() {
1557        let member = make_member("manager", RoleType::Manager, None);
1558        let role = RoleDef {
1559            name: "manager".to_string(),
1560            role_type: RoleType::Manager,
1561            agent: Some("claude".to_string()),
1562            auth_mode: None,
1563            auth_env: vec![],
1564            instances: 1,
1565            prompt: None,
1566            talks_to: vec![],
1567            channel: None,
1568            channel_config: None,
1569            nudge_interval_secs: None,
1570            receives_standup: Some(true),
1571            standup_interval_secs: Some(300),
1572            owns: Vec::new(),
1573            barrier_group: None,
1574            use_worktrees: false,
1575            ..Default::default()
1576        };
1577        let team_config = TeamConfig {
1578            name: "test".to_string(),
1579            agent: None,
1580            workflow_mode: WorkflowMode::Legacy,
1581            workflow_policy: WorkflowPolicy::default(),
1582            board: BoardConfig::default(),
1583            standup: StandupConfig {
1584                interval_secs: 600,
1585                output_lines: 30,
1586            },
1587            automation: AutomationConfig::default(),
1588            automation_sender: None,
1589            external_senders: Vec::new(),
1590            orchestrator_pane: false,
1591            orchestrator_position: OrchestratorPosition::Bottom,
1592            layout: None,
1593            cost: Default::default(),
1594            grafana: Default::default(),
1595            use_shim: false,
1596            use_sdk_mode: false,
1597            auto_respawn_on_crash: false,
1598            shim_health_check_interval_secs: 60,
1599            shim_health_timeout_secs: 120,
1600            shim_shutdown_timeout_secs: 30,
1601            shim_working_state_timeout_secs: 1800,
1602            pending_queue_max_age_secs: 600,
1603            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1604            retro_min_duration_secs: 60,
1605            roles: vec![role],
1606        };
1607        let members = vec![member];
1608
1609        let interval = standup_interval_for_member_name(&team_config, &members, "manager");
1610        assert_eq!(interval, Some(Duration::from_secs(300)));
1611    }
1612
1613    #[test]
1614    fn standup_interval_for_manager_falls_back_to_global() {
1615        let member = make_member("manager", RoleType::Manager, None);
1616        let role = RoleDef {
1617            name: "manager".to_string(),
1618            role_type: RoleType::Manager,
1619            agent: Some("claude".to_string()),
1620            auth_mode: None,
1621            auth_env: vec![],
1622            instances: 1,
1623            prompt: None,
1624            talks_to: vec![],
1625            channel: None,
1626            channel_config: None,
1627            nudge_interval_secs: None,
1628            receives_standup: None,      // defaults to true for Manager
1629            standup_interval_secs: None, // falls back to global
1630            owns: Vec::new(),
1631            barrier_group: None,
1632            use_worktrees: false,
1633            ..Default::default()
1634        };
1635        let team_config = TeamConfig {
1636            name: "test".to_string(),
1637            agent: None,
1638            workflow_mode: WorkflowMode::Legacy,
1639            workflow_policy: WorkflowPolicy::default(),
1640            board: BoardConfig::default(),
1641            standup: StandupConfig {
1642                interval_secs: 900,
1643                output_lines: 30,
1644            },
1645            automation: AutomationConfig::default(),
1646            automation_sender: None,
1647            external_senders: Vec::new(),
1648            orchestrator_pane: false,
1649            orchestrator_position: OrchestratorPosition::Bottom,
1650            layout: None,
1651            cost: Default::default(),
1652            grafana: Default::default(),
1653            use_shim: false,
1654            use_sdk_mode: false,
1655            auto_respawn_on_crash: false,
1656            shim_health_check_interval_secs: 60,
1657            shim_health_timeout_secs: 120,
1658            shim_shutdown_timeout_secs: 30,
1659            shim_working_state_timeout_secs: 1800,
1660            pending_queue_max_age_secs: 600,
1661            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1662            retro_min_duration_secs: 60,
1663            roles: vec![role],
1664        };
1665        let members = vec![member];
1666
1667        let interval = standup_interval_for_member_name(&team_config, &members, "manager");
1668        assert_eq!(interval, Some(Duration::from_secs(900)));
1669    }
1670
1671    #[test]
1672    fn standup_interval_for_engineer_returns_none() {
1673        let member = make_member("eng-1", RoleType::Engineer, Some("manager"));
1674        let role = RoleDef {
1675            name: "eng-1".to_string(),
1676            role_type: RoleType::Engineer,
1677            agent: Some("claude".to_string()),
1678            auth_mode: None,
1679            auth_env: vec![],
1680            instances: 1,
1681            prompt: None,
1682            talks_to: vec![],
1683            channel: None,
1684            channel_config: None,
1685            nudge_interval_secs: None,
1686            receives_standup: None, // defaults to false for Engineer
1687            standup_interval_secs: None,
1688            owns: Vec::new(),
1689            barrier_group: None,
1690            use_worktrees: false,
1691            ..Default::default()
1692        };
1693        let team_config = TeamConfig {
1694            name: "test".to_string(),
1695            agent: None,
1696            workflow_mode: WorkflowMode::Legacy,
1697            workflow_policy: WorkflowPolicy::default(),
1698            board: BoardConfig::default(),
1699            standup: StandupConfig::default(),
1700            automation: AutomationConfig::default(),
1701            automation_sender: None,
1702            external_senders: Vec::new(),
1703            orchestrator_pane: false,
1704            orchestrator_position: OrchestratorPosition::Bottom,
1705            layout: None,
1706            cost: Default::default(),
1707            grafana: Default::default(),
1708            use_shim: false,
1709            use_sdk_mode: false,
1710            auto_respawn_on_crash: false,
1711            shim_health_check_interval_secs: 60,
1712            shim_health_timeout_secs: 120,
1713            shim_shutdown_timeout_secs: 30,
1714            shim_working_state_timeout_secs: 1800,
1715            pending_queue_max_age_secs: 600,
1716            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1717            retro_min_duration_secs: 60,
1718            roles: vec![role],
1719        };
1720        let members = vec![member];
1721
1722        let interval = standup_interval_for_member_name(&team_config, &members, "eng-1");
1723        assert_eq!(interval, None);
1724    }
1725
1726    #[test]
1727    fn standup_interval_for_unknown_member_returns_none() {
1728        let team_config = TeamConfig {
1729            name: "test".to_string(),
1730            agent: None,
1731            workflow_mode: WorkflowMode::Legacy,
1732            workflow_policy: WorkflowPolicy::default(),
1733            board: BoardConfig::default(),
1734            standup: StandupConfig::default(),
1735            automation: AutomationConfig::default(),
1736            automation_sender: None,
1737            external_senders: Vec::new(),
1738            orchestrator_pane: false,
1739            orchestrator_position: OrchestratorPosition::Bottom,
1740            layout: None,
1741            cost: Default::default(),
1742            grafana: Default::default(),
1743            use_shim: false,
1744            use_sdk_mode: false,
1745            auto_respawn_on_crash: false,
1746            shim_health_check_interval_secs: 60,
1747            shim_health_timeout_secs: 120,
1748            shim_shutdown_timeout_secs: 30,
1749            shim_working_state_timeout_secs: 1800,
1750            pending_queue_max_age_secs: 600,
1751            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1752            retro_min_duration_secs: 60,
1753            roles: vec![],
1754        };
1755
1756        let interval = standup_interval_for_member_name(&team_config, &[], "nobody");
1757        assert_eq!(interval, None);
1758    }
1759
1760    #[test]
1761    fn maybe_generate_standup_skips_when_standups_disabled() {
1762        let tmp = tempfile::tempdir().unwrap();
1763        let member = make_member("manager", RoleType::Manager, None);
1764        let role = RoleDef {
1765            name: "manager".to_string(),
1766            role_type: RoleType::Manager,
1767            agent: Some("claude".to_string()),
1768            auth_mode: None,
1769            auth_env: vec![],
1770            instances: 1,
1771            prompt: None,
1772            talks_to: vec![],
1773            channel: None,
1774            channel_config: None,
1775            nudge_interval_secs: None,
1776            receives_standup: Some(true),
1777            standup_interval_secs: Some(60),
1778            owns: Vec::new(),
1779            barrier_group: None,
1780            use_worktrees: false,
1781            ..Default::default()
1782        };
1783        let team_config = TeamConfig {
1784            name: "test".to_string(),
1785            agent: None,
1786            workflow_mode: WorkflowMode::Legacy,
1787            workflow_policy: WorkflowPolicy::default(),
1788            board: BoardConfig::default(),
1789            standup: StandupConfig {
1790                interval_secs: 60,
1791                output_lines: 30,
1792            },
1793            automation: AutomationConfig {
1794                standups: false,
1795                ..AutomationConfig::default()
1796            },
1797            automation_sender: None,
1798            external_senders: Vec::new(),
1799            orchestrator_pane: false,
1800            orchestrator_position: OrchestratorPosition::Bottom,
1801            layout: None,
1802            cost: Default::default(),
1803            grafana: Default::default(),
1804            use_shim: false,
1805            use_sdk_mode: false,
1806            auto_respawn_on_crash: false,
1807            shim_health_check_interval_secs: 60,
1808            shim_health_timeout_secs: 120,
1809            shim_shutdown_timeout_secs: 30,
1810            shim_working_state_timeout_secs: 1800,
1811            pending_queue_max_age_secs: 600,
1812            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1813            retro_min_duration_secs: 60,
1814            roles: vec![role],
1815        };
1816        let members = vec![member];
1817        let mut last_standup = HashMap::new();
1818
1819        let generated = maybe_generate_standup(StandupGenerationContext {
1820            project_root: tmp.path(),
1821            team_config: &team_config,
1822            members: &members,
1823            watchers: &HashMap::new(),
1824            states: &HashMap::new(),
1825            pane_map: &HashMap::new(),
1826            telegram_bot: None,
1827            paused_standups: &HashSet::new(),
1828            last_standup: &mut last_standup,
1829            backend_health: &HashMap::new(),
1830        })
1831        .unwrap();
1832
1833        assert!(generated.is_empty());
1834    }
1835
1836    #[test]
1837    fn maybe_generate_standup_skips_paused_recipients() {
1838        let tmp = tempfile::tempdir().unwrap();
1839        let member = make_member("manager", RoleType::Manager, None);
1840        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1841        let role = RoleDef {
1842            name: "manager".to_string(),
1843            role_type: RoleType::Manager,
1844            agent: Some("claude".to_string()),
1845            auth_mode: None,
1846            auth_env: vec![],
1847            instances: 1,
1848            prompt: None,
1849            talks_to: vec![],
1850            channel: None,
1851            channel_config: None,
1852            nudge_interval_secs: None,
1853            receives_standup: Some(true),
1854            standup_interval_secs: Some(1),
1855            owns: Vec::new(),
1856            barrier_group: None,
1857            use_worktrees: false,
1858            ..Default::default()
1859        };
1860        let eng_role = RoleDef {
1861            name: "eng-1".to_string(),
1862            role_type: RoleType::Engineer,
1863            agent: Some("claude".to_string()),
1864            auth_mode: None,
1865            auth_env: vec![],
1866            instances: 1,
1867            prompt: None,
1868            talks_to: vec![],
1869            channel: None,
1870            channel_config: None,
1871            nudge_interval_secs: None,
1872            receives_standup: Some(false),
1873            standup_interval_secs: None,
1874            owns: Vec::new(),
1875            barrier_group: None,
1876            use_worktrees: false,
1877            ..Default::default()
1878        };
1879        let team_config = TeamConfig {
1880            name: "test".to_string(),
1881            agent: None,
1882            workflow_mode: WorkflowMode::Legacy,
1883            workflow_policy: WorkflowPolicy::default(),
1884            board: BoardConfig::default(),
1885            standup: StandupConfig {
1886                interval_secs: 1,
1887                output_lines: 30,
1888            },
1889            automation: AutomationConfig::default(),
1890            automation_sender: None,
1891            external_senders: Vec::new(),
1892            orchestrator_pane: false,
1893            orchestrator_position: OrchestratorPosition::Bottom,
1894            layout: None,
1895            cost: Default::default(),
1896            grafana: Default::default(),
1897            use_shim: false,
1898            use_sdk_mode: false,
1899            auto_respawn_on_crash: false,
1900            shim_health_check_interval_secs: 60,
1901            shim_health_timeout_secs: 120,
1902            shim_shutdown_timeout_secs: 30,
1903            shim_working_state_timeout_secs: 1800,
1904            pending_queue_max_age_secs: 600,
1905            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1906            retro_min_duration_secs: 60,
1907            roles: vec![role, eng_role],
1908        };
1909        let members = vec![member, eng];
1910        let paused = HashSet::from(["manager".to_string()]);
1911        let mut last_standup = HashMap::from([(
1912            "manager".to_string(),
1913            Instant::now() - Duration::from_secs(100),
1914        )]);
1915
1916        let generated = maybe_generate_standup(StandupGenerationContext {
1917            project_root: tmp.path(),
1918            team_config: &team_config,
1919            members: &members,
1920            watchers: &HashMap::new(),
1921            states: &HashMap::new(),
1922            pane_map: &HashMap::new(),
1923            telegram_bot: None,
1924            paused_standups: &paused,
1925            last_standup: &mut last_standup,
1926            backend_health: &HashMap::new(),
1927        })
1928        .unwrap();
1929
1930        assert!(generated.is_empty());
1931    }
1932
1933    #[test]
1934    fn maybe_generate_standup_first_call_seeds_timer_without_generating() {
1935        let tmp = tempfile::tempdir().unwrap();
1936        let member = make_member("manager", RoleType::Manager, None);
1937        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1938        let role = RoleDef {
1939            name: "manager".to_string(),
1940            role_type: RoleType::Manager,
1941            agent: Some("claude".to_string()),
1942            auth_mode: None,
1943            auth_env: vec![],
1944            instances: 1,
1945            prompt: None,
1946            talks_to: vec![],
1947            channel: None,
1948            channel_config: None,
1949            nudge_interval_secs: None,
1950            receives_standup: Some(true),
1951            standup_interval_secs: Some(1),
1952            owns: Vec::new(),
1953            barrier_group: None,
1954            use_worktrees: false,
1955            ..Default::default()
1956        };
1957        let eng_role = RoleDef {
1958            name: "eng-1".to_string(),
1959            role_type: RoleType::Engineer,
1960            agent: Some("claude".to_string()),
1961            auth_mode: None,
1962            auth_env: vec![],
1963            instances: 1,
1964            prompt: None,
1965            talks_to: vec![],
1966            channel: None,
1967            channel_config: None,
1968            nudge_interval_secs: None,
1969            receives_standup: Some(false),
1970            standup_interval_secs: None,
1971            owns: Vec::new(),
1972            barrier_group: None,
1973            use_worktrees: false,
1974            ..Default::default()
1975        };
1976        let team_config = TeamConfig {
1977            name: "test".to_string(),
1978            agent: None,
1979            workflow_mode: WorkflowMode::Legacy,
1980            workflow_policy: WorkflowPolicy::default(),
1981            board: BoardConfig::default(),
1982            standup: StandupConfig {
1983                interval_secs: 1,
1984                output_lines: 30,
1985            },
1986            automation: AutomationConfig::default(),
1987            automation_sender: None,
1988            external_senders: Vec::new(),
1989            orchestrator_pane: false,
1990            orchestrator_position: OrchestratorPosition::Bottom,
1991            layout: None,
1992            cost: Default::default(),
1993            grafana: Default::default(),
1994            use_shim: false,
1995            use_sdk_mode: false,
1996            auto_respawn_on_crash: false,
1997            shim_health_check_interval_secs: 60,
1998            shim_health_timeout_secs: 120,
1999            shim_shutdown_timeout_secs: 30,
2000            shim_working_state_timeout_secs: 1800,
2001            pending_queue_max_age_secs: 600,
2002            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
2003            retro_min_duration_secs: 60,
2004            roles: vec![role, eng_role],
2005        };
2006        let members = vec![member, eng];
2007        let mut last_standup = HashMap::new(); // empty — first call
2008
2009        let generated = maybe_generate_standup(StandupGenerationContext {
2010            project_root: tmp.path(),
2011            team_config: &team_config,
2012            members: &members,
2013            watchers: &HashMap::new(),
2014            states: &HashMap::new(),
2015            pane_map: &HashMap::new(),
2016            telegram_bot: None,
2017            paused_standups: &HashSet::new(),
2018            last_standup: &mut last_standup,
2019            backend_health: &HashMap::new(),
2020        })
2021        .unwrap();
2022
2023        // First call seeds the timer but does not generate
2024        assert!(generated.is_empty());
2025        assert!(last_standup.contains_key("manager"));
2026    }
2027}