1use serde::{Deserialize, Serialize};
13
14use crate::rules::RuleCategory;
15use crate::trigger::{PostMortemTrigger, TriggerReason};
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum LessonSeverity {
21 Low,
23 Medium,
25 High,
27}
28
29#[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#[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#[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#[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
76pub 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
113fn analyze_failures(
116 input: &AnalysisInput,
117 lessons: &mut Vec<Lesson>,
118 rule_proposals: &mut Vec<RuleProposal>,
119) {
120 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 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 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
253fn 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 assert!(result.lessons.len() >= 3);
356 }
357}