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            use_sdk_mode: false,
881            auto_respawn_on_crash: false,
882            shim_health_check_interval_secs: 60,
883            shim_health_timeout_secs: 120,
884            shim_shutdown_timeout_secs: 30,
885            shim_working_state_timeout_secs: 1800,
886            pending_queue_max_age_secs: 600,
887            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
888            retro_min_duration_secs: 60,
889            roles: vec![role],
890        };
891        let members = vec![member];
892        let mut paused_standups = HashSet::new();
893        let mut last_standup = HashMap::from([(
894            "manager".to_string(),
895            Instant::now() - Duration::from_secs(120),
896        )]);
897
898        update_timer_for_state(
899            &team_config,
900            &members,
901            &mut paused_standups,
902            &mut last_standup,
903            "manager",
904            MemberState::Working,
905        );
906
907        assert!(paused_standups.contains("manager"));
908        assert!(!last_standup.contains_key("manager"));
909
910        update_timer_for_state(
911            &team_config,
912            &members,
913            &mut paused_standups,
914            &mut last_standup,
915            "manager",
916            MemberState::Idle,
917        );
918
919        assert!(!paused_standups.contains("manager"));
920        assert!(last_standup["manager"].elapsed() < Duration::from_secs(1));
921    }
922
923    #[test]
924    fn maybe_generate_standup_skips_when_global_interval_is_zero() {
925        let tmp = tempfile::tempdir().unwrap();
926        let member = make_member("manager", RoleType::Manager, None);
927        let role = RoleDef {
928            name: "manager".to_string(),
929            role_type: RoleType::Manager,
930            agent: Some("claude".to_string()),
931            instances: 1,
932            prompt: None,
933            talks_to: vec![],
934            channel: None,
935            channel_config: None,
936            nudge_interval_secs: None,
937            receives_standup: Some(true),
938            standup_interval_secs: Some(600),
939            owns: Vec::new(),
940            use_worktrees: false,
941        };
942        let team_config = TeamConfig {
943            name: "test".to_string(),
944            agent: None,
945            workflow_mode: WorkflowMode::Legacy,
946            workflow_policy: WorkflowPolicy::default(),
947            board: BoardConfig::default(),
948            standup: StandupConfig {
949                interval_secs: 0,
950                output_lines: 30,
951            },
952            automation: AutomationConfig::default(),
953            automation_sender: None,
954            external_senders: Vec::new(),
955            orchestrator_pane: false,
956            orchestrator_position: OrchestratorPosition::Bottom,
957            layout: None,
958            cost: Default::default(),
959            grafana: Default::default(),
960            use_shim: false,
961            use_sdk_mode: false,
962            auto_respawn_on_crash: false,
963            shim_health_check_interval_secs: 60,
964            shim_health_timeout_secs: 120,
965            shim_shutdown_timeout_secs: 30,
966            shim_working_state_timeout_secs: 1800,
967            pending_queue_max_age_secs: 600,
968            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
969            retro_min_duration_secs: 60,
970            roles: vec![role],
971        };
972        let members = vec![member];
973        let mut last_standup = HashMap::new();
974
975        let generated = maybe_generate_standup(StandupGenerationContext {
976            project_root: tmp.path(),
977            team_config: &team_config,
978            members: &members,
979            watchers: &HashMap::new(),
980            states: &HashMap::new(),
981            pane_map: &HashMap::new(),
982            telegram_bot: None,
983            paused_standups: &HashSet::new(),
984            last_standup: &mut last_standup,
985            backend_health: &HashMap::new(),
986        })
987        .unwrap();
988
989        assert!(generated.is_empty());
990        assert!(last_standup.is_empty());
991    }
992
993    #[test]
994    fn maybe_generate_standup_writes_user_report_to_file_without_telegram_bot() {
995        let tmp = tempfile::tempdir().unwrap();
996        let user = MemberInstance {
997            name: "user".to_string(),
998            role_name: "user".to_string(),
999            role_type: RoleType::User,
1000            agent: None,
1001            prompt: None,
1002            reports_to: None,
1003            use_worktrees: false,
1004        };
1005        let architect = MemberInstance {
1006            name: "architect".to_string(),
1007            role_name: "architect".to_string(),
1008            role_type: RoleType::Architect,
1009            agent: Some("claude".to_string()),
1010            prompt: None,
1011            reports_to: Some("user".to_string()),
1012            use_worktrees: false,
1013        };
1014        let user_role = RoleDef {
1015            name: "user".to_string(),
1016            role_type: RoleType::User,
1017            agent: None,
1018            instances: 1,
1019            prompt: None,
1020            talks_to: vec!["architect".to_string()],
1021            channel: None,
1022            channel_config: None,
1023            nudge_interval_secs: None,
1024            receives_standup: Some(true),
1025            standup_interval_secs: Some(1),
1026            owns: Vec::new(),
1027            use_worktrees: false,
1028        };
1029        let architect_role = RoleDef {
1030            name: "architect".to_string(),
1031            role_type: RoleType::Architect,
1032            agent: Some("claude".to_string()),
1033            instances: 1,
1034            prompt: None,
1035            talks_to: vec![],
1036            channel: None,
1037            channel_config: None,
1038            nudge_interval_secs: None,
1039            receives_standup: Some(false),
1040            standup_interval_secs: None,
1041            owns: Vec::new(),
1042            use_worktrees: false,
1043        };
1044        let team_config = TeamConfig {
1045            name: "test".to_string(),
1046            agent: None,
1047            workflow_mode: WorkflowMode::Legacy,
1048            workflow_policy: WorkflowPolicy::default(),
1049            board: BoardConfig::default(),
1050            standup: StandupConfig {
1051                interval_secs: 1,
1052                output_lines: 30,
1053            },
1054            automation: AutomationConfig::default(),
1055            automation_sender: None,
1056            external_senders: Vec::new(),
1057            orchestrator_pane: false,
1058            orchestrator_position: OrchestratorPosition::Bottom,
1059            layout: None,
1060            cost: Default::default(),
1061            grafana: Default::default(),
1062            use_shim: false,
1063            use_sdk_mode: false,
1064            auto_respawn_on_crash: false,
1065            shim_health_check_interval_secs: 60,
1066            shim_health_timeout_secs: 120,
1067            shim_shutdown_timeout_secs: 30,
1068            shim_working_state_timeout_secs: 1800,
1069            pending_queue_max_age_secs: 600,
1070            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1071            retro_min_duration_secs: 60,
1072            roles: vec![user_role, architect_role],
1073        };
1074        let members = vec![user.clone(), architect];
1075        let states = HashMap::from([("architect".to_string(), MemberState::Working)]);
1076        let mut last_standup =
1077            HashMap::from([(user.name.clone(), Instant::now() - Duration::from_secs(5))]);
1078
1079        let generated = maybe_generate_standup(StandupGenerationContext {
1080            project_root: tmp.path(),
1081            team_config: &team_config,
1082            members: &members,
1083            watchers: &HashMap::new(),
1084            states: &states,
1085            pane_map: &HashMap::new(),
1086            telegram_bot: None,
1087            paused_standups: &HashSet::new(),
1088            last_standup: &mut last_standup,
1089            backend_health: &HashMap::new(),
1090        })
1091        .unwrap();
1092
1093        assert_eq!(generated, vec!["user".to_string()]);
1094
1095        let standups_dir = tmp.path().join(".batty").join("standups");
1096        let entries = std::fs::read_dir(&standups_dir)
1097            .unwrap()
1098            .collect::<std::io::Result<Vec<_>>>()
1099            .unwrap();
1100        assert_eq!(entries.len(), 1);
1101
1102        let report = std::fs::read_to_string(entries[0].path()).unwrap();
1103        assert!(report.contains("=== STANDUP for user ==="));
1104        assert!(report.contains("[architect] status: working"));
1105    }
1106
1107    #[test]
1108    fn standup_includes_backend_health_warning_for_unhealthy_agent() {
1109        let manager = make_member("manager", RoleType::Manager, None);
1110        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1111        let members = vec![manager.clone(), eng.clone()];
1112        let states = HashMap::new();
1113
1114        let mut backend_health = HashMap::new();
1115        backend_health.insert(
1116            "eng-1".to_string(),
1117            crate::agent::BackendHealth::Unreachable,
1118        );
1119
1120        let report = generate_board_aware_standup_for(
1121            &manager,
1122            &members,
1123            &HashMap::new(),
1124            &states,
1125            5,
1126            None,
1127            &backend_health,
1128        );
1129
1130        assert!(
1131            report.contains("backend: unreachable"),
1132            "standup should warn about unhealthy backend: {report}"
1133        );
1134    }
1135
1136    #[test]
1137    fn standup_omits_backend_health_when_healthy() {
1138        let manager = make_member("manager", RoleType::Manager, None);
1139        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1140        let members = vec![manager.clone(), eng.clone()];
1141        let states = HashMap::new();
1142
1143        let mut backend_health = HashMap::new();
1144        backend_health.insert("eng-1".to_string(), crate::agent::BackendHealth::Healthy);
1145
1146        let report = generate_board_aware_standup_for(
1147            &manager,
1148            &members,
1149            &HashMap::new(),
1150            &states,
1151            5,
1152            None,
1153            &backend_health,
1154        );
1155
1156        assert!(
1157            !report.contains("backend:"),
1158            "standup should not mention backend when healthy: {report}"
1159        );
1160    }
1161
1162    // --- New tests for content generation edge cases ---
1163
1164    #[test]
1165    fn standup_shows_degraded_backend_health() {
1166        let manager = make_member("manager", RoleType::Manager, None);
1167        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1168        let members = vec![manager.clone(), eng];
1169        let mut backend_health = HashMap::new();
1170        backend_health.insert("eng-1".to_string(), crate::agent::BackendHealth::Degraded);
1171
1172        let report = generate_board_aware_standup_for(
1173            &manager,
1174            &members,
1175            &HashMap::new(),
1176            &HashMap::new(),
1177            5,
1178            None,
1179            &backend_health,
1180        );
1181
1182        assert!(
1183            report.contains("backend: degraded"),
1184            "standup should warn about degraded backend: {report}"
1185        );
1186    }
1187
1188    #[test]
1189    fn standup_default_state_is_idle() {
1190        let manager = make_member("manager", RoleType::Manager, None);
1191        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1192        let members = vec![manager.clone(), eng];
1193        // No state entry for eng-1 → defaults to Idle
1194        let report = generate_standup_for(&manager, &members, &HashMap::new(), &HashMap::new(), 5);
1195        assert!(report.contains("[eng-1] status: idle"));
1196    }
1197
1198    #[test]
1199    fn board_aware_standup_all_done_tasks_shows_none_assigned() {
1200        let tmp = tempfile::tempdir().unwrap();
1201        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1202        // Only a "done" task — should not show in assigned list
1203        write_task(&board_dir, 10, "finished", "done", Some("eng-1"), None);
1204
1205        let members = vec![
1206            make_member("manager", RoleType::Manager, None),
1207            make_member("eng-1", RoleType::Engineer, Some("manager")),
1208        ];
1209        let states = HashMap::from([("eng-1".to_string(), MemberState::Working)]);
1210
1211        let report = generate_board_aware_standup_for(
1212            &members[0],
1213            &members,
1214            &HashMap::new(),
1215            &states,
1216            5,
1217            Some(&board_dir),
1218            &HashMap::new(),
1219        );
1220
1221        assert!(report.contains("assigned tasks: none"));
1222    }
1223
1224    #[test]
1225    fn board_aware_standup_multiple_tasks_sorted_ascending() {
1226        let tmp = tempfile::tempdir().unwrap();
1227        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1228        write_task(&board_dir, 5, "second", "in-progress", Some("eng-1"), None);
1229        write_task(&board_dir, 2, "first", "in-progress", Some("eng-1"), None);
1230        write_task(&board_dir, 9, "third", "review", Some("eng-1"), None);
1231
1232        let members = vec![
1233            make_member("manager", RoleType::Manager, None),
1234            make_member("eng-1", RoleType::Engineer, Some("manager")),
1235        ];
1236
1237        let report = generate_board_aware_standup_for(
1238            &members[0],
1239            &members,
1240            &HashMap::new(),
1241            &HashMap::new(),
1242            5,
1243            Some(&board_dir),
1244            &HashMap::new(),
1245        );
1246
1247        assert!(report.contains("assigned tasks: #2, #5, #9"));
1248    }
1249
1250    #[test]
1251    fn board_aware_standup_no_idle_warning_when_no_runnable_work() {
1252        let tmp = tempfile::tempdir().unwrap();
1253        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1254        // All tasks done or in-progress — nothing runnable
1255        write_task(&board_dir, 1, "active", "in-progress", Some("eng-1"), None);
1256        write_task(&board_dir, 2, "done-task", "done", Some("eng-2"), None);
1257
1258        let members = vec![
1259            make_member("manager", RoleType::Manager, None),
1260            make_member("eng-1", RoleType::Engineer, Some("manager")),
1261            make_member("eng-2", RoleType::Engineer, Some("manager")),
1262        ];
1263        let states = HashMap::from([("eng-2".to_string(), MemberState::Idle)]);
1264
1265        let report = generate_board_aware_standup_for(
1266            &members[0],
1267            &members,
1268            &HashMap::new(),
1269            &states,
1270            5,
1271            Some(&board_dir),
1272            &HashMap::new(),
1273        );
1274
1275        assert!(!report.contains("warning: idle while runnable work exists"));
1276    }
1277
1278    #[test]
1279    fn board_aware_standup_review_pipeline_metrics_when_present() {
1280        let tmp = tempfile::tempdir().unwrap();
1281        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1282        // One review task triggers oldest review age
1283        write_task(&board_dir, 1, "in-review", "review", Some("eng-1"), None);
1284        // One runnable task
1285        write_task(&board_dir, 2, "ready", "todo", None, None);
1286
1287        let members = vec![
1288            make_member("manager", RoleType::Manager, None),
1289            make_member("eng-1", RoleType::Engineer, Some("manager")),
1290        ];
1291
1292        let report = generate_board_aware_standup_for(
1293            &members[0],
1294            &members,
1295            &HashMap::new(),
1296            &HashMap::new(),
1297            5,
1298            Some(&board_dir),
1299            &HashMap::new(),
1300        );
1301
1302        assert!(report.contains("Workflow signals:"));
1303        assert!(report.contains("blocked tasks: 0"));
1304        assert!(report.contains("oldest review age:"));
1305    }
1306
1307    #[test]
1308    fn format_assigned_task_ids_empty_vec() {
1309        let ids: Vec<u32> = vec![];
1310        assert_eq!(format_assigned_task_ids(Some(&ids)), "none");
1311    }
1312
1313    #[test]
1314    fn format_assigned_task_ids_none() {
1315        assert_eq!(format_assigned_task_ids(None), "none");
1316    }
1317
1318    #[test]
1319    fn format_assigned_task_ids_single() {
1320        let ids = vec![42];
1321        assert_eq!(format_assigned_task_ids(Some(&ids)), "#42");
1322    }
1323
1324    #[test]
1325    fn format_age_with_value() {
1326        assert_eq!(format_age(Some(120)), "120s");
1327    }
1328
1329    #[test]
1330    fn format_age_none() {
1331        assert_eq!(format_age(None), "n/a");
1332    }
1333
1334    #[test]
1335    fn project_root_from_board_dir_valid_path() {
1336        let root = Path::new("/project");
1337        let board_dir = root.join(".batty").join("team_config").join("board");
1338        assert_eq!(project_root_from_board_dir(Some(&board_dir)), Some(root));
1339    }
1340
1341    #[test]
1342    fn project_root_from_board_dir_invalid_structure() {
1343        let bad_path = Path::new("/some/random/path");
1344        assert_eq!(project_root_from_board_dir(Some(bad_path)), None);
1345    }
1346
1347    #[test]
1348    fn project_root_from_board_dir_none() {
1349        assert_eq!(project_root_from_board_dir(None), None);
1350    }
1351
1352    // --- Timer state tests ---
1353
1354    #[test]
1355    fn snapshot_and_restore_timer_roundtrip() {
1356        let mut timers = HashMap::new();
1357        timers.insert(
1358            "manager".to_string(),
1359            Instant::now() - Duration::from_secs(30),
1360        );
1361        timers.insert(
1362            "architect".to_string(),
1363            Instant::now() - Duration::from_secs(120),
1364        );
1365
1366        let snapshot = snapshot_timer_state(&timers);
1367        assert!(snapshot["manager"] >= 30);
1368        assert!(snapshot["architect"] >= 120);
1369
1370        let restored = restore_timer_state(snapshot);
1371        // Restored timers should show elapsed >= the original elapsed
1372        assert!(restored["manager"].elapsed().as_secs() >= 30);
1373        assert!(restored["architect"].elapsed().as_secs() >= 120);
1374    }
1375
1376    #[test]
1377    fn update_timer_for_non_standup_member_clears_state() {
1378        let eng = make_member("eng-1", RoleType::Engineer, None);
1379        let role = RoleDef {
1380            name: "eng-1".to_string(),
1381            role_type: RoleType::Engineer,
1382            agent: Some("claude".to_string()),
1383            instances: 1,
1384            prompt: None,
1385            talks_to: vec![],
1386            channel: None,
1387            channel_config: None,
1388            nudge_interval_secs: None,
1389            receives_standup: Some(false),
1390            standup_interval_secs: None,
1391            owns: Vec::new(),
1392            use_worktrees: false,
1393        };
1394        let team_config = TeamConfig {
1395            name: "test".to_string(),
1396            agent: None,
1397            workflow_mode: WorkflowMode::Legacy,
1398            workflow_policy: WorkflowPolicy::default(),
1399            board: BoardConfig::default(),
1400            standup: StandupConfig::default(),
1401            automation: AutomationConfig::default(),
1402            automation_sender: None,
1403            external_senders: Vec::new(),
1404            orchestrator_pane: false,
1405            orchestrator_position: OrchestratorPosition::Bottom,
1406            layout: None,
1407            cost: Default::default(),
1408            grafana: Default::default(),
1409            use_shim: false,
1410            use_sdk_mode: false,
1411            auto_respawn_on_crash: false,
1412            shim_health_check_interval_secs: 60,
1413            shim_health_timeout_secs: 120,
1414            shim_shutdown_timeout_secs: 30,
1415            shim_working_state_timeout_secs: 1800,
1416            pending_queue_max_age_secs: 600,
1417            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1418            retro_min_duration_secs: 60,
1419            roles: vec![role],
1420        };
1421        let members = vec![eng];
1422        let mut paused = HashSet::from(["eng-1".to_string()]);
1423        let mut last = HashMap::from([("eng-1".to_string(), Instant::now())]);
1424
1425        update_timer_for_state(
1426            &team_config,
1427            &members,
1428            &mut paused,
1429            &mut last,
1430            "eng-1",
1431            MemberState::Idle,
1432        );
1433
1434        assert!(!paused.contains("eng-1"));
1435        assert!(!last.contains_key("eng-1"));
1436    }
1437
1438    #[test]
1439    fn standup_interval_for_manager_uses_role_override() {
1440        let member = make_member("manager", RoleType::Manager, None);
1441        let role = RoleDef {
1442            name: "manager".to_string(),
1443            role_type: RoleType::Manager,
1444            agent: Some("claude".to_string()),
1445            instances: 1,
1446            prompt: None,
1447            talks_to: vec![],
1448            channel: None,
1449            channel_config: None,
1450            nudge_interval_secs: None,
1451            receives_standup: Some(true),
1452            standup_interval_secs: Some(300),
1453            owns: Vec::new(),
1454            use_worktrees: false,
1455        };
1456        let team_config = TeamConfig {
1457            name: "test".to_string(),
1458            agent: None,
1459            workflow_mode: WorkflowMode::Legacy,
1460            workflow_policy: WorkflowPolicy::default(),
1461            board: BoardConfig::default(),
1462            standup: StandupConfig {
1463                interval_secs: 600,
1464                output_lines: 30,
1465            },
1466            automation: AutomationConfig::default(),
1467            automation_sender: None,
1468            external_senders: Vec::new(),
1469            orchestrator_pane: false,
1470            orchestrator_position: OrchestratorPosition::Bottom,
1471            layout: None,
1472            cost: Default::default(),
1473            grafana: Default::default(),
1474            use_shim: false,
1475            use_sdk_mode: false,
1476            auto_respawn_on_crash: false,
1477            shim_health_check_interval_secs: 60,
1478            shim_health_timeout_secs: 120,
1479            shim_shutdown_timeout_secs: 30,
1480            shim_working_state_timeout_secs: 1800,
1481            pending_queue_max_age_secs: 600,
1482            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1483            retro_min_duration_secs: 60,
1484            roles: vec![role],
1485        };
1486        let members = vec![member];
1487
1488        let interval = standup_interval_for_member_name(&team_config, &members, "manager");
1489        assert_eq!(interval, Some(Duration::from_secs(300)));
1490    }
1491
1492    #[test]
1493    fn standup_interval_for_manager_falls_back_to_global() {
1494        let member = make_member("manager", RoleType::Manager, None);
1495        let role = RoleDef {
1496            name: "manager".to_string(),
1497            role_type: RoleType::Manager,
1498            agent: Some("claude".to_string()),
1499            instances: 1,
1500            prompt: None,
1501            talks_to: vec![],
1502            channel: None,
1503            channel_config: None,
1504            nudge_interval_secs: None,
1505            receives_standup: None,      // defaults to true for Manager
1506            standup_interval_secs: None, // falls back to global
1507            owns: Vec::new(),
1508            use_worktrees: false,
1509        };
1510        let team_config = TeamConfig {
1511            name: "test".to_string(),
1512            agent: None,
1513            workflow_mode: WorkflowMode::Legacy,
1514            workflow_policy: WorkflowPolicy::default(),
1515            board: BoardConfig::default(),
1516            standup: StandupConfig {
1517                interval_secs: 900,
1518                output_lines: 30,
1519            },
1520            automation: AutomationConfig::default(),
1521            automation_sender: None,
1522            external_senders: Vec::new(),
1523            orchestrator_pane: false,
1524            orchestrator_position: OrchestratorPosition::Bottom,
1525            layout: None,
1526            cost: Default::default(),
1527            grafana: Default::default(),
1528            use_shim: false,
1529            use_sdk_mode: false,
1530            auto_respawn_on_crash: false,
1531            shim_health_check_interval_secs: 60,
1532            shim_health_timeout_secs: 120,
1533            shim_shutdown_timeout_secs: 30,
1534            shim_working_state_timeout_secs: 1800,
1535            pending_queue_max_age_secs: 600,
1536            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1537            retro_min_duration_secs: 60,
1538            roles: vec![role],
1539        };
1540        let members = vec![member];
1541
1542        let interval = standup_interval_for_member_name(&team_config, &members, "manager");
1543        assert_eq!(interval, Some(Duration::from_secs(900)));
1544    }
1545
1546    #[test]
1547    fn standup_interval_for_engineer_returns_none() {
1548        let member = make_member("eng-1", RoleType::Engineer, Some("manager"));
1549        let role = RoleDef {
1550            name: "eng-1".to_string(),
1551            role_type: RoleType::Engineer,
1552            agent: Some("claude".to_string()),
1553            instances: 1,
1554            prompt: None,
1555            talks_to: vec![],
1556            channel: None,
1557            channel_config: None,
1558            nudge_interval_secs: None,
1559            receives_standup: None, // defaults to false for Engineer
1560            standup_interval_secs: None,
1561            owns: Vec::new(),
1562            use_worktrees: false,
1563        };
1564        let team_config = TeamConfig {
1565            name: "test".to_string(),
1566            agent: None,
1567            workflow_mode: WorkflowMode::Legacy,
1568            workflow_policy: WorkflowPolicy::default(),
1569            board: BoardConfig::default(),
1570            standup: StandupConfig::default(),
1571            automation: AutomationConfig::default(),
1572            automation_sender: None,
1573            external_senders: Vec::new(),
1574            orchestrator_pane: false,
1575            orchestrator_position: OrchestratorPosition::Bottom,
1576            layout: None,
1577            cost: Default::default(),
1578            grafana: Default::default(),
1579            use_shim: false,
1580            use_sdk_mode: false,
1581            auto_respawn_on_crash: false,
1582            shim_health_check_interval_secs: 60,
1583            shim_health_timeout_secs: 120,
1584            shim_shutdown_timeout_secs: 30,
1585            shim_working_state_timeout_secs: 1800,
1586            pending_queue_max_age_secs: 600,
1587            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1588            retro_min_duration_secs: 60,
1589            roles: vec![role],
1590        };
1591        let members = vec![member];
1592
1593        let interval = standup_interval_for_member_name(&team_config, &members, "eng-1");
1594        assert_eq!(interval, None);
1595    }
1596
1597    #[test]
1598    fn standup_interval_for_unknown_member_returns_none() {
1599        let team_config = TeamConfig {
1600            name: "test".to_string(),
1601            agent: None,
1602            workflow_mode: WorkflowMode::Legacy,
1603            workflow_policy: WorkflowPolicy::default(),
1604            board: BoardConfig::default(),
1605            standup: StandupConfig::default(),
1606            automation: AutomationConfig::default(),
1607            automation_sender: None,
1608            external_senders: Vec::new(),
1609            orchestrator_pane: false,
1610            orchestrator_position: OrchestratorPosition::Bottom,
1611            layout: None,
1612            cost: Default::default(),
1613            grafana: Default::default(),
1614            use_shim: false,
1615            use_sdk_mode: false,
1616            auto_respawn_on_crash: false,
1617            shim_health_check_interval_secs: 60,
1618            shim_health_timeout_secs: 120,
1619            shim_shutdown_timeout_secs: 30,
1620            shim_working_state_timeout_secs: 1800,
1621            pending_queue_max_age_secs: 600,
1622            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1623            retro_min_duration_secs: 60,
1624            roles: vec![],
1625        };
1626
1627        let interval = standup_interval_for_member_name(&team_config, &[], "nobody");
1628        assert_eq!(interval, None);
1629    }
1630
1631    #[test]
1632    fn maybe_generate_standup_skips_when_standups_disabled() {
1633        let tmp = tempfile::tempdir().unwrap();
1634        let member = make_member("manager", RoleType::Manager, None);
1635        let role = RoleDef {
1636            name: "manager".to_string(),
1637            role_type: RoleType::Manager,
1638            agent: Some("claude".to_string()),
1639            instances: 1,
1640            prompt: None,
1641            talks_to: vec![],
1642            channel: None,
1643            channel_config: None,
1644            nudge_interval_secs: None,
1645            receives_standup: Some(true),
1646            standup_interval_secs: Some(60),
1647            owns: Vec::new(),
1648            use_worktrees: false,
1649        };
1650        let team_config = TeamConfig {
1651            name: "test".to_string(),
1652            agent: None,
1653            workflow_mode: WorkflowMode::Legacy,
1654            workflow_policy: WorkflowPolicy::default(),
1655            board: BoardConfig::default(),
1656            standup: StandupConfig {
1657                interval_secs: 60,
1658                output_lines: 30,
1659            },
1660            automation: AutomationConfig {
1661                standups: false,
1662                ..AutomationConfig::default()
1663            },
1664            automation_sender: None,
1665            external_senders: Vec::new(),
1666            orchestrator_pane: false,
1667            orchestrator_position: OrchestratorPosition::Bottom,
1668            layout: None,
1669            cost: Default::default(),
1670            grafana: Default::default(),
1671            use_shim: false,
1672            use_sdk_mode: false,
1673            auto_respawn_on_crash: false,
1674            shim_health_check_interval_secs: 60,
1675            shim_health_timeout_secs: 120,
1676            shim_shutdown_timeout_secs: 30,
1677            shim_working_state_timeout_secs: 1800,
1678            pending_queue_max_age_secs: 600,
1679            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1680            retro_min_duration_secs: 60,
1681            roles: vec![role],
1682        };
1683        let members = vec![member];
1684        let mut last_standup = HashMap::new();
1685
1686        let generated = maybe_generate_standup(StandupGenerationContext {
1687            project_root: tmp.path(),
1688            team_config: &team_config,
1689            members: &members,
1690            watchers: &HashMap::new(),
1691            states: &HashMap::new(),
1692            pane_map: &HashMap::new(),
1693            telegram_bot: None,
1694            paused_standups: &HashSet::new(),
1695            last_standup: &mut last_standup,
1696            backend_health: &HashMap::new(),
1697        })
1698        .unwrap();
1699
1700        assert!(generated.is_empty());
1701    }
1702
1703    #[test]
1704    fn maybe_generate_standup_skips_paused_recipients() {
1705        let tmp = tempfile::tempdir().unwrap();
1706        let member = make_member("manager", RoleType::Manager, None);
1707        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1708        let role = RoleDef {
1709            name: "manager".to_string(),
1710            role_type: RoleType::Manager,
1711            agent: Some("claude".to_string()),
1712            instances: 1,
1713            prompt: None,
1714            talks_to: vec![],
1715            channel: None,
1716            channel_config: None,
1717            nudge_interval_secs: None,
1718            receives_standup: Some(true),
1719            standup_interval_secs: Some(1),
1720            owns: Vec::new(),
1721            use_worktrees: false,
1722        };
1723        let eng_role = RoleDef {
1724            name: "eng-1".to_string(),
1725            role_type: RoleType::Engineer,
1726            agent: Some("claude".to_string()),
1727            instances: 1,
1728            prompt: None,
1729            talks_to: vec![],
1730            channel: None,
1731            channel_config: None,
1732            nudge_interval_secs: None,
1733            receives_standup: Some(false),
1734            standup_interval_secs: None,
1735            owns: Vec::new(),
1736            use_worktrees: false,
1737        };
1738        let team_config = TeamConfig {
1739            name: "test".to_string(),
1740            agent: None,
1741            workflow_mode: WorkflowMode::Legacy,
1742            workflow_policy: WorkflowPolicy::default(),
1743            board: BoardConfig::default(),
1744            standup: StandupConfig {
1745                interval_secs: 1,
1746                output_lines: 30,
1747            },
1748            automation: AutomationConfig::default(),
1749            automation_sender: None,
1750            external_senders: Vec::new(),
1751            orchestrator_pane: false,
1752            orchestrator_position: OrchestratorPosition::Bottom,
1753            layout: None,
1754            cost: Default::default(),
1755            grafana: Default::default(),
1756            use_shim: false,
1757            use_sdk_mode: false,
1758            auto_respawn_on_crash: false,
1759            shim_health_check_interval_secs: 60,
1760            shim_health_timeout_secs: 120,
1761            shim_shutdown_timeout_secs: 30,
1762            shim_working_state_timeout_secs: 1800,
1763            pending_queue_max_age_secs: 600,
1764            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1765            retro_min_duration_secs: 60,
1766            roles: vec![role, eng_role],
1767        };
1768        let members = vec![member, eng];
1769        let paused = HashSet::from(["manager".to_string()]);
1770        let mut last_standup = HashMap::from([(
1771            "manager".to_string(),
1772            Instant::now() - Duration::from_secs(100),
1773        )]);
1774
1775        let generated = maybe_generate_standup(StandupGenerationContext {
1776            project_root: tmp.path(),
1777            team_config: &team_config,
1778            members: &members,
1779            watchers: &HashMap::new(),
1780            states: &HashMap::new(),
1781            pane_map: &HashMap::new(),
1782            telegram_bot: None,
1783            paused_standups: &paused,
1784            last_standup: &mut last_standup,
1785            backend_health: &HashMap::new(),
1786        })
1787        .unwrap();
1788
1789        assert!(generated.is_empty());
1790    }
1791
1792    #[test]
1793    fn maybe_generate_standup_first_call_seeds_timer_without_generating() {
1794        let tmp = tempfile::tempdir().unwrap();
1795        let member = make_member("manager", RoleType::Manager, None);
1796        let eng = make_member("eng-1", RoleType::Engineer, Some("manager"));
1797        let role = RoleDef {
1798            name: "manager".to_string(),
1799            role_type: RoleType::Manager,
1800            agent: Some("claude".to_string()),
1801            instances: 1,
1802            prompt: None,
1803            talks_to: vec![],
1804            channel: None,
1805            channel_config: None,
1806            nudge_interval_secs: None,
1807            receives_standup: Some(true),
1808            standup_interval_secs: Some(1),
1809            owns: Vec::new(),
1810            use_worktrees: false,
1811        };
1812        let eng_role = RoleDef {
1813            name: "eng-1".to_string(),
1814            role_type: RoleType::Engineer,
1815            agent: Some("claude".to_string()),
1816            instances: 1,
1817            prompt: None,
1818            talks_to: vec![],
1819            channel: None,
1820            channel_config: None,
1821            nudge_interval_secs: None,
1822            receives_standup: Some(false),
1823            standup_interval_secs: None,
1824            owns: Vec::new(),
1825            use_worktrees: false,
1826        };
1827        let team_config = TeamConfig {
1828            name: "test".to_string(),
1829            agent: None,
1830            workflow_mode: WorkflowMode::Legacy,
1831            workflow_policy: WorkflowPolicy::default(),
1832            board: BoardConfig::default(),
1833            standup: StandupConfig {
1834                interval_secs: 1,
1835                output_lines: 30,
1836            },
1837            automation: AutomationConfig::default(),
1838            automation_sender: None,
1839            external_senders: Vec::new(),
1840            orchestrator_pane: false,
1841            orchestrator_position: OrchestratorPosition::Bottom,
1842            layout: None,
1843            cost: Default::default(),
1844            grafana: Default::default(),
1845            use_shim: false,
1846            use_sdk_mode: false,
1847            auto_respawn_on_crash: false,
1848            shim_health_check_interval_secs: 60,
1849            shim_health_timeout_secs: 120,
1850            shim_shutdown_timeout_secs: 30,
1851            shim_working_state_timeout_secs: 1800,
1852            pending_queue_max_age_secs: 600,
1853            event_log_max_bytes: crate::team::DEFAULT_EVENT_LOG_MAX_BYTES,
1854            retro_min_duration_secs: 60,
1855            roles: vec![role, eng_role],
1856        };
1857        let members = vec![member, eng];
1858        let mut last_standup = HashMap::new(); // empty — first call
1859
1860        let generated = maybe_generate_standup(StandupGenerationContext {
1861            project_root: tmp.path(),
1862            team_config: &team_config,
1863            members: &members,
1864            watchers: &HashMap::new(),
1865            states: &HashMap::new(),
1866            pane_map: &HashMap::new(),
1867            telegram_bot: None,
1868            paused_standups: &HashSet::new(),
1869            last_standup: &mut last_standup,
1870            backend_health: &HashMap::new(),
1871        })
1872        .unwrap();
1873
1874        // First call seeds the timer but does not generate
1875        assert!(generated.is_empty());
1876        assert!(last_standup.contains_key("manager"));
1877    }
1878}