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;
18use crate::tmux;
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    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                if let Some(pane_id) = pane_map.get(&recipient.name) {
326                    if let Err(error) = inject_standup(pane_id, &report) {
327                        warn!(member = %recipient.name, error = %error, "failed to inject standup");
328                    } else {
329                        generated_recipients.push(recipient.name.clone());
330                    }
331                }
332            }
333        }
334
335        last_standup.insert(recipient.name.clone(), Instant::now());
336    }
337
338    if !generated_recipients.is_empty() {
339        tracing::info!("standups generated and delivered");
340    }
341
342    Ok(generated_recipients)
343}
344
345pub(crate) fn update_timer_for_state(
346    team_config: &TeamConfig,
347    members: &[MemberInstance],
348    paused_standups: &mut HashSet<String>,
349    last_standup: &mut HashMap<String, Instant>,
350    member_name: &str,
351    new_state: MemberState,
352) {
353    if standup_interval_for_member_name(team_config, members, member_name).is_none() {
354        paused_standups.remove(member_name);
355        last_standup.remove(member_name);
356        return;
357    }
358
359    match new_state {
360        MemberState::Working => {
361            paused_standups.insert(member_name.to_string());
362            last_standup.remove(member_name);
363        }
364        MemberState::Idle => {
365            let was_paused = paused_standups.remove(member_name);
366            if was_paused || !last_standup.contains_key(member_name) {
367                last_standup.insert(member_name.to_string(), Instant::now());
368            }
369        }
370    }
371}
372
373pub(crate) fn standup_interval_for_member_name(
374    team_config: &TeamConfig,
375    members: &[MemberInstance],
376    member_name: &str,
377) -> Option<Duration> {
378    let member = members.iter().find(|member| member.name == member_name)?;
379    let role_def = team_config
380        .roles
381        .iter()
382        .find(|role| role.name == member.role_name);
383
384    let receives = role_def
385        .and_then(|role| role.receives_standup)
386        .unwrap_or(matches!(
387            member.role_type,
388            RoleType::Manager | RoleType::Architect
389        ));
390    if !receives {
391        return None;
392    }
393
394    let interval_secs = role_def
395        .and_then(|role| role.standup_interval_secs)
396        .unwrap_or(team_config.standup.interval_secs);
397    Some(Duration::from_secs(interval_secs))
398}
399
400pub(crate) fn restore_timer_state(
401    last_standup_elapsed_secs: HashMap<String, u64>,
402) -> HashMap<String, Instant> {
403    last_standup_elapsed_secs
404        .into_iter()
405        .map(|(member, elapsed_secs)| {
406            (
407                member,
408                Instant::now()
409                    .checked_sub(Duration::from_secs(elapsed_secs))
410                    .unwrap_or_else(Instant::now),
411            )
412        })
413        .collect()
414}
415
416pub(crate) fn snapshot_timer_state(
417    last_standup: &HashMap<String, Instant>,
418) -> HashMap<String, u64> {
419    last_standup
420        .iter()
421        .map(|(member, instant)| (member.clone(), instant.elapsed().as_secs()))
422        .collect()
423}
424
425/// Inject standup text into a pane via load-buffer + paste-buffer.
426pub fn inject_standup(pane_id: &str, standup: &str) -> Result<()> {
427    tmux::load_buffer(standup)?;
428    tmux::paste_buffer(pane_id)?;
429    // paste-buffer needs a moment to complete before we press Enter
430    std::thread::sleep(std::time::Duration::from_millis(500));
431    tmux::send_keys(pane_id, "", true)?;
432    Ok(())
433}
434
435/// Write standup text to a timestamped Markdown file under `.batty/standups/`.
436pub fn write_standup_file(project_root: &Path, standup: &str) -> Result<PathBuf> {
437    let standups_dir = project_root.join(".batty").join("standups");
438    std::fs::create_dir_all(&standups_dir)
439        .with_context(|| format!("failed to create {}", standups_dir.display()))?;
440
441    let timestamp = SystemTime::now()
442        .duration_since(UNIX_EPOCH)
443        .context("system clock is before UNIX_EPOCH")?
444        .as_millis();
445    let path = standups_dir.join(format!("{timestamp}.md"));
446
447    std::fs::write(&path, standup)
448        .with_context(|| format!("failed to write {}", path.display()))?;
449    Ok(path)
450}
451
452/// Simple member state enum used by standup reporting.
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(rename_all = "snake_case")]
455pub enum MemberState {
456    Idle,
457    Working,
458}
459
460#[derive(Debug, Clone)]
461struct BoardContext {
462    metrics: metrics::WorkflowMetrics,
463    assigned_task_ids: HashMap<String, Vec<u32>>,
464    idle_with_runnable: HashSet<String>,
465}
466
467fn load_board_context(
468    board_dir: Option<&Path>,
469    members: &[MemberInstance],
470) -> Option<BoardContext> {
471    let board_dir = board_dir?;
472    let tasks_dir = board_dir.join("tasks");
473    if !tasks_dir.is_dir() {
474        return None;
475    }
476
477    let metrics = metrics::compute_metrics(board_dir, members).ok()?;
478    let tasks = task::load_tasks_from_dir(&tasks_dir).ok()?;
479    let mut assigned_task_ids = HashMap::<String, Vec<u32>>::new();
480
481    for task in tasks
482        .into_iter()
483        .filter(|task| !matches!(task.status.as_str(), "done" | "archived"))
484    {
485        let Some(claimed_by) = task.claimed_by else {
486            continue;
487        };
488        assigned_task_ids
489            .entry(claimed_by)
490            .or_default()
491            .push(task.id);
492    }
493
494    for task_ids in assigned_task_ids.values_mut() {
495        task_ids.sort_unstable();
496    }
497
498    Some(BoardContext {
499        idle_with_runnable: metrics.idle_with_runnable.iter().cloned().collect(),
500        metrics,
501        assigned_task_ids,
502    })
503}
504
505fn format_assigned_task_ids(task_ids: Option<&Vec<u32>>) -> String {
506    let Some(task_ids) = task_ids else {
507        return "none".to_string();
508    };
509
510    if task_ids.is_empty() {
511        "none".to_string()
512    } else {
513        task_ids
514            .iter()
515            .map(|task_id| format!("#{task_id}"))
516            .collect::<Vec<_>>()
517            .join(", ")
518    }
519}
520
521fn format_age(age_secs: Option<u64>) -> String {
522    age_secs
523        .map(|secs| format!("{secs}s"))
524        .unwrap_or_else(|| "n/a".to_string())
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use crate::team::config::{
531        AutomationConfig, BoardConfig, OrchestratorPosition, RoleDef, RoleType, StandupConfig,
532        TeamConfig, WorkflowMode, WorkflowPolicy,
533    };
534    use std::path::Path;
535
536    fn make_member(name: &str, role_type: RoleType, reports_to: Option<&str>) -> MemberInstance {
537        MemberInstance {
538            name: name.to_string(),
539            role_name: name.to_string(),
540            role_type,
541            agent: Some("claude".to_string()),
542            prompt: None,
543            reports_to: reports_to.map(|s| s.to_string()),
544            use_worktrees: false,
545        }
546    }
547
548    fn write_task(
549        board_dir: &Path,
550        id: u32,
551        title: &str,
552        status: &str,
553        claimed_by: Option<&str>,
554        blocked: Option<&str>,
555    ) {
556        let tasks_dir = board_dir.join("tasks");
557        std::fs::create_dir_all(&tasks_dir).unwrap();
558        let mut content =
559            format!("---\nid: {id}\ntitle: {title}\nstatus: {status}\npriority: medium\n");
560        if let Some(claimed_by) = claimed_by {
561            content.push_str(&format!("claimed_by: {claimed_by}\n"));
562        }
563        if let Some(blocked) = blocked {
564            content.push_str(&format!("blocked: {blocked}\n"));
565        }
566        content.push_str("class: standard\n---\n\nTask body.\n");
567        std::fs::write(tasks_dir.join(format!("{id:03}-{title}.md")), content).unwrap();
568    }
569
570    #[test]
571    fn standup_shows_only_direct_reports() {
572        let members = vec![
573            make_member("architect", RoleType::Architect, None),
574            make_member("manager", RoleType::Manager, Some("architect")),
575            make_member("eng-1-1", RoleType::Engineer, Some("manager")),
576            make_member("eng-1-2", RoleType::Engineer, Some("manager")),
577        ];
578        let watchers = HashMap::new();
579        let mut states = HashMap::new();
580        states.insert("eng-1-1".to_string(), MemberState::Working);
581        states.insert("eng-1-2".to_string(), MemberState::Idle);
582        states.insert("architect".to_string(), MemberState::Working);
583
584        // Manager standup should only show engineers, not architect
585        let manager = &members[1];
586        let report = generate_standup_for(manager, &members, &watchers, &states, 5);
587        assert!(report.contains("[eng-1-1] status: working"));
588        assert!(report.contains("[eng-1-2] status: idle"));
589        assert!(!report.contains("[architect]"));
590        assert!(report.contains("STANDUP for manager"));
591    }
592
593    #[test]
594    fn standup_architect_sees_manager() {
595        let members = vec![
596            make_member("architect", RoleType::Architect, None),
597            make_member("manager", RoleType::Manager, Some("architect")),
598            make_member("eng-1-1", RoleType::Engineer, Some("manager")),
599        ];
600        let watchers = HashMap::new();
601        let states = HashMap::new();
602
603        let architect = &members[0];
604        let report = generate_standup_for(architect, &members, &watchers, &states, 5);
605        assert!(report.contains("[manager]"));
606        assert!(!report.contains("[eng-1-1]"));
607    }
608
609    #[test]
610    fn standup_no_reports_for_engineer() {
611        let members = vec![
612            make_member("manager", RoleType::Manager, None),
613            make_member("eng-1-1", RoleType::Engineer, Some("manager")),
614        ];
615        let watchers = HashMap::new();
616        let states = HashMap::new();
617
618        let eng = &members[1];
619        let report = generate_standup_for(eng, &members, &watchers, &states, 5);
620        assert!(report.contains("no direct reports"));
621    }
622
623    #[test]
624    fn standup_excludes_user_role() {
625        let members = vec![MemberInstance {
626            name: "human".to_string(),
627            role_name: "human".to_string(),
628            role_type: RoleType::User,
629            agent: None,
630            prompt: None,
631            reports_to: None,
632            use_worktrees: false,
633        }];
634        let report =
635            generate_standup_for(&members[0], &members, &HashMap::new(), &HashMap::new(), 5);
636        assert!(!report.contains("[human]"));
637    }
638
639    #[test]
640    fn test_generate_standup_for_formats_various_member_states() {
641        let members = vec![
642            make_member("manager", RoleType::Manager, None),
643            make_member("eng-idle", RoleType::Engineer, Some("manager")),
644            make_member("eng-working", RoleType::Engineer, Some("manager")),
645        ];
646        let mut states = HashMap::new();
647        states.insert("eng-working".to_string(), MemberState::Working);
648
649        let report = generate_standup_for(&members[0], &members, &HashMap::new(), &states, 5);
650
651        assert!(report.contains("=== STANDUP for manager ==="));
652        assert!(report.contains("[eng-idle] status: idle"));
653        assert!(report.contains("[eng-working] status: working"));
654        assert!(report.contains("=== END STANDUP ==="));
655    }
656
657    #[test]
658    fn test_generate_standup_for_empty_members_returns_no_direct_reports() {
659        let recipient = make_member("manager", RoleType::Manager, None);
660        let report = generate_standup_for(&recipient, &[], &HashMap::new(), &HashMap::new(), 5);
661
662        assert!(report.contains("=== STANDUP for manager ==="));
663        assert!(report.contains("(no direct reports)"));
664        assert!(report.contains("=== END STANDUP ==="));
665    }
666
667    #[test]
668    fn test_generate_standup_for_all_same_status_lists_each_direct_report() {
669        let members = vec![
670            make_member("manager", RoleType::Manager, None),
671            make_member("eng-1", RoleType::Engineer, Some("manager")),
672            make_member("eng-2", RoleType::Engineer, Some("manager")),
673            make_member("eng-3", RoleType::Engineer, Some("manager")),
674        ];
675        let states = HashMap::from([
676            ("eng-1".to_string(), MemberState::Working),
677            ("eng-2".to_string(), MemberState::Working),
678            ("eng-3".to_string(), MemberState::Working),
679        ]);
680
681        let report = generate_standup_for(&members[0], &members, &HashMap::new(), &states, 5);
682
683        assert_eq!(report.matches("status: working").count(), 3);
684        assert!(report.contains("[eng-1] status: working"));
685        assert!(report.contains("[eng-2] status: working"));
686        assert!(report.contains("[eng-3] status: working"));
687    }
688
689    #[test]
690    fn board_aware_standup_appends_task_ids_and_workflow_signals() {
691        let tmp = tempfile::tempdir().unwrap();
692        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
693        write_task(&board_dir, 1, "active", "in-progress", Some("eng-1"), None);
694        write_task(
695            &board_dir,
696            2,
697            "blocked",
698            "blocked",
699            Some("eng-2"),
700            Some("waiting"),
701        );
702        write_task(&board_dir, 3, "review", "review", Some("eng-2"), None);
703        write_task(&board_dir, 4, "runnable", "todo", None, None);
704
705        let members = vec![
706            make_member("manager", RoleType::Manager, None),
707            make_member("eng-1", RoleType::Engineer, Some("manager")),
708            make_member("eng-2", RoleType::Engineer, Some("manager")),
709            make_member("eng-3", RoleType::Engineer, Some("manager")),
710        ];
711        let states = HashMap::from([
712            ("eng-1".to_string(), MemberState::Working),
713            ("eng-2".to_string(), MemberState::Working),
714            ("eng-3".to_string(), MemberState::Idle),
715        ]);
716
717        let report = generate_board_aware_standup_for(
718            &members[0],
719            &members,
720            &HashMap::new(),
721            &states,
722            5,
723            Some(&board_dir),
724            &HashMap::new(),
725        );
726
727        assert!(report.contains("assigned tasks: #1"));
728        assert!(report.contains("assigned tasks: #2, #3"));
729        assert!(report.contains("[eng-3] status: idle"));
730        assert!(report.contains("assigned tasks: none"));
731        assert!(report.contains("warning: idle while runnable work exists on the board"));
732        assert!(report.contains("Workflow signals:"));
733        assert!(report.contains("blocked tasks: 1"));
734        assert!(report.contains("idle with runnable: eng-3"));
735        assert!(report.contains("oldest review age: "));
736        assert!(!report.contains("oldest review age: n/a"));
737    }
738
739    #[test]
740    fn board_aware_standup_falls_back_when_board_is_missing() {
741        let tmp = tempfile::tempdir().unwrap();
742        let missing_board_dir = tmp.path().join("missing-board");
743        let members = vec![
744            make_member("manager", RoleType::Manager, None),
745            make_member("eng-1", RoleType::Engineer, Some("manager")),
746        ];
747        let states = HashMap::from([("eng-1".to_string(), MemberState::Idle)]);
748
749        let report = generate_board_aware_standup_for(
750            &members[0],
751            &members,
752            &HashMap::new(),
753            &states,
754            5,
755            Some(&missing_board_dir),
756            &HashMap::new(),
757        );
758
759        assert!(report.contains("[eng-1] status: idle"));
760        assert!(!report.contains("assigned tasks:"));
761        assert!(!report.contains("Workflow signals:"));
762        assert!(!report.contains("warning: idle while runnable work exists on the board"));
763    }
764
765    #[test]
766    fn board_aware_standup_prepends_review_policy_context() {
767        let tmp = tempfile::tempdir().unwrap();
768        let team_config_dir = tmp.path().join(".batty").join("team_config");
769        let board_dir = team_config_dir.join("board");
770        std::fs::create_dir_all(&board_dir).unwrap();
771        std::fs::write(
772            team_config_dir.join("review_policy.md"),
773            "Approve only after tests pass.",
774        )
775        .unwrap();
776
777        let members = vec![
778            make_member("manager", RoleType::Manager, None),
779            make_member("eng-1", RoleType::Engineer, Some("manager")),
780        ];
781        let states = HashMap::from([("eng-1".to_string(), MemberState::Idle)]);
782
783        let report = generate_board_aware_standup_for(
784            &members[0],
785            &members,
786            &HashMap::new(),
787            &states,
788            5,
789            Some(&board_dir),
790            &HashMap::new(),
791        );
792
793        assert!(report.starts_with("Review policy context:\nApprove only after tests pass."));
794        assert!(report.contains("=== STANDUP for manager ==="));
795    }
796
797    #[test]
798    fn board_aware_standup_reloads_updated_review_policy_contents() {
799        let tmp = tempfile::tempdir().unwrap();
800        let team_config_dir = tmp.path().join(".batty").join("team_config");
801        let board_dir = team_config_dir.join("board");
802        std::fs::create_dir_all(&board_dir).unwrap();
803        let policy_path = team_config_dir.join("review_policy.md");
804        std::fs::write(&policy_path, "Initial policy").unwrap();
805
806        let members = vec![
807            make_member("manager", RoleType::Manager, None),
808            make_member("eng-1", RoleType::Engineer, Some("manager")),
809        ];
810        let states = HashMap::from([("eng-1".to_string(), MemberState::Idle)]);
811
812        let first = generate_board_aware_standup_for(
813            &members[0],
814            &members,
815            &HashMap::new(),
816            &states,
817            5,
818            Some(&board_dir),
819            &HashMap::new(),
820        );
821        std::fs::write(&policy_path, "Updated policy").unwrap();
822        let second = generate_board_aware_standup_for(
823            &members[0],
824            &members,
825            &HashMap::new(),
826            &states,
827            5,
828            Some(&board_dir),
829            &HashMap::new(),
830        );
831
832        assert!(first.contains("Initial policy"));
833        assert!(second.contains("Updated policy"));
834        assert!(!second.contains("Initial policy"));
835    }
836
837    #[test]
838    fn write_standup_file_creates_timestamped_markdown_in_batty_dir() {
839        let tmp = tempfile::tempdir().unwrap();
840        let report = "=== STANDUP for user ===\n[architect] status: working\n";
841        let expected_dir = tmp.path().join(".batty").join("standups");
842
843        let path = write_standup_file(tmp.path(), report).unwrap();
844
845        assert_eq!(path.parent(), Some(expected_dir.as_path()));
846        assert_eq!(path.extension().and_then(|ext| ext.to_str()), Some("md"));
847        assert_eq!(std::fs::read_to_string(&path).unwrap(), report);
848    }
849
850    #[test]
851    fn update_timer_for_state_pauses_while_working_and_restarts_on_idle() {
852        let member = make_member("manager", RoleType::Manager, None);
853        let role = RoleDef {
854            name: "manager".to_string(),
855            role_type: RoleType::Manager,
856            agent: Some("claude".to_string()),
857            instances: 1,
858            prompt: None,
859            talks_to: vec![],
860            channel: None,
861            channel_config: None,
862            nudge_interval_secs: None,
863            receives_standup: Some(true),
864            standup_interval_secs: Some(600),
865            owns: Vec::new(),
866            use_worktrees: false,
867        };
868        let team_config = TeamConfig {
869            name: "test".to_string(),
870            agent: None,
871            workflow_mode: WorkflowMode::Legacy,
872            workflow_policy: WorkflowPolicy::default(),
873            board: BoardConfig::default(),
874            standup: StandupConfig::default(),
875            automation: AutomationConfig::default(),
876            automation_sender: None,
877            external_senders: Vec::new(),
878            orchestrator_pane: true,
879            orchestrator_position: OrchestratorPosition::Bottom,
880            layout: None,
881            cost: Default::default(),
882            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
883            retro_min_duration_secs: 60,
884            roles: vec![role],
885        };
886        let members = vec![member];
887        let mut paused_standups = HashSet::new();
888        let mut last_standup = HashMap::from([(
889            "manager".to_string(),
890            Instant::now() - Duration::from_secs(120),
891        )]);
892
893        update_timer_for_state(
894            &team_config,
895            &members,
896            &mut paused_standups,
897            &mut last_standup,
898            "manager",
899            MemberState::Working,
900        );
901
902        assert!(paused_standups.contains("manager"));
903        assert!(!last_standup.contains_key("manager"));
904
905        update_timer_for_state(
906            &team_config,
907            &members,
908            &mut paused_standups,
909            &mut last_standup,
910            "manager",
911            MemberState::Idle,
912        );
913
914        assert!(!paused_standups.contains("manager"));
915        assert!(last_standup["manager"].elapsed() < Duration::from_secs(1));
916    }
917
918    #[test]
919    fn maybe_generate_standup_skips_when_global_interval_is_zero() {
920        let tmp = tempfile::tempdir().unwrap();
921        let member = make_member("manager", RoleType::Manager, None);
922        let role = RoleDef {
923            name: "manager".to_string(),
924            role_type: RoleType::Manager,
925            agent: Some("claude".to_string()),
926            instances: 1,
927            prompt: None,
928            talks_to: vec![],
929            channel: None,
930            channel_config: None,
931            nudge_interval_secs: None,
932            receives_standup: Some(true),
933            standup_interval_secs: Some(600),
934            owns: Vec::new(),
935            use_worktrees: false,
936        };
937        let team_config = TeamConfig {
938            name: "test".to_string(),
939            agent: None,
940            workflow_mode: WorkflowMode::Legacy,
941            workflow_policy: WorkflowPolicy::default(),
942            board: BoardConfig::default(),
943            standup: StandupConfig {
944                interval_secs: 0,
945                output_lines: 30,
946            },
947            automation: AutomationConfig::default(),
948            automation_sender: None,
949            external_senders: Vec::new(),
950            orchestrator_pane: false,
951            orchestrator_position: OrchestratorPosition::Bottom,
952            layout: None,
953            cost: Default::default(),
954            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
955            retro_min_duration_secs: 60,
956            roles: vec![role],
957        };
958        let members = vec![member];
959        let mut last_standup = HashMap::new();
960
961        let generated = maybe_generate_standup(StandupGenerationContext {
962            project_root: tmp.path(),
963            team_config: &team_config,
964            members: &members,
965            watchers: &HashMap::new(),
966            states: &HashMap::new(),
967            pane_map: &HashMap::new(),
968            telegram_bot: None,
969            paused_standups: &HashSet::new(),
970            last_standup: &mut last_standup,
971            backend_health: &HashMap::new(),
972        })
973        .unwrap();
974
975        assert!(generated.is_empty());
976        assert!(last_standup.is_empty());
977    }
978
979    #[test]
980    fn maybe_generate_standup_writes_user_report_to_file_without_telegram_bot() {
981        let tmp = tempfile::tempdir().unwrap();
982        let user = MemberInstance {
983            name: "user".to_string(),
984            role_name: "user".to_string(),
985            role_type: RoleType::User,
986            agent: None,
987            prompt: None,
988            reports_to: None,
989            use_worktrees: false,
990        };
991        let architect = MemberInstance {
992            name: "architect".to_string(),
993            role_name: "architect".to_string(),
994            role_type: RoleType::Architect,
995            agent: Some("claude".to_string()),
996            prompt: None,
997            reports_to: Some("user".to_string()),
998            use_worktrees: false,
999        };
1000        let user_role = RoleDef {
1001            name: "user".to_string(),
1002            role_type: RoleType::User,
1003            agent: None,
1004            instances: 1,
1005            prompt: None,
1006            talks_to: vec!["architect".to_string()],
1007            channel: None,
1008            channel_config: None,
1009            nudge_interval_secs: None,
1010            receives_standup: Some(true),
1011            standup_interval_secs: Some(1),
1012            owns: Vec::new(),
1013            use_worktrees: false,
1014        };
1015        let architect_role = RoleDef {
1016            name: "architect".to_string(),
1017            role_type: RoleType::Architect,
1018            agent: Some("claude".to_string()),
1019            instances: 1,
1020            prompt: None,
1021            talks_to: vec![],
1022            channel: None,
1023            channel_config: None,
1024            nudge_interval_secs: None,
1025            receives_standup: Some(false),
1026            standup_interval_secs: None,
1027            owns: Vec::new(),
1028            use_worktrees: false,
1029        };
1030        let team_config = TeamConfig {
1031            name: "test".to_string(),
1032            agent: None,
1033            workflow_mode: WorkflowMode::Legacy,
1034            workflow_policy: WorkflowPolicy::default(),
1035            board: BoardConfig::default(),
1036            standup: StandupConfig {
1037                interval_secs: 1,
1038                output_lines: 30,
1039            },
1040            automation: AutomationConfig::default(),
1041            automation_sender: None,
1042            external_senders: Vec::new(),
1043            orchestrator_pane: false,
1044            orchestrator_position: OrchestratorPosition::Bottom,
1045            layout: None,
1046            cost: Default::default(),
1047            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1048            retro_min_duration_secs: 60,
1049            roles: vec![user_role, architect_role],
1050        };
1051        let members = vec![user.clone(), architect];
1052        let states = HashMap::from([("architect".to_string(), MemberState::Working)]);
1053        let mut last_standup =
1054            HashMap::from([(user.name.clone(), Instant::now() - Duration::from_secs(5))]);
1055
1056        let generated = maybe_generate_standup(StandupGenerationContext {
1057            project_root: tmp.path(),
1058            team_config: &team_config,
1059            members: &members,
1060            watchers: &HashMap::new(),
1061            states: &states,
1062            pane_map: &HashMap::new(),
1063            telegram_bot: None,
1064            paused_standups: &HashSet::new(),
1065            last_standup: &mut last_standup,
1066            backend_health: &HashMap::new(),
1067        })
1068        .unwrap();
1069
1070        assert_eq!(generated, vec!["user".to_string()]);
1071
1072        let standups_dir = tmp.path().join(".batty").join("standups");
1073        let entries = std::fs::read_dir(&standups_dir)
1074            .unwrap()
1075            .collect::<std::io::Result<Vec<_>>>()
1076            .unwrap();
1077        assert_eq!(entries.len(), 1);
1078
1079        let report = std::fs::read_to_string(entries[0].path()).unwrap();
1080        assert!(report.contains("=== STANDUP for user ==="));
1081        assert!(report.contains("[architect] status: working"));
1082    }
1083
1084    #[test]
1085    fn standup_includes_backend_health_warning_for_unhealthy_agent() {
1086        let manager = make_member("manager", RoleType::Manager, None);
1087        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1088        let members = vec![manager.clone(), eng.clone()];
1089        let states = HashMap::new();
1090
1091        let mut backend_health = HashMap::new();
1092        backend_health.insert(
1093            "eng-1".to_string(),
1094            crate::agent::BackendHealth::Unreachable,
1095        );
1096
1097        let report = generate_board_aware_standup_for(
1098            &manager,
1099            &members,
1100            &HashMap::new(),
1101            &states,
1102            5,
1103            None,
1104            &backend_health,
1105        );
1106
1107        assert!(
1108            report.contains("backend: unreachable"),
1109            "standup should warn about unhealthy backend: {report}"
1110        );
1111    }
1112
1113    #[test]
1114    fn standup_omits_backend_health_when_healthy() {
1115        let manager = make_member("manager", RoleType::Manager, None);
1116        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1117        let members = vec![manager.clone(), eng.clone()];
1118        let states = HashMap::new();
1119
1120        let mut backend_health = HashMap::new();
1121        backend_health.insert("eng-1".to_string(), crate::agent::BackendHealth::Healthy);
1122
1123        let report = generate_board_aware_standup_for(
1124            &manager,
1125            &members,
1126            &HashMap::new(),
1127            &states,
1128            5,
1129            None,
1130            &backend_health,
1131        );
1132
1133        assert!(
1134            !report.contains("backend:"),
1135            "standup should not mention backend when healthy: {report}"
1136        );
1137    }
1138
1139    // --- New tests for content generation edge cases ---
1140
1141    #[test]
1142    fn standup_shows_degraded_backend_health() {
1143        let manager = make_member("manager", RoleType::Manager, None);
1144        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1145        let members = vec![manager.clone(), eng];
1146        let mut backend_health = HashMap::new();
1147        backend_health.insert("eng-1".to_string(), crate::agent::BackendHealth::Degraded);
1148
1149        let report = generate_board_aware_standup_for(
1150            &manager,
1151            &members,
1152            &HashMap::new(),
1153            &HashMap::new(),
1154            5,
1155            None,
1156            &backend_health,
1157        );
1158
1159        assert!(
1160            report.contains("backend: degraded"),
1161            "standup should warn about degraded backend: {report}"
1162        );
1163    }
1164
1165    #[test]
1166    fn standup_default_state_is_idle() {
1167        let manager = make_member("manager", RoleType::Manager, None);
1168        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1169        let members = vec![manager.clone(), eng];
1170        // No state entry for eng-1 → defaults to Idle
1171        let report = generate_standup_for(&manager, &members, &HashMap::new(), &HashMap::new(), 5);
1172        assert!(report.contains("[eng-1] status: idle"));
1173    }
1174
1175    #[test]
1176    fn board_aware_standup_all_done_tasks_shows_none_assigned() {
1177        let tmp = tempfile::tempdir().unwrap();
1178        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1179        // Only a "done" task — should not show in assigned list
1180        write_task(&board_dir, 10, "finished", "done", Some("eng-1"), None);
1181
1182        let members = vec![
1183            make_member("manager", RoleType::Manager, None),
1184            make_member("eng-1", RoleType::Engineer, Some("manager")),
1185        ];
1186        let states = HashMap::from([("eng-1".to_string(), MemberState::Working)]);
1187
1188        let report = generate_board_aware_standup_for(
1189            &members[0],
1190            &members,
1191            &HashMap::new(),
1192            &states,
1193            5,
1194            Some(&board_dir),
1195            &HashMap::new(),
1196        );
1197
1198        assert!(report.contains("assigned tasks: none"));
1199    }
1200
1201    #[test]
1202    fn board_aware_standup_multiple_tasks_sorted_ascending() {
1203        let tmp = tempfile::tempdir().unwrap();
1204        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1205        write_task(&board_dir, 5, "second", "in-progress", Some("eng-1"), None);
1206        write_task(&board_dir, 2, "first", "in-progress", Some("eng-1"), None);
1207        write_task(&board_dir, 9, "third", "review", Some("eng-1"), None);
1208
1209        let members = vec![
1210            make_member("manager", RoleType::Manager, None),
1211            make_member("eng-1", RoleType::Engineer, Some("manager")),
1212        ];
1213
1214        let report = generate_board_aware_standup_for(
1215            &members[0],
1216            &members,
1217            &HashMap::new(),
1218            &HashMap::new(),
1219            5,
1220            Some(&board_dir),
1221            &HashMap::new(),
1222        );
1223
1224        assert!(report.contains("assigned tasks: #2, #5, #9"));
1225    }
1226
1227    #[test]
1228    fn board_aware_standup_no_idle_warning_when_no_runnable_work() {
1229        let tmp = tempfile::tempdir().unwrap();
1230        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1231        // All tasks done or in-progress — nothing runnable
1232        write_task(&board_dir, 1, "active", "in-progress", Some("eng-1"), None);
1233        write_task(&board_dir, 2, "done-task", "done", Some("eng-2"), None);
1234
1235        let members = vec![
1236            make_member("manager", RoleType::Manager, None),
1237            make_member("eng-1", RoleType::Engineer, Some("manager")),
1238            make_member("eng-2", RoleType::Engineer, Some("manager")),
1239        ];
1240        let states = HashMap::from([("eng-2".to_string(), MemberState::Idle)]);
1241
1242        let report = generate_board_aware_standup_for(
1243            &members[0],
1244            &members,
1245            &HashMap::new(),
1246            &states,
1247            5,
1248            Some(&board_dir),
1249            &HashMap::new(),
1250        );
1251
1252        assert!(!report.contains("warning: idle while runnable work exists"));
1253    }
1254
1255    #[test]
1256    fn board_aware_standup_review_pipeline_metrics_when_present() {
1257        let tmp = tempfile::tempdir().unwrap();
1258        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1259        // One review task triggers oldest review age
1260        write_task(&board_dir, 1, "in-review", "review", Some("eng-1"), None);
1261        // One runnable task
1262        write_task(&board_dir, 2, "ready", "todo", None, None);
1263
1264        let members = vec![
1265            make_member("manager", RoleType::Manager, None),
1266            make_member("eng-1", RoleType::Engineer, Some("manager")),
1267        ];
1268
1269        let report = generate_board_aware_standup_for(
1270            &members[0],
1271            &members,
1272            &HashMap::new(),
1273            &HashMap::new(),
1274            5,
1275            Some(&board_dir),
1276            &HashMap::new(),
1277        );
1278
1279        assert!(report.contains("Workflow signals:"));
1280        assert!(report.contains("blocked tasks: 0"));
1281        assert!(report.contains("oldest review age:"));
1282    }
1283
1284    #[test]
1285    fn format_assigned_task_ids_empty_vec() {
1286        let ids: Vec<u32> = vec![];
1287        assert_eq!(format_assigned_task_ids(Some(&ids)), "none");
1288    }
1289
1290    #[test]
1291    fn format_assigned_task_ids_none() {
1292        assert_eq!(format_assigned_task_ids(None), "none");
1293    }
1294
1295    #[test]
1296    fn format_assigned_task_ids_single() {
1297        let ids = vec![42];
1298        assert_eq!(format_assigned_task_ids(Some(&ids)), "#42");
1299    }
1300
1301    #[test]
1302    fn format_age_with_value() {
1303        assert_eq!(format_age(Some(120)), "120s");
1304    }
1305
1306    #[test]
1307    fn format_age_none() {
1308        assert_eq!(format_age(None), "n/a");
1309    }
1310
1311    #[test]
1312    fn project_root_from_board_dir_valid_path() {
1313        let root = Path::new("/project");
1314        let board_dir = root.join(".batty").join("team_config").join("board");
1315        assert_eq!(project_root_from_board_dir(Some(&board_dir)), Some(root));
1316    }
1317
1318    #[test]
1319    fn project_root_from_board_dir_invalid_structure() {
1320        let bad_path = Path::new("/some/random/path");
1321        assert_eq!(project_root_from_board_dir(Some(bad_path)), None);
1322    }
1323
1324    #[test]
1325    fn project_root_from_board_dir_none() {
1326        assert_eq!(project_root_from_board_dir(None), None);
1327    }
1328
1329    // --- Timer state tests ---
1330
1331    #[test]
1332    fn snapshot_and_restore_timer_roundtrip() {
1333        let mut timers = HashMap::new();
1334        timers.insert(
1335            "manager".to_string(),
1336            Instant::now() - Duration::from_secs(30),
1337        );
1338        timers.insert(
1339            "architect".to_string(),
1340            Instant::now() - Duration::from_secs(120),
1341        );
1342
1343        let snapshot = snapshot_timer_state(&timers);
1344        assert!(snapshot["manager"] >= 30);
1345        assert!(snapshot["architect"] >= 120);
1346
1347        let restored = restore_timer_state(snapshot);
1348        // Restored timers should show elapsed >= the original elapsed
1349        assert!(restored["manager"].elapsed().as_secs() >= 30);
1350        assert!(restored["architect"].elapsed().as_secs() >= 120);
1351    }
1352
1353    #[test]
1354    fn update_timer_for_non_standup_member_clears_state() {
1355        let eng = make_member("eng-1", RoleType::Engineer, None);
1356        let role = RoleDef {
1357            name: "eng-1".to_string(),
1358            role_type: RoleType::Engineer,
1359            agent: Some("claude".to_string()),
1360            instances: 1,
1361            prompt: None,
1362            talks_to: vec![],
1363            channel: None,
1364            channel_config: None,
1365            nudge_interval_secs: None,
1366            receives_standup: Some(false),
1367            standup_interval_secs: None,
1368            owns: Vec::new(),
1369            use_worktrees: false,
1370        };
1371        let team_config = TeamConfig {
1372            name: "test".to_string(),
1373            agent: None,
1374            workflow_mode: WorkflowMode::Legacy,
1375            workflow_policy: WorkflowPolicy::default(),
1376            board: BoardConfig::default(),
1377            standup: StandupConfig::default(),
1378            automation: AutomationConfig::default(),
1379            automation_sender: None,
1380            external_senders: Vec::new(),
1381            orchestrator_pane: false,
1382            orchestrator_position: OrchestratorPosition::Bottom,
1383            layout: None,
1384            cost: Default::default(),
1385            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1386            retro_min_duration_secs: 60,
1387            roles: vec![role],
1388        };
1389        let members = vec![eng];
1390        let mut paused = HashSet::from(["eng-1".to_string()]);
1391        let mut last = HashMap::from([("eng-1".to_string(), Instant::now())]);
1392
1393        update_timer_for_state(
1394            &team_config,
1395            &members,
1396            &mut paused,
1397            &mut last,
1398            "eng-1",
1399            MemberState::Idle,
1400        );
1401
1402        assert!(!paused.contains("eng-1"));
1403        assert!(!last.contains_key("eng-1"));
1404    }
1405
1406    #[test]
1407    fn standup_interval_for_manager_uses_role_override() {
1408        let member = make_member("manager", RoleType::Manager, None);
1409        let role = RoleDef {
1410            name: "manager".to_string(),
1411            role_type: RoleType::Manager,
1412            agent: Some("claude".to_string()),
1413            instances: 1,
1414            prompt: None,
1415            talks_to: vec![],
1416            channel: None,
1417            channel_config: None,
1418            nudge_interval_secs: None,
1419            receives_standup: Some(true),
1420            standup_interval_secs: Some(300),
1421            owns: Vec::new(),
1422            use_worktrees: false,
1423        };
1424        let team_config = TeamConfig {
1425            name: "test".to_string(),
1426            agent: None,
1427            workflow_mode: WorkflowMode::Legacy,
1428            workflow_policy: WorkflowPolicy::default(),
1429            board: BoardConfig::default(),
1430            standup: StandupConfig {
1431                interval_secs: 600,
1432                output_lines: 30,
1433            },
1434            automation: AutomationConfig::default(),
1435            automation_sender: None,
1436            external_senders: Vec::new(),
1437            orchestrator_pane: false,
1438            orchestrator_position: OrchestratorPosition::Bottom,
1439            layout: None,
1440            cost: Default::default(),
1441            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1442            retro_min_duration_secs: 60,
1443            roles: vec![role],
1444        };
1445        let members = vec![member];
1446
1447        let interval = standup_interval_for_member_name(&team_config, &members, "manager");
1448        assert_eq!(interval, Some(Duration::from_secs(300)));
1449    }
1450
1451    #[test]
1452    fn standup_interval_for_manager_falls_back_to_global() {
1453        let member = make_member("manager", RoleType::Manager, None);
1454        let role = RoleDef {
1455            name: "manager".to_string(),
1456            role_type: RoleType::Manager,
1457            agent: Some("claude".to_string()),
1458            instances: 1,
1459            prompt: None,
1460            talks_to: vec![],
1461            channel: None,
1462            channel_config: None,
1463            nudge_interval_secs: None,
1464            receives_standup: None,      // defaults to true for Manager
1465            standup_interval_secs: None, // falls back to global
1466            owns: Vec::new(),
1467            use_worktrees: false,
1468        };
1469        let team_config = TeamConfig {
1470            name: "test".to_string(),
1471            agent: None,
1472            workflow_mode: WorkflowMode::Legacy,
1473            workflow_policy: WorkflowPolicy::default(),
1474            board: BoardConfig::default(),
1475            standup: StandupConfig {
1476                interval_secs: 900,
1477                output_lines: 30,
1478            },
1479            automation: AutomationConfig::default(),
1480            automation_sender: None,
1481            external_senders: Vec::new(),
1482            orchestrator_pane: false,
1483            orchestrator_position: OrchestratorPosition::Bottom,
1484            layout: None,
1485            cost: Default::default(),
1486            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1487            retro_min_duration_secs: 60,
1488            roles: vec![role],
1489        };
1490        let members = vec![member];
1491
1492        let interval = standup_interval_for_member_name(&team_config, &members, "manager");
1493        assert_eq!(interval, Some(Duration::from_secs(900)));
1494    }
1495
1496    #[test]
1497    fn standup_interval_for_engineer_returns_none() {
1498        let member = make_member("eng-1", RoleType::Engineer, Some("manager"));
1499        let role = RoleDef {
1500            name: "eng-1".to_string(),
1501            role_type: RoleType::Engineer,
1502            agent: Some("claude".to_string()),
1503            instances: 1,
1504            prompt: None,
1505            talks_to: vec![],
1506            channel: None,
1507            channel_config: None,
1508            nudge_interval_secs: None,
1509            receives_standup: None, // defaults to false for Engineer
1510            standup_interval_secs: None,
1511            owns: Vec::new(),
1512            use_worktrees: false,
1513        };
1514        let team_config = TeamConfig {
1515            name: "test".to_string(),
1516            agent: None,
1517            workflow_mode: WorkflowMode::Legacy,
1518            workflow_policy: WorkflowPolicy::default(),
1519            board: BoardConfig::default(),
1520            standup: StandupConfig::default(),
1521            automation: AutomationConfig::default(),
1522            automation_sender: None,
1523            external_senders: Vec::new(),
1524            orchestrator_pane: false,
1525            orchestrator_position: OrchestratorPosition::Bottom,
1526            layout: None,
1527            cost: Default::default(),
1528            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1529            retro_min_duration_secs: 60,
1530            roles: vec![role],
1531        };
1532        let members = vec![member];
1533
1534        let interval = standup_interval_for_member_name(&team_config, &members, "eng-1");
1535        assert_eq!(interval, None);
1536    }
1537
1538    #[test]
1539    fn standup_interval_for_unknown_member_returns_none() {
1540        let team_config = TeamConfig {
1541            name: "test".to_string(),
1542            agent: None,
1543            workflow_mode: WorkflowMode::Legacy,
1544            workflow_policy: WorkflowPolicy::default(),
1545            board: BoardConfig::default(),
1546            standup: StandupConfig::default(),
1547            automation: AutomationConfig::default(),
1548            automation_sender: None,
1549            external_senders: Vec::new(),
1550            orchestrator_pane: false,
1551            orchestrator_position: OrchestratorPosition::Bottom,
1552            layout: None,
1553            cost: Default::default(),
1554            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1555            retro_min_duration_secs: 60,
1556            roles: vec![],
1557        };
1558
1559        let interval = standup_interval_for_member_name(&team_config, &[], "nobody");
1560        assert_eq!(interval, None);
1561    }
1562
1563    #[test]
1564    fn maybe_generate_standup_skips_when_standups_disabled() {
1565        let tmp = tempfile::tempdir().unwrap();
1566        let member = make_member("manager", RoleType::Manager, None);
1567        let role = RoleDef {
1568            name: "manager".to_string(),
1569            role_type: RoleType::Manager,
1570            agent: Some("claude".to_string()),
1571            instances: 1,
1572            prompt: None,
1573            talks_to: vec![],
1574            channel: None,
1575            channel_config: None,
1576            nudge_interval_secs: None,
1577            receives_standup: Some(true),
1578            standup_interval_secs: Some(60),
1579            owns: Vec::new(),
1580            use_worktrees: false,
1581        };
1582        let team_config = TeamConfig {
1583            name: "test".to_string(),
1584            agent: None,
1585            workflow_mode: WorkflowMode::Legacy,
1586            workflow_policy: WorkflowPolicy::default(),
1587            board: BoardConfig::default(),
1588            standup: StandupConfig {
1589                interval_secs: 60,
1590                output_lines: 30,
1591            },
1592            automation: AutomationConfig {
1593                standups: false,
1594                ..AutomationConfig::default()
1595            },
1596            automation_sender: None,
1597            external_senders: Vec::new(),
1598            orchestrator_pane: false,
1599            orchestrator_position: OrchestratorPosition::Bottom,
1600            layout: None,
1601            cost: Default::default(),
1602            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1603            retro_min_duration_secs: 60,
1604            roles: vec![role],
1605        };
1606        let members = vec![member];
1607        let mut last_standup = HashMap::new();
1608
1609        let generated = maybe_generate_standup(StandupGenerationContext {
1610            project_root: tmp.path(),
1611            team_config: &team_config,
1612            members: &members,
1613            watchers: &HashMap::new(),
1614            states: &HashMap::new(),
1615            pane_map: &HashMap::new(),
1616            telegram_bot: None,
1617            paused_standups: &HashSet::new(),
1618            last_standup: &mut last_standup,
1619            backend_health: &HashMap::new(),
1620        })
1621        .unwrap();
1622
1623        assert!(generated.is_empty());
1624    }
1625
1626    #[test]
1627    fn maybe_generate_standup_skips_paused_recipients() {
1628        let tmp = tempfile::tempdir().unwrap();
1629        let member = make_member("manager", RoleType::Manager, None);
1630        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1631        let role = RoleDef {
1632            name: "manager".to_string(),
1633            role_type: RoleType::Manager,
1634            agent: Some("claude".to_string()),
1635            instances: 1,
1636            prompt: None,
1637            talks_to: vec![],
1638            channel: None,
1639            channel_config: None,
1640            nudge_interval_secs: None,
1641            receives_standup: Some(true),
1642            standup_interval_secs: Some(1),
1643            owns: Vec::new(),
1644            use_worktrees: false,
1645        };
1646        let eng_role = RoleDef {
1647            name: "eng-1".to_string(),
1648            role_type: RoleType::Engineer,
1649            agent: Some("claude".to_string()),
1650            instances: 1,
1651            prompt: None,
1652            talks_to: vec![],
1653            channel: None,
1654            channel_config: None,
1655            nudge_interval_secs: None,
1656            receives_standup: Some(false),
1657            standup_interval_secs: None,
1658            owns: Vec::new(),
1659            use_worktrees: false,
1660        };
1661        let team_config = TeamConfig {
1662            name: "test".to_string(),
1663            agent: None,
1664            workflow_mode: WorkflowMode::Legacy,
1665            workflow_policy: WorkflowPolicy::default(),
1666            board: BoardConfig::default(),
1667            standup: StandupConfig {
1668                interval_secs: 1,
1669                output_lines: 30,
1670            },
1671            automation: AutomationConfig::default(),
1672            automation_sender: None,
1673            external_senders: Vec::new(),
1674            orchestrator_pane: false,
1675            orchestrator_position: OrchestratorPosition::Bottom,
1676            layout: None,
1677            cost: Default::default(),
1678            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1679            retro_min_duration_secs: 60,
1680            roles: vec![role, eng_role],
1681        };
1682        let members = vec![member, eng];
1683        let paused = HashSet::from(["manager".to_string()]);
1684        let mut last_standup = HashMap::from([(
1685            "manager".to_string(),
1686            Instant::now() - Duration::from_secs(100),
1687        )]);
1688
1689        let generated = maybe_generate_standup(StandupGenerationContext {
1690            project_root: tmp.path(),
1691            team_config: &team_config,
1692            members: &members,
1693            watchers: &HashMap::new(),
1694            states: &HashMap::new(),
1695            pane_map: &HashMap::new(),
1696            telegram_bot: None,
1697            paused_standups: &paused,
1698            last_standup: &mut last_standup,
1699            backend_health: &HashMap::new(),
1700        })
1701        .unwrap();
1702
1703        assert!(generated.is_empty());
1704    }
1705
1706    #[test]
1707    fn maybe_generate_standup_first_call_seeds_timer_without_generating() {
1708        let tmp = tempfile::tempdir().unwrap();
1709        let member = make_member("manager", RoleType::Manager, None);
1710        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1711        let role = RoleDef {
1712            name: "manager".to_string(),
1713            role_type: RoleType::Manager,
1714            agent: Some("claude".to_string()),
1715            instances: 1,
1716            prompt: None,
1717            talks_to: vec![],
1718            channel: None,
1719            channel_config: None,
1720            nudge_interval_secs: None,
1721            receives_standup: Some(true),
1722            standup_interval_secs: Some(1),
1723            owns: Vec::new(),
1724            use_worktrees: false,
1725        };
1726        let eng_role = RoleDef {
1727            name: "eng-1".to_string(),
1728            role_type: RoleType::Engineer,
1729            agent: Some("claude".to_string()),
1730            instances: 1,
1731            prompt: None,
1732            talks_to: vec![],
1733            channel: None,
1734            channel_config: None,
1735            nudge_interval_secs: None,
1736            receives_standup: Some(false),
1737            standup_interval_secs: None,
1738            owns: Vec::new(),
1739            use_worktrees: false,
1740        };
1741        let team_config = TeamConfig {
1742            name: "test".to_string(),
1743            agent: None,
1744            workflow_mode: WorkflowMode::Legacy,
1745            workflow_policy: WorkflowPolicy::default(),
1746            board: BoardConfig::default(),
1747            standup: StandupConfig {
1748                interval_secs: 1,
1749                output_lines: 30,
1750            },
1751            automation: AutomationConfig::default(),
1752            automation_sender: None,
1753            external_senders: Vec::new(),
1754            orchestrator_pane: false,
1755            orchestrator_position: OrchestratorPosition::Bottom,
1756            layout: None,
1757            cost: Default::default(),
1758            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1759            retro_min_duration_secs: 60,
1760            roles: vec![role, eng_role],
1761        };
1762        let members = vec![member, eng];
1763        let mut last_standup = HashMap::new(); // empty — first call
1764
1765        let generated = maybe_generate_standup(StandupGenerationContext {
1766            project_root: tmp.path(),
1767            team_config: &team_config,
1768            members: &members,
1769            watchers: &HashMap::new(),
1770            states: &HashMap::new(),
1771            pane_map: &HashMap::new(),
1772            telegram_bot: None,
1773            paused_standups: &HashSet::new(),
1774            last_standup: &mut last_standup,
1775            backend_health: &HashMap::new(),
1776        })
1777        .unwrap();
1778
1779        // First call seeds the timer but does not generate
1780        assert!(generated.is_empty());
1781        assert!(last_standup.contains_key("manager"));
1782    }
1783}