@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.
- package/CHANGELOG.md +97 -0
- package/bin/rafa.mjs +30 -5
- package/blueprint/.claude/agents/atlas.md +2 -1
- package/blueprint/.claude/commands/rafa.md +71 -6
- package/blueprint/.claude/rafa/contract.md +32 -0
- package/blueprint/.claude/rafa/hooks/post-tool.mjs +62 -0
- package/blueprint/.claude/rafa/hooks/pre-push +24 -0
- package/blueprint/.claude/rafa/hooks/session-start.mjs +229 -0
- package/blueprint/.claude/rafa/hooks/statusline.mjs +113 -0
- package/blueprint/.claude/rafa/hooks/user-prompt-submit.mjs +87 -0
- package/blueprint/.claude/skills/rafa-build/SKILL.md +11 -4
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +7 -0
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +14 -3
- package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
- package/lib/blueprint.mjs +9 -1
- package/lib/brain-repo.mjs +10 -4
- package/lib/ci-setup.mjs +2 -0
- package/lib/claude-config.mjs +77 -0
- package/lib/dirty.mjs +114 -0
- package/lib/distill.mjs +4 -0
- package/lib/gate/compile.mjs +40 -0
- package/lib/gate/verify-citations.mjs +214 -23
- package/lib/githook.mjs +54 -0
- package/lib/init.mjs +18 -0
- package/lib/pull.mjs +7 -0
- package/lib/push.mjs +21 -0
- package/lib/reflex.mjs +76 -0
- package/lib/releases.mjs +35 -0
- package/lib/status.mjs +152 -0
- package/lib/update.mjs +13 -0
- package/package.json +1 -1
|
@@ -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).
|
|
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.
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
85
|
-
anywhere" require a repo-wide
|
|
86
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/brain-repo.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
79
|
-
|
|
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
|
|