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