lean_ctx/core/gotcha_tracker/
learn.rs1use super::model::{Gotcha, GotchaStore};
2
3pub 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
31pub 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
55pub 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
80pub fn apply_to_agents_md(project_root: &str, learnings: &[Learning]) -> Result<String, String> {
82 let agents_path = std::path::Path::new(project_root).join("AGENTS.md");
83
84 let existing = if agents_path.exists() {
85 std::fs::read_to_string(&agents_path)
86 .map_err(|e| format!("Failed to read AGENTS.md: {e}"))?
87 } else {
88 String::new()
89 };
90
91 let section = format_agents_section(learnings);
92 if section.is_empty() {
93 return Ok("No learnings to write (need >=2 occurrences with >=50% confidence).".into());
94 }
95
96 let updated = if existing.contains(AGENTS_MARKER_START) {
97 let before = existing
98 .split(AGENTS_MARKER_START)
99 .next()
100 .unwrap_or(&existing);
101 let after = existing.split(AGENTS_MARKER_END).nth(1).unwrap_or("");
102 format!(
103 "{}{}{}",
104 before.trim_end(),
105 "\n\n",
106 section.trim_end().to_owned() + after
107 )
108 } else if existing.is_empty() {
109 format!("# AGENTS.md\n\n{section}")
110 } else {
111 format!("{}\n\n{section}", existing.trim_end())
112 };
113
114 std::fs::write(&agents_path, &updated)
115 .map_err(|e| format!("Failed to write AGENTS.md: {e}"))?;
116
117 Ok(format!(
118 "Wrote {} learnings to {}",
119 learnings.len(),
120 agents_path.display()
121 ))
122}