@rafinery/cli 0.5.0 → 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.
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ // rafa hook · SessionStart (startup|resume|clear) — the state digest.
3
+ //
4
+ // M5 capture engine, sensor #2: the session starts KNOWING instead of assuming —
5
+ // staleness (dirty code files → the brain notes citing them, via the local manifest
6
+ // or the platform's get_code_context), pending checkpoint conflicts (*.theirs.md),
7
+ // and the active plan. State, never activity (no-gossip principle: nothing here
8
+ // narrates what other devs did — only what THIS working copy's brain plane looks like).
9
+ //
10
+ // Everything printed to stdout lands in Claude's context. Silence = nothing to
11
+ // report (the honest digest). Budget: local reads + ONE optional platform call
12
+ // (get_code_context batch, ≤ 2s, fail-soft to local counts). Never blocks a
13
+ // session: every path exits 0. Honors RAFA_HOOKS_DISABLED=1.
14
+
15
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ const DIRTY_FILES_ALARM = 15; // ≥ → recommend /rafa scan --brain-only (the fire-alarm rung)
20
+ const CITED_NOTES_ALARM = 10;
21
+ const MCP_BUDGET_MS = 2000;
22
+ const MCP_MAX_FILES = 6;
23
+
24
+ function readJson(file) {
25
+ try {
26
+ return JSON.parse(readFileSync(file, "utf8"));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function dirtyEntries(rafaDir) {
33
+ const f = join(rafaDir, "dirty.jsonl");
34
+ if (!existsSync(f)) return [];
35
+ const files = new Map(); // path → latest touch
36
+ for (const line of readFileSync(f, "utf8").split("\n")) {
37
+ if (!line.trim()) continue;
38
+ try {
39
+ const e = JSON.parse(line);
40
+ if (e && typeof e.f === "string") files.set(e.f, e.t ?? "");
41
+ } catch {
42
+ /* a torn line never breaks the digest */
43
+ }
44
+ }
45
+ return [...files.keys()];
46
+ }
47
+
48
+ // file → citing note ids, from the LOCAL manifest (a full pull / prior compile).
49
+ function citersFromManifest(rafaDir, files) {
50
+ const m = readJson(join(rafaDir, "manifest.json"));
51
+ if (!m || !Array.isArray(m.notes)) return null;
52
+ const want = new Set(files);
53
+ const byNote = new Map();
54
+ for (const group of [m.notes, Array.isArray(m.improvements) ? m.improvements : []]) {
55
+ for (const n of group) {
56
+ const hits = (n.cites ?? []).filter((c) => want.has(c.file)).map((c) => c.file);
57
+ if (hits.length) byNote.set(n.id, [...new Set(hits)]);
58
+ }
59
+ }
60
+ return byNote;
61
+ }
62
+
63
+ // Fallback: the platform's inverted cite graph (get_code_context), fail-soft.
64
+ async function citersFromPlatform(root, files) {
65
+ const stamp = readJson(join(root, "rafa.json"));
66
+ const repoId = stamp?.repoId;
67
+ if (!repoId) return null;
68
+ let key = process.env.RAFA_MCP_KEY || null;
69
+ let url = null;
70
+ const local = readJson(join(root, ".claude", "settings.local.json"));
71
+ if (!key && local?.env?.RAFA_MCP_KEY) key = local.env.RAFA_MCP_KEY;
72
+ const creds = readJson(join(homedir(), ".config", "rafinery", "credentials.json"));
73
+ const entry = creds?.repos?.[repoId];
74
+ if (!key && entry?.key) key = entry.key;
75
+ if (entry?.mcpUrl) url = entry.mcpUrl;
76
+ if (!key || !url || typeof fetch !== "function") return null;
77
+
78
+ const byNote = new Map();
79
+ const ctrl = new AbortController();
80
+ const timer = setTimeout(() => ctrl.abort(), MCP_BUDGET_MS);
81
+ try {
82
+ await Promise.all(
83
+ files.slice(0, MCP_MAX_FILES).map(async (path, i) => {
84
+ const res = await fetch(url, {
85
+ method: "POST",
86
+ signal: ctrl.signal,
87
+ headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
88
+ body: JSON.stringify({
89
+ jsonrpc: "2.0",
90
+ id: i + 1,
91
+ method: "tools/call",
92
+ params: { name: "get_code_context", arguments: { path } },
93
+ }),
94
+ });
95
+ if (!res.ok) return;
96
+ const rpc = await res.json();
97
+ const text = rpc?.result?.content?.[0]?.text;
98
+ const env = typeof text === "string" ? JSON.parse(text) : text;
99
+ for (const ref of [...(env?.notes ?? []), ...(env?.improvements ?? [])]) {
100
+ const id = ref?.id;
101
+ if (!id) continue;
102
+ if (!byNote.has(id)) byNote.set(id, []);
103
+ byNote.get(id).push(path);
104
+ }
105
+ }),
106
+ );
107
+ } catch {
108
+ return null; // offline / slow / shape drift → local counts still serve
109
+ } finally {
110
+ clearTimeout(timer);
111
+ }
112
+ return byNote.size ? byNote : null;
113
+ }
114
+
115
+ // Unprocessed corrections (the reflex queue) — monotonic; an abandoned session
116
+ // loses nothing because the next session starts by seeing these.
117
+ function pendingCorrections(rafaDir) {
118
+ const f = join(rafaDir, "reflex.jsonl");
119
+ if (!existsSync(f)) return [];
120
+ const out = [];
121
+ for (const line of readFileSync(f, "utf8").split("\n")) {
122
+ if (!line.trim()) continue;
123
+ try {
124
+ const e = JSON.parse(line);
125
+ if (e && e.id && !e.done) out.push(e);
126
+ } catch {
127
+ /* torn line never breaks the digest */
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function conflictCopies(rafaDir) {
134
+ const out = [];
135
+ const walk = (dir, depth) => {
136
+ if (depth > 4 || !existsSync(dir)) return;
137
+ for (const e of readdirSync(dir)) {
138
+ if (e === ".git") continue;
139
+ const p = join(dir, e);
140
+ try {
141
+ if (statSync(p).isDirectory()) walk(p, depth + 1);
142
+ else if (e.endsWith(".theirs.md")) out.push(p.slice(rafaDir.length + 1));
143
+ } catch {
144
+ /* races are fine */
145
+ }
146
+ }
147
+ };
148
+ walk(rafaDir, 0);
149
+ return out;
150
+ }
151
+
152
+ try {
153
+ if (process.env.RAFA_HOOKS_DISABLED === "1") process.exit(0);
154
+ const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
155
+ if (!existsSync(join(root, "rafa.json"))) process.exit(0);
156
+ const rafaDir = join(root, ".rafa");
157
+
158
+ const lines = [];
159
+
160
+ const dirty = dirtyEntries(rafaDir);
161
+ if (dirty.length) {
162
+ let byNote = citersFromManifest(rafaDir, dirty);
163
+ if (byNote === null || byNote.size === 0) byNote = (await citersFromPlatform(root, dirty)) ?? byNote;
164
+ const noteIds = byNote ? [...byNote.keys()] : [];
165
+ const shown = noteIds.slice(0, 8).join(", ") + (noteIds.length > 8 ? ` (+${noteIds.length - 8} more)` : "");
166
+ lines.push(
167
+ `[rafa · staleness] ${dirty.length} code file(s) edited since the last brain reconcile` +
168
+ (noteIds.length
169
+ ? ` → ${noteIds.length} brain note(s) cite them: ${shown}.`
170
+ : byNote === null
171
+ ? " (citing notes unresolved here — run `rafa dirty` for the mapping)."
172
+ : " — no brain note cites them (nothing to refresh)."),
173
+ );
174
+ if (noteIds.length || byNote === null)
175
+ lines.push(
176
+ dirty.length >= DIRTY_FILES_ALARM || noteIds.length >= CITED_NOTES_ALARM
177
+ ? `[rafa · staleness] drift is past the threshold (${dirty.length} files / ${noteIds.length} notes) — recommend a brain refresh from main (\`/rafa scan --brain-only\`) at the next natural boundary.`
178
+ : `[rafa · staleness] at the next natural boundary, OFFER a scoped refresh of just the citing notes (atlas re-derives them, gates run as usual, then checkpoint). After the refresh: \`rafa dirty --consume\`.`,
179
+ );
180
+ }
181
+
182
+ const corrections = pendingCorrections(rafaDir);
183
+ if (corrections.length)
184
+ lines.push(
185
+ `[rafa · reflex] ${corrections.length} unprocessed correction(s) from earlier sessions: ` +
186
+ corrections
187
+ .slice(0, 3)
188
+ .map((c) => `${c.id} ("${c.p.slice(0, 60)}${c.p.length > 60 ? "…" : ""}")`)
189
+ .join(" · ") +
190
+ (corrections.length > 3 ? " …" : "") +
191
+ ` — fold into the bootstrap digest: bank the durable ones through the gates (see \`rafa reflex\`), consume each with its verdict.`,
192
+ );
193
+
194
+ const conflicts = conflictCopies(rafaDir);
195
+ if (conflicts.length)
196
+ lines.push(
197
+ `[rafa · conflicts] ${conflicts.length} checkpoint conflict cop${conflicts.length === 1 ? "y" : "ies"} await a decision (never auto-resolved): ${conflicts.slice(0, 5).join(", ")}${conflicts.length > 5 ? " …" : ""} — read each .theirs.md, merge/adopt/keep, re-run \`rafa checkpoint\`.`,
198
+ );
199
+
200
+ let activePlanId = null;
201
+ const activePath = join(rafaDir, "active.md");
202
+ if (existsSync(activePath)) {
203
+ const first = readFileSync(activePath, "utf8").split("\n")[0].trim();
204
+ if (first.startsWith("#") && first !== "# No active plan") {
205
+ activePlanId = first.replace(/^#\s*/, "");
206
+ lines.push(`[rafa · plan] active: ${activePlanId} (materialized under .rafa/plans/).`);
207
+ }
208
+ }
209
+
210
+ // Suggested next — ONE deterministic recommendation, ranked by consequence:
211
+ // a teammate blocked by a conflict > an unbanked correction > resuming the
212
+ // active plan > staleness repair. Guidance is front-loaded here (and ambient
213
+ // in the statusline) — never interleaved mid-flow (offer etiquette).
214
+ {
215
+ let next = null;
216
+ if (conflicts.length)
217
+ next = `resolve the checkpoint conflict${conflicts.length > 1 ? "s" : ""} (${conflicts.length} .theirs.md) — a teammate's copy is waiting on your decision`;
218
+ else if (corrections.length)
219
+ next = `work the ${corrections.length} unprocessed correction${corrections.length > 1 ? "s" : ""} (bank the durable ones — \`rafa reflex\`)`;
220
+ else if (activePlanId) next = `resume plan ${activePlanId}`;
221
+ else if (dirty.length) next = `refresh the notes your edits staled (offer the scoped refresh)`;
222
+ if (next) lines.push(`[rafa · next] suggested next: ${next}. Fold into the bootstrap digest — one question, never serial.`);
223
+ }
224
+
225
+ if (lines.length) process.stdout.write(lines.join("\n") + "\n");
226
+ } catch {
227
+ /* the digest is a courtesy — never a crash */
228
+ }
229
+ process.exit(0);
@@ -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,7 +17,7 @@ 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 |
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
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
@@ -47,9 +47,12 @@ platform MCP (one read path — the same surface any third-party agent uses).
47
47
  in shared stores; mirror each into the item's `## Decisions` section) +
48
48
  `rafa checkpoint` (the branch working set), so the platform and every MCP
49
49
  consumer reflect live progress. Checkpoint moments: task done · plan
50
- approved · explicit ask · cadence · git push/pull — never session-end. A
51
- checkpoint CONFLICT (a teammate's newer copy of the same file) is decided
52
- 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.
53
56
  3. **Brain changes mid-build — WHERE you are decides WHERE it goes.**
54
57
  - **On the default branch (main):** run a full `/rafa scan` (regenerate →
55
58
  prism → compile → push); the brain re-stamps at the new sha, so
@@ -66,6 +69,10 @@ platform MCP (one read path — the same surface any third-party agent uses).
66
69
  4. **Verify** (prism-style) before declaring the plan done; final `push_plan` +
67
70
  `set_active_plan` (clear) + `rafa checkpoint`. A plan that stops being worth
68
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.
69
76
 
70
77
  **Lite plans** (single-child, from plan-lite) run the same per-task loop — the
71
78
  Done-check gate never relaxes; only the ceremony around it shrinks (no bloom nudge,
@@ -67,6 +67,13 @@ The full choreography earns its weight on cross-cutting work; a one-file change
67
67
  through five steps teaches devs to route around rafa — and a route-around is a product
68
68
  failure. So the conductor weighs the **blast radius** (from coverage at recall time):
69
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.
70
77
  - **Lite** (radius ≤ 2 domains and no contract/schema surface touched): ONE parent +
71
78
  ONE child file, recall scoped to the touched domains, bloom pull skipped, prism gate
72
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
@@ -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
  }
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