1use std::path::Path;
14
15use anyhow::{Context, Result};
16
17use super::write_if_changed;
18
19const COMMAND_BODY: &str = "\
20---
21description: Enrich a file or directory into the mati knowledge store
22argument-hint: [path]
23disable-model-invocation: true
24---
25
26Enrich into the mati knowledge store. Target path(s): $ARGUMENTS
27(no path given -> enrich the top hotspot gaps).
28
29## /mati-enrich
30
31Run /mati-enrich [path] to enrich a file or directory.
32
33Before enriching each file, call mem_get(\"file:<path>\"). If the record has
34source \"claude_enrich\" or \"developer_manual\" and confidence >= 0.60, skip it —
35already enriched. Only re-enrich if the user explicitly passes the file path.
36
37Per-file flow: mem_get → Read file → extract purpose + gotchas → mem_set file → mem_set each gotcha.
38Single file: mem_set to write, then mem_set action=\"confirm\" for each gotcha — mati prompts the developer to approve each one.
39Directory/batch: mem_set only (confirmed=false).
40
41When enrichment is complete, print a summary:
42 Enriched: X files (Y skipped — already enriched)
43 Gotcha candidates extracted: Z
44 Run `mati review` to confirm candidates and activate hook enforcement.
45 Run `mati stats` to see updated coverage and onboarding score.
46
47## /mati-enrich — extraction pipeline (v0.2)
48
49The four-stage pipeline below is the operational instruction set for
50extracting gotcha candidates. It SUPERSEDES the brief overview above
51for the actual extraction steps; the intro stays as the high-level
52intent. Apply all four stages per file.
53
54### Stage 1 — Setup (before reading)
55
561. `mem_query mode=\"dir_gotchas\" query=\"<dirname-of-file>\" limit 5`
57 → top 5 confirmed gotchas for that directory as POSITIVE
58 EXEMPLARS. If the array is empty (cold start), continue with
59 schema-only guidance. Do not use `mode=\"text\"` here: the search
60 index carries no `affected_files`, so a directory query returns
61 file records instead of gotchas.
622. `mem_get(\"file:<path>\")` — mints the consultation receipt, returns
63 existing gotcha_keys, AND returns the `enrichment_depth_hint` field
64 (D2-α: one of \"fast\", \"standard\", \"deep\"). Use it to pick the
65 tier branch below. If absent (older daemon), default to \"deep\".
663. **Deep tier only**: call via Bash
67 `mati ls tombstoned --dir <dirname-of-file> --recent 30d --json`
68 to retrieve NEGATIVE EXEMPLARS — rules that were proposed for
69 this directory and then tombstoned. Use them in Stage 2 to
70 calibrate AGAINST proposing similar rules. If `count` is 0,
71 skip the negative block. Record whether the block was actually
72 used — controls the `with-neg-exemplars` tag in Stage 4.
734. **AST seeding**: call `mati extract-signals --file <path>` via Bash
74 for deterministic, AST-aware signal extraction across all 12
75 supported languages. Returns JSON
76 `{ file, language, signal_count, signals: [{ file_line, tier,
77 kind, evidence }, ...] }`. Treat each `file_line` as a SEED that
78 Stage 2 must examine. Seeds never replace the file scan — read the
79 file in Stage 2 at every tier, whatever `signal_count` says.
80 For `panic`, `assert`, and `unwrap_like` signals, `evidence` is a
81 bare identifier (`bail`, `expect`), not source text. A candidate
82 drafted from that alone passes Stage 3 by construction — the quote
83 is the token the extractor matched — and carries an invented
84 reason. Take `evidence_quote` from the file, not from `evidence`.
85 The extractor also misses signals inside macro bodies, which
86 tree-sitter parses as token trees.
87
88### Tier branches (D2)
89
90| Tier | Positive exemplars | Negative exemplars | Stage 2 file scan |
91| --------- | ------------------ | ------------------ | ----------------- |
92| fast | no (schema only) | no | yes |
93| standard | yes | no | yes |
94| deep | yes | yes | yes |
95
96`fast` for trivial files (LoC < 100, isolated blast, no cluster).
97`standard` is the default. `deep` adds negative exemplars for
98hotspot / signal-rich files. Tier gates exemplars only — it never
99gates the file scan.
100
101Stage 3 runs at every tier. It is one CLI call per candidate, not a
102reasoning pass, so tier does not gate it.
103
104### Stage 2 — Enumeration (maximize recall)
105
106Read the file. Output a JSON array of candidates, using the POSITIVE
107EXEMPLARS as calibration for this project's specific bar.
108
109Signal ranking (extract from highest first):
110 HIGH: WARNING / FIXME / HACK / SAFETY / IMPORTANT comments;
111 panic!/assert!/expect(\"…\") with non-trivial messages;
112 comments explaining \"why this looks weird\" or \"do not\".
113 MEDIUM: Defensive guards (early returns, custom error paths);
114 non-obvious literal arguments (e.g. with_versioning(true, 0));
115 error handling that diverges from the rest of the file.
116 LOW: Raw API usage with no comment context.
117
118Schema (strict JSON, one element per candidate):
119[
120 { \"candidate_id\": \"C1\",
121 \"signal_tier\": \"high\" | \"medium\" | \"low\",
122 \"file_line\": \"L42\",
123 \"evidence_quote\": \"exact text from file at that line\",
124 \"draft_rule\": \"imperative verb + specific target\",
125 \"draft_reason\": \"what breaks and why\",
126 \"draft_severity\": \"critical\" | \"high\" | \"normal\" | \"low\" } ]
127
128Write each candidate to be Specific (names a concrete API, value, or
129pattern — never \"be careful\" or \"review carefully\"), Enforceable (a
130hook could deny a real mistake on it), Non-obvious (not derivable
131from type signatures alone), and Causal (the reason says WHAT breaks,
132with \"because\"/\"since\"). These shape how you draft a candidate. They
133are not a filter — do not drop a candidate for failing them here.
134
135Goal: maximize recall. Weak candidates are OK — filtered next.
136
137### Stage 3 — Evidence verification (deterministic, D-α)
138
139One pass, no rounds. For each candidate, call `mati verify-evidence`
140via Bash:
141 mati verify-evidence \\
142 --file <path> \\
143 --line <candidate.file_line> \\
144 --quote \"<candidate.evidence_quote>\" \\
145 --pattern \"<api/literal named in candidate.draft_rule>\"
146The CLI returns JSON. Parse it:
147 { \"verified\": true, ... } → keep, add \"verified\": true
148 { \"verified\": false, ... } → DISCARD (hallucinated citation, or
149 rule generalizes beyond visible scope)
150The CLI is the source of truth. Do not second-guess a verdict, and do
151not re-run a candidate that already returned one — the check is
152deterministic, so a repeat call returns the same answer.
153
154Known limit: the check reads a ±5-line window. It proves the citation
155is real, not that the rule follows from it. Keep `draft_rule` anchored
156to what is visible at `file_line` — a rule whose claim spans more of
157the file than that window passes unverified.
158
159### Stage 4 — Refinement and write
160
161For each verified candidate:
162
1631. Tighten rule: imperative verb first; concrete names not pronouns;
164 ≤ 80 chars where possible. If a candidate is still vague after
165 tightening — no concrete API, value, or pattern to enforce on —
166 drop it here.
1672. Verify reason uses \"because\"/\"since\"/\"as\" — add if missing.
1683. Assign severity (D-β). One judgment, one deterministic floor:
169
170 3a. SEMANTIC pass — the severity. Judge rule + reason against:
171 critical — data loss, corruption, security, unbounded growth
172 high — wrong result, silent failure, race, broken invariant
173 normal — performance, workflow blocker, non-obvious cleanup
174 low — informational, stylistic, minor inconvenience
175
176 3b. KEYWORD FLOOR — deterministic, raises only, never lowers.
177 Scan rule + reason case-insensitively for these stems:
178 \"data loss\" / \"corrupt\" / \"security\" / \"unbounded\" → critical
179 \"silent\" / \"race\" / \"wrong result\" / \"lost\" → high
180 No stem present → no floor.
181
182 3c. severity = the higher of 3a and the floor.
183 Tag \"severity-disputed\" ONLY when the floor RAISED 3a — the
184 text names a failure the judgment underrated. No stem matched,
185 or 3a already at or above the floor → no tag. The tag flags a
186 real conflict for the reviewer; it is not a routine annotation.
187
1884. Call `mem_set`:
189 key: `gotcha:<slug>`
190 rule, reason, severity (from step 3)
191 affected_files: [<path>]
192 tags: [\"enriched\", \"depth:<tier>\"]
193 + [\"signal-source:ast\"] (if this candidate's file_line was
194 an extract-signals seed) else [\"signal-source:llm\"]
195 + [\"with-neg-exemplars\"] (if Stage 1 step 3 used negatives)
196 + ([\"severity-disputed\"] if step 3c flagged)
197 confirmed: false
198
199 `signal-source:*` is per candidate, not per file — it records
200 which channel first surfaced the line. The `depth:<tier>` tag
201 (D3) drives per-tier accuracy in `mati doctor`.
202
203 This is attribution, not an experiment: Stage 2 sees the seeds
204 while scanning, so `signal-source:llm` candidates are not an
205 uncontaminated control.
206
207### Notes
208
209- Per-file token budget: ~8K tokens for Stage 2. Stage 3 is CLI
210 calls, near-zero tokens. If you exceed the budget, truncate Stage 2
211 candidates to top 10 by signal_tier.
212- The Rust-side quality gate still applies at write time. The
213 pipeline maximizes what gets through; the gate enforces the floor.
214- Do not add verification passes of your own, and do not spawn a
215 subagent to double-check candidates. Stage 3 and the write-time
216 quality gate are the only filters. Extra self-review costs tokens
217 and suppresses recall without improving precision.
218";
219
220pub fn write_mati_enrich_command(project_root: &Path) -> Result<WriteResult> {
227 let claude_dir = project_root.join(".claude");
228 if !claude_dir.is_dir() {
229 return Ok(WriteResult::NoClaude);
230 }
231
232 let commands_dir = claude_dir.join("commands");
233 std::fs::create_dir_all(&commands_dir)
234 .with_context(|| format!("failed to create {}", commands_dir.display()))?;
235
236 let path = commands_dir.join("mati-enrich.md");
237 let existed = path.exists();
238 write_if_changed(&path, COMMAND_BODY)
239 .with_context(|| format!("failed to write {}", path.display()))?;
240
241 Ok(if existed {
242 WriteResult::AlreadyPresent
243 } else {
244 WriteResult::Created
245 })
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum WriteResult {
251 Created,
253 AlreadyPresent,
255 NoClaude,
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use tempfile::TempDir;
263
264 #[test]
265 fn creates_command_file_when_claude_dir_exists() {
266 let dir = TempDir::new().unwrap();
267 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
268
269 let result = write_mati_enrich_command(dir.path()).unwrap();
270 assert_eq!(result, WriteResult::Created);
271
272 let content =
273 std::fs::read_to_string(dir.path().join(".claude/commands/mati-enrich.md")).unwrap();
274 assert!(content.contains("description:"));
275 assert!(content.contains("argument-hint: [path]"));
276 assert!(content.contains("$ARGUMENTS"));
277 assert!(content.contains("disable-model-invocation: true"));
278 assert!(content.contains("### Stage 1 — Setup"));
279 assert!(content.contains("### Stage 4 — Refinement and write"));
280 }
281
282 #[test]
283 fn skips_when_no_claude_dir() {
284 let dir = TempDir::new().unwrap();
285 assert!(!dir.path().join(".claude").exists());
286
287 let result = write_mati_enrich_command(dir.path()).unwrap();
288 assert_eq!(result, WriteResult::NoClaude);
289 assert!(!dir.path().join(".claude/commands").exists());
290 }
291
292 #[test]
293 fn idempotent_on_rerun() {
294 let dir = TempDir::new().unwrap();
295 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
296
297 let first = write_mati_enrich_command(dir.path()).unwrap();
298 assert_eq!(first, WriteResult::Created);
299
300 let second = write_mati_enrich_command(dir.path()).unwrap();
301 assert_eq!(second, WriteResult::AlreadyPresent);
302
303 let content =
304 std::fs::read_to_string(dir.path().join(".claude/commands/mati-enrich.md")).unwrap();
305 assert_eq!(content, COMMAND_BODY);
306 }
307}