@rafinery/cli 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +144 -0
- package/bin/rafa.mjs +30 -5
- package/blueprint/.claude/agents/atlas.md +10 -5
- package/blueprint/.claude/agents/compass.md +3 -1
- package/blueprint/.claude/commands/rafa.md +103 -12
- package/blueprint/.claude/rafa/contract.md +111 -47
- 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 +17 -5
- package/blueprint/.claude/skills/rafa-insights/SKILL.md +13 -2
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +18 -3
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +23 -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/checkpoint.mjs +7 -0
- package/lib/ci-setup.mjs +2 -0
- package/lib/claude-config.mjs +113 -0
- package/lib/dirty.mjs +114 -0
- package/lib/distill.mjs +47 -2
- package/lib/gate/compile.mjs +145 -32
- package/lib/gate/verify-citations.mjs +214 -23
- package/lib/githook.mjs +54 -0
- package/lib/init.mjs +45 -0
- package/lib/leverage.mjs +13 -2
- package/lib/migrations/index.mjs +57 -2
- package/lib/pull.mjs +7 -0
- package/lib/push.mjs +21 -0
- package/lib/reflex.mjs +76 -0
- package/lib/releases.mjs +66 -0
- package/lib/status.mjs +152 -0
- package/lib/update.mjs +20 -0
- package/package.json +2 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Deterministic citation checker for the atlas scan output. Lives INSIDE
|
|
2
2
|
// @rafinery/cli (blueprint split, 0.4.0) — run as `rafa verify-citations`.
|
|
3
|
-
//
|
|
3
|
+
// Five gates (checker v2 — the 2026-06-08 ratchet: prism's two recommended
|
|
4
|
+
// checks mechanized, per the disposition protocol "every prism catch gets
|
|
5
|
+
// mechanized | eval-cased | judgment"):
|
|
4
6
|
//
|
|
5
7
|
// RESOLUTION (B1) — every cite `- <file>:<line>[-<end>] :: <token>` points at a line
|
|
6
8
|
// that actually contains <token>. Catches off-by-N / wrong-file.
|
|
@@ -11,18 +13,48 @@
|
|
|
11
13
|
// or `anchor: none` for composition/ordering contracts that don't grep
|
|
12
14
|
// as one token. So completeness can't be silently skipped (closes F1:
|
|
13
15
|
// the guarantee is mandatory, not opt-in; exemptions are explicit).
|
|
16
|
+
// ABSENCE (B3) — a note may declare `absent: <token>` (repeatable, anchor-style):
|
|
17
|
+
// "this token appears NOWHERE in code." The checker re-greps every
|
|
18
|
+
// declared absence and FAILS if it now matches (docs/.md excluded).
|
|
19
|
+
// Mechanizes the 2026-06-08 blocker class: a false absence-claim
|
|
20
|
+
// (the note said stubbed; the code had grown the real thing).
|
|
21
|
+
// INVENTORY — coverage.md may declare `inventory:` entries
|
|
22
|
+
// `- <name> :: <glob> :: <count>`; the checker recomputes each via
|
|
23
|
+
// `git ls-files ':(glob)<glob>'` and FAILS on drift. Mechanizes the
|
|
24
|
+
// route-inventory diff (coverage claiming N surface files vs reality).
|
|
14
25
|
//
|
|
15
|
-
//
|
|
26
|
+
// Plus a non-failing heuristic: notes whose title/summary reads existence-shaped
|
|
27
|
+
// ("does not exist", "stubbed", "not implemented"…) without declaring `absent:` are
|
|
28
|
+
// listed as WARNs — prism's worklist, never a gate failure (a heuristic that fails
|
|
29
|
+
// the gate would be an assumed value).
|
|
30
|
+
//
|
|
31
|
+
// Writes a generated report to <root>/citation-check.md (don't hand-paste it) and a
|
|
32
|
+
// machine record to <root>/citation-check.json ({ checkerVersion, pass, gates … }) —
|
|
33
|
+
// `rafa compile` folds that record into manifest.citations so the platform knows
|
|
34
|
+
// which gate level a brain actually passed (recorded run, never assumed).
|
|
16
35
|
// Returns exit code 1 on any failure. Run from repo root: rafa verify-citations
|
|
17
36
|
|
|
18
|
-
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
37
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdtempSync, rmSync, mkdirSync } from "node:fs";
|
|
19
38
|
import { execSync } from "node:child_process";
|
|
20
39
|
import { join } from "node:path";
|
|
21
40
|
import { tmpdir } from "node:os";
|
|
22
41
|
|
|
42
|
+
export const CHECKER_VERSION = 2; // v1 = the 3-gate era (resolution/completeness/policy)
|
|
43
|
+
|
|
23
44
|
const CITE = /^\s*-\s+(.+?):(\d+)(?:-(\d+))?\s*::\s*(.+?)\s*$/; // - file:start[-end] :: token
|
|
24
45
|
const ANCHOR = /^anchor:\s*(.+?)\s*$/;
|
|
46
|
+
const ABSENT = /^absent:\s*(.+?)\s*$/;
|
|
25
47
|
const TYPE = /^type:\s*(.+?)\s*$/;
|
|
48
|
+
const TITLE = /^title:\s*(.+?)\s*$/;
|
|
49
|
+
const SUMMARY = /^summary:\s*(.+?)\s*$/;
|
|
50
|
+
const INV_ENTRY = /^(.+?)\s*::\s*(.+?)\s*::\s*(\d+)$/; // name :: glob :: count
|
|
51
|
+
|
|
52
|
+
// Existence-shaped phrases (title/summary only — the machine surface; the body is
|
|
53
|
+
// never parsed). Deliberately narrow: policy claims ("never hardcode") must not hit.
|
|
54
|
+
const ABSENCE_HEURISTIC =
|
|
55
|
+
/(does\s?n[o']?t exist|do not exist|no longer exists?|nowhere in|not (yet )?(implemented|built|wired|created|present)|only a stub|stubbed|unbuilt|unimplemented|nothing (reads|writes|calls|imports|consumes)|absent from)/i;
|
|
56
|
+
|
|
57
|
+
const strip = (s) => s.replace(/\s+#.*$/, "").replace(/^["']|["']$/g, "").trim();
|
|
26
58
|
|
|
27
59
|
function walk(dir) {
|
|
28
60
|
if (!existsSync(dir)) return [];
|
|
@@ -32,19 +64,33 @@ function walk(dir) {
|
|
|
32
64
|
});
|
|
33
65
|
}
|
|
34
66
|
|
|
35
|
-
function gitGrep(token) {
|
|
67
|
+
function gitGrep(token, cwd = process.cwd()) {
|
|
36
68
|
try {
|
|
37
|
-
const out = execSync(`git grep -nF -e ${JSON.stringify(token)}`, { encoding: "utf8" });
|
|
69
|
+
const out = execSync(`git grep -nF -e ${JSON.stringify(token)}`, { encoding: "utf8", cwd });
|
|
38
70
|
return out.split("\n").filter(Boolean).map((l) => {
|
|
39
71
|
const m = l.match(/^(.+?):(\d+):/);
|
|
40
72
|
return m ? { file: m[1], line: +m[2] } : null;
|
|
41
|
-
}).filter(Boolean)
|
|
73
|
+
}).filter(Boolean)
|
|
74
|
+
// The brain is never "code": exclude .rafa/** categorically so the checker
|
|
75
|
+
// can't self-collide with manifests/notes if .rafa is (accidentally) tracked.
|
|
76
|
+
.filter((h) => !h.file.startsWith(".rafa/"));
|
|
42
77
|
} catch (e) {
|
|
43
78
|
if (e.status === 1) return []; // git grep: no matches
|
|
44
79
|
throw e;
|
|
45
80
|
}
|
|
46
81
|
}
|
|
47
82
|
|
|
83
|
+
// Tracked files matching a glob — deterministic inventory ground truth.
|
|
84
|
+
// `:(glob)` pathspec magic makes `**` match across directories.
|
|
85
|
+
function gitLsGlob(glob, cwd = process.cwd()) {
|
|
86
|
+
try {
|
|
87
|
+
const out = execSync(`git ls-files -- ${JSON.stringify(`:(glob)${glob}`)}`, { encoding: "utf8", cwd });
|
|
88
|
+
return out.split("\n").filter(Boolean);
|
|
89
|
+
} catch {
|
|
90
|
+
return null; // bad pathspec → caller reports it as a failure, never guesses 0
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
48
94
|
function citeResolves(file, start, end, token) {
|
|
49
95
|
if (!existsSync(file)) return { ok: false, reason: "file missing" };
|
|
50
96
|
const src = readFileSync(file, "utf8").split("\n");
|
|
@@ -53,28 +99,81 @@ function citeResolves(file, start, end, token) {
|
|
|
53
99
|
return { ok: false, reason: `token not on ${start}${end !== start ? "-" + end : ""}` };
|
|
54
100
|
}
|
|
55
101
|
|
|
102
|
+
// coverage.md frontmatter `inventory:` block list → [{ name, glob, count }] + malformed lines.
|
|
103
|
+
function parseCoverageInventory(path) {
|
|
104
|
+
const entries = [];
|
|
105
|
+
const malformed = [];
|
|
106
|
+
if (!existsSync(path)) return { entries, malformed };
|
|
107
|
+
const lines = readFileSync(path, "utf8").split("\n");
|
|
108
|
+
let fence = 0;
|
|
109
|
+
let inBlock = false;
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
if (line.trim() === "---") { fence++; if (fence >= 2) break; continue; }
|
|
112
|
+
if (fence !== 1) continue;
|
|
113
|
+
if (/^inventory:\s*$/.test(line)) { inBlock = true; continue; }
|
|
114
|
+
if (inBlock) {
|
|
115
|
+
const item = line.match(/^\s+-\s+(.+?)\s*$/);
|
|
116
|
+
if (item) {
|
|
117
|
+
const m = strip(item[1]).match(INV_ENTRY);
|
|
118
|
+
if (m) entries.push({ name: m[1].trim(), glob: m[2].trim(), count: +m[3] });
|
|
119
|
+
else malformed.push(item[1]);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (/^\S/.test(line)) inBlock = false; // next top-level key ends the block
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { entries, malformed };
|
|
126
|
+
}
|
|
127
|
+
|
|
56
128
|
export function runVerifyCitations(argv = []) {
|
|
57
129
|
const arg = (k, d) => { const m = argv.find((a) => a.startsWith(`--${k}=`)); return m ? m.slice(k.length + 3) : d; };
|
|
58
130
|
const ROOT = arg("root", ".rafa/brain"); // --root=.rafa/improve for the improve ledger
|
|
59
131
|
const NOTE_DIRS = arg("dirs", "rules,playbooks").split(",").filter(Boolean); // --dirs=improvements
|
|
60
132
|
|
|
61
|
-
// --selftest: prove
|
|
62
|
-
//
|
|
63
|
-
//
|
|
133
|
+
// --selftest: prove each gate's logic on throwaway fixtures (no brain/repo pollution).
|
|
134
|
+
// prism runs this as its mutation probe — re-running the checker is not proof it still
|
|
135
|
+
// works; this is. Covers: resolution (good+bad cite) · absence (present token must
|
|
136
|
+
// flag, missing token must pass) · inventory (count drift must flag, exact must pass).
|
|
64
137
|
if (argv.includes("--selftest")) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
138
|
+
const results = [];
|
|
139
|
+
// resolution — pure file logic
|
|
140
|
+
{
|
|
141
|
+
const dir = mkdtempSync(join(tmpdir(), "vc-selftest-"));
|
|
142
|
+
const f = join(dir, "sample.txt");
|
|
143
|
+
writeFileSync(f, "alpha\nTOKEN beta\ngamma\n");
|
|
144
|
+
results.push(["resolution good cite", citeResolves(f, 2, 2, "TOKEN").ok === true]);
|
|
145
|
+
results.push(["resolution bad cite", citeResolves(f, 1, 1, "TOKEN").ok === false]);
|
|
146
|
+
rmSync(dir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
// absence + inventory — need a real (temp) git index
|
|
149
|
+
{
|
|
150
|
+
const dir = mkdtempSync(join(tmpdir(), "vc-selftest-git-"));
|
|
151
|
+
try {
|
|
152
|
+
execSync("git init -q", { cwd: dir });
|
|
153
|
+
mkdirSync(join(dir, "app", "x"), { recursive: true });
|
|
154
|
+
writeFileSync(join(dir, "app", "x", "page.tsx"), "export default function X() { return SELFTEST_PRESENT; }\n");
|
|
155
|
+
writeFileSync(join(dir, "app", "readme.md"), "SELFTEST_PRESENT in docs must not count\n");
|
|
156
|
+
execSync("git add -A", { cwd: dir });
|
|
157
|
+
const present = gitGrep("SELFTEST_PRESENT", dir).filter((h) => !h.file.endsWith(".md"));
|
|
158
|
+
const gone = gitGrep("SELFTEST_TRULY_ABSENT", dir).filter((h) => !h.file.endsWith(".md"));
|
|
159
|
+
results.push(["absence flags a present token", present.length === 1]); // code hit only, .md excluded
|
|
160
|
+
results.push(["absence passes a truly absent token", gone.length === 0]);
|
|
161
|
+
const found = gitLsGlob("app/**/page.tsx", dir);
|
|
162
|
+
results.push(["inventory exact count passes", (found ?? []).length === 1]);
|
|
163
|
+
results.push(["inventory drift flags", (found ?? []).length !== 2]);
|
|
164
|
+
} finally {
|
|
165
|
+
rmSync(dir, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const sane = results.every(([, ok]) => ok);
|
|
169
|
+
for (const [name, ok] of results) console.log(` ${ok ? "✓" : "✗"} ${name}`);
|
|
170
|
+
console.log(`checker self-test (v${CHECKER_VERSION}): ${sane ? "PASS" : "FAIL"}`);
|
|
73
171
|
return sane ? 0 : 1;
|
|
74
172
|
}
|
|
75
173
|
|
|
76
174
|
const notes = NOTE_DIRS.flatMap((d) => walk(join(ROOT, d)));
|
|
77
|
-
|
|
175
|
+
const coveragePath = join(ROOT, "coverage.md");
|
|
176
|
+
if (notes.length === 0 && !existsSync(coveragePath)) {
|
|
78
177
|
console.log(`No notes under ${ROOT}/{${NOTE_DIRS.join(",")}}/ — run a scan first.`);
|
|
79
178
|
return 0;
|
|
80
179
|
}
|
|
@@ -82,13 +181,19 @@ export function runVerifyCitations(argv = []) {
|
|
|
82
181
|
const resolution = []; // { note, loc, token, ok, reason }
|
|
83
182
|
const completeness = []; // { note, anchor, site, ok, reason }
|
|
84
183
|
const policy = []; // { note, ok, reason }
|
|
184
|
+
const absence = []; // { note, token, site, ok, reason }
|
|
185
|
+
const inventory = []; // { name, glob, declared, found, ok, reason, paths }
|
|
186
|
+
const warns = []; // { note, phrase } — heuristic, never fails the gate
|
|
85
187
|
|
|
86
188
|
for (const note of notes) {
|
|
87
189
|
const rel = note.replace(ROOT + "/", "");
|
|
88
190
|
const lines = readFileSync(note, "utf8").split("\n");
|
|
89
191
|
const cites = [];
|
|
90
192
|
const anchors = [];
|
|
193
|
+
const absents = [];
|
|
91
194
|
let noteType = "";
|
|
195
|
+
let title = "";
|
|
196
|
+
let summary = "";
|
|
92
197
|
let fence = 0;
|
|
93
198
|
for (const line of lines) {
|
|
94
199
|
if (line.trim() === "---") { fence++; if (fence >= 2) break; continue; }
|
|
@@ -96,9 +201,15 @@ export function runVerifyCitations(argv = []) {
|
|
|
96
201
|
const c = line.match(CITE);
|
|
97
202
|
if (c) { cites.push({ file: c[1], start: +c[2], end: c[3] ? +c[3] : +c[2], token: c[4] }); continue; }
|
|
98
203
|
const a = line.match(ANCHOR);
|
|
99
|
-
if (a) { anchors.push(a[1]
|
|
204
|
+
if (a) { anchors.push(strip(a[1])); continue; }
|
|
205
|
+
const ab = line.match(ABSENT);
|
|
206
|
+
if (ab) { absents.push(strip(ab[1])); continue; }
|
|
100
207
|
const t = line.match(TYPE);
|
|
101
|
-
if (t) noteType = t[1]
|
|
208
|
+
if (t) { noteType = strip(t[1]); continue; }
|
|
209
|
+
const ti = line.match(TITLE);
|
|
210
|
+
if (ti) { title = strip(ti[1]); continue; }
|
|
211
|
+
const s = line.match(SUMMARY);
|
|
212
|
+
if (s) summary = strip(s[1]);
|
|
102
213
|
}
|
|
103
214
|
|
|
104
215
|
// POLICY — every contract must declare an anchor (token or explicit `none`)
|
|
@@ -126,15 +237,56 @@ export function runVerifyCitations(argv = []) {
|
|
|
126
237
|
completeness.push({ note: rel, anchor, site: `${h.file}:${h.line}`, ok: covered, reason: covered ? "" : "grep hit not cited — site omitted" });
|
|
127
238
|
}
|
|
128
239
|
}
|
|
240
|
+
|
|
241
|
+
// ABSENCE (B3) — a declared absence must still be absent
|
|
242
|
+
for (const token of absents) {
|
|
243
|
+
if (token.toLowerCase() === "none") continue;
|
|
244
|
+
const hits = gitGrep(token).filter((h) => !h.file.endsWith(".md")); // docs excluded, same rule as completeness
|
|
245
|
+
if (hits.length === 0) {
|
|
246
|
+
absence.push({ note: rel, token, site: "(nowhere — as claimed)", ok: true, reason: "" });
|
|
247
|
+
} else {
|
|
248
|
+
for (const h of hits) {
|
|
249
|
+
absence.push({ note: rel, token, site: `${h.file}:${h.line}`, ok: false, reason: "claimed absent but exists — the claim went stale" });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// WARN heuristic — existence-shaped claim with no declared `absent:` token
|
|
255
|
+
if (absents.length === 0) {
|
|
256
|
+
const m = (title + " · " + summary).match(ABSENCE_HEURISTIC);
|
|
257
|
+
if (m) warns.push({ note: rel, phrase: m[0] });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// INVENTORY — coverage.md declared counts vs tracked reality
|
|
262
|
+
{
|
|
263
|
+
const { entries, malformed } = parseCoverageInventory(coveragePath);
|
|
264
|
+
for (const bad of malformed)
|
|
265
|
+
inventory.push({ name: "(malformed)", glob: bad, declared: NaN, found: NaN, ok: false, reason: "entry must be `<name> :: <glob> :: <count>`", paths: [] });
|
|
266
|
+
for (const e of entries) {
|
|
267
|
+
const paths = gitLsGlob(e.glob);
|
|
268
|
+
if (paths === null) {
|
|
269
|
+
inventory.push({ name: e.name, glob: e.glob, declared: e.count, found: NaN, ok: false, reason: "glob is not a valid git pathspec", paths: [] });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const ok = paths.length === e.count;
|
|
273
|
+
inventory.push({
|
|
274
|
+
name: e.name, glob: e.glob, declared: e.count, found: paths.length, ok,
|
|
275
|
+
reason: ok ? "" : `declared ${e.count}, found ${paths.length} — coverage is stale (refresh the scan)`,
|
|
276
|
+
paths: ok ? [] : paths.slice(0, 20),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
129
279
|
}
|
|
130
280
|
|
|
131
281
|
const rFail = resolution.filter((r) => !r.ok);
|
|
132
282
|
const cFail = completeness.filter((r) => !r.ok);
|
|
133
283
|
const pFail = policy.filter((r) => !r.ok);
|
|
134
|
-
const
|
|
284
|
+
const aFail = absence.filter((r) => !r.ok);
|
|
285
|
+
const iFail = inventory.filter((r) => !r.ok);
|
|
286
|
+
const fail = rFail.length + cFail.length + pFail.length + aFail.length + iFail.length;
|
|
135
287
|
|
|
136
288
|
const md = [
|
|
137
|
-
|
|
289
|
+
`# Citation check (generated — do not hand-edit) · checker v${CHECKER_VERSION}`,
|
|
138
290
|
"",
|
|
139
291
|
`## Resolution (B1): ${resolution.length - rFail.length}/${resolution.length} ✓`,
|
|
140
292
|
...resolution.map((r) => `${r.ok ? "✓" : "✗"} ${r.loc} :: ${r.token}${r.ok ? "" : " — " + r.reason + " [" + r.note + "]"}`),
|
|
@@ -145,12 +297,51 @@ export function runVerifyCitations(argv = []) {
|
|
|
145
297
|
`## Policy (contract → anchor declared): ${policy.length - pFail.length}/${policy.length} ✓`,
|
|
146
298
|
...policy.map((r) => `${r.ok ? "✓" : "✗"} ${r.note}${r.ok ? "" : " — " + r.reason}`),
|
|
147
299
|
"",
|
|
300
|
+
`## Absence (B3, declared \`absent:\` re-grepped): ${absence.length - aFail.length}/${absence.length} ✓`,
|
|
301
|
+
...absence.map((r) => `${r.ok ? "✓" : "✗"} absent '${r.token}' → ${r.site}${r.ok ? "" : " — " + r.reason + " [" + r.note + "]"}`),
|
|
302
|
+
"",
|
|
303
|
+
`## Inventory (coverage declared vs \`git ls-files\`): ${inventory.length - iFail.length}/${inventory.length} ✓`,
|
|
304
|
+
...inventory.flatMap((r) => [
|
|
305
|
+
`${r.ok ? "✓" : "✗"} ${r.name} '${r.glob}' declared ${r.declared} · found ${r.found}${r.ok ? "" : " — " + r.reason}`,
|
|
306
|
+
...r.paths.map((p) => ` · ${p}`),
|
|
307
|
+
]),
|
|
308
|
+
"",
|
|
309
|
+
`## Warns (heuristic, non-failing — existence-shaped title/summary with no \`absent:\` declared): ${warns.length}`,
|
|
310
|
+
...warns.map((w) => `⚠ ${w.note} — reads as an absence claim ("${w.phrase}") — declare \`absent: <token>\` so the gate can re-grep it, or reword`),
|
|
311
|
+
"",
|
|
148
312
|
fail ? `**${fail} FAILED.**` : "**All pass.**",
|
|
149
313
|
"",
|
|
150
314
|
].join("\n");
|
|
151
315
|
writeFileSync(join(ROOT, "citation-check.md"), md);
|
|
152
316
|
|
|
317
|
+
// Machine record of THIS run — compile folds it into manifest.citations so the
|
|
318
|
+
// platform knows the gate level a brain passed. Recorded, never assumed.
|
|
319
|
+
writeFileSync(
|
|
320
|
+
join(ROOT, "citation-check.json"),
|
|
321
|
+
JSON.stringify(
|
|
322
|
+
{
|
|
323
|
+
checkerVersion: CHECKER_VERSION,
|
|
324
|
+
at: new Date().toISOString(),
|
|
325
|
+
pass: fail === 0,
|
|
326
|
+
gates: {
|
|
327
|
+
resolution: { pass: resolution.length - rFail.length, total: resolution.length },
|
|
328
|
+
completeness: { pass: completeness.length - cFail.length, total: completeness.length },
|
|
329
|
+
policy: { pass: policy.length - pFail.length, total: policy.length },
|
|
330
|
+
absence: { pass: absence.length - aFail.length, total: absence.length },
|
|
331
|
+
inventory: { pass: inventory.length - iFail.length, total: inventory.length },
|
|
332
|
+
},
|
|
333
|
+
warns: warns.length,
|
|
334
|
+
},
|
|
335
|
+
null,
|
|
336
|
+
2,
|
|
337
|
+
) + "\n",
|
|
338
|
+
);
|
|
339
|
+
|
|
153
340
|
console.log(md);
|
|
154
|
-
console.log(
|
|
341
|
+
console.log(
|
|
342
|
+
`resolution ${resolution.length - rFail.length}/${resolution.length} · completeness ${completeness.length - cFail.length}/${completeness.length} · policy ${policy.length - pFail.length}/${policy.length} · absence ${absence.length - aFail.length}/${absence.length} · inventory ${inventory.length - iFail.length}/${inventory.length}` +
|
|
343
|
+
(warns.length ? ` · ${warns.length} warn(s)` : "") +
|
|
344
|
+
(fail ? ` · ${fail} FAILED` : " · all pass"),
|
|
345
|
+
);
|
|
155
346
|
return fail ? 1 : 0;
|
|
156
347
|
}
|
package/lib/githook.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The git pre-push hook installer — M5 sensor #3 (the deterministic checkpoint
|
|
2
|
+
// boundary). `git push` of the code branch is a ratified natural checkpoint
|
|
3
|
+
// moment; this hook makes it MECHANICAL instead of SOP-remembered.
|
|
4
|
+
//
|
|
5
|
+
// .git/hooks is per-clone (git never versions it), so this runs at init AND
|
|
6
|
+
// pull (the teammate path) AND update — every working copy gets the sensor.
|
|
7
|
+
// Never clobbers a foreign hook: ours is recognized by its marker line; anything
|
|
8
|
+
// else is respected and the chain-line printed for the dev to add themselves.
|
|
9
|
+
|
|
10
|
+
import { chmodSync, copyFileSync, existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const MARKER = "rafa pre-push hook";
|
|
15
|
+
const CHAIN_LINE = "npx -y @rafinery/cli checkpoint || true";
|
|
16
|
+
|
|
17
|
+
export function installPrePush(targetDir) {
|
|
18
|
+
const src = join(targetDir, ".claude", "rafa", "hooks", "pre-push");
|
|
19
|
+
if (!existsSync(src)) return { skipped: "no vendored hook template (run `rafa update`)" };
|
|
20
|
+
|
|
21
|
+
let hooksDir;
|
|
22
|
+
try {
|
|
23
|
+
// --git-path resolves worktrees/submodules correctly; never assume .git is a dir.
|
|
24
|
+
hooksDir = execSync("git rev-parse --git-path hooks", { cwd: targetDir, encoding: "utf8" }).trim();
|
|
25
|
+
hooksDir = join(targetDir, hooksDir);
|
|
26
|
+
} catch {
|
|
27
|
+
return { skipped: "not a git repo" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const dst = join(hooksDir, "pre-push");
|
|
31
|
+
if (existsSync(dst)) {
|
|
32
|
+
const body = readFileSync(dst, "utf8");
|
|
33
|
+
if (!body.includes(MARKER)) {
|
|
34
|
+
return {
|
|
35
|
+
skipped:
|
|
36
|
+
"a pre-push hook already exists (not rafa's) — to chain the checkpoint boundary, " +
|
|
37
|
+
`add this line to it yourself: ${CHAIN_LINE}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// ours → refresh in place (idempotent update)
|
|
41
|
+
copyFileSync(src, dst);
|
|
42
|
+
chmodSync(dst, 0o755);
|
|
43
|
+
return { updated: true };
|
|
44
|
+
}
|
|
45
|
+
copyFileSync(src, dst);
|
|
46
|
+
chmodSync(dst, 0o755);
|
|
47
|
+
return { installed: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function reportGitHook(r) {
|
|
51
|
+
if (r.installed) console.log(" ✓ git pre-push hook → checkpoint runs at every code push (non-blocking)");
|
|
52
|
+
else if (r.updated) console.log(" ✓ git pre-push hook refreshed");
|
|
53
|
+
else if (r.skipped) console.log(` ! git pre-push hook not installed — ${r.skipped}`);
|
|
54
|
+
}
|
package/lib/init.mjs
CHANGED
|
@@ -105,6 +105,31 @@ export default async function init(args) {
|
|
|
105
105
|
);
|
|
106
106
|
// Grant rafa's tools their permissions (merge, never overwrite).
|
|
107
107
|
reportSettings(TARGET);
|
|
108
|
+
// Wire the M5 sensor hooks (SessionStart digest + PostToolUse dirty-marker)
|
|
109
|
+
// into .claude/settings.json — merge, never clobber the dev's own hooks.
|
|
110
|
+
{
|
|
111
|
+
const { mergeClaudeHooks, mergeStatusLine } = await import("./claude-config.mjs");
|
|
112
|
+
const h = mergeClaudeHooks(TARGET);
|
|
113
|
+
if (h.skipped) console.log(` ! hooks untouched — ${h.skipped}`);
|
|
114
|
+
else if (h.added?.length) console.log(` ✓ sensor hooks → .claude/settings.json (${h.added.join(", ")})`);
|
|
115
|
+
else console.log(" ✓ sensor hooks already wired");
|
|
116
|
+
const sl = mergeStatusLine(TARGET);
|
|
117
|
+
if (sl.skipped) console.log(` ! statusline untouched — ${sl.skipped}`);
|
|
118
|
+
else if (sl.added?.length) console.log(" ✓ loop-state statusline → .claude/settings.json");
|
|
119
|
+
else console.log(" ✓ loop-state statusline already wired");
|
|
120
|
+
}
|
|
121
|
+
// The pre-push checkpoint boundary is per-clone (.git/hooks is never committed).
|
|
122
|
+
{
|
|
123
|
+
const { installPrePush, reportGitHook } = await import("./githook.mjs");
|
|
124
|
+
reportGitHook(installPrePush(TARGET));
|
|
125
|
+
}
|
|
126
|
+
// Wire the repo's own MCP servers into the (owned) agent cards so subagents
|
|
127
|
+
// can actually reach them (zero-command orchestration, 2026-07-12).
|
|
128
|
+
{
|
|
129
|
+
const { mergeRepoMcpsIntoCards } = await import("./claude-config.mjs");
|
|
130
|
+
const r = mergeRepoMcpsIntoCards(TARGET);
|
|
131
|
+
for (const u of r.updated) console.log(` ✓ repo MCPs → ${u}`);
|
|
132
|
+
}
|
|
108
133
|
|
|
109
134
|
// Keep the brain out of the code repo's diffs; keep the per-dev MCP key out of git.
|
|
110
135
|
const ensureIgnored = (line, label) => {
|
|
@@ -151,6 +176,26 @@ export default async function init(args) {
|
|
|
151
176
|
);
|
|
152
177
|
}
|
|
153
178
|
|
|
179
|
+
// CI reconciliation — OPT-IN at provisioning (owner feedback #5): the workflow
|
|
180
|
+
// runs on the ORG's own secrets (their LLM key never touches our platform), so
|
|
181
|
+
// it is offered, never silently installed. Non-TTY (CI/agent shells) skips the
|
|
182
|
+
// question entirely — explicit `rafa ci-setup` remains the path there.
|
|
183
|
+
if (isInteractive()) {
|
|
184
|
+
const a = await ask(
|
|
185
|
+
" • Wire CI reconciliation now? (GitHub Actions: mechanical fold on branch merges,\n" +
|
|
186
|
+
" distillation on merge-to-main — runs on YOUR CI secrets) [y/N] ",
|
|
187
|
+
);
|
|
188
|
+
if (a === "y" || a === "yes") {
|
|
189
|
+
const { default: ciSetup } = await import("./ci-setup.mjs");
|
|
190
|
+
await ciSetup([]);
|
|
191
|
+
} else {
|
|
192
|
+
console.log(
|
|
193
|
+
" • skipped — wire it anytime with `rafa ci-setup`. Until then, the next dev\n" +
|
|
194
|
+
" session offers the same reconciliation after each merge (nothing is lost).",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
154
199
|
// Stamp the versions this repo was provisioned at — the compat anchor `update`/`migrate`
|
|
155
200
|
// diff against. Carries platform identity too when provisioned via a setup URL.
|
|
156
201
|
stampCurrent(
|
package/lib/leverage.mjs
CHANGED
|
@@ -3,12 +3,23 @@
|
|
|
3
3
|
// only: it reads, ranks, and reports — it changes nothing. Run from your repo root.
|
|
4
4
|
|
|
5
5
|
import { advise } from "./leverage/engine.mjs";
|
|
6
|
-
import { PLANNED } from "./leverage/adapters/index.mjs";
|
|
6
|
+
import { adapters, PLANNED } from "./leverage/adapters/index.mjs";
|
|
7
7
|
|
|
8
|
-
export default async function leverage() {
|
|
8
|
+
export default async function leverage(args = []) {
|
|
9
9
|
const cwd = process.cwd();
|
|
10
10
|
const { detected, tips } = advise(cwd);
|
|
11
11
|
|
|
12
|
+
// --json: the DETERMINISTIC toolbox inventory (ratified 2026-07-12) — what
|
|
13
|
+
// the conductor loads at bootstrap and atlas consults during scan's toolbox
|
|
14
|
+
// pass. Machine truth: only what is actually installed, never inferred.
|
|
15
|
+
if (args.includes("--json")) {
|
|
16
|
+
const inventory = {};
|
|
17
|
+
for (const a of adapters)
|
|
18
|
+
if (a.detect(cwd)) inventory[a.id] = a.inspect(cwd);
|
|
19
|
+
console.log(JSON.stringify({ detected, inventory, tips }, null, 2));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
if (detected.length === 0) {
|
|
13
24
|
console.log(
|
|
14
25
|
"No supported agent toolchain detected here.\n" +
|
package/lib/migrations/index.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// run: (cwd) => backfillPlanField(cwd, "owner", ownerOf(cwd)),
|
|
29
29
|
// };
|
|
30
30
|
|
|
31
|
-
import { existsSync, rmSync } from "node:fs";
|
|
31
|
+
import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
32
32
|
import { join } from "node:path";
|
|
33
33
|
import { cmpSemver } from "../releases.mjs";
|
|
34
34
|
|
|
@@ -66,7 +66,62 @@ const blueprint_split_0_4_0 = {
|
|
|
66
66
|
},
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
// plans v2 (contract §7 v2, ratified 2026-07-12): the work-item tree. Mechanical
|
|
70
|
+
// half: kind parent→epic, child→task (rank semantics unchanged for flat v1 plans),
|
|
71
|
+
// stored `status: blocked` → `status: todo` + a blocked_reason note (blocked is
|
|
72
|
+
// DERIVED in v2). Only frontmatter lines are touched; bodies never.
|
|
73
|
+
const plans_v1_to_v2 = {
|
|
74
|
+
id: "plans-v1-to-v2-work-item-tree",
|
|
75
|
+
kind: "plans",
|
|
76
|
+
mode: "mechanical",
|
|
77
|
+
summary:
|
|
78
|
+
"plans become a work-item tree (epic|task|subtask); blocked becomes derived " +
|
|
79
|
+
"(kind parent→epic, child→task; status blocked→todo + blocked_reason)",
|
|
80
|
+
appliesTo: (from, to) => from.plans < 2 && to.plans >= 2,
|
|
81
|
+
run: (cwd) => {
|
|
82
|
+
const changed = [];
|
|
83
|
+
const notes = [];
|
|
84
|
+
const dir = join(cwd, ".rafa", "plans");
|
|
85
|
+
const walk = (d) => {
|
|
86
|
+
if (!existsSync(d)) return [];
|
|
87
|
+
return readdirSync(d).flatMap((e) => {
|
|
88
|
+
const p = join(d, e);
|
|
89
|
+
return statSync(p).isDirectory() ? walk(p) : e.endsWith(".md") ? [p] : [];
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
for (const file of walk(dir)) {
|
|
93
|
+
const text = readFileSync(file, "utf8");
|
|
94
|
+
if (!text.startsWith("---")) continue;
|
|
95
|
+
const end = text.indexOf("\n---", 3);
|
|
96
|
+
if (end === -1) continue;
|
|
97
|
+
let fm = text.slice(0, end);
|
|
98
|
+
const body = text.slice(end);
|
|
99
|
+
const before = fm;
|
|
100
|
+
fm = fm.replace(/^kind:\s*parent\s*$/m, "kind: epic");
|
|
101
|
+
fm = fm.replace(/^kind:\s*child\s*$/m, "kind: task");
|
|
102
|
+
if (/^status:\s*blocked\s*$/m.test(fm)) {
|
|
103
|
+
fm = fm.replace(/^status:\s*blocked\s*$/m, "status: todo");
|
|
104
|
+
if (!/^blocked_reason:/m.test(fm))
|
|
105
|
+
fm += `\nblocked_reason: "(migrated) was 'blocked' in plans v1 — re-state the real blocker"`;
|
|
106
|
+
notes.push(`${file.slice(cwd.length + 1)}: blocked → todo + blocked_reason (re-state the real blocker)`);
|
|
107
|
+
}
|
|
108
|
+
if (fm !== before) {
|
|
109
|
+
writeFileSync(file, fm + body);
|
|
110
|
+
changed.push(file.slice(cwd.length + 1));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
changed,
|
|
115
|
+
notes: changed.length
|
|
116
|
+
? notes.length
|
|
117
|
+
? notes
|
|
118
|
+
: ["kinds re-ranked (parent→epic, child→task); review the diff"]
|
|
119
|
+
: ["no v1 plan files found — nothing to migrate"],
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const MIGRATIONS = [blueprint_split_0_4_0, plans_v1_to_v2];
|
|
70
125
|
|
|
71
126
|
// The migrations that apply to a given move (stamped `from` → shipped `to`), in order.
|
|
72
127
|
export function pendingMigrations(from, to) {
|
package/lib/pull.mjs
CHANGED
|
@@ -32,6 +32,13 @@ export default async function pull(args = []) {
|
|
|
32
32
|
const inRafa = inDir(rafaDir);
|
|
33
33
|
if (bootstrapped) console.log(`• bootstrapped lazy .rafa/ from rafa.json (remote: ${remote})`);
|
|
34
34
|
|
|
35
|
+
// Every working copy gets the M5 checkpoint boundary (.git/hooks is per-clone —
|
|
36
|
+
// pull IS the teammate path, so this is where a fresh clone gains the sensor).
|
|
37
|
+
{
|
|
38
|
+
const { installPrePush, reportGitHook } = await import("./githook.mjs");
|
|
39
|
+
reportGitHook(installPrePush(ROOT));
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
// Reachability + default branch — loud if the remote can't be reached.
|
|
36
43
|
let branch = "main";
|
|
37
44
|
try {
|
package/lib/push.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { execSync } from "node:child_process";
|
|
|
13
13
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { runCompile } from "./gate/compile.mjs";
|
|
16
|
+
import { runVerifyCitations } from "./gate/verify-citations.mjs";
|
|
16
17
|
import { ensureBrainRepo, remoteDefaultBranch, inDir, die } from "./brain-repo.mjs";
|
|
17
18
|
import { CLI_VERSION } from "./releases.mjs";
|
|
18
19
|
|
|
@@ -39,6 +40,26 @@ export default async function push(args = []) {
|
|
|
39
40
|
/* no origin — manifest.repo stays "" */
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// ── citation gate (in-process) ──
|
|
44
|
+
// Re-run the checker HERE so the record that rides the push is the record of
|
|
45
|
+
// THIS push — a stale/hand-stamped citation-check.json can never ship
|
|
46
|
+
// (mechanized 2026-07-13 from prism's live-run recommendation: the stale-record
|
|
47
|
+
// class dies at the transport layer, not in review).
|
|
48
|
+
console.log("• rafa verify-citations — re-grounding every cite/absence/inventory …");
|
|
49
|
+
if (runVerifyCitations([]) !== 0)
|
|
50
|
+
die(
|
|
51
|
+
"citations failed the checker (see report above). Fix the notes against the " +
|
|
52
|
+
"code (or have atlas repair them), then re-push.",
|
|
53
|
+
);
|
|
54
|
+
if (
|
|
55
|
+
existsSync(join(ROOT, ".rafa", "improve", "improvements")) &&
|
|
56
|
+
runVerifyCitations(["--root=.rafa/improve", "--dirs=improvements"]) !== 0
|
|
57
|
+
)
|
|
58
|
+
die(
|
|
59
|
+
"improvement citations failed the checker (see report above). Fix the ledger " +
|
|
60
|
+
"files against the code (or have bloom correct them), then re-push.",
|
|
61
|
+
);
|
|
62
|
+
|
|
42
63
|
// ── contract gate (in-process) ──
|
|
43
64
|
// Compile + validate the brain. A schema-invalid brain NEVER leaves the machine —
|
|
44
65
|
// the platform ingests JSON, so what we push must already conform to the contract.
|