Skip to main content

edda_postmortem/
analyzer.rs

1//! Post-mortem analysis: produce lessons and rule proposals from session data.
2//!
3//! The analyzer takes session statistics and trigger reasons, then produces
4//! structured findings. The current implementation uses deterministic heuristics;
5//! future versions can delegate to LLM (Sonnet) for deeper analysis.
6//!
7//! Output hierarchy (from GH-157 spec):
8//!   - **Rules**: Hook-enforced (block/auto-run), 100% compliance
9//!   - **Lessons**: CLAUDE.md auto-maintained paragraph, ~90% compliance
10//!   - **Observations**: `edda ask` on-demand, no enforcement
11
12use serde::{Deserialize, Serialize};
13
14use crate::rules::RuleCategory;
15use crate::trigger::{PostMortemTrigger, TriggerReason};
16
17/// Severity of a lesson.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum LessonSeverity {
21    /// Minor observation, useful context.
22    Low,
23    /// Actionable insight, should influence future work.
24    Medium,
25    /// Critical lesson, likely produces a rule proposal.
26    High,
27}
28
29/// A lesson extracted from post-mortem analysis (descriptive, not prescriptive).
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Lesson {
32    pub id: String,
33    pub text: String,
34    pub severity: LessonSeverity,
35    pub tags: Vec<String>,
36    pub source_trigger: String,
37}
38
39/// A rule proposal from post-mortem analysis (prescriptive).
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct RuleProposal {
42    pub trigger: String,
43    pub action: String,
44    pub anchor_file: Option<String>,
45    pub category: RuleCategory,
46    pub confidence: f64,
47    pub evidence: Vec<String>,
48}
49
50/// Complete result of a post-mortem analysis.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct PostMortemResult {
53    pub session_id: String,
54    pub triggers: Vec<TriggerReason>,
55    pub lessons: Vec<Lesson>,
56    pub rule_proposals: Vec<RuleProposal>,
57    pub analyzed_at: String,
58}
59
60/// Session data available for analysis.
61#[derive(Debug, Clone, Default)]
62pub struct AnalysisInput {
63    pub session_id: String,
64    pub user_prompts: u64,
65    pub tool_failures: u64,
66    pub failed_commands: Vec<String>,
67    pub files_modified: Vec<String>,
68    pub file_edit_counts: Vec<(String, u64)>,
69    pub commits_made: Vec<String>,
70    pub decisions_superseded: u64,
71    pub had_conflict: bool,
72    pub outcome: String,
73    pub duration_minutes: u64,
74}
75
76/// Run deterministic post-mortem analysis on a triggered session.
77///
78/// Produces lessons and rule proposals based on trigger reasons and session data.
79/// This is the heuristic analyzer — no LLM calls. Future: `analyze_with_llm()`.
80pub fn analyze(trigger: &PostMortemTrigger, input: &AnalysisInput) -> PostMortemResult {
81    let mut lessons = Vec::new();
82    let mut rule_proposals = Vec::new();
83
84    for reason in &trigger.reasons {
85        match reason {
86            TriggerReason::SessionFailures => {
87                analyze_failures(input, &mut lessons, &mut rule_proposals);
88            }
89            TriggerReason::AbnormallyLong => {
90                analyze_long_session(input, &mut lessons);
91            }
92            TriggerReason::ExcessiveFileEdits => {
93                analyze_file_churn(input, &mut lessons, &mut rule_proposals);
94            }
95            TriggerReason::DecisionSuperseded => {
96                analyze_decision_reversal(input, &mut lessons);
97            }
98            TriggerReason::MultiAgentConflict => {
99                analyze_conflict(input, &mut lessons, &mut rule_proposals);
100            }
101        }
102    }
103
104    PostMortemResult {
105        session_id: input.session_id.clone(),
106        triggers: trigger.reasons.clone(),
107        lessons,
108        rule_proposals,
109        analyzed_at: now_rfc3339(),
110    }
111}
112
113// -- Per-trigger analyzers --
114
115fn analyze_failures(
116    input: &AnalysisInput,
117    lessons: &mut Vec<Lesson>,
118    rule_proposals: &mut Vec<RuleProposal>,
119) {
120    // Lesson: session had failures
121    if input.outcome == "error_stuck" {
122        lessons.push(Lesson {
123            id: new_lesson_id(),
124            text: format!(
125                "Session got stuck after {} consecutive failures. \
126                 Consider breaking the task into smaller steps.",
127                input.tool_failures
128            ),
129            severity: LessonSeverity::High,
130            tags: vec!["failure".into(), "stuck".into()],
131            source_trigger: "session_failures".into(),
132        });
133    }
134
135    // Rule proposal: if specific commands keep failing, suggest a pre-check.
136    // Filter out compound commands, variable assignments, and shell
137    // builtins/keywords/common utilities: their failures are environmental
138    // noise, not a missing-tool signal, and such rules previously fired on
139    // every Bash call (GH-813).
140    for cmd in input
141        .failed_commands
142        .iter()
143        .filter(|cmd| crate::rules::is_trackable_command(cmd))
144    {
145        let Some(short_cmd) = crate::rules::command_word(cmd) else {
146            continue;
147        };
148        rule_proposals.push(RuleProposal {
149            trigger: format!("command_failure:{short_cmd}"),
150            action: format!("Verify {short_cmd} is available and configured before running"),
151            anchor_file: None,
152            category: RuleCategory::Workflow,
153            confidence: 0.6,
154            evidence: vec![format!("Failed command: {cmd}")],
155        });
156    }
157
158    if input.tool_failures > 3 {
159        lessons.push(Lesson {
160            id: new_lesson_id(),
161            text: format!(
162                "{} tool failures in session. Pattern suggests environment or dependency issue.",
163                input.tool_failures
164            ),
165            severity: LessonSeverity::Medium,
166            tags: vec!["failure".into(), "tools".into()],
167            source_trigger: "session_failures".into(),
168        });
169    }
170}
171
172fn analyze_long_session(input: &AnalysisInput, lessons: &mut Vec<Lesson>) {
173    lessons.push(Lesson {
174        id: new_lesson_id(),
175        text: format!(
176            "Session ran for {} user prompts ({} minutes). \
177             Long sessions reduce focus — consider splitting into sub-tasks.",
178            input.user_prompts, input.duration_minutes
179        ),
180        severity: LessonSeverity::Medium,
181        tags: vec!["long_session".into(), "productivity".into()],
182        source_trigger: "abnormally_long".into(),
183    });
184}
185
186fn analyze_file_churn(
187    input: &AnalysisInput,
188    lessons: &mut Vec<Lesson>,
189    rule_proposals: &mut Vec<RuleProposal>,
190) {
191    let churned: Vec<&(String, u64)> = input
192        .file_edit_counts
193        .iter()
194        .filter(|(_, count)| *count >= 3)
195        .collect();
196
197    for (file, count) in &churned {
198        lessons.push(Lesson {
199            id: new_lesson_id(),
200            text: format!(
201                "File '{file}' was edited {count} times. \
202                 Frequent edits to the same file suggest unclear requirements or iterative debugging.",
203            ),
204            severity: LessonSeverity::Medium,
205            tags: vec!["churn".into(), "file_edits".into()],
206            source_trigger: "excessive_file_edits".into(),
207        });
208
209        // Propose a rule: if a file is frequently edited, add a pre-commit check
210        rule_proposals.push(RuleProposal {
211            trigger: format!("file_churn:{file}"),
212            action: format!("Review '{file}' carefully before committing — historically unstable"),
213            anchor_file: Some(file.clone()),
214            category: RuleCategory::PreCommit,
215            confidence: 0.5,
216            evidence: vec![format!(
217                "Edited {count} times in session {}",
218                input.session_id
219            )],
220        });
221    }
222}
223
224fn analyze_decision_reversal(input: &AnalysisInput, lessons: &mut Vec<Lesson>) {
225    lessons.push(Lesson {
226        id: new_lesson_id(),
227        text: format!(
228            "{} decision(s) were superseded during this session. \
229             Consider spending more time on upfront design before committing to an approach.",
230            input.decisions_superseded
231        ),
232        severity: LessonSeverity::High,
233        tags: vec!["decision".into(), "reversal".into()],
234        source_trigger: "decision_superseded".into(),
235    });
236}
237
238fn analyze_conflict(
239    input: &AnalysisInput,
240    lessons: &mut Vec<Lesson>,
241    rule_proposals: &mut Vec<RuleProposal>,
242) {
243    lessons.push(Lesson {
244        id: new_lesson_id(),
245        text: "Multi-agent conflict detected. \
246               Ensure agents claim non-overlapping scopes before starting work."
247            .to_string(),
248        severity: LessonSeverity::High,
249        tags: vec!["conflict".into(), "multi_agent".into()],
250        source_trigger: "multi_agent_conflict".into(),
251    });
252
253    rule_proposals.push(RuleProposal {
254        trigger: "multi_agent_start".to_string(),
255        action: "Run `edda claim` to claim scope before starting multi-agent work".to_string(),
256        anchor_file: None,
257        category: RuleCategory::Workflow,
258        confidence: 0.8,
259        evidence: vec![format!("Conflict detected in session {}", input.session_id)],
260    });
261}
262
263// -- Helpers --
264
265fn new_lesson_id() -> String {
266    format!("lesson_{}", ulid::Ulid::new().to_string().to_lowercase())
267}
268
269fn now_rfc3339() -> String {
270    let now = time::OffsetDateTime::now_utc();
271    now.format(&time::format_description::well_known::Rfc3339)
272        .expect("RFC3339 formatting should not fail")
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::trigger::PostMortemTrigger;
279
280    fn trigger_with(reasons: Vec<TriggerReason>) -> PostMortemTrigger {
281        PostMortemTrigger {
282            should_analyze: true,
283            reasons,
284            session_id: "test-session".to_string(),
285        }
286    }
287
288    fn base_input() -> AnalysisInput {
289        AnalysisInput {
290            session_id: "test-session".to_string(),
291            outcome: "completed".to_string(),
292            ..Default::default()
293        }
294    }
295
296    #[test]
297    fn analyze_session_failures_produces_lessons() {
298        let trigger = trigger_with(vec![TriggerReason::SessionFailures]);
299        let mut input = base_input();
300        input.outcome = "error_stuck".to_string();
301        input.tool_failures = 5;
302        input.failed_commands = vec!["npm test".to_string()];
303
304        let result = analyze(&trigger, &input);
305        assert!(!result.lessons.is_empty());
306        assert!(!result.rule_proposals.is_empty());
307        assert!(result
308            .lessons
309            .iter()
310            .any(|l| l.tags.contains(&"stuck".to_string())));
311    }
312
313    #[test]
314    fn analyze_long_session_produces_lesson() {
315        let trigger = trigger_with(vec![TriggerReason::AbnormallyLong]);
316        let mut input = base_input();
317        input.user_prompts = 30;
318        input.duration_minutes = 45;
319
320        let result = analyze(&trigger, &input);
321        assert_eq!(result.lessons.len(), 1);
322        assert!(result.lessons[0].text.contains("30 user prompts"));
323    }
324
325    #[test]
326    fn analyze_file_churn_produces_rule_proposal() {
327        let trigger = trigger_with(vec![TriggerReason::ExcessiveFileEdits]);
328        let mut input = base_input();
329        input.file_edit_counts = vec![("src/main.rs".to_string(), 5)];
330
331        let result = analyze(&trigger, &input);
332        assert!(!result.lessons.is_empty());
333        assert!(!result.rule_proposals.is_empty());
334        assert!(result.rule_proposals[0].trigger.contains("file_churn"));
335    }
336
337    #[test]
338    fn analyze_conflict_produces_high_severity() {
339        let trigger = trigger_with(vec![TriggerReason::MultiAgentConflict]);
340        let input = base_input();
341
342        let result = analyze(&trigger, &input);
343        assert!(result
344            .lessons
345            .iter()
346            .any(|l| l.severity == LessonSeverity::High));
347        assert!(!result.rule_proposals.is_empty());
348    }
349
350    #[test]
351    fn failed_command_filters_builtins_compound_and_assignments() {
352        let trigger = trigger_with(vec![TriggerReason::SessionFailures]);
353        let mut input = base_input();
354        input.outcome = "error_stuck".to_string();
355        input.tool_failures = 6;
356        input.failed_commands = vec![
357            "cd /tmp && npm test".to_string(),
358            "echo hi; ls -la".to_string(),
359            "git status | head".to_string(),
360            "FOO=bar npm test".to_string(),
361            "cd /tmp".to_string(),
362            "echo missing-file".to_string(),
363            "grep pattern".to_string(),
364            "for f in *; do echo $f; done".to_string(),
365            "   ".to_string(),
366            "".to_string(),
367            "npm test".to_string(),
368        ];
369
370        let result = analyze(&trigger, &input);
371        // Only the trackable real command survives the filter (GH-813:
372        // builtins/keywords/assignments/compound commands produced noise
373        // rules that fired on every Bash call).
374        assert_eq!(result.rule_proposals.len(), 1);
375        assert_eq!(result.rule_proposals[0].trigger, "command_failure:npm");
376    }
377
378    #[test]
379    fn failed_command_normalizes_quoted_command_triggers() {
380        let trigger = trigger_with(vec![TriggerReason::SessionFailures]);
381        let mut input = base_input();
382        input.failed_commands = vec![r#""python" -V"#.to_string()];
383
384        let result = analyze(&trigger, &input);
385        assert_eq!(result.rule_proposals.len(), 1);
386        assert_eq!(result.rule_proposals[0].trigger, "command_failure:python");
387        assert_eq!(
388            result.rule_proposals[0].action,
389            "Verify python is available and configured before running"
390        );
391    }
392
393    #[test]
394    fn multiple_triggers_produce_combined_results() {
395        let trigger = trigger_with(vec![
396            TriggerReason::SessionFailures,
397            TriggerReason::AbnormallyLong,
398            TriggerReason::ExcessiveFileEdits,
399        ]);
400        let mut input = base_input();
401        input.tool_failures = 5;
402        input.user_prompts = 30;
403        input.duration_minutes = 45;
404        input.file_edit_counts = vec![("a.rs".to_string(), 4)];
405
406        let result = analyze(&trigger, &input);
407        // Should have lessons from multiple triggers
408        assert!(result.lessons.len() >= 3);
409    }
410}