@rafinery/cli 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/bin/rafa.mjs +30 -5
  3. package/blueprint/.claude/agents/atlas.md +10 -5
  4. package/blueprint/.claude/agents/compass.md +3 -1
  5. package/blueprint/.claude/commands/rafa.md +103 -12
  6. package/blueprint/.claude/rafa/contract.md +111 -47
  7. package/blueprint/.claude/rafa/hooks/post-tool.mjs +62 -0
  8. package/blueprint/.claude/rafa/hooks/pre-push +24 -0
  9. package/blueprint/.claude/rafa/hooks/session-start.mjs +229 -0
  10. package/blueprint/.claude/rafa/hooks/statusline.mjs +113 -0
  11. package/blueprint/.claude/rafa/hooks/user-prompt-submit.mjs +87 -0
  12. package/blueprint/.claude/skills/rafa-build/SKILL.md +17 -5
  13. package/blueprint/.claude/skills/rafa-insights/SKILL.md +13 -2
  14. package/blueprint/.claude/skills/rafa-plan/SKILL.md +18 -3
  15. package/blueprint/.claude/skills/rafa-scan/SKILL.md +23 -3
  16. package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
  17. package/lib/blueprint.mjs +9 -1
  18. package/lib/brain-repo.mjs +10 -4
  19. package/lib/checkpoint.mjs +7 -0
  20. package/lib/ci-setup.mjs +2 -0
  21. package/lib/claude-config.mjs +113 -0
  22. package/lib/dirty.mjs +114 -0
  23. package/lib/distill.mjs +47 -2
  24. package/lib/gate/compile.mjs +145 -32
  25. package/lib/gate/verify-citations.mjs +214 -23
  26. package/lib/githook.mjs +54 -0
  27. package/lib/init.mjs +45 -0
  28. package/lib/leverage.mjs +13 -2
  29. package/lib/migrations/index.mjs +57 -2
  30. package/lib/pull.mjs +7 -0
  31. package/lib/push.mjs +21 -0
  32. package/lib/reflex.mjs +76 -0
  33. package/lib/releases.mjs +66 -0
  34. package/lib/status.mjs +152 -0
  35. package/lib/update.mjs +20 -0
  36. package/package.json +2 -2
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ // rafa · statusline — the CLAUDE CODE ADAPTER for the loop-state line.
3
+ //
4
+ // HARNESS ADAPTER, deliberately thin (portability directive 2026-07-13: built
5
+ // for Claude Code now, Cursor/Codex later). The semantics are owned by the
6
+ // harness-neutral core — `rafa status` (packages/cli/lib/status.mjs); this
7
+ // script mirrors that computation as a self-contained, dependency-free file
8
+ // because statuslines re-render often (an npx call here would lag the UI).
9
+ // Any drift between this and `rafa status --line` is a bug against the core.
10
+ //
11
+ // Claude Code invokes it with session JSON on stdin; whatever one line it
12
+ // prints becomes the statusline. Ambient guidance, zero keystrokes, zero nags:
13
+ // rafa ▸ plan auth-rate-limit 2/5 → add limiter to route · 3 stale · 1 correction
14
+ // rafa ▸ in sync
15
+ // Outside a rafa repo it prints nothing (silent — never hijacks the statusline).
16
+
17
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ function readJsonl(file) {
21
+ if (!existsSync(file)) return [];
22
+ const out = [];
23
+ for (const line of readFileSync(file, "utf8").split("\n")) {
24
+ if (!line.trim()) continue;
25
+ try {
26
+ const e = JSON.parse(line);
27
+ if (e) out.push(e);
28
+ } catch {
29
+ /* torn line */
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function walk(dir, depth, hit, out = []) {
36
+ if (depth < 0 || !existsSync(dir)) return out;
37
+ for (const e of readdirSync(dir)) {
38
+ if (e === ".git") continue;
39
+ const p = join(dir, e);
40
+ try {
41
+ if (statSync(p).isDirectory()) walk(p, depth - 1, hit, out);
42
+ else if (hit(e)) out.push(p);
43
+ } catch {
44
+ /* races are fine */
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function fmField(path, key) {
51
+ try {
52
+ let fence = 0;
53
+ for (const line of readFileSync(path, "utf8").split("\n")) {
54
+ if (line.trim() === "---") { fence++; if (fence >= 2) break; continue; }
55
+ if (fence !== 1) continue;
56
+ const m = line.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`));
57
+ if (m) return m[1].replace(/\s+#.*$/, "").replace(/^["']|["']$/g, "").trim();
58
+ }
59
+ } catch {
60
+ /* unreadable = absent */
61
+ }
62
+ return null;
63
+ }
64
+
65
+ try {
66
+ let root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
67
+ try {
68
+ const evt = JSON.parse(readFileSync(0, "utf8"));
69
+ root = evt?.workspace?.current_dir || evt?.workspace?.project_dir || evt?.cwd || root;
70
+ } catch {
71
+ /* no/bad stdin — fall back to env/cwd */
72
+ }
73
+ if (!existsSync(join(root, "rafa.json"))) process.exit(0); // silent outside rafa repos
74
+
75
+ const rafaDir = join(root, ".rafa");
76
+ const dirty = new Set(
77
+ readJsonl(join(rafaDir, "dirty.jsonl")).filter((e) => typeof e.f === "string").map((e) => e.f),
78
+ ).size;
79
+ const byId = new Map();
80
+ for (const e of readJsonl(join(rafaDir, "reflex.jsonl"))) if (e.id) byId.set(e.id, { ...(byId.get(e.id) ?? {}), ...e });
81
+ const reflex = [...byId.values()].filter((e) => !e.done).length;
82
+ const conflicts = walk(rafaDir, 4, (n) => n.endsWith(".theirs.md")).length;
83
+
84
+ let plan = null;
85
+ const activePath = join(rafaDir, "active.md");
86
+ if (existsSync(activePath)) {
87
+ const first = readFileSync(activePath, "utf8").split("\n")[0].trim();
88
+ const id = first.startsWith("# ") && first !== "# No active plan" ? first.slice(2).trim() : null;
89
+ if (id) {
90
+ let done = 0, total = 0, current = null;
91
+ for (const f of walk(join(rafaDir, "plans"), 3, (n) => n.endsWith(".md") && !n.startsWith("_"))) {
92
+ if (fmField(f, "plan") !== id) continue;
93
+ const kind = fmField(f, "kind");
94
+ if (kind !== "task" && kind !== "subtask") continue;
95
+ total++;
96
+ const status = fmField(f, "status");
97
+ if (status === "done") done++;
98
+ else if (status === "in-progress" && !current) current = fmField(f, "title") ?? fmField(f, "id");
99
+ }
100
+ plan = { id, done, total, current };
101
+ }
102
+ }
103
+
104
+ const parts = [];
105
+ if (plan) parts.push(`plan ${plan.id} ${plan.done}/${plan.total}` + (plan.current ? ` → ${plan.current}` : ""));
106
+ if (dirty) parts.push(`${dirty} stale`);
107
+ if (reflex) parts.push(`${reflex} correction${reflex === 1 ? "" : "s"}`);
108
+ if (conflicts) parts.push(`${conflicts} conflict${conflicts === 1 ? "" : "s"}`);
109
+ process.stdout.write(parts.length ? `rafa ▸ ${parts.join(" · ")}` : "rafa ▸ in sync");
110
+ } catch {
111
+ /* a statusline must never error the UI */
112
+ }
113
+ process.exit(0);
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ // rafa hook · UserPromptSubmit — the correction reflex, detect half (M5 sensor #4).
3
+ //
4
+ // A dev correcting the agent is the highest-value knowledge signal the system
5
+ // sees. This hook makes the MOMENT deterministic: a correction-shaped prompt is
6
+ // (a) queued to .rafa/reflex.jsonl (monotonic — an abandoned session loses
7
+ // nothing; the SessionStart digest resurfaces unprocessed corrections), and
8
+ // (b) answered with a steering injection so THIS session validates and banks it
9
+ // through the normal gates right now — same session, not next scan.
10
+ //
11
+ // The hook only detects CANDIDATES (narrow patterns, cheap, no LLM); judging
12
+ // whether it's durable repo knowledge is model work downstream (capture is
13
+ // cheap, distillation is rigorous). False positive cost: one injected line.
14
+ // Privacy: the queue stores a short prompt snippet + a LOCAL transcript path —
15
+ // nothing here ever ships; only a distilled, cited note passes the gates.
16
+ //
17
+ // Hard rules: never block (exit 0 everywhere), no network, no LLM.
18
+ // Honors RAFA_HOOKS_DISABLED=1 (headless/CI recursion guard).
19
+
20
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+
23
+ // Narrow, strong correction shapes — leading negation/contradiction, or an
24
+ // explicit never/don't-use instruction. Deliberately NOT matching every "no"
25
+ // mid-sentence; missed captures still have the dev's "bank that" as a path.
26
+ const CORRECTION = [
27
+ /^(no\b|nope\b|wrong\b|incorrect\b|that'?s (wrong|incorrect|not right)|not (quite|right)\b|actually[,\s])/i,
28
+ /\b(never|don'?t|do not|stop) (use|do|call|import|write|put|add|touch)\b/i,
29
+ /\buse\s+\S[^.!?]{0,60}\binstead\b/i,
30
+ /\binstead of\b[^.!?]{0,60}\buse\b/i,
31
+ ];
32
+
33
+ try {
34
+ if (process.env.RAFA_HOOKS_DISABLED === "1") process.exit(0);
35
+
36
+ let evt = null;
37
+ try {
38
+ evt = JSON.parse(readFileSync(0, "utf8"));
39
+ } catch {
40
+ process.exit(0);
41
+ }
42
+ const root = process.env.CLAUDE_PROJECT_DIR || evt?.cwd || process.cwd();
43
+ if (!existsSync(join(root, "rafa.json"))) process.exit(0);
44
+
45
+ const prompt = typeof evt?.prompt === "string" ? evt.prompt.trim() : "";
46
+ if (!prompt || prompt.startsWith("/")) process.exit(0); // commands are not corrections
47
+ if (!CORRECTION.some((re) => re.test(prompt))) process.exit(0);
48
+
49
+ const rafaDir = join(root, ".rafa");
50
+ mkdirSync(rafaDir, { recursive: true });
51
+ // Transport exclusion (same belt-and-braces as the dirty queue).
52
+ const gi = join(rafaDir, ".gitignore");
53
+ const body = existsSync(gi) ? readFileSync(gi, "utf8") : "";
54
+ if (!/^reflex\.jsonl$/m.test(body))
55
+ writeFileSync(gi, body + (body === "" || body.endsWith("\n") ? "" : "\n") + "reflex.jsonl\n");
56
+
57
+ const id = `rx_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
58
+ appendFileSync(
59
+ join(rafaDir, "reflex.jsonl"),
60
+ JSON.stringify({
61
+ id,
62
+ p: prompt.slice(0, 300),
63
+ t: new Date().toISOString(),
64
+ tp: typeof evt?.transcript_path === "string" ? evt.transcript_path : null, // LOCAL pointer, never ships
65
+ }) + "\n",
66
+ );
67
+
68
+ // The steering injection — arrives WITH the correction itself, so the session
69
+ // handles it at exactly the right moment (no SOP memory involved).
70
+ process.stdout.write(
71
+ JSON.stringify({
72
+ hookSpecificOutput: {
73
+ hookEventName: "UserPromptSubmit",
74
+ additionalContext:
75
+ `[rafa · reflex ${id}] This prompt reads as a CORRECTION of prior agent output. ` +
76
+ `After addressing it: judge whether it encodes DURABLE repo knowledge (code-groundable — a convention, contract, or fact a future session would need). ` +
77
+ `If yes: bank it NOW through the gates — on a branch: author/edit the note under .rafa/brain/** with ≥1 resolving cite + \`rafa checkpoint\`; on main: full gates (verify-citations · compile · push). ` +
78
+ `Then confirm to the dev in ONE line (note id + where it landed) and run \`rafa reflex --consume ${id}\`. ` +
79
+ `If it is NOT durable repo knowledge (one-off steer, preference, out-of-code claim): say it stays session-only, and still \`rafa reflex --consume ${id}\` with reason "session-only". ` +
80
+ `Never bank an ungroundable claim; never quote the transcript into any shared store.`,
81
+ },
82
+ }),
83
+ );
84
+ } catch {
85
+ /* a sensor must never take down the session */
86
+ }
87
+ process.exit(0);
@@ -17,8 +17,8 @@ platform MCP (one read path — the same surface any third-party agent uses).
17
17
  | Role | Agent | Job per task |
18
18
  |---|---|---|
19
19
  | **Executor** | atlas | RECALL the task's brain slice via MCP (`search_knowledge` + `get_rule`/`get_playbook`; honor non-exemplars) → implement, convention-adherent |
20
- | **Validator** | prism | validate the execution against the child's `## Done-check` — strict, unbiased, against code + brain, never against atlas's claims. **`status: done` only on prism PASS**; FAIL → atlas corrects (validate-and-correct at work time). This gate is conductor-flow SOP deterministic enforcement is the deferred capture-engine's job |
21
- | **Improver** | bloom | **push**: new improvement opportunities spotted during execution → new ledger files. **close**: improvements fixed in passing → `status: fixed`. **nudge**: top-leverage open item in the task's blast radius — opt-in, never blocking |
20
+ | **Validator** | prism | validate the execution against the child's `## Done-check` — strict, unbiased, against code + brain, never against atlas's claims. **`status: done` only on prism PASS**; FAIL → atlas corrects (validate-and-correct at work time). Plan-done adds one line to the verdict: **working set reviewed captured, or clean-with-reason** (a build that learned nothing SAYS so; a build that learned something SHOWS the files) |
21
+ | **Improver** | bloom | **push**: new improvement opportunities spotted during execution → new ledger files. **close**: improvements fixed in passing → `status: fixed` in the ledger file + `report_improvement_status(id, fixed)` so the platform shows it LIVE as pending-reconciliation (the ledger row itself changes only at the next brain push — K1). **nudge**: top-leverage open item in the task's blast radius — opt-in, never blocking |
22
22
 
23
23
  ## Procedure
24
24
 
@@ -40,11 +40,19 @@ platform MCP (one read path — the same surface any third-party agent uses).
40
40
  parsed; the plan files at `.rafa/plans/<plan>/` ARE the local cache) → at the
41
41
  task-done CHECKPOINT (under the session consent): `push_plan` /
42
42
  `update_plan_status` (the plans channel — statuses + Log journals) +
43
+ **`log_decision` for each deliberation since the last checkpoint** — what
44
+ came up, what was considered, what the DEV chose, why (actor = the dev for
45
+ steering, the agent for its own proposals; PARAPHRASE + short verbatim
46
+ quotes only where the wording carries the decision — transcripts never land
47
+ in shared stores; mirror each into the item's `## Decisions` section) +
43
48
  `rafa checkpoint` (the branch working set), so the platform and every MCP
44
49
  consumer reflect live progress. Checkpoint moments: task done · plan
45
- approved · explicit ask · cadence · git push/pull — never session-end. A
46
- checkpoint CONFLICT (a teammate's newer copy of the same file) is decided
47
- IN THIS SESSION: read the `.theirs.md` copy, merge/adopt/keep, re-checkpoint.
50
+ approved · explicit ask · cadence · git push/pull — never session-end. The
51
+ git-push boundary is MECHANICAL (M5): the pre-push hook runs `rafa checkpoint`
52
+ itself, non-blocking the session still owns the task-done/plan-approved
53
+ moments. A checkpoint CONFLICT (a teammate's newer copy of the same file) is
54
+ decided IN THIS SESSION: read the `.theirs.md` copy, merge/adopt/keep,
55
+ re-checkpoint.
48
56
  3. **Brain changes mid-build — WHERE you are decides WHERE it goes.**
49
57
  - **On the default branch (main):** run a full `/rafa scan` (regenerate →
50
58
  prism → compile → push); the brain re-stamps at the new sha, so
@@ -61,6 +69,10 @@ platform MCP (one read path — the same surface any third-party agent uses).
61
69
  4. **Verify** (prism-style) before declaring the plan done; final `push_plan` +
62
70
  `set_active_plan` (clear) + `rafa checkpoint`. A plan that stops being worth
63
71
  finishing closes honestly: `superseded` or `abandoned`, never fake-`done`.
72
+ Plan-done is also a **staleness boundary**: read `rafa dirty --json` — if the
73
+ build's edits dirtied notes this session didn't already refresh, surface the
74
+ scoped-refresh offer NOW (on main: refresh → gates → push; on a branch:
75
+ working-set edit → checkpoint), and `rafa dirty --consume` only after it ships.
64
76
 
65
77
  **Lite plans** (single-child, from plan-lite) run the same per-task loop — the
66
78
  Done-check gate never relaxes; only the ceremony around it shrinks (no bloom nudge,
@@ -58,9 +58,20 @@ code-level → the branch working set (not yours — rafa.md §capture routes it
58
58
  existing insight (`put_dev_insight` with its id) rather than accreting near-
59
59
  copies.
60
60
 
61
+ ### 2b. Personal tooling (ratified 2026-07-12 — the user-plane toolbox)
62
+ Under the dev's STANDING insights consent, inventory the tooling they carry
63
+ across repos — `~/.claude/skills` + user-level MCPs — and bank each as a
64
+ `kind: tooling` insight (NAME + DESCRIPTION only, never file contents). Update
65
+ beats duplicate; tombstones honored. This is what lets rafa orchestrate a
66
+ dev's personal migration skill in a repo that has never seen it. The REPO
67
+ toolbox is atlas's plane (scan's toolbox domain), never yours.
68
+
61
69
  ### 3. Steering (the payoff — propose, the dev disposes)
62
- At natural boundaries only, at most one nudge: surface the insight that changes
63
- what the dev does next — "you've corrected X three times: want it banked as a
70
+ At natural boundaries only, at most one nudge and ALWAYS have a candidate
71
+ ready (leverage-coaching: cross the repo's toolbox inventory with the dev's
72
+ patterns + tooling insights — "you've hand-run this migration three times; the
73
+ repo ships a skill for it"). Availability is always; frequency never rises.
74
+ Surface the insight that changes what the dev does next — "you've corrected X three times: want it banked as a
64
75
  rule?" · "this decomposition worked before, reuse it?" · "you never use plan
65
76
  mode on big changes; it would have caught this." Dismissal is final for the
66
77
  session. Never rank, never compare devs, never nag.
@@ -37,9 +37,17 @@ Planning is a choreography, not one agent (spec: knowledge-mcp-build-agent):
37
37
  1. **Staleness check** — compare the platform envelope's `brainForSha` against the
38
38
  local brain stamp; if the platform is behind, surface "run `rafa push`" (never
39
39
  proceed silently on knowledge you know is stale — never block either).
40
- 2. **Recall** (atlas, via MCP) → **decompose** ADR-style: decision + rationale +
41
- alternatives + risk surface + non-goals; tasks bound to domains with a
42
- `## Done-check` each.
40
+ 2. **Recall** (atlas, via MCP) → **decompose** into the WORK-ITEM TREE (contract
41
+ §7 v2): one epic tasks subtasks (three ranks, never deeper). Every item
42
+ carries the glimpse fields — `title` (what) · `description` (why) ·
43
+ `approach` (how, one line) · `assignee` when known · `blocked_by` for
44
+ intra-plan dependencies (a dependency IS a blocker; blocked is DERIVED,
45
+ never a status) · optional `priority` 0–4 / `estimate`. Every LEAF carries a
46
+ `## Done-check`. The blast radius goes on the EPIC's `domains:` — it rides
47
+ `push_plan` and the platform renders the plan's brain slice beside it.
48
+ ADR material (alternatives, risks, non-goals) lives in the epic body AND
49
+ the pivotal choices are logged as DECISIONS at approval (`log_decision`:
50
+ context · options · decision · rationale; actor = the dev for their calls).
43
51
  3. **Ledger pull** (bloom) → optional leverage tasks in the blast radius.
44
52
  4. **Leverage-match** — recommend existing skills/tools/MCP that fit the tasks;
45
53
  never plan to hand-roll what a capability already does.
@@ -59,6 +67,13 @@ The full choreography earns its weight on cross-cutting work; a one-file change
59
67
  through five steps teaches devs to route around rafa — and a route-around is a product
60
68
  failure. So the conductor weighs the **blast radius** (from coverage at recall time):
61
69
 
70
+ - **Below planning entirely — DIRECT-DO (conductor 1.8.0):** radius ≤ 1 domain, no
71
+ contract surface, fits one sitting → NO plan files are created at all. The
72
+ conductor acts (recall → implement → verify) and the sensors carry the loop
73
+ (dirty-mark · reflex · checkpoint-at-push · capture if knowledge emerged). Plans
74
+ begin where RESUMABILITY or COORDINATION begins — not before. If direct-do work
75
+ GROWS (2nd domain, contract surface, multi-session), the conductor escalates to
76
+ lite with one announce line and creates the files THEN.
62
77
  - **Lite** (radius ≤ 2 domains and no contract/schema surface touched): ONE parent +
63
78
  ONE child file, recall scoped to the touched domains, bloom pull skipped, prism gate
64
79
  collapses to the two invariants that never relax — every task grounded, the child
@@ -81,9 +81,14 @@ file, finds nothing, and stops trusting the brain. So fidelity is non-negotiable
81
81
  - **One docs rule, stated once.** Contract site lists count **code** occurrences; exclude
82
82
  docs/markdown/comments. Apply this identically to every contract — never "the full
83
83
  surface" for one and "the rest are docs" for another. State the exclusion once per note.
84
- - **Never assert absence without an exhaustive grep.** "none yet", "greenfield", "not used
85
- anywhere" require a repo-wide `git grep` of the pattern first. A sampled read is not
86
- evidence of absence one un-grepped route (e.g. a demo page) invalidates the claim.
84
+ - **Never assert absence without an exhaustive grep and DECLARE it so the gate re-greps
85
+ it forever.** "none yet", "greenfield", "not used anywhere" require a repo-wide
86
+ `git grep` of the pattern first; a sampled read is not evidence of absence. Then declare
87
+ the token in frontmatter — `absent: <token>` (repeatable, one per line) — so
88
+ `verify-citations` gate **B3** re-greps it on every run and the claim can never silently
89
+ go stale (the 2026-06-08 blocker class: code grew the thing the note said was missing).
90
+ An absence-shaped title/summary with no `absent:` declared is flagged as a checker WARN —
91
+ resolve every WARN before hand-off (declare the token, or reword the claim).
87
92
 
88
93
  ---
89
94
 
@@ -94,6 +99,12 @@ file, finds nothing, and stops trusting the brain. So fidelity is non-negotiable
94
99
  - `.rafa/brain/coverage.md` — the coverage report. **Machine-read frontmatter** per
95
100
  [`.claude/rafa/contract.md`](../../rafa/contract.md) §6: `domains: { <domain>: mapped|thin|stubbed|empty, … }`
96
101
  — one entry per domain from Step 1. The body keeps the per-criterion PASS/FAIL narrative.
102
+ **Declare `inventory:` entries** (`- <name> :: <glob> :: <count>`) for each framework
103
+ surface the scan counted — route pages, API routes, agent graphs, whatever is
104
+ load-bearing in THIS repo. The checker recomputes each via `git ls-files ':(glob)…'`
105
+ every run and fails on drift, so coverage can never silently claim an inventory the
106
+ repo has outgrown. Compute the count FROM the same `git ls-files` command — never from
107
+ memory of the tree.
97
108
 
98
109
  **Note format.** The **strict contract is [`.claude/rafa/contract.md`](../../rafa/contract.md) §2** — every
99
110
  required field there is mandatory and `rafa compile` (Step 7) **rejects** any violation with a
@@ -138,6 +149,15 @@ retrieval index. Bodies read like a senior engineer explaining that one concept
138
149
  the internal node/relationship model the next steps reason over. (Not emitted as a
139
150
  file; it feeds the notes.)
140
151
 
152
+ 3b. **Toolbox inventory** [deterministic] — ratified 2026-07-12: the REPO toolbox is a
153
+ first-class brain domain (`toolbox`). Run `npx @rafinery/cli leverage --json` (the
154
+ deterministic extractor) and author cited notes for the committed toolbox — skills
155
+ (`.claude/skills/*/SKILL.md` name+description), commands, `.mcp.json` servers,
156
+ granted permissions — cites into the config files themselves (contract §2; they are
157
+ citable file:line). This makes the toolbox recallable through the SAME MCP surface
158
+ as all knowledge and refreshable like any note. Personal `~/.claude/` is NEVER
159
+ inventoried here (user-profile plane, compass's job, standing consent).
160
+
141
161
  4. **Convention detection** [deterministic + light LLM] — for **each** domain, the local
142
162
  idiom (design tokens, component export pattern + any server/client split such as RSC,
143
163
  data-op shape, naming).
@@ -32,8 +32,11 @@ agent. So:
32
32
  ## Procedure (run in order; ground everything in code)
33
33
 
34
34
  1. **Re-run the checker yourself** — `npx @rafinery/cli verify-citations`. Record exit
35
- code + counts (resolution / completeness / policy). Exit 0 **blocker(s)**. Never
36
- trust a pasted table.
35
+ code + counts (resolution / completeness / policy / **absence / inventory** — checker
36
+ v2 mechanizes the 2026-06-08 ratchet: declared `absent:` tokens re-grepped, coverage
37
+ `inventory:` counts re-computed). Exit ≠ 0 → **blocker(s)**. Never trust a pasted
38
+ table; confirm `citation-check.json` (checkerVersion · pass · at) matches the run you
39
+ just made — a stale record riding a push is itself a **blocker**.
37
40
  2. **Trust-but-verify the checker** —
38
41
  (a) independently re-verify a sample (~10) of cites against the raw files by a *different*
39
42
  method (hand grep / read), to confirm the checker isn't lying.
@@ -44,6 +47,16 @@ agent. So:
44
47
  A mismatch in (a), or a non-zero `--selftest` in (b) → **blocker** (the verifier is broken).
45
48
  3. **Adversarial completeness probe** — for each contract `anchor:`, run `git grep` yourself
46
49
  and confirm the cited sites equal the grep hits. Hunt for an omitted site.
50
+
51
+ 3b. **Absence audit — work the WARN list.** The checker's report ends with heuristic WARNs
52
+ (absence-shaped title/summary with no `absent:` declared). For each: decide whether the
53
+ claim is truly existence-dependent → if yes, it MUST declare `absent: <token>` (finding:
54
+ major — an undeclared absence claim is exactly the class that went stale in 2026-06-08);
55
+ if the wording is just loose, note it as minor. Then hunt for absence claims the
56
+ heuristic missed: any note whose truth depends on something NOT existing, with no
57
+ `absent:` gate behind it. The mechanized gate only covers what is declared — YOUR job
58
+ is the undeclared remainder (the ratchet's disposition protocol: every catch here
59
+ should end as a new declaration, an eval case, or a recorded judgment).
47
60
  4. **Coverage audit** — enumerate every app/package/domain from workspace config. Confirm
48
61
  each has a *substantive* note (not a token stub). Tunneling / imbalance → **major**.
49
62
  5. **Load-bearing test (the core score)** — pick ONE real feature + ONE real bug. Using ONLY
package/lib/blueprint.mjs CHANGED
@@ -43,6 +43,10 @@ export const BLUEPRINT = {
43
43
  ".claude/skills/rafa-leverage",
44
44
  ],
45
45
  lockstep: [".claude/rafa/contract.md"],
46
+ // The M5 sensor hooks — deterministic protocol machinery (like the contract):
47
+ // they move with the CLI version, clients don't tune them. NOT knowledge —
48
+ // the brain (.rafa/) stays executable-free; these live in the code repo.
49
+ lockstepDirs: [".claude/rafa/hooks"],
46
50
  };
47
51
 
48
52
  const HERE = dirname(fileURLToPath(import.meta.url)); // …/packages/cli/lib
@@ -96,7 +100,11 @@ export function blueprintFiles(from) {
96
100
  ...BLUEPRINT.owned,
97
101
  ...BLUEPRINT.ownedDirs.flatMap((d) => filesUnder(from, d)),
98
102
  ];
99
- return { owned, lockstep: [...BLUEPRINT.lockstep] };
103
+ const lockstep = [
104
+ ...BLUEPRINT.lockstep,
105
+ ...(BLUEPRINT.lockstepDirs ?? []).flatMap((d) => filesUnder(from, d)),
106
+ ];
107
+ return { owned, lockstep };
100
108
  }
101
109
 
102
110
  function sameBytes(a, b) {
@@ -6,7 +6,7 @@
6
6
  // which materializes the skeleton + git wiring from the committed rafa.json so
7
7
  // `clone → npx rafa <cmd>` just works with no init step on the machine.
8
8
 
9
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { execSync } from "node:child_process";
11
11
  import { join } from "node:path";
12
12
  import { readStamp } from "./stamp.mjs";
@@ -73,10 +73,16 @@ export function ensureBrainRepo(cwd, { requireRemote = true } = {}) {
73
73
  }
74
74
 
75
75
  // Session/state debris never rides a brain push: the sidecar, conflict
76
- // copies, and distill staging are transport-excluded here (not knowledge).
76
+ // copies, distill staging, and the M5 dirty queue are transport-excluded here
77
+ // (not knowledge). MERGED line-by-line so clones ignored under an older CLI
78
+ // still gain new exclusions.
77
79
  const ignore = join(rafaDir, ".gitignore");
78
- const IGNORES = "hydration.json\n*.theirs.md\ndistill-incoming/\ndistill-verdicts.json\n";
79
- if (!existsSync(ignore)) writeFileSync(ignore, IGNORES);
80
+ const IGNORES = ["hydration.json", "*.theirs.md", "distill-incoming/", "distill-verdicts.json", "dirty.jsonl", "reflex.jsonl"];
81
+ const cur = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
82
+ const have = new Set(cur.split("\n").map((l) => l.trim()).filter(Boolean));
83
+ const missing = IGNORES.filter((l) => !have.has(l));
84
+ if (missing.length)
85
+ writeFileSync(ignore, cur + (cur === "" || cur.endsWith("\n") ? "" : "\n") + missing.join("\n") + "\n");
80
86
 
81
87
  return { rafaDir, remote, bootstrapped };
82
88
  }
@@ -84,6 +84,13 @@ export default async function checkpoint() {
84
84
  try {
85
85
  payload = await callTool(ROOT, "checkpoint_sync", {
86
86
  branch,
87
+ // WHO/WHAT executed — the CLI knows itself; the MODEL is the session's to
88
+ // report (RAFA_ACTOR_MODEL env, set by the conductor) — never guessed.
89
+ actorMeta: {
90
+ agent: "rafa-cli@" + (process.env.npm_package_version ?? "0.5.0"),
91
+ runner: process.env.CI ? "ci" : "session",
92
+ model: process.env.RAFA_ACTOR_MODEL ?? "unreported",
93
+ },
87
94
  files: candidates.map(({ path, content, baseVersion, baseBrainSha }) => ({
88
95
  path,
89
96
  content,
package/lib/ci-setup.mjs CHANGED
@@ -46,6 +46,7 @@ jobs:
46
46
  - name: Mechanical fold (no LLM — rigor lives at main)
47
47
  env:
48
48
  RAFA_MCP_KEY: \${{ secrets.RAFA_MCP_KEY }}
49
+ RAFA_HOOKS_DISABLED: "1" # headless — the M5 sensors are session instruments
49
50
  run: npx -y @rafinery/cli fold --from "\${{ github.event.pull_request.head.ref }}" --to "\${{ github.event.pull_request.base.ref }}"
50
51
 
51
52
  distill:
@@ -66,6 +67,7 @@ jobs:
66
67
  ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
67
68
  RAFA_MCP_KEY: \${{ secrets.RAFA_MCP_KEY }}
68
69
  RAFA_BRAIN_TOKEN: \${{ secrets.RAFA_BRAIN_TOKEN }}
70
+ RAFA_HOOKS_DISABLED: "1" # headless — the M5 sensors are session instruments
69
71
  run: npx -y @rafinery/cli distill --headless "\${{ github.event.pull_request.head.ref }}"
70
72
  `;
71
73
 
@@ -13,6 +13,119 @@ export const RAFA_PERMISSIONS = [
13
13
  "Bash(rafa:*)",
14
14
  ];
15
15
 
16
+ // The M5 sensor hooks (capture engine): SessionStart injects the state digest
17
+ // (staleness · conflicts · active plan), PostToolUse dirty-marks edited code
18
+ // files into .rafa/dirty.jsonl. Both scripts are vendored LOCKSTEP at
19
+ // .claude/rafa/hooks/ and are no-ops outside a rafa-provisioned repo.
20
+ export const RAFA_HOOKS = {
21
+ SessionStart: {
22
+ matcher: "startup|resume|clear",
23
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/rafa/hooks/session-start.mjs"',
24
+ },
25
+ PostToolUse: {
26
+ matcher: "Edit|Write|MultiEdit|NotebookEdit",
27
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/rafa/hooks/post-tool.mjs"',
28
+ },
29
+ // The correction reflex (M5 sensor #4): detect correction-shaped prompts,
30
+ // queue them, and inject the bank-it-now steering at exactly that moment.
31
+ UserPromptSubmit: {
32
+ matcher: "*",
33
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/rafa/hooks/user-prompt-submit.mjs"',
34
+ },
35
+ };
36
+
37
+ // The ambient loop-state statusline (frictionless loop, 2026-07-13). Set ONLY
38
+ // when the dev has no statusline of their own, or theirs is already ours —
39
+ // a configured statusline is the dev's turf, never replaced (merge, don't own).
40
+ export const RAFA_STATUSLINE = {
41
+ type: "command",
42
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/rafa/hooks/statusline.mjs"',
43
+ };
44
+
45
+ export function mergeStatusLine(targetDir) {
46
+ const file = settingsPath(targetDir);
47
+ const existing = readClaudeSettings(targetDir);
48
+ if (existing && existing.error) {
49
+ return { skipped: `.claude/settings.json is not valid JSON (${existing.error})` };
50
+ }
51
+ const settings = existing ?? {};
52
+ const cur = settings.statusLine;
53
+ const ours = typeof cur?.command === "string" && cur.command.includes(".claude/rafa/hooks/statusline.mjs");
54
+ if (cur && !ours) return { skipped: "you already have a statusline — keeping yours (rafa's shows via `rafa status --line`)" };
55
+ if (ours && cur.command === RAFA_STATUSLINE.command) return { added: [] };
56
+ const next = { ...settings, statusLine: { ...RAFA_STATUSLINE } };
57
+ mkdirSync(dirname(file), { recursive: true });
58
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
59
+ return { added: ["statusLine"] };
60
+ }
61
+
62
+ // Merge rafa's sensor hooks into .claude/settings.json WITHOUT clobbering the
63
+ // dev's own hook config: a rafa hook is recognized by its script path — if any
64
+ // handler for that event already points at .claude/rafa/hooks/<script>, the
65
+ // event is left untouched (matcher/command tuning survives updates). Returns
66
+ // { added: [eventNames] } or { skipped: "reason" }.
67
+ export function mergeClaudeHooks(targetDir, defs = RAFA_HOOKS) {
68
+ const file = settingsPath(targetDir);
69
+ const existing = readClaudeSettings(targetDir);
70
+ if (existing && existing.error) {
71
+ return { skipped: `.claude/settings.json is not valid JSON (${existing.error})` };
72
+ }
73
+ const settings = existing ?? {};
74
+ const hooks = { ...(settings.hooks ?? {}) };
75
+ const added = [];
76
+ for (const [event, def] of Object.entries(defs)) {
77
+ const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
78
+ const marker = def.command.match(/\.claude\/rafa\/hooks\/[\w.-]+/)?.[0] ?? def.command;
79
+ const present = groups.some((g) =>
80
+ (g?.hooks ?? []).some((h) => typeof h?.command === "string" && h.command.includes(marker)),
81
+ );
82
+ if (present) continue;
83
+ hooks[event] = [...groups, { matcher: def.matcher, hooks: [{ type: "command", command: def.command }] }];
84
+ added.push(event);
85
+ }
86
+ if (added.length === 0 && existing) return { added: [] };
87
+ const next = { ...settings, hooks };
88
+ mkdirSync(dirname(file), { recursive: true });
89
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
90
+ return { added };
91
+ }
92
+
93
+ // Merge the repo's .mcp.json server names into the OWNED agent cards' tools
94
+ // lines (ratified 2026-07-12: subagents must be able to reach the repo's MCPs
95
+ // — a Convex repo's mcp__convex tools were structurally invisible to atlas).
96
+ // Owned-file edit, idempotent, reviewable in git diff; blueprint stays generic.
97
+ export function mergeRepoMcpsIntoCards(targetDir) {
98
+ const mcp = readClaudeSettingsFile(join(targetDir, ".mcp.json"));
99
+ const servers = Object.keys(mcp?.mcpServers ?? {}).filter((n) => n !== "rafinery");
100
+ if (servers.length === 0) return { updated: [] };
101
+ const updated = [];
102
+ const dir = join(targetDir, ".claude", "agents");
103
+ if (!existsSync(dir)) return { updated };
104
+ for (const card of ["atlas.md", "bloom.md", "prism.md", "compass.md"]) {
105
+ const file = join(dir, card);
106
+ if (!existsSync(file)) continue;
107
+ let text = readFileSync(file, "utf8");
108
+ const m = text.match(/^tools: (.+)$/m);
109
+ if (!m) continue;
110
+ const have = m[1].split(",").map((t) => t.trim());
111
+ const missing = servers.map((s2) => `mcp__${s2}`).filter((t) => !have.includes(t));
112
+ if (missing.length === 0) continue;
113
+ text = text.replace(/^tools: .+$/m, `tools: ${[...have, ...missing].join(", ")}`);
114
+ writeFileSync(file, text);
115
+ updated.push(`${card} (+${missing.join(", ")})`);
116
+ }
117
+ return { updated };
118
+ }
119
+
120
+ function readClaudeSettingsFile(file) {
121
+ if (!existsSync(file)) return null;
122
+ try {
123
+ return JSON.parse(readFileSync(file, "utf8"));
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
16
129
  export function settingsPath(targetDir) {
17
130
  return join(targetDir, ".claude", "settings.json");
18
131
  }