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