@rafinery/cli 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/bin/rafa-distiller.mjs +13 -0
  3. package/bin/rafa.mjs +9 -8
  4. package/blueprint/.claude/agents/atlas.md +5 -1
  5. package/blueprint/.claude/agents/bloom.md +5 -1
  6. package/blueprint/.claude/agents/compass.md +5 -1
  7. package/blueprint/.claude/agents/prism.md +5 -1
  8. package/blueprint/.claude/agents/sage.md +5 -1
  9. package/blueprint/.claude/commands/rafa.md +20 -2
  10. package/blueprint/.claude/rafa/contract.md +31 -3
  11. package/blueprint/.claude/rafa/hooks/brain-commit.mjs +81 -0
  12. package/blueprint/.claude/rafa/hooks/brain-map.mjs +64 -0
  13. package/blueprint/.claude/rafa/hooks/brain-switch.mjs +48 -0
  14. package/blueprint/.claude/rafa/hooks/post-checkout +10 -0
  15. package/blueprint/.claude/rafa/hooks/post-commit +11 -0
  16. package/blueprint/.claude/rafa/hooks/post-rewrite +10 -0
  17. package/blueprint/.claude/rafa/hooks/pre-push +22 -0
  18. package/blueprint/.claude/rafa/hooks/session-start.mjs +93 -0
  19. package/blueprint/.claude/skills/rafa-improve/SKILL.md +5 -0
  20. package/blueprint/.claude/skills/rafa-scan/SKILL.md +1 -1
  21. package/blueprint/.claude/skills/rafa-validate/SKILL.md +6 -0
  22. package/lib/checkpoint.mjs +42 -0
  23. package/lib/ci-setup.mjs +54 -3
  24. package/lib/dirty.mjs +37 -0
  25. package/lib/distill.mjs +313 -37
  26. package/lib/distiller/doctrine.mjs +379 -0
  27. package/lib/distiller/state-plane.mjs +159 -0
  28. package/lib/distiller.mjs +410 -0
  29. package/lib/doctor.mjs +110 -0
  30. package/lib/gate/compile.mjs +31 -0
  31. package/lib/gate/verify-citations.mjs +9 -2
  32. package/lib/githook.mjs +75 -33
  33. package/lib/init.mjs +22 -23
  34. package/lib/mcp-client.mjs +22 -5
  35. package/lib/pull.mjs +2 -2
  36. package/lib/push.mjs +67 -2
  37. package/lib/releases.mjs +13 -0
  38. package/lib/sensor-health.mjs +91 -0
  39. package/lib/status.mjs +47 -1
  40. package/lib/update.mjs +2 -2
  41. package/lib/working-set.mjs +19 -1
  42. package/package.json +12 -10
  43. package/LICENSE +0 -21
@@ -12,6 +12,7 @@
12
12
  // (get_code_context batch, ≤ 2s, fail-soft to local counts). Never blocks a
13
13
  // session: every path exits 0. Honors RAFA_HOOKS_DISABLED=1.
14
14
 
15
+ import { execSync } from "node:child_process";
15
16
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
16
17
  import { homedir } from "node:os";
17
18
  import { join } from "node:path";
@@ -45,6 +46,22 @@ function dirtyEntries(rafaDir) {
45
46
  return [...files.keys()];
46
47
  }
47
48
 
49
+ // OPEN improvements whose cited files are dirty — fixed-in-passing candidates
50
+ // (the ledger lies until each is verified + report_improvement_status'd; this
51
+ // nag is deterministic code, not SOP adherence). null = no local manifest.
52
+ function openFixCandidates(rafaDir, files) {
53
+ const m = readJson(join(rafaDir, "manifest.json"));
54
+ if (!m || !Array.isArray(m.improvements)) return null;
55
+ const want = new Set(files);
56
+ const out = [];
57
+ for (const i of m.improvements) {
58
+ if (i.status !== "open") continue;
59
+ const hits = [...new Set((i.cites ?? []).filter((c) => want.has(c.file)).map((c) => c.file))];
60
+ if (hits.length) out.push({ id: i.id, priority: i.priority });
61
+ }
62
+ return out;
63
+ }
64
+
48
65
  // file → citing note ids, from the LOCAL manifest (a full pull / prior compile).
49
66
  function citersFromManifest(rafaDir, files) {
50
67
  const m = readJson(join(rafaDir, "manifest.json"));
@@ -149,6 +166,58 @@ function conflictCopies(rafaDir) {
149
166
  return out;
150
167
  }
151
168
 
169
+ // Capture-machinery self-check (P0, owner ask 2026-07-18): a session that
170
+ // starts — or resumes — on broken sensors must SAY so and hand the dev next
171
+ // steps, not silently lose its knowledge. Wiring + recorded failures only;
172
+ // never a network call inside a hook (doctor does the round-trip on demand).
173
+ function machineryProblems(root, rafaDir) {
174
+ const problems = [];
175
+ let settings = null;
176
+ try {
177
+ settings = JSON.parse(readFileSync(join(root, ".claude", "settings.json"), "utf8"));
178
+ } catch {
179
+ /* handled by the wired() checks below */
180
+ }
181
+ const wired = (event, script) =>
182
+ JSON.stringify(settings?.hooks?.[event] ?? []).includes(`rafa/hooks/${script}`) &&
183
+ existsSync(join(root, ".claude", "rafa", "hooks", script));
184
+ if (!wired("PostToolUse", "post-tool.mjs")) problems.push("dirty-marker unwired");
185
+ if (!wired("UserPromptSubmit", "user-prompt-submit.mjs")) problems.push("steering unwired");
186
+ let hooksDir = join(root, ".git", "hooks");
187
+ try {
188
+ // --git-path resolves worktrees correctly; plain .git/hooks is the fallback.
189
+ hooksDir = join(
190
+ root,
191
+ execSync("git rev-parse --git-path hooks", {
192
+ cwd: root,
193
+ encoding: "utf8",
194
+ stdio: ["ignore", "pipe", "ignore"],
195
+ }).trim(),
196
+ );
197
+ } catch {
198
+ /* plain .git/hooks fallback */
199
+ }
200
+ for (const h of ["pre-push", "post-commit", "post-checkout", "post-rewrite"]) {
201
+ try {
202
+ if (!readFileSync(join(hooksDir, h), "utf8").includes("rafa"))
203
+ problems.push(`git ${h} hook is foreign (chain line missing)`);
204
+ } catch {
205
+ problems.push(`git ${h} hook missing`);
206
+ }
207
+ }
208
+ try {
209
+ const rows = readFileSync(join(rafaDir, "sensor-errors.jsonl"), "utf8")
210
+ .split("\n")
211
+ .filter(Boolean);
212
+ const last = rows.length ? JSON.parse(rows[rows.length - 1]) : null;
213
+ if (last?.t && Date.now() - last.t < 48 * 3600 * 1000)
214
+ problems.push(`recent swallowed failure: ${last.error ?? last.hook}`);
215
+ } catch {
216
+ /* no recorded failures */
217
+ }
218
+ return problems;
219
+ }
220
+
152
221
  try {
153
222
  if (process.env.RAFA_HOOKS_DISABLED === "1") process.exit(0);
154
223
  const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -157,6 +226,16 @@ try {
157
226
 
158
227
  const lines = [];
159
228
 
229
+ const machinery = machineryProblems(root, rafaDir);
230
+ if (machinery.length)
231
+ lines.push(
232
+ `[rafa · doctor] capture machinery is NOT fully live: ` +
233
+ machinery.slice(0, 4).join(" · ") +
234
+ (machinery.length > 4 ? ` (+${machinery.length - 4} more)` : "") +
235
+ ` — run \`npx -y @rafinery/cli doctor\` FIRST and relay its named fixes to the dev as ` +
236
+ `concrete next steps; until fixed, this session's knowledge may be silently lost.`,
237
+ );
238
+
160
239
  const dirty = dirtyEntries(rafaDir);
161
240
  if (dirty.length) {
162
241
  let byNote = citersFromManifest(rafaDir, dirty);
@@ -179,6 +258,20 @@ try {
179
258
  );
180
259
  }
181
260
 
261
+ if (dirty.length) {
262
+ const candidates = openFixCandidates(rafaDir, dirty);
263
+ if (candidates && candidates.length)
264
+ lines.push(
265
+ `[rafa · ledger] ${candidates.length} OPEN improvement(s) cite dirty files — possible fixed-in-passing: ` +
266
+ candidates
267
+ .slice(0, 6)
268
+ .map((c) => `${c.priority} ${c.id}`)
269
+ .join(" · ") +
270
+ (candidates.length > 6 ? " …" : "") +
271
+ ` — verify each; fixed → report_improvement_status(id, fixed). An unreported fix leaves the ledger lying.`,
272
+ );
273
+ }
274
+
182
275
  const corrections = pendingCorrections(rafaDir);
183
276
  if (corrections.length)
184
277
  lines.push(
@@ -5,6 +5,11 @@ description: "rafa SOP — bloom's continuous-improvement pass: multi-lens, cite
5
5
 
6
6
  # improve — continuous improvement (capability #2)
7
7
 
8
+ > **First worklist, every pass:** `list_improvements` and treat every `reported`
9
+ > overlay (a live session's fixed-in-passing signal) as a verification candidate —
10
+ > confirm against the code, update the row through the gates. A report stays
11
+ > pending until the ROW catches up; only this pass makes it converge.
12
+
8
13
  rafa's second mission: **drive the codebase worst → well-managed, compounding via work
9
14
  the dev is already doing.** A living, prioritized, cited **improvement ledger** — not a throwaway
10
15
  report. Kept in `.rafa/improve/`, **never** in `.rafa/brain/` (knowledge and assessment
@@ -254,7 +254,7 @@ pipeline; **`--brain-only`** stops after the brain is validated (step 5 PASS)
254
254
  spawn `bloom` → `.rafa/improve/`. It reads the *validated* brain as its index, so it only
255
255
  runs after PASS.
256
256
  7. **Push** — present the full summary (brain verdict/score + top improvements). **On the
257
- dev's explicit approval**, `npx @rafinery/cli push` — commits `.rafa/` and pushes to the
257
+ dev's explicit approval**, `npx @rafinery/cli push --verb=scan` — commits `.rafa/` and pushes to the
258
258
  brain remote using the dev's own git auth, stamped `brain-for: <code sha>`. Never without approval.
259
259
  8. **The coach offer (founding scan only)** — if this was the repo's FIRST scan and the dev's
260
260
  user brain is empty (`list_dev_insights` → none), offer ONCE: *"the code side is mapped —
@@ -31,6 +31,12 @@ agent. So:
31
31
 
32
32
  ## Procedure (run in order; ground everything in code)
33
33
 
34
+ 0. **Prove the machinery first** — `npx -y @rafinery/cli doctor` (capture-engine P0). A
35
+ brain can only stay valid if capture is alive: sensors wired, hook scripts parse, the
36
+ heartbeat lands. Doctor failing is NOT a brain blocker (the brain may still be
37
+ faithful) — record it as a named finding in the checklist's health notes with
38
+ doctor's own fixes as the next steps; the dev must see a dead capture chain in the
39
+ report card, not discover it at the next empty distill.
34
40
  1. **Re-run the checker yourself** — `npx @rafinery/cli verify-citations`. Record exit
35
41
  code + counts (resolution / completeness / policy / **absence / inventory** — checker
36
42
  v2 mechanizes the 2026-06-08 ratchet: declared `absent:` tokens re-grepped, coverage
@@ -40,6 +40,16 @@ export default async function checkpoint() {
40
40
  } catch {
41
41
  die("not a git repo — run from your code repo root");
42
42
  }
43
+ // P0 heartbeat — sensor liveness must reach the PLATFORM, not just a dev who
44
+ // thinks to ask (`rafa status`). Fires on every checkpoint, every branch,
45
+ // BEFORE the trunk guard; best-effort — telemetry never blocks the job.
46
+ try {
47
+ const { collectSensorHealth } = await import("./sensor-health.mjs");
48
+ await callTool(ROOT, "report_sensor_health", collectSensorHealth(ROOT));
49
+ } catch {
50
+ /* unprovisioned repo or platform unreachable — the checkpoint continues */
51
+ }
52
+
43
53
  // The working set is BRANCH state. On the trunk, knowledge changes go
44
54
  // through scan → compile → rafa push (the org brain's one writer surface).
45
55
  if (branch === "main" || branch === "master") {
@@ -49,6 +59,21 @@ export default async function checkpoint() {
49
59
  return;
50
60
  }
51
61
 
62
+ // The commit this capture is grounded against (contract: capturedAtSha —
63
+ // absent = unknown grounding). One HEAD per checkpoint push; the merge-time
64
+ // ancestry sweep keys on it, so a missing sha only narrows that sweep,
65
+ // never blocks the checkpoint.
66
+ let capturedAtSha = "";
67
+ try {
68
+ capturedAtSha = execSync("git rev-parse HEAD", {
69
+ cwd: ROOT,
70
+ encoding: "utf8",
71
+ stdio: ["ignore", "pipe", "ignore"],
72
+ }).trim();
73
+ } catch {
74
+ /* unborn HEAD (fresh repo) — sync without grounding */
75
+ }
76
+
52
77
  const sidecar = readSidecar(ROOT);
53
78
  const syncBranch = (sidecar.sync[branch] = sidecar.sync[branch] ?? {});
54
79
 
@@ -76,6 +101,22 @@ export default async function checkpoint() {
76
101
 
77
102
  if (candidates.length === 0) {
78
103
  console.log(`✓ working set clean on ${branch} — nothing to checkpoint`);
104
+ // Knowledge-absence nudge (live-catch 2026-07-17: a session can do real
105
+ // work and bank ZERO knowledge, silently — no sensor fires on absence).
106
+ // Deterministic signal only: code edits recorded, no brain delta on this
107
+ // branch. Non-blocking, one line — the human decides if it's a real gap.
108
+ try {
109
+ const { readDirty } = await import("./dirty.mjs");
110
+ const dirtyCount = readDirty(ROOT).files.length;
111
+ if (dirtyCount > 0)
112
+ console.log(
113
+ `⚑ ${dirtyCount} code edit(s) recorded this cycle, ZERO brain files captured on ${branch} — ` +
114
+ `if decisions/gotchas/contracts were learned, they die with the session. ` +
115
+ `Worth a note? (notes, improvement files, and intent records all ride \`rafa checkpoint\`)`,
116
+ );
117
+ } catch {
118
+ /* nudge is best-effort — never block a checkpoint */
119
+ }
79
120
  return;
80
121
  }
81
122
 
@@ -84,6 +125,7 @@ export default async function checkpoint() {
84
125
  try {
85
126
  payload = await callTool(ROOT, "checkpoint_sync", {
86
127
  branch,
128
+ ...(capturedAtSha ? { capturedAtSha } : {}),
87
129
  // WHO/WHAT executed — the CLI knows itself; the MODEL is the session's to
88
130
  // report (RAFA_ACTOR_MODEL env, set by the conductor) — never guessed.
89
131
  actorMeta: {
package/lib/ci-setup.mjs CHANGED
@@ -23,9 +23,13 @@ const WORKFLOW = `# rafa reconcile — knowledge propagates exactly like the cod
23
23
  # RAFA_BRAIN_TOKEN a token with push access to the BRAIN repo (distill only)
24
24
  #
25
25
  # Fires on the merge EVENT itself (PR closed+merged), so squash/rebase merges are
26
- # detected correctly git ancestry is never consulted. If distillation cannot run
27
- # (missing secret, failed gate) it fails LOUD and the branch's working set stays
28
- # intactthe next dev session gets the standard distill offer (fallback chain).
26
+ # detected correctly. The distill job ALSO runs an ancestry sweep: any still-active
27
+ # working-set row (any branch) whose capturedAtSha is reachable from merged main is
28
+ # provably-merged backlog an earlier merge whose distill never ran, or a direct
29
+ # push — and folds into the same run. The sweep needs full history (fetch-depth: 0).
30
+ # If distillation cannot run (missing secret, failed gate) it fails LOUD and the
31
+ # branch's working set stays intact — the next dev session gets the standard
32
+ # distill offer (fallback chain).
29
33
 
30
34
  name: rafa reconcile
31
35
 
@@ -58,6 +62,7 @@ jobs:
58
62
  - uses: actions/checkout@v4
59
63
  with:
60
64
  ref: \${{ github.event.repository.default_branch }} # merged main = the validation target
65
+ fetch-depth: 0 # full history — the ancestry sweep proves prior merges
61
66
  - uses: actions/setup-node@v4
62
67
  with:
63
68
  node-version: 20
@@ -76,13 +81,52 @@ jobs:
76
81
  RAFA_BRAIN_TOKEN: \${{ secrets.RAFA_BRAIN_TOKEN }}
77
82
  RAFA_AGENT_SDK_DIR: \${{ runner.temp }}/rafa-sdk
78
83
  RAFA_HOOKS_DISABLED: "1" # headless — the M5 sensors are session instruments
84
+ RAFA_MERGE_SHA: \${{ github.event.pull_request.merge_commit_sha }} # keys the run record
85
+ RAFA_TARGET_BRANCH: \${{ github.event.pull_request.base.ref }}
79
86
  run: npx -y @rafinery/cli distill --headless "\${{ github.event.pull_request.head.ref }}"
80
87
  `;
81
88
 
89
+ // Pristine-detection for upgrades: a byte-diff alone cannot tell "user tuned
90
+ // this" from "our template moved on" — and mislabeling a stale pristine
91
+ // install as tuned strands it on old behavior forever (the ancestry sweep is
92
+ // inert without fetch-depth: 0). Comparison ignores comments/blank lines;
93
+ // PREVIOUS_NORMALIZED holds every historical pristine body, so a file that
94
+ // matches one is upgraded in place and only a genuinely tuned file is left
95
+ // alone. (Comment-only tuning is knowingly not preserved.)
96
+ const normalize = (s) =>
97
+ s
98
+ .split("\n")
99
+ .map((l) => l.trimEnd())
100
+ .filter((l) => {
101
+ const t = l.trim();
102
+ return t !== "" && !t.startsWith("#");
103
+ })
104
+ .join("\n");
105
+ const PREVIOUS_NORMALIZED = [
106
+ // v1 (pre ancestry-sweep, pre run-record): identical body minus the lines
107
+ // those two features added. Derived, not duplicated — v1 never shipped a
108
+ // byte that differs otherwise.
109
+ normalize(WORKFLOW)
110
+ .split("\n")
111
+ .filter(
112
+ (l) =>
113
+ !l.includes("fetch-depth: 0") &&
114
+ !l.includes("RAFA_MERGE_SHA") &&
115
+ !l.includes("RAFA_TARGET_BRANCH"),
116
+ )
117
+ .join("\n"),
118
+ ];
119
+
82
120
  export default async function ciSetup(args = []) {
83
121
  const ROOT = process.cwd();
84
122
  const target = join(ROOT, WORKFLOW_PATH);
85
123
 
124
+ console.log(
125
+ "OPTIONAL — the platform reconciles merges automatically (no CI, no repo secrets).\n" +
126
+ "This wires the org-CI ADAPTER instead: reconciliation compute runs in YOUR\n" +
127
+ "GitHub Actions on YOUR secrets. Most teams should skip this.\n",
128
+ );
129
+
86
130
  if (existsSync(target)) {
87
131
  const current = readFileSync(target, "utf8");
88
132
  if (current === WORKFLOW) {
@@ -90,6 +134,11 @@ export default async function ciSetup(args = []) {
90
134
  } else if (args.includes("--overwrite")) {
91
135
  writeFileSync(target, WORKFLOW);
92
136
  console.log(`✓ ${WORKFLOW_PATH} updated (--overwrite)`);
137
+ } else if (PREVIOUS_NORMALIZED.includes(normalize(current))) {
138
+ writeFileSync(target, WORKFLOW);
139
+ console.log(
140
+ `✓ ${WORKFLOW_PATH} upgraded from an older pristine template (no local tuning detected)`,
141
+ );
93
142
  } else {
94
143
  console.log(
95
144
  `! ${WORKFLOW_PATH} exists and differs — left untouched (yours may be tuned).\n` +
@@ -113,6 +162,8 @@ Next steps (repo Settings → Secrets and variables → Actions):
113
162
  4. RAFA_BRAIN_TOKEN — a token with push access to the BRAIN repo (the distill
114
163
  job pushes validated knowledge there).
115
164
  5. Commit the workflow via your normal MR flow.
165
+ 6. Verify the whole capture chain: npx @rafinery/cli doctor (sensors, scripts,
166
+ platform round-trip — exit 1 names any fix).
116
167
 
117
168
  The rigor gradient this wires: dev↔dev = CAS + session prompt · branch↔branch =
118
169
  free mechanical fold · branch→main = full distillation + gates. Cost tracks
package/lib/dirty.mjs CHANGED
@@ -34,6 +34,31 @@ export function readDirty(root = process.cwd()) {
34
34
  return { file, files: [...files.entries()].map(([f, t]) => ({ f, t })) };
35
35
  }
36
36
 
37
+ // OPEN improvements whose cited files are dirty — the fixed-in-passing detector
38
+ // (live-catch 2026-07-17: a session hand-fixed five P2s and the ledger kept
39
+ // saying "open"; SOP text alone is the proven weak link, so the nag is CODE).
40
+ // A dirty file cited by an open improvement is a strong signal the fix may
41
+ // already exist — the session verifies and reports; the queue never guesses.
42
+ // null = no local manifest here (honest unknown, not zero).
43
+ export function resolveOpenFixCandidates(root, dirtyFiles) {
44
+ const path = join(root, ".rafa", "manifest.json");
45
+ if (!existsSync(path)) return null;
46
+ let m;
47
+ try {
48
+ m = JSON.parse(readFileSync(path, "utf8"));
49
+ } catch {
50
+ return null;
51
+ }
52
+ const want = new Set(dirtyFiles);
53
+ const out = [];
54
+ for (const i of m.improvements ?? []) {
55
+ if (i.status !== "open") continue;
56
+ const files = [...new Set((i.cites ?? []).filter((c) => want.has(c.file)).map((c) => c.file))];
57
+ if (files.length) out.push({ id: i.id, priority: i.priority, title: i.title, files });
58
+ }
59
+ return out;
60
+ }
61
+
37
62
  // file → citing notes from the local manifest; null = no manifest here (lazy .rafa).
38
63
  export function resolveCiters(root, dirtyFiles) {
39
64
  const path = join(root, ".rafa", "manifest.json");
@@ -72,6 +97,7 @@ export default async function dirty(args = []) {
72
97
  }
73
98
 
74
99
  const notes = resolveCiters(ROOT, files.map((e) => e.f));
100
+ const fixCandidates = resolveOpenFixCandidates(ROOT, files.map((e) => e.f));
75
101
  const alarm =
76
102
  files.length >= DIRTY_FILES_ALARM || (notes !== null && notes.length >= CITED_NOTES_ALARM);
77
103
 
@@ -81,6 +107,7 @@ export default async function dirty(args = []) {
81
107
  {
82
108
  files: files.map((e) => e.f),
83
109
  notes, // null = unresolved here (no local manifest) — an honest unknown, not zero
110
+ openImprovements: fixCandidates, // dirty ∩ open-improvement cites — verify & report_improvement_status
84
111
  thresholds: { files: DIRTY_FILES_ALARM, notes: CITED_NOTES_ALARM },
85
112
  alarm,
86
113
  },
@@ -106,6 +133,16 @@ export default async function dirty(args = []) {
106
133
  console.log(`${notes.length} brain note(s) cite them:`);
107
134
  for (const n of notes) console.log(` · ${n.id} ← ${n.files.join(", ")}`);
108
135
  }
136
+ if (fixCandidates && fixCandidates.length) {
137
+ console.log(
138
+ `⚑ ${fixCandidates.length} OPEN improvement(s) cite files you changed — possible fixed-in-passing:`,
139
+ );
140
+ for (const c of fixCandidates)
141
+ console.log(` · ${c.priority} ${c.id} ← ${c.files.join(", ")}`);
142
+ console.log(
143
+ " verify each: fixed → `report_improvement_status(id, fixed)` (bloom updates the row at its next pass); still open → leave it. An unreported fix leaves the ledger lying.",
144
+ );
145
+ }
109
146
  console.log(
110
147
  alarm
111
148
  ? `⚠ drift past threshold — recommend a brain refresh from main (/rafa scan --brain-only), then \`rafa dirty --consume\`.`