Skip to main content

lean_ctx/core/gotcha_tracker/
learn.rs

1use super::model::{Gotcha, GotchaStore};
2
3/// A distilled learning from error-resolution correlation.
4pub struct Learning {
5    pub category: String,
6    pub trigger: String,
7    pub resolution: String,
8    pub confidence: f32,
9    pub occurrences: u32,
10    pub sessions: usize,
11}
12
13impl std::fmt::Display for Learning {
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        write!(
16            f,
17            "[{cat}] {trigger} → {res} (confidence: {conf:.0}%, seen {occ}x across {sess} sessions)",
18            cat = self.category,
19            trigger = self.trigger,
20            res = self.resolution,
21            conf = self.confidence * 100.0,
22            occ = self.occurrences,
23            sess = self.sessions,
24        )
25    }
26}
27
28const MIN_CONFIDENCE: f32 = 0.5;
29const MIN_OCCURRENCES: u32 = 2;
30
31/// Extract high-confidence learnings from the gotcha store.
32pub fn extract_learnings(store: &GotchaStore) -> Vec<Learning> {
33    store
34        .gotchas
35        .iter()
36        .filter(|g| g.confidence >= MIN_CONFIDENCE && g.occurrences >= MIN_OCCURRENCES)
37        .map(gotcha_to_learning)
38        .collect()
39}
40
41fn gotcha_to_learning(g: &Gotcha) -> Learning {
42    Learning {
43        category: g.category.short_label().to_string(),
44        trigger: g.trigger.clone(),
45        resolution: g.resolution.clone(),
46        confidence: g.confidence,
47        occurrences: g.occurrences,
48        sessions: g.session_ids.len(),
49    }
50}
51
52const AGENTS_MARKER_START: &str = "<!-- lean-ctx-learn-start -->";
53const AGENTS_MARKER_END: &str = "<!-- lean-ctx-learn-end -->";
54
55/// Generate the markdown section to inject into AGENTS.md.
56pub fn format_agents_section(learnings: &[Learning]) -> String {
57    if learnings.is_empty() {
58        return String::new();
59    }
60
61    let mut out = String::new();
62    out.push_str(AGENTS_MARKER_START);
63    out.push('\n');
64    out.push_str("## Learned Gotchas (auto-generated by `lean-ctx learn`)\n\n");
65    out.push_str("Do NOT edit this section manually — it is overwritten on each `lean-ctx learn --apply`.\n\n");
66
67    for l in learnings {
68        out.push_str(&format!(
69            "- **[{cat}]** {trigger}\n  → {res}\n",
70            cat = l.category,
71            trigger = l.trigger,
72            res = l.resolution,
73        ));
74    }
75    out.push_str(AGENTS_MARKER_END);
76    out.push('\n');
77    out
78}
79
80/// Merge the learnings `section` into an existing memory-file body, replacing any
81/// previous marker block (or appending after a clean `\n\n` separator). `title`
82/// is the H1 used only when the file is created from scratch.
83fn merge_marker_section(existing: &str, section: &str, title: &str) -> String {
84    if existing.contains(AGENTS_MARKER_START) {
85        let before = existing
86            .split(AGENTS_MARKER_START)
87            .next()
88            .unwrap_or(existing);
89        let after = existing.split(AGENTS_MARKER_END).nth(1).unwrap_or("");
90        format!(
91            "{}\n\n{}",
92            before.trim_end(),
93            section.trim_end().to_owned() + after
94        )
95    } else if existing.is_empty() {
96        format!("# {title}\n\n{section}")
97    } else {
98        format!("{}\n\n{section}", existing.trim_end())
99    }
100}
101
102/// Write learnings into one memory file, replacing any existing marker section.
103/// The write is atomic (tmp + rename) so a crash mid-write can never truncate the
104/// user's AGENTS.md / CLAUDE.local.md. `create_if_missing = false` leaves a
105/// non-existent file untouched (returns `Ok(false)`), so optional targets like
106/// CLAUDE.local.md are only updated when the project already keeps one.
107fn apply_to_memory_file(
108    path: &std::path::Path,
109    section: &str,
110    create_if_missing: bool,
111) -> Result<bool, String> {
112    let exists = path.exists();
113    if !exists && !create_if_missing {
114        return Ok(false);
115    }
116    let existing = if exists {
117        std::fs::read_to_string(path)
118            .map_err(|e| format!("Failed to read {}: {e}", path.display()))?
119    } else {
120        String::new()
121    };
122    let title = path
123        .file_name()
124        .and_then(|n| n.to_str())
125        .unwrap_or("AGENTS.md");
126    let updated = merge_marker_section(&existing, section, title);
127    crate::config_io::write_atomic(path, &updated)
128        .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
129    Ok(true)
130}
131
132/// Write learnings into the project's agent-memory files (#980), replacing the
133/// auto-generated marker section in each. AGENTS.md (the lean-ctx standard) is
134/// created if absent; CLAUDE.local.md (Claude Code's local memory) is updated
135/// only when the project already keeps one, so a non-Claude project is never
136/// littered with a file it did not ask for. Returns the file names written.
137pub fn apply_learnings(project_root: &str, learnings: &[Learning]) -> Result<Vec<String>, String> {
138    let section = format_agents_section(learnings);
139    if section.is_empty() {
140        return Ok(Vec::new());
141    }
142    let root = std::path::Path::new(project_root);
143    let mut written = Vec::new();
144    if apply_to_memory_file(&root.join("AGENTS.md"), &section, true)? {
145        written.push("AGENTS.md".to_string());
146    }
147    if apply_to_memory_file(&root.join("CLAUDE.local.md"), &section, false)? {
148        written.push("CLAUDE.local.md".to_string());
149    }
150    Ok(written)
151}
152
153/// Back-compat single-target wrapper (AGENTS.md only). Prefer [`apply_learnings`],
154/// which also updates CLAUDE.local.md when present.
155pub fn apply_to_agents_md(project_root: &str, learnings: &[Learning]) -> Result<String, String> {
156    let section = format_agents_section(learnings);
157    if section.is_empty() {
158        return Ok("No learnings to write (need >=2 occurrences with >=50% confidence).".into());
159    }
160    let path = std::path::Path::new(project_root).join("AGENTS.md");
161    apply_to_memory_file(&path, &section, true)?;
162    Ok(format!(
163        "Wrote {} learnings to {}",
164        learnings.len(),
165        path.display()
166    ))
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    fn sample() -> Vec<Learning> {
174        vec![Learning {
175            category: "Build".into(),
176            trigger: "cargo E0507".into(),
177            resolution: "clone before the move".into(),
178            confidence: 0.9,
179            occurrences: 3,
180            sessions: 2,
181        }]
182    }
183
184    #[test]
185    fn apply_learnings_creates_agents_and_is_idempotent() {
186        let dir = tempfile::tempdir().unwrap();
187        let root = dir.path().to_string_lossy().to_string();
188
189        let written = apply_learnings(&root, &sample()).unwrap();
190        assert_eq!(written, vec!["AGENTS.md".to_string()]);
191
192        let agents = dir.path().join("AGENTS.md");
193        let body = std::fs::read_to_string(&agents).unwrap();
194        assert!(body.contains("cargo E0507"));
195        assert!(body.contains(AGENTS_MARKER_START));
196
197        // Re-applying replaces the marker block instead of stacking a second one.
198        apply_learnings(&root, &sample()).unwrap();
199        let body2 = std::fs::read_to_string(&agents).unwrap();
200        assert_eq!(
201            body2.matches(AGENTS_MARKER_START).count(),
202            1,
203            "the marker section must be replaced, never duplicated"
204        );
205    }
206
207    #[test]
208    fn apply_learnings_updates_claude_local_only_when_present() {
209        let dir = tempfile::tempdir().unwrap();
210        let root = dir.path().to_string_lossy().to_string();
211        let claude = dir.path().join("CLAUDE.local.md");
212        std::fs::write(&claude, "# My notes\n\nkeep this line\n").unwrap();
213
214        let written = apply_learnings(&root, &sample()).unwrap();
215        assert!(written.contains(&"AGENTS.md".to_string()));
216        assert!(written.contains(&"CLAUDE.local.md".to_string()));
217
218        let body = std::fs::read_to_string(&claude).unwrap();
219        assert!(body.contains("keep this line"), "user content is preserved");
220        assert!(body.contains("cargo E0507"), "learnings are injected");
221    }
222
223    #[test]
224    fn apply_learnings_skips_absent_claude_local() {
225        let dir = tempfile::tempdir().unwrap();
226        let root = dir.path().to_string_lossy().to_string();
227
228        let written = apply_learnings(&root, &sample()).unwrap();
229        assert_eq!(
230            written,
231            vec!["AGENTS.md".to_string()],
232            "CLAUDE.local.md is never created unsolicited"
233        );
234        assert!(!dir.path().join("CLAUDE.local.md").exists());
235    }
236
237    #[test]
238    fn apply_learnings_empty_writes_nothing() {
239        let dir = tempfile::tempdir().unwrap();
240        let root = dir.path().to_string_lossy().to_string();
241        let written = apply_learnings(&root, &[]).unwrap();
242        assert!(written.is_empty());
243        assert!(!dir.path().join("AGENTS.md").exists());
244    }
245}