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
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 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
263fn 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 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 assert!(result.lessons.len() >= 3);
409 }
410}