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    for cmd in &input.failed_commands {
137        let short_cmd = cmd.split_whitespace().next().unwrap_or(cmd);
138        rule_proposals.push(RuleProposal {
139            trigger: format!("command_failure:{short_cmd}"),
140            action: format!("Verify {short_cmd} is available and configured before running"),
141            anchor_file: None,
142            category: RuleCategory::Workflow,
143            confidence: 0.6,
144            evidence: vec![format!("Failed command: {cmd}")],
145        });
146    }
147
148    if input.tool_failures > 3 {
149        lessons.push(Lesson {
150            id: new_lesson_id(),
151            text: format!(
152                "{} tool failures in session. Pattern suggests environment or dependency issue.",
153                input.tool_failures
154            ),
155            severity: LessonSeverity::Medium,
156            tags: vec!["failure".into(), "tools".into()],
157            source_trigger: "session_failures".into(),
158        });
159    }
160}
161
162fn analyze_long_session(input: &AnalysisInput, lessons: &mut Vec<Lesson>) {
163    lessons.push(Lesson {
164        id: new_lesson_id(),
165        text: format!(
166            "Session ran for {} user prompts ({} minutes). \
167             Long sessions reduce focus — consider splitting into sub-tasks.",
168            input.user_prompts, input.duration_minutes
169        ),
170        severity: LessonSeverity::Medium,
171        tags: vec!["long_session".into(), "productivity".into()],
172        source_trigger: "abnormally_long".into(),
173    });
174}
175
176fn analyze_file_churn(
177    input: &AnalysisInput,
178    lessons: &mut Vec<Lesson>,
179    rule_proposals: &mut Vec<RuleProposal>,
180) {
181    let churned: Vec<&(String, u64)> = input
182        .file_edit_counts
183        .iter()
184        .filter(|(_, count)| *count >= 3)
185        .collect();
186
187    for (file, count) in &churned {
188        lessons.push(Lesson {
189            id: new_lesson_id(),
190            text: format!(
191                "File '{file}' was edited {count} times. \
192                 Frequent edits to the same file suggest unclear requirements or iterative debugging.",
193            ),
194            severity: LessonSeverity::Medium,
195            tags: vec!["churn".into(), "file_edits".into()],
196            source_trigger: "excessive_file_edits".into(),
197        });
198
199        // Propose a rule: if a file is frequently edited, add a pre-commit check
200        rule_proposals.push(RuleProposal {
201            trigger: format!("file_churn:{file}"),
202            action: format!("Review '{file}' carefully before committing — historically unstable"),
203            anchor_file: Some(file.clone()),
204            category: RuleCategory::PreCommit,
205            confidence: 0.5,
206            evidence: vec![format!(
207                "Edited {count} times in session {}",
208                input.session_id
209            )],
210        });
211    }
212}
213
214fn analyze_decision_reversal(input: &AnalysisInput, lessons: &mut Vec<Lesson>) {
215    lessons.push(Lesson {
216        id: new_lesson_id(),
217        text: format!(
218            "{} decision(s) were superseded during this session. \
219             Consider spending more time on upfront design before committing to an approach.",
220            input.decisions_superseded
221        ),
222        severity: LessonSeverity::High,
223        tags: vec!["decision".into(), "reversal".into()],
224        source_trigger: "decision_superseded".into(),
225    });
226}
227
228fn analyze_conflict(
229    input: &AnalysisInput,
230    lessons: &mut Vec<Lesson>,
231    rule_proposals: &mut Vec<RuleProposal>,
232) {
233    lessons.push(Lesson {
234        id: new_lesson_id(),
235        text: "Multi-agent conflict detected. \
236               Ensure agents claim non-overlapping scopes before starting work."
237            .to_string(),
238        severity: LessonSeverity::High,
239        tags: vec!["conflict".into(), "multi_agent".into()],
240        source_trigger: "multi_agent_conflict".into(),
241    });
242
243    rule_proposals.push(RuleProposal {
244        trigger: "multi_agent_start".to_string(),
245        action: "Run `edda claim` to claim scope before starting multi-agent work".to_string(),
246        anchor_file: None,
247        category: RuleCategory::Workflow,
248        confidence: 0.8,
249        evidence: vec![format!("Conflict detected in session {}", input.session_id)],
250    });
251}
252
253// -- Helpers --
254
255fn new_lesson_id() -> String {
256    format!("lesson_{}", ulid::Ulid::new().to_string().to_lowercase())
257}
258
259fn now_rfc3339() -> String {
260    let now = time::OffsetDateTime::now_utc();
261    now.format(&time::format_description::well_known::Rfc3339)
262        .expect("RFC3339 formatting should not fail")
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::trigger::PostMortemTrigger;
269
270    fn trigger_with(reasons: Vec<TriggerReason>) -> PostMortemTrigger {
271        PostMortemTrigger {
272            should_analyze: true,
273            reasons,
274            session_id: "test-session".to_string(),
275        }
276    }
277
278    fn base_input() -> AnalysisInput {
279        AnalysisInput {
280            session_id: "test-session".to_string(),
281            outcome: "completed".to_string(),
282            ..Default::default()
283        }
284    }
285
286    #[test]
287    fn analyze_session_failures_produces_lessons() {
288        let trigger = trigger_with(vec![TriggerReason::SessionFailures]);
289        let mut input = base_input();
290        input.outcome = "error_stuck".to_string();
291        input.tool_failures = 5;
292        input.failed_commands = vec!["npm test".to_string()];
293
294        let result = analyze(&trigger, &input);
295        assert!(!result.lessons.is_empty());
296        assert!(!result.rule_proposals.is_empty());
297        assert!(result
298            .lessons
299            .iter()
300            .any(|l| l.tags.contains(&"stuck".to_string())));
301    }
302
303    #[test]
304    fn analyze_long_session_produces_lesson() {
305        let trigger = trigger_with(vec![TriggerReason::AbnormallyLong]);
306        let mut input = base_input();
307        input.user_prompts = 30;
308        input.duration_minutes = 45;
309
310        let result = analyze(&trigger, &input);
311        assert_eq!(result.lessons.len(), 1);
312        assert!(result.lessons[0].text.contains("30 user prompts"));
313    }
314
315    #[test]
316    fn analyze_file_churn_produces_rule_proposal() {
317        let trigger = trigger_with(vec![TriggerReason::ExcessiveFileEdits]);
318        let mut input = base_input();
319        input.file_edit_counts = vec![("src/main.rs".to_string(), 5)];
320
321        let result = analyze(&trigger, &input);
322        assert!(!result.lessons.is_empty());
323        assert!(!result.rule_proposals.is_empty());
324        assert!(result.rule_proposals[0].trigger.contains("file_churn"));
325    }
326
327    #[test]
328    fn analyze_conflict_produces_high_severity() {
329        let trigger = trigger_with(vec![TriggerReason::MultiAgentConflict]);
330        let input = base_input();
331
332        let result = analyze(&trigger, &input);
333        assert!(result
334            .lessons
335            .iter()
336            .any(|l| l.severity == LessonSeverity::High));
337        assert!(!result.rule_proposals.is_empty());
338    }
339
340    #[test]
341    fn multiple_triggers_produce_combined_results() {
342        let trigger = trigger_with(vec![
343            TriggerReason::SessionFailures,
344            TriggerReason::AbnormallyLong,
345            TriggerReason::ExcessiveFileEdits,
346        ]);
347        let mut input = base_input();
348        input.tool_failures = 5;
349        input.user_prompts = 30;
350        input.duration_minutes = 45;
351        input.file_edit_counts = vec![("a.rs".to_string(), 4)];
352
353        let result = analyze(&trigger, &input);
354        // Should have lessons from multiple triggers
355        assert!(result.lessons.len() >= 3);
356    }
357}