@rafinery/cli 0.5.0 → 0.7.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 +37 -5
- package/blueprint/.claude/agents/atlas.md +2 -1
- package/blueprint/.claude/agents/sage.md +66 -0
- package/blueprint/.claude/commands/rafa.md +214 -269
- package/blueprint/.claude/rafa/contract.md +204 -115
- 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 +20 -5
- package/blueprint/.claude/skills/rafa-distill/SKILL.md +6 -1
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +7 -0
- package/blueprint/.claude/skills/rafa-sage/SKILL.md +201 -0
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +55 -5
- package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
- package/lib/benchmark.mjs +573 -0
- package/lib/blueprint.mjs +11 -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 +293 -44
- 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
|
|
@@ -45,15 +45,26 @@ platform MCP (one read path — the same surface any third-party agent uses).
|
|
|
45
45
|
steering, the agent for its own proposals; PARAPHRASE + short verbatim
|
|
46
46
|
quotes only where the wording carries the decision — transcripts never land
|
|
47
47
|
in shared stores; mirror each into the item's `## Decisions` section) +
|
|
48
|
+
**`report_loop_event(category: "prism-verdict", outcome: PASS|ITERATE,
|
|
49
|
+
subject: <task id>)`** at the moment prism rules on the Done-check (sage's
|
|
50
|
+
evidence — shapes only: the verdict + the task id, never code) +
|
|
48
51
|
`rafa checkpoint` (the branch working set), so the platform and every MCP
|
|
49
52
|
consumer reflect live progress. Checkpoint moments: task done · plan
|
|
50
|
-
approved · explicit ask · cadence · git push/pull — never session-end.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
approved · explicit ask · cadence · git push/pull — never session-end. The
|
|
54
|
+
loop-event emits ride the SAME checkpoint beats — one per outcome as it
|
|
55
|
+
occurs, monotonic, NEVER a session-end sweep. The
|
|
56
|
+
git-push boundary is MECHANICAL (M5): the pre-push hook runs `rafa checkpoint`
|
|
57
|
+
itself, non-blocking — the session still owns the task-done/plan-approved
|
|
58
|
+
moments. A checkpoint CONFLICT (a teammate's newer copy of the same file) is
|
|
59
|
+
decided IN THIS SESSION: read the `.theirs.md` copy, merge/adopt/keep,
|
|
60
|
+
re-checkpoint.
|
|
53
61
|
3. **Brain changes mid-build — WHERE you are decides WHERE it goes.**
|
|
54
62
|
- **On the default branch (main):** run a full `/rafa scan` (regenerate →
|
|
55
63
|
prism → compile → push); the brain re-stamps at the new sha, so
|
|
56
|
-
`brain = f(code@sha)` stays exact.
|
|
64
|
+
`brain = f(code@sha)` stays exact. When the gate runs, emit
|
|
65
|
+
**`report_loop_event(category: "gate-result", outcome: exit0|failed,
|
|
66
|
+
subject: <compile|verify-citations>)`** at that checkpoint beat — the gate's
|
|
67
|
+
own moment, never deferred to session-end.
|
|
57
68
|
- **On any other branch:** the org brain is NEVER written from a branch —
|
|
58
69
|
it describes main, and a branch-state scan would poison it for everyone.
|
|
59
70
|
Invalidated/learned knowledge → the branch **working set**: hydrate the
|
|
@@ -66,6 +77,10 @@ platform MCP (one read path — the same surface any third-party agent uses).
|
|
|
66
77
|
4. **Verify** (prism-style) before declaring the plan done; final `push_plan` +
|
|
67
78
|
`set_active_plan` (clear) + `rafa checkpoint`. A plan that stops being worth
|
|
68
79
|
finishing closes honestly: `superseded` or `abandoned`, never fake-`done`.
|
|
80
|
+
Plan-done is also a **staleness boundary**: read `rafa dirty --json` — if the
|
|
81
|
+
build's edits dirtied notes this session didn't already refresh, surface the
|
|
82
|
+
scoped-refresh offer NOW (on main: refresh → gates → push; on a branch:
|
|
83
|
+
working-set edit → checkpoint), and `rafa dirty --consume` only after it ships.
|
|
69
84
|
|
|
70
85
|
**Lite plans** (single-child, from plan-lite) run the same per-task loop — the
|
|
71
86
|
Done-check gate never relaxes; only the ceremony around it shrinks (no bloom nudge,
|
|
@@ -57,7 +57,12 @@ code it describes.
|
|
|
57
57
|
CAS: only a live row resolves; a failure means ANOTHER runner already
|
|
58
58
|
distilled this branch — STOP and reconcile against what it shipped.
|
|
59
59
|
`resolve_working_file(path, refuted, note)` for failures — TELL the author
|
|
60
|
-
which files died and why (cited); refutation is feedback, not silence.
|
|
60
|
+
which files died and why (cited); refutation is feedback, not silence. At
|
|
61
|
+
that same resolve beat emit **`report_loop_event(category:
|
|
62
|
+
"distill-refutation", outcome: refuted|distilled, subject: <path or note
|
|
63
|
+
id>)`** — sage's evidence that a claim met the gates or was refuted (shapes
|
|
64
|
+
only: the outcome + a file/note reference, never the refuted content, never
|
|
65
|
+
the cited code). Checkpoint-adjacent, monotonic, never a session-end sweep.
|
|
61
66
|
`resolve_working_file(path, needs-adjudication, note)` for the undecidable —
|
|
62
67
|
the next session's digest offers the decision.
|
|
63
68
|
|
|
@@ -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
|