Skip to main content

batty_cli/team/
retrospective.rs

1//! Pure event-log analysis and markdown report generation for retrospectives.
2//!
3//! Prefers SQLite telemetry DB when available, falls back to JSONL parsing.
4
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use rusqlite::{Connection, params};
11
12use super::events::{TeamEvent, read_events};
13use super::telemetry_db;
14use crate::task;
15
16#[derive(Debug, Clone, PartialEq)]
17pub struct RunStats {
18    pub run_start: u64,
19    pub run_end: u64,
20    pub total_duration_secs: u64,
21    pub task_stats: Vec<TaskStats>,
22    pub average_cycle_time_secs: Option<u64>,
23    pub fastest_task_id: Option<String>,
24    pub fastest_cycle_time_secs: Option<u64>,
25    pub longest_task_id: Option<String>,
26    pub longest_cycle_time_secs: Option<u64>,
27    pub idle_time_pct: f64,
28    pub escalation_count: u32,
29    pub message_count: u32,
30    // Review pipeline metrics
31    pub auto_merge_count: u32,
32    pub manual_merge_count: u32,
33    pub rework_count: u32,
34    pub review_nudge_count: u32,
35    pub review_escalation_count: u32,
36    /// Average time (seconds) tasks spent in review before merge.
37    pub avg_review_stall_secs: Option<u64>,
38    /// Longest review stall and the associated task.
39    pub max_review_stall_secs: Option<u64>,
40    pub max_review_stall_task: Option<String>,
41    /// Per-task rework cycle counts (task_id → rework count).
42    pub task_rework_counts: Vec<(String, u32)>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct TaskStats {
47    pub task_id: String,
48    pub assigned_to: String,
49    pub assigned_at: u64,
50    pub completed_at: Option<u64>,
51    pub cycle_time_secs: Option<u64>,
52    pub retry_count: u32,
53    pub was_escalated: bool,
54}
55
56#[derive(Debug, Clone)]
57struct TaskAccumulator {
58    task_id: String,
59    assigned_to: String,
60    assigned_at: u64,
61    completed_at: Option<u64>,
62    cycle_time_secs: Option<u64>,
63    retry_count: u32,
64    was_escalated: bool,
65}
66
67impl TaskAccumulator {
68    fn new(task_id: String, assigned_to: String, assigned_at: u64, retry_count: u32) -> Self {
69        Self {
70            task_id,
71            assigned_to,
72            assigned_at,
73            completed_at: None,
74            cycle_time_secs: None,
75            retry_count,
76            was_escalated: false,
77        }
78    }
79
80    fn into_stats(self) -> TaskStats {
81        TaskStats {
82            task_id: self.task_id,
83            assigned_to: self.assigned_to,
84            assigned_at: self.assigned_at,
85            completed_at: self.completed_at,
86            cycle_time_secs: self.cycle_time_secs,
87            retry_count: self.retry_count,
88            was_escalated: self.was_escalated,
89        }
90    }
91}
92
93fn task_reference(task: &str) -> String {
94    let line = task
95        .lines()
96        .map(str::trim)
97        .find(|line| !line.is_empty())
98        .unwrap_or_else(|| task.trim());
99
100    task_id_from_assignment_line(line).unwrap_or_else(|| line.to_string())
101}
102
103fn task_id_from_assignment_line(line: &str) -> Option<String> {
104    let suffix = line.strip_prefix("Task #")?;
105    let digits: String = suffix
106        .chars()
107        .take_while(|ch| ch.is_ascii_digit())
108        .collect();
109    if digits.is_empty() {
110        None
111    } else {
112        Some(digits)
113    }
114}
115
116type CycleTimeMetrics = (
117    Option<u64>,
118    Option<String>,
119    Option<u64>,
120    Option<String>,
121    Option<u64>,
122);
123
124fn cycle_time_metrics(task_stats: &[TaskStats]) -> CycleTimeMetrics {
125    let completed: Vec<(&TaskStats, u64)> = task_stats
126        .iter()
127        .filter_map(|task| task.cycle_time_secs.map(|cycle| (task, cycle)))
128        .collect();
129    if completed.is_empty() {
130        return (None, None, None, None, None);
131    }
132
133    let total_cycle_secs: u64 = completed.iter().map(|(_, cycle)| *cycle).sum();
134    let average_cycle_time_secs = Some(total_cycle_secs / completed.len() as u64);
135    let (fastest_task, fastest_cycle_time_secs) = completed
136        .iter()
137        .min_by_key(|(_, cycle)| *cycle)
138        .map(|(task, cycle)| (task.task_id.clone(), *cycle))
139        .expect("completed is not empty");
140    let (longest_task, longest_cycle_time_secs) = completed
141        .iter()
142        .max_by_key(|(_, cycle)| *cycle)
143        .map(|(task, cycle)| (task.task_id.clone(), *cycle))
144        .expect("completed is not empty");
145
146    (
147        average_cycle_time_secs,
148        Some(fastest_task),
149        Some(fastest_cycle_time_secs),
150        Some(longest_task),
151        Some(longest_cycle_time_secs),
152    )
153}
154
155/// Analyze a single run (events between consecutive daemon_started events).
156/// Returns the last run's stats.
157pub fn analyze_events(events: &[TeamEvent]) -> Option<RunStats> {
158    if events.is_empty() {
159        return None;
160    }
161
162    let last_run_start = events
163        .iter()
164        .rposition(|event| event.event == "daemon_started")
165        .unwrap_or(0);
166    let run_events = &events[last_run_start..];
167    if run_events.is_empty() {
168        return None;
169    }
170
171    let run_start = run_events[0].ts;
172    let run_end = run_events
173        .iter()
174        .rev()
175        .find(|event| event.event == "daemon_stopped")
176        .map(|event| event.ts)
177        .unwrap_or_else(|| run_events.last().map(|event| event.ts).unwrap_or(run_start));
178
179    let mut tasks: HashMap<String, TaskAccumulator> = HashMap::new();
180    let mut active_task_by_role: HashMap<String, String> = HashMap::new();
181    let mut idle_samples = Vec::new();
182    let mut escalation_count = 0u32;
183    let mut message_count = 0u32;
184    let mut auto_merge_count = 0u32;
185    let mut manual_merge_count = 0u32;
186    let mut rework_count = 0u32;
187    let mut review_nudge_count = 0u32;
188    let mut review_escalation_count = 0u32;
189    // Track per-task: completion timestamp (for stall calc) and rework counts.
190    let mut task_completed_at: HashMap<String, u64> = HashMap::new();
191    let mut review_stall_durations: Vec<(String, u64)> = Vec::new();
192    let mut per_task_rework: HashMap<String, u32> = HashMap::new();
193
194    for event in run_events {
195        match event.event.as_str() {
196            "task_assigned" => {
197                let Some(role) = event.role.as_deref() else {
198                    continue;
199                };
200                let Some(task) = event.task.as_deref() else {
201                    continue;
202                };
203                let task_id = task_reference(task);
204
205                let entry = tasks.entry(task_id.clone()).or_insert_with(|| {
206                    TaskAccumulator::new(task_id.clone(), role.to_string(), event.ts, 0)
207                });
208                entry.retry_count += 1;
209                entry.assigned_to = role.to_string();
210                active_task_by_role.insert(role.to_string(), task_id);
211            }
212            // The completion event does not include a task id, so completion is
213            // attributed to the role's currently active assignment in this run.
214            "task_completed" => {
215                let Some(role) = event.role.as_deref() else {
216                    continue;
217                };
218                let Some(task_id) = active_task_by_role.remove(role) else {
219                    continue;
220                };
221                let Some(task) = tasks.get_mut(&task_id) else {
222                    continue;
223                };
224                if task.completed_at.is_none() {
225                    task.completed_at = Some(event.ts);
226                    task.cycle_time_secs = Some(event.ts.saturating_sub(task.assigned_at));
227                }
228                // Record completion time for review stall calculation.
229                task_completed_at.insert(task_id, event.ts);
230            }
231            "task_escalated" => {
232                escalation_count += 1;
233                let Some(task_id) = event.task.as_deref() else {
234                    continue;
235                };
236                let role = event.role.clone().unwrap_or_default();
237                let entry = tasks.entry(task_id.to_string()).or_insert_with(|| {
238                    TaskAccumulator::new(task_id.to_string(), role, event.ts, 0)
239                });
240                entry.was_escalated = true;
241            }
242            "message_routed" => {
243                message_count += 1;
244            }
245            "task_auto_merged" => {
246                auto_merge_count += 1;
247                if let Some(task) = event.task.as_deref() {
248                    let task_id = task_reference(task);
249                    if let Some(completed_ts) = task_completed_at.get(&task_id) {
250                        review_stall_durations
251                            .push((task_id, event.ts.saturating_sub(*completed_ts)));
252                    }
253                }
254            }
255            "task_manual_merged" => {
256                manual_merge_count += 1;
257                if let Some(task) = event.task.as_deref() {
258                    let task_id = task_reference(task);
259                    if let Some(completed_ts) = task_completed_at.get(&task_id) {
260                        review_stall_durations
261                            .push((task_id, event.ts.saturating_sub(*completed_ts)));
262                    }
263                }
264            }
265            "task_reworked" => {
266                rework_count += 1;
267                if let Some(task) = event.task.as_deref() {
268                    let task_id = task_reference(task);
269                    *per_task_rework.entry(task_id).or_insert(0) += 1;
270                }
271            }
272            "review_nudge_sent" => {
273                review_nudge_count += 1;
274            }
275            "review_escalated" => {
276                review_escalation_count += 1;
277            }
278            "load_snapshot" => {
279                let Some(working_members) = event.working_members else {
280                    continue;
281                };
282                let Some(total_members) = event.total_members else {
283                    continue;
284                };
285                let idle_pct = if total_members == 0 {
286                    1.0
287                } else {
288                    1.0 - (working_members as f64 / total_members as f64)
289                };
290                idle_samples.push(idle_pct);
291            }
292            _ => {}
293        }
294    }
295
296    let mut task_stats: Vec<TaskStats> =
297        tasks.into_values().map(|task| task.into_stats()).collect();
298    task_stats.sort_by(|left, right| {
299        left.assigned_at
300            .cmp(&right.assigned_at)
301            .then_with(|| left.task_id.cmp(&right.task_id))
302    });
303
304    let idle_time_pct = if idle_samples.is_empty() {
305        0.0
306    } else {
307        idle_samples.iter().sum::<f64>() / idle_samples.len() as f64
308    };
309    let (
310        average_cycle_time_secs,
311        fastest_task_id,
312        fastest_cycle_time_secs,
313        longest_task_id,
314        longest_cycle_time_secs,
315    ) = cycle_time_metrics(&task_stats);
316
317    // Compute review stall metrics.
318    let (avg_review_stall_secs, max_review_stall_secs, max_review_stall_task) =
319        if review_stall_durations.is_empty() {
320            (None, None, None)
321        } else {
322            let total: u64 = review_stall_durations.iter().map(|(_, d)| *d).sum();
323            let avg = total / review_stall_durations.len() as u64;
324            let (max_task, max_dur) = review_stall_durations
325                .iter()
326                .max_by_key(|(_, d)| *d)
327                .map(|(t, d)| (t.clone(), *d))
328                .expect("non-empty");
329            (Some(avg), Some(max_dur), Some(max_task))
330        };
331
332    // Collect per-task rework counts, sorted by count descending.
333    let mut task_rework_counts: Vec<(String, u32)> = per_task_rework.into_iter().collect();
334    task_rework_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
335
336    Some(RunStats {
337        run_start,
338        run_end,
339        total_duration_secs: run_end.saturating_sub(run_start),
340        task_stats,
341        average_cycle_time_secs,
342        fastest_task_id,
343        fastest_cycle_time_secs,
344        longest_task_id,
345        longest_cycle_time_secs,
346        idle_time_pct,
347        escalation_count,
348        message_count,
349        auto_merge_count,
350        manual_merge_count,
351        rework_count,
352        review_nudge_count,
353        review_escalation_count,
354        avg_review_stall_secs,
355        max_review_stall_secs,
356        max_review_stall_task,
357        task_rework_counts,
358    })
359}
360
361/// Build a `RunStats` from the SQLite telemetry database.
362///
363/// Queries the `events`, `task_metrics`, `agent_metrics`, and `session_summary`
364/// tables to produce the same report that `analyze_events` builds from JSONL.
365pub fn analyze_from_db(conn: &Connection) -> Option<RunStats> {
366    // Find the last session (last daemon_started event).
367    let last_session_start: Option<i64> = conn
368        .query_row(
369            "SELECT MAX(timestamp) FROM events WHERE event_type = 'daemon_started'",
370            [],
371            |row| row.get(0),
372        )
373        .ok()?;
374    let run_start = last_session_start? as u64;
375
376    // Find run_end: daemon_stopped after last start, or latest event.
377    let run_end: u64 = conn
378        .query_row(
379            "SELECT timestamp FROM events
380             WHERE event_type = 'daemon_stopped' AND timestamp >= ?1
381             ORDER BY timestamp DESC LIMIT 1",
382            params![run_start as i64],
383            |row| row.get::<_, i64>(0),
384        )
385        .unwrap_or_else(|_| {
386            conn.query_row(
387                "SELECT MAX(timestamp) FROM events WHERE timestamp >= ?1",
388                params![run_start as i64],
389                |row| row.get::<_, Option<i64>>(0),
390            )
391            .unwrap_or(None)
392            .unwrap_or(run_start as i64)
393        }) as u64;
394
395    // --- Task stats from events table (same logic as analyze_events) ---
396    // We still need to walk events for the current run to build per-task stats
397    // because task_metrics doesn't track assignment order or role-based completion.
398    let mut stmt = conn
399        .prepare(
400            "SELECT timestamp, event_type, role, task_id, payload FROM events
401             WHERE timestamp >= ?1 ORDER BY timestamp ASC",
402        )
403        .ok()?;
404
405    type EventRow = (i64, String, Option<String>, Option<String>, String);
406    let rows: Vec<EventRow> = stmt
407        .query_map(params![run_start as i64], |row| {
408            Ok((
409                row.get(0)?,
410                row.get(1)?,
411                row.get(2)?,
412                row.get(3)?,
413                row.get(4)?,
414            ))
415        })
416        .ok()?
417        .filter_map(|r| r.ok())
418        .collect();
419
420    if rows.is_empty() {
421        return None;
422    }
423
424    let mut tasks: HashMap<String, TaskAccumulator> = HashMap::new();
425    let mut active_task_by_role: HashMap<String, String> = HashMap::new();
426    let mut idle_samples = Vec::new();
427    let mut escalation_count = 0u32;
428    let mut message_count = 0u32;
429    let mut auto_merge_count = 0u32;
430    let mut manual_merge_count = 0u32;
431    let mut rework_count = 0u32;
432    let mut review_nudge_count = 0u32;
433    let mut review_escalation_count = 0u32;
434    let mut task_completed_at: HashMap<String, u64> = HashMap::new();
435    let mut review_stall_durations: Vec<(String, u64)> = Vec::new();
436    let mut per_task_rework: HashMap<String, u32> = HashMap::new();
437
438    for (ts, event_type, role, task_id, payload) in &rows {
439        let ts = *ts as u64;
440        match event_type.as_str() {
441            "task_assigned" => {
442                let Some(role) = role.as_deref() else {
443                    continue;
444                };
445                let Some(task) = task_id.as_deref() else {
446                    continue;
447                };
448                let tid = task_reference(task);
449                let entry = tasks
450                    .entry(tid.clone())
451                    .or_insert_with(|| TaskAccumulator::new(tid.clone(), role.to_string(), ts, 0));
452                entry.retry_count += 1;
453                entry.assigned_to = role.to_string();
454                active_task_by_role.insert(role.to_string(), tid);
455            }
456            "task_completed" => {
457                let Some(role) = role.as_deref() else {
458                    continue;
459                };
460                let Some(tid) = active_task_by_role.remove(role) else {
461                    continue;
462                };
463                let Some(task) = tasks.get_mut(&tid) else {
464                    continue;
465                };
466                if task.completed_at.is_none() {
467                    task.completed_at = Some(ts);
468                    task.cycle_time_secs = Some(ts.saturating_sub(task.assigned_at));
469                }
470                task_completed_at.insert(tid, ts);
471            }
472            "task_escalated" => {
473                escalation_count += 1;
474                let Some(task) = task_id.as_deref() else {
475                    continue;
476                };
477                let r = role.clone().unwrap_or_default();
478                let entry = tasks
479                    .entry(task.to_string())
480                    .or_insert_with(|| TaskAccumulator::new(task.to_string(), r, ts, 0));
481                entry.was_escalated = true;
482            }
483            "message_routed" => {
484                message_count += 1;
485            }
486            "task_auto_merged" => {
487                auto_merge_count += 1;
488                if let Some(task) = task_id.as_deref() {
489                    let tid = task_reference(task);
490                    if let Some(completed_ts) = task_completed_at.get(&tid) {
491                        review_stall_durations.push((tid, ts.saturating_sub(*completed_ts)));
492                    }
493                }
494            }
495            "task_manual_merged" => {
496                manual_merge_count += 1;
497                if let Some(task) = task_id.as_deref() {
498                    let tid = task_reference(task);
499                    if let Some(completed_ts) = task_completed_at.get(&tid) {
500                        review_stall_durations.push((tid, ts.saturating_sub(*completed_ts)));
501                    }
502                }
503            }
504            "task_reworked" => {
505                rework_count += 1;
506                if let Some(task) = task_id.as_deref() {
507                    let tid = task_reference(task);
508                    *per_task_rework.entry(tid).or_insert(0) += 1;
509                }
510            }
511            "review_nudge_sent" => {
512                review_nudge_count += 1;
513            }
514            "review_escalated" => {
515                review_escalation_count += 1;
516            }
517            "load_snapshot" => {
518                // Parse working_members/total_members from payload JSON.
519                if let Ok(evt) = serde_json::from_str::<TeamEvent>(payload) {
520                    let Some(working_members) = evt.working_members else {
521                        continue;
522                    };
523                    let Some(total_members) = evt.total_members else {
524                        continue;
525                    };
526                    let idle_pct = if total_members == 0 {
527                        1.0
528                    } else {
529                        1.0 - (working_members as f64 / total_members as f64)
530                    };
531                    idle_samples.push(idle_pct);
532                }
533            }
534            _ => {}
535        }
536    }
537
538    let mut task_stats: Vec<TaskStats> = tasks.into_values().map(|t| t.into_stats()).collect();
539    task_stats.sort_by(|a, b| {
540        a.assigned_at
541            .cmp(&b.assigned_at)
542            .then_with(|| a.task_id.cmp(&b.task_id))
543    });
544
545    let idle_time_pct = if idle_samples.is_empty() {
546        0.0
547    } else {
548        idle_samples.iter().sum::<f64>() / idle_samples.len() as f64
549    };
550
551    let (
552        average_cycle_time_secs,
553        fastest_task_id,
554        fastest_cycle_time_secs,
555        longest_task_id,
556        longest_cycle_time_secs,
557    ) = cycle_time_metrics(&task_stats);
558
559    let (avg_review_stall_secs, max_review_stall_secs, max_review_stall_task) =
560        if review_stall_durations.is_empty() {
561            (None, None, None)
562        } else {
563            let total: u64 = review_stall_durations.iter().map(|(_, d)| *d).sum();
564            let avg = total / review_stall_durations.len() as u64;
565            let (max_task, max_dur) = review_stall_durations
566                .iter()
567                .max_by_key(|(_, d)| *d)
568                .map(|(t, d)| (t.clone(), *d))
569                .expect("non-empty");
570            (Some(avg), Some(max_dur), Some(max_task))
571        };
572
573    let mut task_rework_counts: Vec<(String, u32)> = per_task_rework.into_iter().collect();
574    task_rework_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
575
576    Some(RunStats {
577        run_start,
578        run_end,
579        total_duration_secs: run_end.saturating_sub(run_start),
580        task_stats,
581        average_cycle_time_secs,
582        fastest_task_id,
583        fastest_cycle_time_secs,
584        longest_task_id,
585        longest_cycle_time_secs,
586        idle_time_pct,
587        escalation_count,
588        message_count,
589        auto_merge_count,
590        manual_merge_count,
591        rework_count,
592        review_nudge_count,
593        review_escalation_count,
594        avg_review_stall_secs,
595        max_review_stall_secs,
596        max_review_stall_task,
597        task_rework_counts,
598    })
599}
600
601/// Analyze the last run. Prefers telemetry DB when available, falls back to JSONL.
602pub fn analyze_project(project_root: &Path) -> Result<Option<RunStats>> {
603    let db_path = project_root.join(".batty").join("telemetry.db");
604    if db_path.exists() {
605        if let Ok(conn) = telemetry_db::open(project_root) {
606            if let Some(stats) = analyze_from_db(&conn) {
607                return Ok(Some(stats));
608            }
609        }
610    }
611
612    // Fallback to JSONL
613    let events_path = project_root
614        .join(".batty")
615        .join("team_config")
616        .join("events.jsonl");
617    analyze_event_log(&events_path)
618}
619
620/// Parse the events file and analyze.
621pub fn analyze_event_log(path: &Path) -> Result<Option<RunStats>> {
622    let events = read_events(path)?;
623    Ok(analyze_events(&events))
624}
625
626pub fn should_generate_retro(
627    project_root: &Path,
628    retro_generated: bool,
629    min_duration_secs: u64,
630) -> Result<Option<RunStats>> {
631    if retro_generated {
632        return Ok(None);
633    }
634
635    let board_dir = project_root
636        .join(".batty")
637        .join("team_config")
638        .join("board");
639    let tasks_dir = board_dir.join("tasks");
640    if !tasks_dir.is_dir() {
641        return Ok(None);
642    }
643
644    let tasks = task::load_tasks_from_dir(&tasks_dir)?;
645    let active_tasks: Vec<&task::Task> = tasks
646        .iter()
647        .filter(|task| task.status != "archived")
648        .collect();
649    if active_tasks.is_empty() || active_tasks.iter().any(|task| task.status != "done") {
650        return Ok(None);
651    }
652
653    let stats = analyze_project(project_root)?;
654
655    // Suppress trivial retrospectives: short runs with zero completions.
656    // Completions override the duration check — a short run that finished
657    // tasks is still worth reporting.
658    if let Some(ref stats) = stats {
659        let completed = stats
660            .task_stats
661            .iter()
662            .filter(|t| t.completed_at.is_some())
663            .count();
664        if stats.total_duration_secs < min_duration_secs && completed == 0 {
665            tracing::debug!(
666                duration_secs = stats.total_duration_secs,
667                completed_tasks = completed,
668                "Skipping trivial retrospective: {}s, {} tasks",
669                stats.total_duration_secs,
670                completed,
671            );
672            return Ok(None);
673        }
674    }
675
676    Ok(stats)
677}
678
679pub fn generate_retrospective(project_root: &Path, stats: &RunStats) -> Result<PathBuf> {
680    let retrospectives_dir = project_root.join(".batty").join("retrospectives");
681    fs::create_dir_all(&retrospectives_dir).with_context(|| {
682        format!(
683            "failed to create retrospectives directory: {}",
684            retrospectives_dir.display()
685        )
686    })?;
687
688    let report_path = retrospectives_dir.join(format!("{}.md", stats.run_end));
689    let report = render_retrospective(stats);
690    fs::write(&report_path, report)
691        .with_context(|| format!("failed to write retrospective: {}", report_path.display()))?;
692
693    Ok(report_path)
694}
695
696pub fn format_duration(secs: u64) -> String {
697    let hours = secs / 3_600;
698    let minutes = (secs % 3_600) / 60;
699    let seconds = secs % 60;
700
701    if hours > 0 {
702        format!("{hours}h {minutes:02}m {seconds:02}s")
703    } else if minutes > 0 {
704        format!("{minutes}m {seconds:02}s")
705    } else {
706        format!("{seconds}s")
707    }
708}
709
710fn render_retrospective(stats: &RunStats) -> String {
711    let completed_tasks = stats
712        .task_stats
713        .iter()
714        .filter(|task| task.completed_at.is_some())
715        .count();
716    let average_cycle_time = stats
717        .average_cycle_time_secs
718        .map(format_duration)
719        .unwrap_or_else(|| "-".to_string());
720    let fastest_cycle_time = stats
721        .fastest_cycle_time_secs
722        .map(|cycle| {
723            format!(
724                "{} ({})",
725                format_duration(cycle),
726                stats.fastest_task_id.as_deref().unwrap_or("-")
727            )
728        })
729        .unwrap_or_else(|| "-".to_string());
730    let longest_cycle_time = stats
731        .longest_cycle_time_secs
732        .map(|cycle| {
733            format!(
734                "{} ({})",
735                format_duration(cycle),
736                stats.longest_task_id.as_deref().unwrap_or("-")
737            )
738        })
739        .unwrap_or_else(|| "-".to_string());
740
741    let task_cycle_rows = render_task_cycle_rows(&stats.task_stats);
742    let bottlenecks = render_bottlenecks(&stats.task_stats);
743    let recommendations = render_recommendations(stats);
744    let review_section = render_review_performance(stats);
745
746    format!(
747        "# Batty Retrospective\n\n\
748## Summary\n\n\
749- Duration: {}\n\
750- Tasks completed: {}\n\
751- Average cycle time: {}\n\
752- Fastest task: {}\n\
753- Longest task: {}\n\
754- Messages: {}\n\
755- Escalations: {}\n\
756- Idle: {:.1}%\n\n\
757## Task Cycle Times\n\n\
758| Task | Assignee | Status | Cycle Time | Retries | Escalated |\n\
759| --- | --- | --- | --- | --- | --- |\n\
760{}\
761\n\
762{}\
763## Bottlenecks\n\n\
764{}\
765\n\
766## Recommendations\n\n\
767{}",
768        format_duration(stats.total_duration_secs),
769        completed_tasks,
770        average_cycle_time,
771        fastest_cycle_time,
772        longest_cycle_time,
773        stats.message_count,
774        stats.escalation_count,
775        stats.idle_time_pct * 100.0,
776        task_cycle_rows,
777        review_section,
778        bottlenecks,
779        recommendations
780    )
781}
782
783fn render_review_performance(stats: &RunStats) -> String {
784    let total_merges = stats.auto_merge_count + stats.manual_merge_count;
785    if total_merges == 0 && stats.rework_count == 0 && stats.review_nudge_count == 0 {
786        return String::new();
787    }
788
789    let auto_rate = if total_merges > 0 {
790        format!(
791            "{:.0}%",
792            stats.auto_merge_count as f64 / total_merges as f64 * 100.0
793        )
794    } else {
795        "-".to_string()
796    };
797    let total_reviewed = total_merges + stats.rework_count;
798    let rework_rate = if total_reviewed > 0 {
799        format!(
800            "{:.0}%",
801            stats.rework_count as f64 / total_reviewed as f64 * 100.0
802        )
803    } else {
804        "-".to_string()
805    };
806
807    let avg_stall = stats
808        .avg_review_stall_secs
809        .map(format_duration)
810        .unwrap_or_else(|| "-".to_string());
811    let max_stall = stats
812        .max_review_stall_secs
813        .map(|secs| {
814            format!(
815                "{} ({})",
816                format_duration(secs),
817                stats.max_review_stall_task.as_deref().unwrap_or("-")
818            )
819        })
820        .unwrap_or_else(|| "-".to_string());
821
822    let mut section = format!(
823        "## Review Pipeline\n\n\
824- Auto-merged: {}\n\
825- Manually merged: {}\n\
826- Auto-merge rate: {}\n\
827- Avg review stall: {}\n\
828- Max review stall: {}\n\
829- Rework cycles: {}\n\
830- Rework rate: {}\n\
831- Review nudges: {}\n\
832- Review escalations: {}\n",
833        stats.auto_merge_count,
834        stats.manual_merge_count,
835        auto_rate,
836        avg_stall,
837        max_stall,
838        stats.rework_count,
839        rework_rate,
840        stats.review_nudge_count,
841        stats.review_escalation_count,
842    );
843
844    if !stats.task_rework_counts.is_empty() {
845        section.push_str(
846            "\n### Rework by Task\n\n\
847| Task | Rework Cycles |\n\
848| --- | --- |\n",
849        );
850        for (task_id, count) in &stats.task_rework_counts {
851            section.push_str(&format!("| {} | {} |\n", task_id, count));
852        }
853    }
854
855    section.push('\n');
856    section
857}
858
859fn render_task_cycle_rows(tasks: &[TaskStats]) -> String {
860    if tasks.is_empty() {
861        return "| No tasks recorded | - | - | - | - | - |\n".to_string();
862    }
863
864    let mut rows = String::new();
865    for task in tasks {
866        let status = if task.completed_at.is_some() {
867            "completed"
868        } else {
869            "incomplete"
870        };
871        let cycle_time = task
872            .cycle_time_secs
873            .map(format_duration)
874            .unwrap_or_else(|| "-".to_string());
875        let escalated = if task.was_escalated { "yes" } else { "no" };
876        rows.push_str(&format!(
877            "| {} | {} | {} | {} | {} | {} |\n",
878            task.task_id, task.assigned_to, status, cycle_time, task.retry_count, escalated
879        ));
880    }
881    rows
882}
883
884fn render_bottlenecks(tasks: &[TaskStats]) -> String {
885    let longest_task = tasks
886        .iter()
887        .filter_map(|task| task.cycle_time_secs.map(|cycle| (task, cycle)))
888        .max_by_key(|(_, cycle)| *cycle);
889
890    let most_retried = tasks.iter().max_by_key(|task| task.retry_count);
891
892    let mut lines = Vec::new();
893    match longest_task {
894        Some((task, cycle)) => lines.push(format!(
895            "- Longest task: `{}` owned by `{}` at {}.",
896            task.task_id,
897            task.assigned_to,
898            format_duration(cycle)
899        )),
900        None => lines.push("- Longest task: no completed tasks recorded.".to_string()),
901    }
902
903    match most_retried {
904        Some(task) if task.retry_count > 1 => lines.push(format!(
905            "- Most retried: `{}` retried {} times.",
906            task.task_id, task.retry_count
907        )),
908        _ => lines.push("- Most retried: no task needed multiple attempts.".to_string()),
909    }
910
911    format!("{}\n", lines.join("\n"))
912}
913
914fn render_recommendations(stats: &RunStats) -> String {
915    let mut lines = Vec::new();
916    let max_retry_count = stats
917        .task_stats
918        .iter()
919        .map(|task| task.retry_count)
920        .max()
921        .unwrap_or(0);
922
923    if stats.idle_time_pct >= 0.5 {
924        lines.push(
925            "- Idle time stayed high. Queue more ready tasks so engineers are not waiting on assignment."
926                .to_string(),
927        );
928    }
929
930    if max_retry_count >= 3 {
931        lines.push(
932            "- Several retries were needed. Break work into smaller tasks with clearer acceptance criteria."
933                .to_string(),
934        );
935    }
936
937    if lines.is_empty() {
938        lines.push(
939            "- No major bottlenecks stood out. Keep the current task sizing and routing cadence."
940                .to_string(),
941        );
942    }
943
944    format!("{}\n", lines.join("\n"))
945}
946
947#[cfg(test)]
948mod tests {
949    use tempfile::tempdir;
950
951    use super::*;
952
953    fn at(mut event: TeamEvent, ts: u64) -> TeamEvent {
954        event.ts = ts;
955        event
956    }
957
958    #[test]
959    fn test_analyze_events_basic_run() {
960        let events = vec![
961            at(TeamEvent::daemon_started(), 100),
962            at(TeamEvent::task_assigned("eng-1", "42"), 110),
963            at(TeamEvent::message_routed("manager", "eng-1"), 115),
964            at(TeamEvent::task_completed("eng-1", None), 150),
965            at(TeamEvent::daemon_stopped_with_reason("signal", 50), 160),
966        ];
967
968        let stats = analyze_events(&events).unwrap();
969
970        assert_eq!(stats.run_start, 100);
971        assert_eq!(stats.run_end, 160);
972        assert_eq!(stats.total_duration_secs, 60);
973        assert_eq!(stats.escalation_count, 0);
974        assert_eq!(stats.message_count, 1);
975        assert_eq!(stats.task_stats.len(), 1);
976        assert_eq!(stats.average_cycle_time_secs, Some(40));
977        assert_eq!(stats.fastest_task_id.as_deref(), Some("42"));
978        assert_eq!(stats.fastest_cycle_time_secs, Some(40));
979        assert_eq!(stats.longest_task_id.as_deref(), Some("42"));
980        assert_eq!(stats.longest_cycle_time_secs, Some(40));
981        assert_eq!(
982            stats.task_stats[0],
983            TaskStats {
984                task_id: "42".to_string(),
985                assigned_to: "eng-1".to_string(),
986                assigned_at: 110,
987                completed_at: Some(150),
988                cycle_time_secs: Some(40),
989                retry_count: 1,
990                was_escalated: false,
991            }
992        );
993    }
994
995    #[test]
996    fn test_analyze_events_with_retries() {
997        let events = vec![
998            at(TeamEvent::daemon_started(), 100),
999            at(
1000                TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
1001                110,
1002            ),
1003            at(
1004                TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
1005                130,
1006            ),
1007            at(TeamEvent::task_completed("eng-1", None), 170),
1008            at(TeamEvent::daemon_stopped_with_reason("signal", 70), 180),
1009        ];
1010
1011        let stats = analyze_events(&events).unwrap();
1012
1013        assert_eq!(stats.task_stats.len(), 1);
1014        assert_eq!(stats.task_stats[0].retry_count, 2);
1015        assert_eq!(stats.task_stats[0].assigned_at, 110);
1016        assert_eq!(stats.task_stats[0].cycle_time_secs, Some(60));
1017        assert_eq!(stats.task_stats[0].task_id, "42");
1018    }
1019
1020    #[test]
1021    fn test_analyze_events_with_escalation() {
1022        let events = vec![
1023            at(TeamEvent::daemon_started(), 100),
1024            at(TeamEvent::task_assigned("eng-1", "42"), 110),
1025            at(TeamEvent::task_escalated("eng-1", "42", None), 125),
1026            at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1027        ];
1028
1029        let stats = analyze_events(&events).unwrap();
1030
1031        assert_eq!(stats.escalation_count, 1);
1032        assert_eq!(stats.task_stats.len(), 1);
1033        assert!(stats.task_stats[0].was_escalated);
1034        assert_eq!(stats.task_stats[0].completed_at, None);
1035    }
1036
1037    #[test]
1038    fn test_analyze_events_idle_time() {
1039        let events = vec![
1040            at(TeamEvent::daemon_started(), 100),
1041            at(TeamEvent::load_snapshot(1, 4, true), 110),
1042            at(TeamEvent::load_snapshot(3, 4, true), 120),
1043            at(TeamEvent::daemon_stopped_with_reason("signal", 25), 125),
1044        ];
1045
1046        let stats = analyze_events(&events).unwrap();
1047
1048        assert!((stats.idle_time_pct - 0.5).abs() < 1e-9);
1049    }
1050
1051    #[test]
1052    fn test_analyze_events_empty() {
1053        assert_eq!(analyze_events(&[]), None);
1054    }
1055
1056    #[test]
1057    fn test_analyze_events_multiple_runs() {
1058        let events = vec![
1059            at(TeamEvent::daemon_started(), 100),
1060            at(TeamEvent::task_assigned("eng-1", "old-task"), 105),
1061            at(TeamEvent::daemon_stopped_with_reason("signal", 10), 110),
1062            at(TeamEvent::daemon_started(), 200),
1063            at(
1064                TeamEvent::task_assigned("eng-2", "Task #12: new-task\n\nTask details."),
1065                210,
1066            ),
1067            at(TeamEvent::task_completed("eng-2", None), 240),
1068            at(TeamEvent::daemon_stopped_with_reason("signal", 45), 245),
1069        ];
1070
1071        let stats = analyze_events(&events).unwrap();
1072
1073        assert_eq!(stats.run_start, 200);
1074        assert_eq!(stats.run_end, 245);
1075        assert_eq!(stats.task_stats.len(), 1);
1076        assert_eq!(stats.task_stats[0].task_id, "12");
1077        assert_eq!(stats.task_stats[0].assigned_to, "eng-2");
1078        assert_eq!(stats.task_stats[0].cycle_time_secs, Some(30));
1079        assert_eq!(stats.average_cycle_time_secs, Some(30));
1080        assert_eq!(stats.fastest_task_id.as_deref(), Some("12"));
1081        assert_eq!(stats.longest_task_id.as_deref(), Some("12"));
1082    }
1083
1084    #[test]
1085    fn test_analyze_events_computes_average_fastest_and_longest_cycle_times() {
1086        let events = vec![
1087            at(TeamEvent::daemon_started(), 100),
1088            at(
1089                TeamEvent::task_assigned("eng-1", "Task #11: short task\n\nBody."),
1090                110,
1091            ),
1092            at(TeamEvent::task_completed("eng-1", None), 140),
1093            at(
1094                TeamEvent::task_assigned("eng-2", "Task #12: long task\n\nBody."),
1095                150,
1096            ),
1097            at(TeamEvent::task_completed("eng-2", None), 240),
1098            at(TeamEvent::daemon_stopped_with_reason("signal", 150), 250),
1099        ];
1100
1101        let stats = analyze_events(&events).unwrap();
1102
1103        assert_eq!(stats.average_cycle_time_secs, Some(60));
1104        assert_eq!(stats.fastest_task_id.as_deref(), Some("11"));
1105        assert_eq!(stats.fastest_cycle_time_secs, Some(30));
1106        assert_eq!(stats.longest_task_id.as_deref(), Some("12"));
1107        assert_eq!(stats.longest_cycle_time_secs, Some(90));
1108    }
1109
1110    fn sample_task(task_id: &str, cycle_time_secs: Option<u64>, retry_count: u32) -> TaskStats {
1111        TaskStats {
1112            task_id: task_id.to_string(),
1113            assigned_to: "eng-1".to_string(),
1114            assigned_at: 100,
1115            completed_at: cycle_time_secs.map(|cycle| 100 + cycle),
1116            cycle_time_secs,
1117            retry_count,
1118            was_escalated: retry_count > 2,
1119        }
1120    }
1121
1122    #[test]
1123    fn format_duration_variants() {
1124        assert_eq!(format_duration(45), "45s");
1125        assert_eq!(format_duration(65), "1m 05s");
1126        assert_eq!(format_duration(3_665), "1h 01m 05s");
1127    }
1128
1129    #[test]
1130    fn generate_retrospective_writes_report_with_sections() {
1131        let tmp = tempdir().unwrap();
1132        let stats = RunStats {
1133            run_start: 1_700_000_000,
1134            run_end: 1_700_000_123,
1135            total_duration_secs: 123,
1136            task_stats: vec![
1137                sample_task("T-101", Some(90), 1),
1138                sample_task("T-102", Some(30), 2),
1139            ],
1140            average_cycle_time_secs: Some(60),
1141            fastest_task_id: Some("T-102".to_string()),
1142            fastest_cycle_time_secs: Some(30),
1143            longest_task_id: Some("T-101".to_string()),
1144            longest_cycle_time_secs: Some(90),
1145            idle_time_pct: 0.25,
1146            escalation_count: 1,
1147            message_count: 6,
1148            auto_merge_count: 0,
1149            manual_merge_count: 0,
1150            rework_count: 0,
1151            review_nudge_count: 0,
1152            review_escalation_count: 0,
1153            avg_review_stall_secs: None,
1154            max_review_stall_secs: None,
1155            max_review_stall_task: None,
1156            task_rework_counts: Vec::new(),
1157        };
1158
1159        let path = generate_retrospective(tmp.path(), &stats).unwrap();
1160        let content = fs::read_to_string(&path).unwrap();
1161
1162        assert_eq!(
1163            path,
1164            tmp.path()
1165                .join(".batty")
1166                .join("retrospectives")
1167                .join("1700000123.md")
1168        );
1169        assert!(content.contains("## Summary"));
1170        assert!(content.contains("## Task Cycle Times"));
1171        assert!(content.contains("## Bottlenecks"));
1172        assert!(content.contains("## Recommendations"));
1173        assert!(content.contains("| T-101 | eng-1 | completed | 1m 30s | 1 | no |"));
1174        assert!(content.contains("- Tasks completed: 2"));
1175        assert!(content.contains("- Average cycle time: 1m 00s"));
1176        assert!(content.contains("- Fastest task: 30s (T-102)"));
1177        assert!(content.contains("- Longest task: 1m 30s (T-101)"));
1178    }
1179
1180    #[test]
1181    fn generate_retrospective_handles_empty_tasks() {
1182        let tmp = tempdir().unwrap();
1183        let stats = RunStats {
1184            run_start: 10,
1185            run_end: 20,
1186            total_duration_secs: 10,
1187            task_stats: Vec::new(),
1188            average_cycle_time_secs: None,
1189            fastest_task_id: None,
1190            fastest_cycle_time_secs: None,
1191            longest_task_id: None,
1192            longest_cycle_time_secs: None,
1193            idle_time_pct: 0.0,
1194            escalation_count: 0,
1195            message_count: 0,
1196            auto_merge_count: 0,
1197            manual_merge_count: 0,
1198            rework_count: 0,
1199            review_nudge_count: 0,
1200            review_escalation_count: 0,
1201            avg_review_stall_secs: None,
1202            max_review_stall_secs: None,
1203            max_review_stall_task: None,
1204            task_rework_counts: Vec::new(),
1205        };
1206
1207        let path = generate_retrospective(tmp.path(), &stats).unwrap();
1208        let content = fs::read_to_string(path).unwrap();
1209
1210        assert!(content.contains("| No tasks recorded | - | - | - | - | - |"));
1211        assert!(content.contains("- Average cycle time: -"));
1212        assert!(content.contains("- Fastest task: -"));
1213        assert!(content.contains("- Longest task: -"));
1214        assert!(content.contains("- Longest task: no completed tasks recorded."));
1215        assert!(content.contains("- Most retried: no task needed multiple attempts."));
1216    }
1217
1218    #[test]
1219    fn generate_retrospective_adds_high_idle_recommendation() {
1220        let tmp = tempdir().unwrap();
1221        let stats = RunStats {
1222            run_start: 10,
1223            run_end: 30,
1224            total_duration_secs: 20,
1225            task_stats: vec![sample_task("T-201", Some(20), 1)],
1226            average_cycle_time_secs: Some(20),
1227            fastest_task_id: Some("T-201".to_string()),
1228            fastest_cycle_time_secs: Some(20),
1229            longest_task_id: Some("T-201".to_string()),
1230            longest_cycle_time_secs: Some(20),
1231            idle_time_pct: 0.75,
1232            escalation_count: 0,
1233            message_count: 1,
1234            auto_merge_count: 0,
1235            manual_merge_count: 0,
1236            rework_count: 0,
1237            review_nudge_count: 0,
1238            review_escalation_count: 0,
1239            avg_review_stall_secs: None,
1240            max_review_stall_secs: None,
1241            max_review_stall_task: None,
1242            task_rework_counts: Vec::new(),
1243        };
1244
1245        let path = generate_retrospective(tmp.path(), &stats).unwrap();
1246        let content = fs::read_to_string(path).unwrap();
1247
1248        assert!(content.contains("Idle time stayed high"));
1249        assert!(content.contains("Queue more ready tasks"));
1250    }
1251
1252    #[test]
1253    fn generate_retrospective_adds_high_retry_recommendation() {
1254        let tmp = tempdir().unwrap();
1255        let stats = RunStats {
1256            run_start: 10,
1257            run_end: 40,
1258            total_duration_secs: 30,
1259            task_stats: vec![sample_task("T-301", Some(25), 3)],
1260            average_cycle_time_secs: Some(25),
1261            fastest_task_id: Some("T-301".to_string()),
1262            fastest_cycle_time_secs: Some(25),
1263            longest_task_id: Some("T-301".to_string()),
1264            longest_cycle_time_secs: Some(25),
1265            idle_time_pct: 0.1,
1266            escalation_count: 0,
1267            message_count: 2,
1268            auto_merge_count: 0,
1269            manual_merge_count: 0,
1270            rework_count: 0,
1271            review_nudge_count: 0,
1272            review_escalation_count: 0,
1273            avg_review_stall_secs: None,
1274            max_review_stall_secs: None,
1275            max_review_stall_task: None,
1276            task_rework_counts: Vec::new(),
1277        };
1278
1279        let path = generate_retrospective(tmp.path(), &stats).unwrap();
1280        let content = fs::read_to_string(path).unwrap();
1281
1282        assert!(content.contains("Several retries were needed"));
1283        assert!(content.contains("smaller tasks"));
1284    }
1285
1286    fn write_owned_task_file(
1287        project_root: &Path,
1288        task_id: u32,
1289        title: &str,
1290        status: &str,
1291        claimed_by: &str,
1292    ) {
1293        let board_dir = project_root
1294            .join(".batty")
1295            .join("team_config")
1296            .join("board");
1297        let tasks_dir = board_dir.join("tasks");
1298        fs::create_dir_all(&tasks_dir).unwrap();
1299        let slug = title.replace(' ', "-");
1300        let task_path = tasks_dir.join(format!("{task_id:03}-{slug}.md"));
1301        let content = format!(
1302            r#"---
1303id: {task_id}
1304title: "{title}"
1305status: {status}
1306claimed_by: {claimed_by}
1307---
1308
1309Task body.
1310"#
1311        );
1312        fs::write(task_path, content).unwrap();
1313    }
1314
1315    fn write_event_log(project_root: &Path, events: &[TeamEvent]) {
1316        let events_path = project_root
1317            .join(".batty")
1318            .join("team_config")
1319            .join("events.jsonl");
1320        fs::create_dir_all(events_path.parent().unwrap()).unwrap();
1321        let body = events
1322            .iter()
1323            .map(|event| serde_json::to_string(event).unwrap())
1324            .collect::<Vec<_>>()
1325            .join("\n");
1326        fs::write(events_path, format!("{body}\n")).unwrap();
1327    }
1328
1329    #[test]
1330    fn should_generate_retro_when_all_active_tasks_are_done() {
1331        let tmp = tempdir().unwrap();
1332        write_owned_task_file(tmp.path(), 45, "retro-task", "done", "eng-1");
1333        write_event_log(
1334            tmp.path(),
1335            &[
1336                at(TeamEvent::daemon_started(), 100),
1337                at(TeamEvent::task_assigned("eng-1", "45"), 110),
1338                at(TeamEvent::task_completed("eng-1", None), 150),
1339                at(TeamEvent::daemon_stopped(), 160),
1340            ],
1341        );
1342
1343        let stats = should_generate_retro(tmp.path(), false, 60)
1344            .unwrap()
1345            .unwrap();
1346        assert_eq!(stats.run_start, 100);
1347        assert_eq!(stats.run_end, 160);
1348        assert_eq!(stats.task_stats.len(), 1);
1349        assert_eq!(stats.task_stats[0].task_id, "45");
1350    }
1351
1352    #[test]
1353    fn should_not_generate_retro_when_task_is_not_done() {
1354        let tmp = tempdir().unwrap();
1355        write_owned_task_file(tmp.path(), 45, "retro-task", "in-progress", "eng-1");
1356        write_event_log(tmp.path(), &[at(TeamEvent::daemon_started(), 100)]);
1357
1358        let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1359        assert_eq!(stats, None);
1360    }
1361
1362    #[test]
1363    fn should_not_generate_retro_twice() {
1364        let tmp = tempdir().unwrap();
1365        write_owned_task_file(tmp.path(), 45, "retro-task", "done", "eng-1");
1366        write_event_log(
1367            tmp.path(),
1368            &[
1369                at(TeamEvent::daemon_started(), 100),
1370                at(TeamEvent::task_assigned("eng-1", "45"), 110),
1371                at(TeamEvent::task_completed("eng-1", None), 150),
1372                at(TeamEvent::daemon_stopped(), 160),
1373            ],
1374        );
1375
1376        let stats = should_generate_retro(tmp.path(), true, 60).unwrap();
1377        assert_eq!(stats, None);
1378    }
1379
1380    #[test]
1381    fn skip_retro_for_short_run() {
1382        let tmp = tempdir().unwrap();
1383        write_owned_task_file(tmp.path(), 50, "short-task", "done", "eng-1");
1384        write_event_log(
1385            tmp.path(),
1386            &[
1387                at(TeamEvent::daemon_started(), 100),
1388                at(TeamEvent::daemon_stopped(), 104),
1389            ],
1390        );
1391
1392        // 4-second run, 0 completions -> suppressed
1393        let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1394        assert_eq!(stats, None);
1395    }
1396
1397    #[test]
1398    fn generate_retro_for_long_run() {
1399        let tmp = tempdir().unwrap();
1400        write_owned_task_file(tmp.path(), 51, "long-task", "done", "eng-1");
1401        write_event_log(
1402            tmp.path(),
1403            &[
1404                at(TeamEvent::daemon_started(), 100),
1405                at(TeamEvent::task_assigned("eng-1", "51"), 110),
1406                at(TeamEvent::task_completed("eng-1", None), 200),
1407                at(TeamEvent::task_assigned("eng-1", "52"), 210),
1408                at(TeamEvent::task_completed("eng-1", None), 300),
1409                at(TeamEvent::task_assigned("eng-1", "53"), 310),
1410                at(TeamEvent::task_completed("eng-1", None), 380),
1411                at(TeamEvent::daemon_stopped(), 400),
1412            ],
1413        );
1414
1415        // 300-second run, 3 completions -> generates
1416        let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1417        assert!(stats.is_some());
1418        let stats = stats.unwrap();
1419        assert_eq!(stats.total_duration_secs, 300);
1420    }
1421
1422    #[test]
1423    fn skip_retro_for_short_run_with_completions() {
1424        let tmp = tempdir().unwrap();
1425        write_owned_task_file(tmp.path(), 55, "quick-task", "done", "eng-1");
1426        write_event_log(
1427            tmp.path(),
1428            &[
1429                at(TeamEvent::daemon_started(), 100),
1430                at(TeamEvent::task_assigned("eng-1", "55"), 105),
1431                at(TeamEvent::task_completed("eng-1", None), 115),
1432                at(TeamEvent::task_assigned("eng-1", "56"), 118),
1433                at(TeamEvent::task_completed("eng-1", None), 125),
1434                at(TeamEvent::daemon_stopped(), 130),
1435            ],
1436        );
1437
1438        // 30-second run but 2 completions -> generates (completions override)
1439        let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1440        assert!(stats.is_some());
1441        let stats = stats.unwrap();
1442        assert_eq!(stats.total_duration_secs, 30);
1443    }
1444
1445    #[test]
1446    fn analyze_events_computes_review_stall_duration() {
1447        let events = vec![
1448            at(TeamEvent::daemon_started(), 100),
1449            at(
1450                TeamEvent::task_assigned("eng-1", "Task #10: fast task"),
1451                110,
1452            ),
1453            at(TeamEvent::task_completed("eng-1", None), 150),
1454            // 30s stall before auto-merge
1455            at(
1456                TeamEvent::task_auto_merged("eng-1", "Task #10: fast task", 0.9, 2, 10),
1457                180,
1458            ),
1459            at(
1460                TeamEvent::task_assigned("eng-2", "Task #20: slow task"),
1461                120,
1462            ),
1463            at(TeamEvent::task_completed("eng-2", None), 200),
1464            // 100s stall before manual merge
1465            at(TeamEvent::task_manual_merged("Task #20: slow task"), 300),
1466            at(TeamEvent::daemon_stopped_with_reason("signal", 210), 310),
1467        ];
1468
1469        let stats = analyze_events(&events).unwrap();
1470
1471        assert_eq!(stats.auto_merge_count, 1);
1472        assert_eq!(stats.manual_merge_count, 1);
1473        // avg of 30s and 100s = 65s
1474        assert_eq!(stats.avg_review_stall_secs, Some(65));
1475        // max is 100s for task 20
1476        assert_eq!(stats.max_review_stall_secs, Some(100));
1477        assert_eq!(stats.max_review_stall_task.as_deref(), Some("20"));
1478    }
1479
1480    #[test]
1481    fn analyze_events_no_stall_without_merges() {
1482        let events = vec![
1483            at(TeamEvent::daemon_started(), 100),
1484            at(TeamEvent::task_assigned("eng-1", "42"), 110),
1485            at(TeamEvent::task_completed("eng-1", None), 150),
1486            at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1487        ];
1488
1489        let stats = analyze_events(&events).unwrap();
1490
1491        assert_eq!(stats.avg_review_stall_secs, None);
1492        assert_eq!(stats.max_review_stall_secs, None);
1493        assert_eq!(stats.max_review_stall_task, None);
1494    }
1495
1496    #[test]
1497    fn analyze_events_tracks_per_task_rework() {
1498        let events = vec![
1499            at(TeamEvent::daemon_started(), 100),
1500            at(TeamEvent::task_assigned("eng-1", "Task #10: reworked"), 110),
1501            at(TeamEvent::task_reworked("eng-1", "Task #10: reworked"), 120),
1502            at(TeamEvent::task_reworked("eng-1", "Task #10: reworked"), 130),
1503            at(TeamEvent::task_assigned("eng-2", "Task #20: once"), 115),
1504            at(TeamEvent::task_reworked("eng-2", "Task #20: once"), 140),
1505            at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1506        ];
1507
1508        let stats = analyze_events(&events).unwrap();
1509
1510        assert_eq!(stats.rework_count, 3);
1511        // Sorted by count descending: task 10 (2), task 20 (1)
1512        assert_eq!(stats.task_rework_counts.len(), 2);
1513        assert_eq!(stats.task_rework_counts[0], ("10".to_string(), 2));
1514        assert_eq!(stats.task_rework_counts[1], ("20".to_string(), 1));
1515    }
1516
1517    #[test]
1518    fn analyze_events_empty_rework_list() {
1519        let events = vec![
1520            at(TeamEvent::daemon_started(), 100),
1521            at(TeamEvent::task_assigned("eng-1", "42"), 110),
1522            at(TeamEvent::task_completed("eng-1", None), 150),
1523            at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1524        ];
1525
1526        let stats = analyze_events(&events).unwrap();
1527
1528        assert!(stats.task_rework_counts.is_empty());
1529    }
1530
1531    #[test]
1532    fn render_review_pipeline_section_includes_stall_and_rework() {
1533        let tmp = tempdir().unwrap();
1534        let stats = RunStats {
1535            run_start: 100,
1536            run_end: 500,
1537            total_duration_secs: 400,
1538            task_stats: Vec::new(),
1539            average_cycle_time_secs: None,
1540            fastest_task_id: None,
1541            fastest_cycle_time_secs: None,
1542            longest_task_id: None,
1543            longest_cycle_time_secs: None,
1544            idle_time_pct: 0.0,
1545            escalation_count: 0,
1546            message_count: 0,
1547            auto_merge_count: 3,
1548            manual_merge_count: 1,
1549            rework_count: 2,
1550            review_nudge_count: 1,
1551            review_escalation_count: 0,
1552            avg_review_stall_secs: Some(90),
1553            max_review_stall_secs: Some(180),
1554            max_review_stall_task: Some("T-5".to_string()),
1555            task_rework_counts: vec![("T-5".to_string(), 2)],
1556        };
1557
1558        let path = generate_retrospective(tmp.path(), &stats).unwrap();
1559        let content = fs::read_to_string(path).unwrap();
1560
1561        assert!(content.contains("## Review Pipeline"));
1562        assert!(content.contains("Auto-merged: 3"));
1563        assert!(content.contains("Manually merged: 1"));
1564        assert!(content.contains("Auto-merge rate: 75%"));
1565        assert!(content.contains("Avg review stall: 1m 30s"));
1566        assert!(content.contains("Max review stall: 3m 00s (T-5)"));
1567        assert!(content.contains("Rework cycles: 2"));
1568        assert!(content.contains("### Rework by Task"));
1569        assert!(content.contains("| T-5 | 2 |"));
1570    }
1571
1572    #[test]
1573    fn render_review_pipeline_no_stall_data() {
1574        let tmp = tempdir().unwrap();
1575        let stats = RunStats {
1576            run_start: 100,
1577            run_end: 300,
1578            total_duration_secs: 200,
1579            task_stats: Vec::new(),
1580            average_cycle_time_secs: None,
1581            fastest_task_id: None,
1582            fastest_cycle_time_secs: None,
1583            longest_task_id: None,
1584            longest_cycle_time_secs: None,
1585            idle_time_pct: 0.0,
1586            escalation_count: 0,
1587            message_count: 0,
1588            auto_merge_count: 2,
1589            manual_merge_count: 0,
1590            rework_count: 0,
1591            review_nudge_count: 0,
1592            review_escalation_count: 0,
1593            avg_review_stall_secs: None,
1594            max_review_stall_secs: None,
1595            max_review_stall_task: None,
1596            task_rework_counts: Vec::new(),
1597        };
1598
1599        let path = generate_retrospective(tmp.path(), &stats).unwrap();
1600        let content = fs::read_to_string(path).unwrap();
1601
1602        assert!(content.contains("## Review Pipeline"));
1603        assert!(content.contains("Avg review stall: -"));
1604        assert!(content.contains("Max review stall: -"));
1605        assert!(!content.contains("### Rework by Task"));
1606    }
1607
1608    // --- New tests for content generation and event analysis ---
1609
1610    #[test]
1611    fn task_reference_extracts_id_from_task_prefix() {
1612        assert_eq!(task_reference("Task #42: build feature"), "42");
1613    }
1614
1615    #[test]
1616    fn task_reference_returns_full_line_when_no_prefix() {
1617        assert_eq!(task_reference("build feature"), "build feature");
1618    }
1619
1620    #[test]
1621    fn task_reference_skips_blank_lines() {
1622        assert_eq!(task_reference("\n\n  Task #99: test\nbody"), "99");
1623    }
1624
1625    #[test]
1626    fn task_reference_handles_whitespace_only_input() {
1627        assert_eq!(task_reference("   "), "");
1628    }
1629
1630    #[test]
1631    fn task_id_from_assignment_line_valid() {
1632        assert_eq!(
1633            task_id_from_assignment_line("Task #123: some task"),
1634            Some("123".to_string())
1635        );
1636    }
1637
1638    #[test]
1639    fn task_id_from_assignment_line_no_prefix() {
1640        assert_eq!(task_id_from_assignment_line("no prefix here"), None);
1641    }
1642
1643    #[test]
1644    fn task_id_from_assignment_line_empty_digits() {
1645        assert_eq!(task_id_from_assignment_line("Task #abc: letters"), None);
1646    }
1647
1648    #[test]
1649    fn cycle_time_metrics_no_completed_tasks() {
1650        let tasks = vec![sample_task("T-1", None, 1)];
1651        let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
1652        assert_eq!(avg, None);
1653        assert_eq!(fastest, None);
1654        assert_eq!(fastest_time, None);
1655        assert_eq!(longest, None);
1656        assert_eq!(longest_time, None);
1657    }
1658
1659    #[test]
1660    fn cycle_time_metrics_single_completed_task() {
1661        let tasks = vec![sample_task("T-1", Some(60), 1)];
1662        let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
1663        assert_eq!(avg, Some(60));
1664        assert_eq!(fastest, Some("T-1".to_string()));
1665        assert_eq!(fastest_time, Some(60));
1666        assert_eq!(longest, Some("T-1".to_string()));
1667        assert_eq!(longest_time, Some(60));
1668    }
1669
1670    #[test]
1671    fn cycle_time_metrics_multiple_tasks_picks_extremes() {
1672        let tasks = vec![
1673            sample_task("T-fast", Some(10), 1),
1674            sample_task("T-mid", Some(50), 1),
1675            sample_task("T-slow", Some(90), 1),
1676            sample_task("T-incomplete", None, 1),
1677        ];
1678        let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
1679        assert_eq!(avg, Some(50)); // (10 + 50 + 90) / 3
1680        assert_eq!(fastest, Some("T-fast".to_string()));
1681        assert_eq!(fastest_time, Some(10));
1682        assert_eq!(longest, Some("T-slow".to_string()));
1683        assert_eq!(longest_time, Some(90));
1684    }
1685
1686    #[test]
1687    fn format_duration_zero() {
1688        assert_eq!(format_duration(0), "0s");
1689    }
1690
1691    #[test]
1692    fn format_duration_exact_minute() {
1693        assert_eq!(format_duration(60), "1m 00s");
1694    }
1695
1696    #[test]
1697    fn format_duration_exact_hour() {
1698        assert_eq!(format_duration(3600), "1h 00m 00s");
1699    }
1700
1701    #[test]
1702    fn format_duration_large() {
1703        assert_eq!(format_duration(7322), "2h 02m 02s");
1704    }
1705
1706    #[test]
1707    fn render_task_cycle_rows_empty() {
1708        let rows = render_task_cycle_rows(&[]);
1709        assert!(rows.contains("No tasks recorded"));
1710    }
1711
1712    #[test]
1713    fn render_task_cycle_rows_completed_and_incomplete() {
1714        let tasks = vec![
1715            sample_task("T-1", Some(120), 1),
1716            sample_task("T-2", None, 2),
1717        ];
1718        let rows = render_task_cycle_rows(&tasks);
1719        assert!(rows.contains("| T-1 | eng-1 | completed | 2m 00s | 1 | no |"));
1720        assert!(rows.contains("| T-2 | eng-1 | incomplete | - | 2 | no |"));
1721    }
1722
1723    #[test]
1724    fn render_task_cycle_rows_escalated_task() {
1725        let tasks = vec![sample_task("T-esc", Some(200), 4)]; // retry > 2 → escalated
1726        let rows = render_task_cycle_rows(&tasks);
1727        assert!(rows.contains("| T-esc | eng-1 | completed | 3m 20s | 4 | yes |"));
1728    }
1729
1730    #[test]
1731    fn render_bottlenecks_no_completed_tasks() {
1732        let tasks = vec![sample_task("T-1", None, 1)];
1733        let output = render_bottlenecks(&tasks);
1734        assert!(output.contains("no completed tasks recorded"));
1735        assert!(output.contains("no task needed multiple attempts"));
1736    }
1737
1738    #[test]
1739    fn render_bottlenecks_with_retries() {
1740        let tasks = vec![
1741            sample_task("T-1", Some(100), 1),
1742            sample_task("T-2", Some(200), 3),
1743        ];
1744        let output = render_bottlenecks(&tasks);
1745        assert!(output.contains("Longest task: `T-2`"));
1746        assert!(output.contains("Most retried: `T-2` retried 3 times"));
1747    }
1748
1749    #[test]
1750    fn render_bottlenecks_single_retry_shows_no_retries_message() {
1751        let tasks = vec![sample_task("T-1", Some(60), 1)];
1752        let output = render_bottlenecks(&tasks);
1753        assert!(output.contains("no task needed multiple attempts"));
1754    }
1755
1756    #[test]
1757    fn render_recommendations_low_idle_low_retries() {
1758        let stats = RunStats {
1759            run_start: 0,
1760            run_end: 100,
1761            total_duration_secs: 100,
1762            task_stats: vec![sample_task("T-1", Some(50), 1)],
1763            average_cycle_time_secs: Some(50),
1764            fastest_task_id: Some("T-1".to_string()),
1765            fastest_cycle_time_secs: Some(50),
1766            longest_task_id: Some("T-1".to_string()),
1767            longest_cycle_time_secs: Some(50),
1768            idle_time_pct: 0.1,
1769            escalation_count: 0,
1770            message_count: 1,
1771            auto_merge_count: 0,
1772            manual_merge_count: 0,
1773            rework_count: 0,
1774            review_nudge_count: 0,
1775            review_escalation_count: 0,
1776            avg_review_stall_secs: None,
1777            max_review_stall_secs: None,
1778            max_review_stall_task: None,
1779            task_rework_counts: Vec::new(),
1780        };
1781        let output = render_recommendations(&stats);
1782        assert!(output.contains("No major bottlenecks"));
1783    }
1784
1785    #[test]
1786    fn render_recommendations_both_high_idle_and_high_retries() {
1787        let stats = RunStats {
1788            run_start: 0,
1789            run_end: 100,
1790            total_duration_secs: 100,
1791            task_stats: vec![sample_task("T-1", Some(50), 5)],
1792            average_cycle_time_secs: Some(50),
1793            fastest_task_id: Some("T-1".to_string()),
1794            fastest_cycle_time_secs: Some(50),
1795            longest_task_id: Some("T-1".to_string()),
1796            longest_cycle_time_secs: Some(50),
1797            idle_time_pct: 0.8,
1798            escalation_count: 0,
1799            message_count: 1,
1800            auto_merge_count: 0,
1801            manual_merge_count: 0,
1802            rework_count: 0,
1803            review_nudge_count: 0,
1804            review_escalation_count: 0,
1805            avg_review_stall_secs: None,
1806            max_review_stall_secs: None,
1807            max_review_stall_task: None,
1808            task_rework_counts: Vec::new(),
1809        };
1810        let output = render_recommendations(&stats);
1811        assert!(output.contains("Idle time stayed high"));
1812        assert!(output.contains("Several retries were needed"));
1813    }
1814
1815    #[test]
1816    fn render_review_performance_empty_when_no_merges() {
1817        let stats = RunStats {
1818            run_start: 0,
1819            run_end: 100,
1820            total_duration_secs: 100,
1821            task_stats: Vec::new(),
1822            average_cycle_time_secs: None,
1823            fastest_task_id: None,
1824            fastest_cycle_time_secs: None,
1825            longest_task_id: None,
1826            longest_cycle_time_secs: None,
1827            idle_time_pct: 0.0,
1828            escalation_count: 0,
1829            message_count: 0,
1830            auto_merge_count: 0,
1831            manual_merge_count: 0,
1832            rework_count: 0,
1833            review_nudge_count: 0,
1834            review_escalation_count: 0,
1835            avg_review_stall_secs: None,
1836            max_review_stall_secs: None,
1837            max_review_stall_task: None,
1838            task_rework_counts: Vec::new(),
1839        };
1840        let section = render_review_performance(&stats);
1841        assert!(section.is_empty());
1842    }
1843
1844    #[test]
1845    fn render_review_performance_100_percent_auto_merge_rate() {
1846        let stats = RunStats {
1847            run_start: 0,
1848            run_end: 100,
1849            total_duration_secs: 100,
1850            task_stats: Vec::new(),
1851            average_cycle_time_secs: None,
1852            fastest_task_id: None,
1853            fastest_cycle_time_secs: None,
1854            longest_task_id: None,
1855            longest_cycle_time_secs: None,
1856            idle_time_pct: 0.0,
1857            escalation_count: 0,
1858            message_count: 0,
1859            auto_merge_count: 5,
1860            manual_merge_count: 0,
1861            rework_count: 0,
1862            review_nudge_count: 0,
1863            review_escalation_count: 0,
1864            avg_review_stall_secs: None,
1865            max_review_stall_secs: None,
1866            max_review_stall_task: None,
1867            task_rework_counts: Vec::new(),
1868        };
1869        let section = render_review_performance(&stats);
1870        assert!(section.contains("Auto-merge rate: 100%"));
1871        assert!(section.contains("Auto-merged: 5"));
1872        assert!(section.contains("Manually merged: 0"));
1873    }
1874
1875    #[test]
1876    fn analyze_events_multiple_tasks_different_engineers() {
1877        let events = vec![
1878            at(TeamEvent::daemon_started(), 100),
1879            at(TeamEvent::task_assigned("eng-1", "Task #10: task-a"), 110),
1880            at(TeamEvent::task_assigned("eng-2", "Task #20: task-b"), 115),
1881            at(TeamEvent::task_completed("eng-1", None), 160),
1882            at(TeamEvent::task_completed("eng-2", None), 200),
1883            at(TeamEvent::daemon_stopped_with_reason("signal", 110), 210),
1884        ];
1885
1886        let stats = analyze_events(&events).unwrap();
1887        assert_eq!(stats.task_stats.len(), 2);
1888
1889        let t10 = stats.task_stats.iter().find(|t| t.task_id == "10").unwrap();
1890        assert_eq!(t10.assigned_to, "eng-1");
1891        assert_eq!(t10.cycle_time_secs, Some(50));
1892
1893        let t20 = stats.task_stats.iter().find(|t| t.task_id == "20").unwrap();
1894        assert_eq!(t20.assigned_to, "eng-2");
1895        assert_eq!(t20.cycle_time_secs, Some(85));
1896    }
1897
1898    #[test]
1899    fn analyze_events_tracks_review_nudges_and_escalations() {
1900        let events = vec![
1901            at(TeamEvent::daemon_started(), 100),
1902            at(
1903                TeamEvent::review_nudge_sent("manager", "Task #5: reviewed"),
1904                120,
1905            ),
1906            at(
1907                TeamEvent::review_nudge_sent("manager", "Task #5: reviewed"),
1908                140,
1909            ),
1910            at(
1911                TeamEvent::review_escalated("Task #5: reviewed", "stale"),
1912                160,
1913            ),
1914            at(TeamEvent::daemon_stopped_with_reason("signal", 80), 180),
1915        ];
1916
1917        let stats = analyze_events(&events).unwrap();
1918        assert_eq!(stats.review_nudge_count, 2);
1919        assert_eq!(stats.review_escalation_count, 1);
1920    }
1921
1922    #[test]
1923    fn analyze_events_completion_without_assignment_is_ignored() {
1924        let events = vec![
1925            at(TeamEvent::daemon_started(), 100),
1926            // Completion without prior assignment
1927            at(TeamEvent::task_completed("eng-1", None), 150),
1928            at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1929        ];
1930
1931        let stats = analyze_events(&events).unwrap();
1932        assert!(stats.task_stats.is_empty());
1933        assert_eq!(stats.average_cycle_time_secs, None);
1934    }
1935
1936    #[test]
1937    fn analyze_events_escalation_without_prior_assignment_creates_task() {
1938        let events = vec![
1939            at(TeamEvent::daemon_started(), 100),
1940            at(
1941                TeamEvent::task_escalated("eng-1", "Task #99: escalated-only", None),
1942                120,
1943            ),
1944            at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1945        ];
1946
1947        let stats = analyze_events(&events).unwrap();
1948        assert_eq!(stats.escalation_count, 1);
1949        assert_eq!(stats.task_stats.len(), 1);
1950        assert!(stats.task_stats[0].was_escalated);
1951        // task_escalated stores the raw task string, not parsed through task_reference
1952        assert_eq!(stats.task_stats[0].task_id, "Task #99: escalated-only");
1953    }
1954
1955    #[test]
1956    fn analyze_events_daemon_started_only() {
1957        let events = vec![at(TeamEvent::daemon_started(), 100)];
1958        let stats = analyze_events(&events).unwrap();
1959        assert_eq!(stats.run_start, 100);
1960        assert_eq!(stats.run_end, 100);
1961        assert_eq!(stats.total_duration_secs, 0);
1962        assert!(stats.task_stats.is_empty());
1963    }
1964
1965    #[test]
1966    fn analyze_events_load_snapshot_all_working() {
1967        let events = vec![
1968            at(TeamEvent::daemon_started(), 100),
1969            at(TeamEvent::load_snapshot(4, 4, true), 110),
1970            at(TeamEvent::load_snapshot(4, 4, true), 120),
1971            at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1972        ];
1973
1974        let stats = analyze_events(&events).unwrap();
1975        assert!((stats.idle_time_pct - 0.0).abs() < 1e-9);
1976    }
1977
1978    #[test]
1979    fn analyze_events_load_snapshot_all_idle() {
1980        let events = vec![
1981            at(TeamEvent::daemon_started(), 100),
1982            at(TeamEvent::load_snapshot(0, 4, true), 110),
1983            at(TeamEvent::load_snapshot(0, 4, true), 120),
1984            at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1985        ];
1986
1987        let stats = analyze_events(&events).unwrap();
1988        assert!((stats.idle_time_pct - 1.0).abs() < 1e-9);
1989    }
1990
1991    #[test]
1992    fn analyze_events_load_snapshot_zero_members() {
1993        let events = vec![
1994            at(TeamEvent::daemon_started(), 100),
1995            at(TeamEvent::load_snapshot(0, 0, true), 110),
1996            at(TeamEvent::daemon_stopped_with_reason("signal", 20), 120),
1997        ];
1998
1999        let stats = analyze_events(&events).unwrap();
2000        assert!((stats.idle_time_pct - 1.0).abs() < 1e-9);
2001    }
2002
2003    #[test]
2004    fn should_generate_retro_no_board_dir_returns_none() {
2005        let tmp = tempdir().unwrap();
2006        // No board dir at all
2007        let result = should_generate_retro(tmp.path(), false, 60).unwrap();
2008        assert_eq!(result, None);
2009    }
2010
2011    #[test]
2012    fn should_generate_retro_empty_board_returns_none() {
2013        let tmp = tempdir().unwrap();
2014        let tasks_dir = tmp
2015            .path()
2016            .join(".batty")
2017            .join("team_config")
2018            .join("board")
2019            .join("tasks");
2020        fs::create_dir_all(&tasks_dir).unwrap();
2021        // tasks dir exists but is empty
2022        let result = should_generate_retro(tmp.path(), false, 60).unwrap();
2023        assert_eq!(result, None);
2024    }
2025
2026    // --- SQLite telemetry DB tests ---
2027
2028    use crate::team::telemetry_db;
2029
2030    /// Populate a telemetry DB with events matching the basic_run test scenario.
2031    fn populate_basic_run_db(conn: &Connection) {
2032        let events = vec![
2033            at(TeamEvent::daemon_started(), 100),
2034            at(TeamEvent::task_assigned("eng-1", "42"), 110),
2035            at(TeamEvent::message_routed("manager", "eng-1"), 115),
2036            at(TeamEvent::task_completed("eng-1", None), 150),
2037            at(TeamEvent::daemon_stopped_with_reason("signal", 50), 160),
2038        ];
2039        for event in &events {
2040            telemetry_db::insert_event(conn, event).unwrap();
2041        }
2042    }
2043
2044    #[test]
2045    fn retro_with_telemetry_db() {
2046        let conn = telemetry_db::open_in_memory().unwrap();
2047        populate_basic_run_db(&conn);
2048
2049        let stats = analyze_from_db(&conn).unwrap();
2050
2051        assert_eq!(stats.run_start, 100);
2052        assert_eq!(stats.run_end, 160);
2053        assert_eq!(stats.total_duration_secs, 60);
2054        assert_eq!(stats.message_count, 1);
2055        assert_eq!(stats.escalation_count, 0);
2056        assert_eq!(stats.task_stats.len(), 1);
2057        assert_eq!(stats.task_stats[0].task_id, "42");
2058        assert_eq!(stats.task_stats[0].assigned_to, "eng-1");
2059        assert_eq!(stats.task_stats[0].cycle_time_secs, Some(40));
2060        assert_eq!(stats.average_cycle_time_secs, Some(40));
2061        assert_eq!(stats.fastest_task_id.as_deref(), Some("42"));
2062        assert_eq!(stats.longest_task_id.as_deref(), Some("42"));
2063    }
2064
2065    #[test]
2066    fn retro_from_db_matches_jsonl_analysis() {
2067        // Same events through both paths should produce identical RunStats.
2068        let events = vec![
2069            at(TeamEvent::daemon_started(), 100),
2070            at(TeamEvent::task_assigned("eng-1", "42"), 110),
2071            at(TeamEvent::message_routed("manager", "eng-1"), 115),
2072            at(TeamEvent::task_completed("eng-1", None), 150),
2073            at(TeamEvent::daemon_stopped_with_reason("signal", 50), 160),
2074        ];
2075
2076        let jsonl_stats = analyze_events(&events).unwrap();
2077
2078        let conn = telemetry_db::open_in_memory().unwrap();
2079        for event in &events {
2080            telemetry_db::insert_event(&conn, event).unwrap();
2081        }
2082        let db_stats = analyze_from_db(&conn).unwrap();
2083
2084        assert_eq!(jsonl_stats.run_start, db_stats.run_start);
2085        assert_eq!(jsonl_stats.run_end, db_stats.run_end);
2086        assert_eq!(
2087            jsonl_stats.total_duration_secs,
2088            db_stats.total_duration_secs
2089        );
2090        assert_eq!(jsonl_stats.task_stats.len(), db_stats.task_stats.len());
2091        assert_eq!(
2092            jsonl_stats.average_cycle_time_secs,
2093            db_stats.average_cycle_time_secs
2094        );
2095        assert_eq!(jsonl_stats.fastest_task_id, db_stats.fastest_task_id);
2096        assert_eq!(jsonl_stats.longest_task_id, db_stats.longest_task_id);
2097        assert_eq!(jsonl_stats.escalation_count, db_stats.escalation_count);
2098        assert_eq!(jsonl_stats.message_count, db_stats.message_count);
2099        assert_eq!(jsonl_stats.idle_time_pct, db_stats.idle_time_pct);
2100        assert_eq!(jsonl_stats.auto_merge_count, db_stats.auto_merge_count);
2101        assert_eq!(jsonl_stats.manual_merge_count, db_stats.manual_merge_count);
2102        assert_eq!(jsonl_stats.rework_count, db_stats.rework_count);
2103    }
2104
2105    #[test]
2106    fn retro_without_db_falls_back() {
2107        // analyze_project with no DB file should fall back to JSONL.
2108        let tmp = tempdir().unwrap();
2109        let events_dir = tmp.path().join(".batty").join("team_config");
2110        fs::create_dir_all(&events_dir).unwrap();
2111
2112        let events = vec![
2113            at(TeamEvent::daemon_started(), 100),
2114            at(TeamEvent::task_assigned("eng-1", "42"), 110),
2115            at(TeamEvent::task_completed("eng-1", None), 150),
2116            at(TeamEvent::daemon_stopped(), 160),
2117        ];
2118        write_event_log(tmp.path(), &events);
2119
2120        // No telemetry.db exists — should fall back to JSONL.
2121        let stats = analyze_project(tmp.path()).unwrap().unwrap();
2122        assert_eq!(stats.run_start, 100);
2123        assert_eq!(stats.run_end, 160);
2124        assert_eq!(stats.task_stats.len(), 1);
2125        assert_eq!(stats.task_stats[0].task_id, "42");
2126    }
2127
2128    #[test]
2129    fn retro_report_format_unchanged() {
2130        // Verify DB-sourced stats produce identical Markdown structure.
2131        let conn = telemetry_db::open_in_memory().unwrap();
2132        populate_basic_run_db(&conn);
2133
2134        let stats = analyze_from_db(&conn).unwrap();
2135        let report = render_retrospective(&stats);
2136
2137        assert!(report.contains("# Batty Retrospective"));
2138        assert!(report.contains("## Summary"));
2139        assert!(report.contains("## Task Cycle Times"));
2140        assert!(report.contains("## Bottlenecks"));
2141        assert!(report.contains("## Recommendations"));
2142        assert!(report.contains("- Tasks completed: 1"));
2143        assert!(report.contains("- Average cycle time: 40s"));
2144        assert!(report.contains("| 42 | eng-1 | completed | 40s | 1 | no |"));
2145    }
2146
2147    #[test]
2148    fn retro_from_db_empty_returns_none() {
2149        let conn = telemetry_db::open_in_memory().unwrap();
2150        assert!(analyze_from_db(&conn).is_none());
2151    }
2152
2153    #[test]
2154    fn retro_from_db_with_retries_and_escalations() {
2155        let conn = telemetry_db::open_in_memory().unwrap();
2156        let events = vec![
2157            at(TeamEvent::daemon_started(), 100),
2158            at(
2159                TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
2160                110,
2161            ),
2162            at(
2163                TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
2164                130,
2165            ),
2166            at(TeamEvent::task_escalated("eng-1", "42", None), 135),
2167            at(TeamEvent::task_completed("eng-1", None), 170),
2168            at(TeamEvent::daemon_stopped_with_reason("signal", 70), 180),
2169        ];
2170        for event in &events {
2171            telemetry_db::insert_event(&conn, event).unwrap();
2172        }
2173
2174        let stats = analyze_from_db(&conn).unwrap();
2175        assert_eq!(stats.task_stats.len(), 1);
2176        assert_eq!(stats.task_stats[0].task_id, "42");
2177        assert_eq!(stats.task_stats[0].retry_count, 2);
2178        assert!(stats.task_stats[0].was_escalated);
2179        assert_eq!(stats.task_stats[0].cycle_time_secs, Some(60));
2180        assert_eq!(stats.escalation_count, 1);
2181    }
2182
2183    #[test]
2184    fn retro_from_db_with_review_pipeline() {
2185        let conn = telemetry_db::open_in_memory().unwrap();
2186        let events = vec![
2187            at(TeamEvent::daemon_started(), 100),
2188            at(TeamEvent::task_assigned("eng-1", "Task #10: fast"), 110),
2189            at(TeamEvent::task_completed("eng-1", None), 150),
2190            at(
2191                TeamEvent::task_auto_merged("eng-1", "Task #10: fast", 0.9, 2, 10),
2192                180,
2193            ),
2194            at(TeamEvent::task_assigned("eng-2", "Task #20: slow"), 120),
2195            at(TeamEvent::task_completed("eng-2", None), 200),
2196            at(TeamEvent::task_manual_merged("Task #20: slow"), 300),
2197            at(TeamEvent::task_reworked("eng-1", "Task #10: fast"), 145),
2198            at(
2199                TeamEvent::review_nudge_sent("manager", "Task #10: fast"),
2200                155,
2201            ),
2202            at(TeamEvent::daemon_stopped_with_reason("signal", 210), 310),
2203        ];
2204        for event in &events {
2205            telemetry_db::insert_event(&conn, event).unwrap();
2206        }
2207
2208        let stats = analyze_from_db(&conn).unwrap();
2209        assert_eq!(stats.auto_merge_count, 1);
2210        assert_eq!(stats.manual_merge_count, 1);
2211        assert_eq!(stats.rework_count, 1);
2212        assert_eq!(stats.review_nudge_count, 1);
2213        // avg of 30s and 100s = 65s
2214        assert_eq!(stats.avg_review_stall_secs, Some(65));
2215        assert_eq!(stats.max_review_stall_secs, Some(100));
2216        assert_eq!(stats.max_review_stall_task.as_deref(), Some("20"));
2217        assert_eq!(stats.task_rework_counts, vec![("10".to_string(), 1)]);
2218    }
2219
2220    #[test]
2221    fn retro_from_db_multiple_runs_uses_last() {
2222        let conn = telemetry_db::open_in_memory().unwrap();
2223        let events = vec![
2224            // First run
2225            at(TeamEvent::daemon_started(), 100),
2226            at(TeamEvent::task_assigned("eng-1", "old-task"), 105),
2227            at(TeamEvent::daemon_stopped_with_reason("signal", 10), 110),
2228            // Second run
2229            at(TeamEvent::daemon_started(), 200),
2230            at(TeamEvent::task_assigned("eng-2", "Task #12: new-task"), 210),
2231            at(TeamEvent::task_completed("eng-2", None), 240),
2232            at(TeamEvent::daemon_stopped_with_reason("signal", 45), 245),
2233        ];
2234        for event in &events {
2235            telemetry_db::insert_event(&conn, event).unwrap();
2236        }
2237
2238        let stats = analyze_from_db(&conn).unwrap();
2239        assert_eq!(stats.run_start, 200);
2240        assert_eq!(stats.run_end, 245);
2241        assert_eq!(stats.task_stats.len(), 1);
2242        assert_eq!(stats.task_stats[0].task_id, "12");
2243    }
2244
2245    #[test]
2246    fn analyze_project_prefers_db_over_jsonl() {
2247        let tmp = tempdir().unwrap();
2248        // Set up JSONL with task "99"
2249        let jsonl_events = vec![
2250            at(TeamEvent::daemon_started(), 100),
2251            at(TeamEvent::task_assigned("eng-1", "99"), 110),
2252            at(TeamEvent::task_completed("eng-1", None), 150),
2253            at(TeamEvent::daemon_stopped(), 160),
2254        ];
2255        write_event_log(tmp.path(), &jsonl_events);
2256
2257        // Set up DB with task "42"
2258        let db_path = tmp.path().join(".batty");
2259        fs::create_dir_all(&db_path).unwrap();
2260        let conn = telemetry_db::open(tmp.path()).unwrap();
2261        let db_events = vec![
2262            at(TeamEvent::daemon_started(), 200),
2263            at(TeamEvent::task_assigned("eng-1", "42"), 210),
2264            at(TeamEvent::task_completed("eng-1", None), 250),
2265            at(TeamEvent::daemon_stopped(), 260),
2266        ];
2267        for event in &db_events {
2268            telemetry_db::insert_event(&conn, event).unwrap();
2269        }
2270        drop(conn);
2271
2272        // analyze_project should use DB (task "42"), not JSONL (task "99").
2273        let stats = analyze_project(tmp.path()).unwrap().unwrap();
2274        assert_eq!(stats.task_stats[0].task_id, "42");
2275    }
2276}