Skip to main content

edda_postmortem/
scars.rs

1//! Real scars: analyzer input source that reads `review` / `friction` notes
2//! from the workspace ledger, and emits High-severity lessons only when the
3//! same problem shows up **twice or more** — havamal's "no scar, no doctrine"
4//! rule made mechanical.
5//!
6//! Why: SELECTOR2 opened the two natural High paths (supersede / conflict),
7//! but analyzer output is still generic self-help ("break the task down").
8//! The ledger's highest-judgment content — human-written review findings and
9//! friction notes — was never read. This module reads it, groups by problem
10//! family, and only proposes candidates when a family recurs.
11//!
12//! Assumptions (queue 324 goal — operator can override at gate):
13//! - **Same problem** = same tag family (`review` or `friction`) + normalized
14//!   first-line prefix equal (case-fold, whitespace-collapse, strip trailing
15//!   punctuation, prefix limit 80 chars).
16//! - **Recurrence window** = 30 days from the most recent occurrence.
17//! - **Per-run cap** stays at 3; real-scar lessons take priority slots ahead
18//!   of statistical heuristic lessons.
19
20use crate::analyzer::{Lesson, LessonSeverity};
21use serde::Deserialize;
22use std::collections::HashMap;
23use std::path::Path;
24use time::format_description::well_known::Rfc3339;
25use time::{Duration, OffsetDateTime};
26
27pub const RECURRENCE_WINDOW_DAYS: i64 = 30;
28/// Family-key length: short enough to catch "same problem, different wording",
29/// long enough to distinguish unrelated tickets. Bumped down from 80 after a
30/// real friction pair collided at the semicolon (queue 324 test fixture).
31/// `pub` since q328 SIGNCHECK so sign_check reuses the exact same family key
32/// (single vocabulary, no rival contradiction concept).
33pub const PREFIX_LEN: usize = 40;
34
35/// One ledger note fed into the scar analyzer.
36#[derive(Debug, Clone)]
37pub struct LedgerNote {
38    pub ts: String,
39    pub tag: String,
40    pub text: String,
41}
42
43/// Recurring-problem analyzer output — folded into the main postmortem lessons
44/// as High severity (single-family cap: one lesson per recurring family).
45pub fn analyze_recurring_scars(notes: &[LedgerNote], now: OffsetDateTime) -> Vec<Lesson> {
46    let cutoff = now - Duration::days(RECURRENCE_WINDOW_DAYS);
47    let mut by_family: HashMap<(String, String), Vec<&LedgerNote>> = HashMap::new();
48
49    for note in notes {
50        let Some(ts) = OffsetDateTime::parse(&note.ts, &Rfc3339).ok() else {
51            continue;
52        };
53        if ts < cutoff {
54            continue;
55        }
56        if !is_scar_tag(&note.tag) {
57            continue;
58        }
59        let prefix = normalize_prefix(&note.text);
60        if prefix.is_empty() {
61            continue;
62        }
63        by_family
64            .entry((note.tag.clone(), prefix))
65            .or_default()
66            .push(note);
67    }
68
69    let mut lessons = Vec::new();
70    for ((tag, prefix), hits) in by_family {
71        if hits.len() < 2 {
72            continue;
73        }
74        // Stable-ish ordering: newest first for the referenced excerpt.
75        let mut ordered = hits.clone();
76        ordered.sort_by(|a, b| b.ts.cmp(&a.ts));
77        let latest = &ordered[0];
78        let excerpt = excerpt_of(&latest.text);
79        let text = format!(
80            "Recurring {tag} ({n}x): \"{excerpt}\". Second occurrence of the same problem — havamal rule says a repeated scar is doctrine material.",
81            tag = tag,
82            n = hits.len(),
83            excerpt = excerpt,
84        );
85        lessons.push(Lesson {
86            id: new_lesson_id(&tag, &prefix),
87            text,
88            severity: LessonSeverity::High,
89            tags: vec![tag.clone(), "recurring".into()],
90            source_trigger: format!("recurring_{tag}"),
91        });
92    }
93
94    // Stable output order by lesson id (families otherwise iterate arbitrarily).
95    lessons.sort_by(|a, b| a.id.cmp(&b.id));
96    lessons
97}
98
99fn is_scar_tag(tag: &str) -> bool {
100    matches!(tag, "review" | "friction")
101}
102
103/// Case-fold, whitespace-collapse, cut at first clause break (`;`, `—`,
104/// full-stop or Chinese `。`), strip trailing punctuation, then take up to
105/// PREFIX_LEN chars. Two writeups of the same scar rarely share a full
106/// sentence, but the clause-head is stable.
107pub fn normalize_prefix(text: &str) -> String {
108    let first_line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
109    let lower = first_line.to_lowercase();
110    let collapsed: String = lower.split_whitespace().collect::<Vec<_>>().join(" ");
111    let clause: String = collapsed
112        .chars()
113        .take_while(|c| !matches!(c, ';' | '.' | '。' | '—' | '~'))
114        .collect();
115    let clause = clause.replace(" - ", " "); // ASCII dash between spaces = clause break too
116    let stripped = clause
117        .trim_end_matches(|c: char| c.is_ascii_punctuation() || c == '。' || c == '、')
118        .trim()
119        .to_string();
120    stripped.chars().take(PREFIX_LEN).collect()
121}
122
123fn excerpt_of(text: &str) -> String {
124    let first_line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
125    let collapsed: String = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
126    let limited: String = collapsed.chars().take(140).collect();
127    limited.replace('"', "'")
128}
129
130fn new_lesson_id(tag: &str, prefix: &str) -> String {
131    use sha2::{Digest, Sha256};
132    let mut hasher = Sha256::new();
133    hasher.update(tag.as_bytes());
134    hasher.update(b"|");
135    hasher.update(prefix.as_bytes());
136    format!("lesson_scar_{}", &hex::encode(hasher.finalize())[..12])
137}
138
139/// JSONL row shape used by `edda log --json` output (only the fields we need).
140#[derive(Debug, Deserialize)]
141struct LogRow {
142    ts: String,
143    payload: Option<LogPayload>,
144}
145
146#[derive(Debug, Deserialize)]
147struct LogPayload {
148    #[serde(default)]
149    text: String,
150    #[serde(default)]
151    tags: Vec<String>,
152}
153
154/// Read notes from a `edda log --json`-style JSONL file (row-per-event).
155/// Malformed lines skipped; missing file ⇒ empty. Kept as a testable path
156/// alongside `notes_from_events` (which reads structured Events directly).
157pub fn read_ledger_notes(path: &Path) -> Vec<LedgerNote> {
158    let Ok(content) = std::fs::read_to_string(path) else {
159        return Vec::new();
160    };
161    let mut out = Vec::new();
162    for line in content.lines() {
163        if line.trim().is_empty() {
164            continue;
165        }
166        let Ok(row) = serde_json::from_str::<LogRow>(line) else {
167            continue;
168        };
169        let Some(payload) = row.payload else { continue };
170        for tag in &payload.tags {
171            if is_scar_tag(tag) {
172                out.push(LedgerNote {
173                    ts: row.ts.clone(),
174                    tag: tag.clone(),
175                    text: payload.text.clone(),
176                });
177            }
178        }
179    }
180    out
181}
182
183/// Extract scar notes from workspace-ledger note events.
184/// One event carrying multiple scar tags fans out to multiple LedgerNotes
185/// (a friction that also carries `review` counts as both — that's their real
186/// classification, we don't second-guess the writer).
187pub fn notes_from_events(events: &[edda_core::types::Event]) -> Vec<LedgerNote> {
188    let mut out = Vec::new();
189    for event in events {
190        if event.event_type != "note" {
191            continue;
192        }
193        let text = event
194            .payload
195            .get("text")
196            .and_then(|v| v.as_str())
197            .unwrap_or("");
198        if text.is_empty() {
199            continue;
200        }
201        let tags = event
202            .payload
203            .get("tags")
204            .and_then(|v| v.as_array())
205            .cloned()
206            .unwrap_or_default();
207        for tag in tags {
208            let Some(tag_str) = tag.as_str() else {
209                continue;
210            };
211            if is_scar_tag(tag_str) {
212                out.push(LedgerNote {
213                    ts: event.ts.clone(),
214                    tag: tag_str.to_string(),
215                    text: text.to_string(),
216                });
217            }
218        }
219    }
220    out
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn note(ts: &str, tag: &str, text: &str) -> LedgerNote {
228        LedgerNote {
229            ts: ts.into(),
230            tag: tag.into(),
231            text: text.into(),
232        }
233    }
234
235    fn now() -> OffsetDateTime {
236        OffsetDateTime::parse("2026-07-08T12:00:00Z", &Rfc3339).unwrap()
237    }
238
239    #[test]
240    fn single_occurrence_yields_no_lesson() {
241        let notes = vec![note(
242            "2026-07-08T01:00:00Z",
243            "friction",
244            "worktree has no .edda, executor cannot inject context",
245        )];
246        assert!(
247            analyze_recurring_scars(&notes, now()).is_empty(),
248            "one hit is not a scar (havamal 二犯規矩)"
249        );
250    }
251
252    #[test]
253    fn two_occurrences_same_prefix_yields_high_lesson_with_excerpt() {
254        let notes = vec![
255            note(
256                "2026-07-05T01:00:00Z",
257                "friction",
258                "Worktree has no .edda; executor cannot inject context on start",
259            ),
260            note(
261                "2026-07-08T01:00:00Z",
262                "friction",
263                "worktree has no .edda — executor context broken again in nested session",
264            ),
265        ];
266        let lessons = analyze_recurring_scars(&notes, now());
267        assert_eq!(lessons.len(), 1);
268        assert_eq!(lessons[0].severity, LessonSeverity::High);
269        assert!(lessons[0].tags.contains(&"friction".to_string()));
270        assert!(lessons[0].tags.contains(&"recurring".to_string()));
271        assert!(lessons[0].text.contains("2x"), "count in text");
272        assert!(
273            lessons[0]
274                .text
275                .to_lowercase()
276                .contains("worktree has no .edda"),
277            "candidate cites the scar text verbatim (not template)"
278        );
279    }
280
281    #[test]
282    fn out_of_window_occurrences_dont_count() {
283        let notes = vec![
284            note(
285                "2026-05-01T00:00:00Z",
286                "friction",
287                "old worktree issue in stale window",
288            ),
289            note(
290                "2026-07-08T00:00:00Z",
291                "friction",
292                "old worktree issue back in fresh window",
293            ),
294        ];
295        assert!(
296            analyze_recurring_scars(&notes, now()).is_empty(),
297            "one in-window hit only (窗外的不算)"
298        );
299    }
300
301    #[test]
302    fn non_scar_tags_ignored() {
303        let notes = vec![
304            note(
305                "2026-07-08T01:00:00Z",
306                "session",
307                "same prefix same day tag session",
308            ),
309            note(
310                "2026-07-08T02:00:00Z",
311                "session",
312                "same prefix same day tag session",
313            ),
314        ];
315        assert!(
316            analyze_recurring_scars(&notes, now()).is_empty(),
317            "session tag is not a scar family"
318        );
319    }
320
321    #[test]
322    fn prefix_normalization_matches_case_and_whitespace_variants() {
323        let notes = vec![
324            note(
325                "2026-07-08T01:00:00Z",
326                "review",
327                "  Findings: dispatch layer authored discipline text\n\nmore detail",
328            ),
329            note(
330                "2026-07-08T02:00:00Z",
331                "review",
332                "FINDINGS:   dispatch  layer authored discipline text!",
333            ),
334        ];
335        let lessons = analyze_recurring_scars(&notes, now());
336        assert_eq!(
337            lessons.len(),
338            1,
339            "case+whitespace+punct differences collapse to one family"
340        );
341    }
342
343    #[test]
344    fn malformed_lines_and_missing_ts_skipped() {
345        let notes = vec![
346            note("garbage-ts", "friction", "hit a"),
347            note("2026-07-08T01:00:00Z", "friction", "hit b"),
348            note("2026-07-08T02:00:00Z", "friction", "hit b"),
349        ];
350        let lessons = analyze_recurring_scars(&notes, now());
351        assert_eq!(
352            lessons.len(),
353            1,
354            "garbage-ts note dropped, valid pair remains"
355        );
356    }
357}