Skip to main content

mati_core/scaffold/
commands.rs

1//! Write the `/mati-enrich` slash command file (M-06-K).
2//!
3//! This file is the source of truth for the four-stage enrichment pipeline.
4//! `.claude/CLAUDE.md` (see [`super::claude_md`]) only names the command; it
5//! does not carry the pipeline. The reason is cost: a command body loads only
6//! when the command runs, while `.claude/CLAUDE.md` loads into every session.
7//! Keeping the ~2.3K-token pipeline here instead of in the always-on stub
8//! keeps it off every non-enrichment turn (design principle P2: pull on
9//! demand). `disable-model-invocation: true` stops the body from auto-loading
10//! speculatively — the command still runs when the developer types
11//! `/mati-enrich`, and the CLAUDE.md stub is what tells Claude it exists.
12
13use 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
220/// Write `.claude/commands/mati-enrich.md`.
221///
222/// - If `.claude/` doesn't exist, the user isn't using Claude Code — skip.
223/// - Otherwise creates `.claude/commands/` if needed and writes the command
224///   file. Idempotent: re-running with the same content is a no-op write
225///   (see `write_if_changed`).
226pub 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/// Outcome of the `/mati-enrich` command file write.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum WriteResult {
251    /// Command file created from scratch.
252    Created,
253    /// Command file already existed with the current content (or was updated in place).
254    AlreadyPresent,
255    /// `.claude/` directory doesn't exist — user isn't using Claude Code.
256    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}