@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
package/lib/githook.mjs CHANGED
@@ -1,54 +1,96 @@
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.
1
+ // The git hook installer — M5 sensor #3 (pre-push checkpoint boundary) plus
2
+ // the capture-engine P1 transport hooks (post-commit 1-1 brain commits ·
3
+ // post-checkout lockstep · post-rewrite rebase maps). "Brain git work is done
4
+ // automatically; the dev never maintains the brain" — these installs are that
5
+ // principle made mechanical.
4
6
  //
5
7
  // .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.
8
+ // pull (the teammate path) AND update — every working copy gets the sensors.
9
+ // Never clobbers a foreign hook: ours is recognized by its marker line;
10
+ // anything else is respected and the chain-line printed for the dev to add
11
+ // themselves.
9
12
 
10
13
  import { chmodSync, copyFileSync, existsSync, readFileSync } from "node:fs";
11
14
  import { execSync } from "node:child_process";
12
15
  import { join } from "node:path";
13
16
 
14
- const MARKER = "rafa pre-push hook";
15
- const CHAIN_LINE = "npx -y @rafinery/cli checkpoint || true";
17
+ const HOOKS = [
18
+ {
19
+ name: "pre-push",
20
+ marker: "rafa pre-push hook",
21
+ chain: "npx -y @rafinery/cli checkpoint || true",
22
+ },
23
+ {
24
+ name: "post-commit",
25
+ marker: "rafa post-commit hook",
26
+ chain: 'node ".claude/rafa/hooks/brain-commit.mjs" || true',
27
+ },
28
+ {
29
+ name: "post-checkout",
30
+ marker: "rafa post-checkout hook",
31
+ chain: 'node ".claude/rafa/hooks/brain-switch.mjs" "$1" "$2" "$3" || true',
32
+ },
33
+ {
34
+ name: "post-rewrite",
35
+ marker: "rafa post-rewrite hook",
36
+ chain: 'node ".claude/rafa/hooks/brain-map.mjs" "$1" || true',
37
+ },
38
+ ];
16
39
 
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");
40
+ function installOne(targetDir, hooksDir, { name, marker, chain }) {
41
+ const src = join(targetDir, ".claude", "rafa", "hooks", name);
42
+ if (!existsSync(src))
43
+ return { hook: name, skipped: "no vendored hook template (run `rafa update`)" };
44
+ const dst = join(hooksDir, name);
31
45
  if (existsSync(dst)) {
32
46
  const body = readFileSync(dst, "utf8");
33
- if (!body.includes(MARKER)) {
47
+ if (!body.includes(marker))
34
48
  return {
49
+ hook: name,
35
50
  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}`,
51
+ `a ${name} hook already exists (not rafa's) — to chain ours, add this ` +
52
+ `line to it yourself: ${chain}`,
38
53
  };
39
- }
40
- // ours → refresh in place (idempotent update)
41
- copyFileSync(src, dst);
54
+ copyFileSync(src, dst); // ours → refresh in place (idempotent update)
42
55
  chmodSync(dst, 0o755);
43
- return { updated: true };
56
+ return { hook: name, updated: true };
44
57
  }
45
58
  copyFileSync(src, dst);
46
59
  chmodSync(dst, 0o755);
47
- return { installed: true };
60
+ return { hook: name, installed: true };
48
61
  }
49
62
 
63
+ export function installGitHooks(targetDir) {
64
+ let hooksDir;
65
+ try {
66
+ // --git-path resolves worktrees/submodules correctly; never assume .git is a dir.
67
+ hooksDir = execSync("git rev-parse --git-path hooks", {
68
+ cwd: targetDir,
69
+ encoding: "utf8",
70
+ }).trim();
71
+ hooksDir = join(targetDir, hooksDir);
72
+ } catch {
73
+ return HOOKS.map((h) => ({ hook: h.name, skipped: "not a git repo" }));
74
+ }
75
+ return HOOKS.map((h) => installOne(targetDir, hooksDir, h));
76
+ }
77
+
78
+ export function reportGitHooks(results) {
79
+ const fresh = results.filter((r) => r.installed).map((r) => r.hook);
80
+ const refreshed = results.filter((r) => r.updated).map((r) => r.hook);
81
+ if (fresh.length)
82
+ console.log(
83
+ ` ✓ git hooks installed: ${fresh.join(" · ")} — checkpoint at push, 1-1 brain commits, branch lockstep, rebase maps (all non-blocking)`,
84
+ );
85
+ if (refreshed.length) console.log(` ✓ git hooks refreshed: ${refreshed.join(" · ")}`);
86
+ for (const r of results.filter((r) => r.skipped))
87
+ console.log(` ! git ${r.hook} hook not installed — ${r.skipped}`);
88
+ }
89
+
90
+ // Back-compat for older callers: pre-push only.
91
+ export function installPrePush(targetDir) {
92
+ return installGitHooks(targetDir).find((r) => r.hook === "pre-push");
93
+ }
50
94
  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}`);
95
+ reportGitHooks([r]);
54
96
  }
package/lib/init.mjs CHANGED
@@ -120,8 +120,8 @@ export default async function init(args) {
120
120
  }
121
121
  // The pre-push checkpoint boundary is per-clone (.git/hooks is never committed).
122
122
  {
123
- const { installPrePush, reportGitHook } = await import("./githook.mjs");
124
- reportGitHook(installPrePush(TARGET));
123
+ const { installGitHooks, reportGitHooks } = await import("./githook.mjs");
124
+ reportGitHooks(installGitHooks(TARGET));
125
125
  }
126
126
  // Wire the repo's own MCP servers into the (owned) agent cards so subagents
127
127
  // can actually reach them (zero-command orchestration, 2026-07-12).
@@ -176,25 +176,12 @@ export default async function init(args) {
176
176
  );
177
177
  }
178
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
- }
179
+ // Reconciliation runs on the PLATFORM (App-only era, owner call 2026-07-18):
180
+ // merged PRs are detected and distilled by the platform's own sandbox no
181
+ // CI workflow, no repo secrets, nothing to wire here. The org-CI adapter
182
+ // stays available as an explicit opt-out for teams that want the compute in
183
+ // their own CI: `rafa ci-setup`. Init no longer asks — one fewer question,
184
+ // one fewer confusion.
198
185
 
199
186
  // Stamp the versions this repo was provisioned at — the compat anchor `update`/`migrate`
200
187
  // diff against. Carries platform identity too when provisioned via a setup URL.
@@ -224,10 +211,22 @@ export default async function init(args) {
224
211
  " Joining an ALREADY-scanned repo: run rafa pull — never re-scan for existing knowledge.\n" +
225
212
  " 4. Open the repo on the rafinery platform — its Overview shows the brain once pushed." +
226
213
  (p?.mcpUrl
227
- ? "\n 5. After the first push, any MCP client with a key can query this repo's knowledge\n" +
228
- ` (endpoint: ${p.mcpUrl} · keys: repo Agent access on the platform).`
214
+ ? "\n 5. Merge PRs as usual the platform reconciles branch knowledge into the\n" +
215
+ " brain automatically (watch the repo's Reconciliations tab)."
229
216
  : ""),
230
217
  );
231
218
 
219
+ // P0: prove the machinery init just wired — sensors, scripts, and the
220
+ // heartbeat round-trip — instead of hoping. Platform-wired inits run the
221
+ // proof now (advisory: init already succeeded; doctor names any fix);
222
+ // blueprint-only inits get the pointer for after the platform is wired.
223
+ if (p?.mcpUrl) {
224
+ console.log("");
225
+ const { runDoctor } = await import("./doctor.mjs");
226
+ await runDoctor(TARGET);
227
+ } else {
228
+ console.log("\n · verify the capture machinery anytime: npx @rafinery/cli doctor");
229
+ }
230
+
232
231
  await notifyIfBehind();
233
232
  }
@@ -41,7 +41,11 @@ export function resolveMcp(cwd) {
41
41
  const creds = readJson(credentialsPath());
42
42
  const repoCreds = creds?.repos?.[repoId] ?? null;
43
43
 
44
+ // Track WHERE each value came from — auth failures name their sources so a
45
+ // wrong-platform mismatch (env override vs credentials vs .mcp.json) is
46
+ // self-diagnosing instead of a scavenger hunt (owner live-catch 2026-07-17).
44
47
  const key = process.env.RAFA_MCP_KEY || repoCreds?.key || null;
48
+ const keySource = process.env.RAFA_MCP_KEY ? "env RAFA_MCP_KEY" : "credentials.json";
45
49
  if (!key)
46
50
  throw new Error(
47
51
  `no agent key — set RAFA_MCP_KEY, or mint one on the platform (repo → Agent access) ` +
@@ -49,17 +53,21 @@ export function resolveMcp(cwd) {
49
53
  );
50
54
 
51
55
  let mcpUrl = process.env.RAFA_MCP_URL || repoCreds?.mcpUrl || null;
56
+ let urlSource = process.env.RAFA_MCP_URL ? "env RAFA_MCP_URL" : "credentials.json";
52
57
  if (!mcpUrl) {
53
58
  const mcpJson = readJson(join(cwd, ".mcp.json"));
54
59
  const url = mcpJson?.mcpServers?.rafinery?.url;
55
- if (typeof url === "string" && url !== "") mcpUrl = url;
60
+ if (typeof url === "string" && url !== "") {
61
+ mcpUrl = url;
62
+ urlSource = ".mcp.json";
63
+ }
56
64
  }
57
65
  if (!mcpUrl)
58
66
  throw new Error(
59
67
  "no MCP endpoint — set RAFA_MCP_URL, or ensure .mcp.json carries the `rafinery` server entry",
60
68
  );
61
69
 
62
- return { repoId, key, mcpUrl };
70
+ return { repoId, key, mcpUrl, keySource, urlSource };
63
71
  }
64
72
 
65
73
  let rpcId = 0;
@@ -67,7 +75,7 @@ let rpcId = 0;
67
75
  // Call one tool; returns the structured payload. Throws with the server's
68
76
  // message on transport or tool errors.
69
77
  export async function callTool(cwd, tool, args = {}) {
70
- const { repoId, key, mcpUrl } = resolveMcp(cwd);
78
+ const { repoId, key, mcpUrl, keySource, urlSource } = resolveMcp(cwd);
71
79
  // A localhost platform is only reachable on the machine running the dev
72
80
  // server — on a CI runner it is a guaranteed dead end; say so up front
73
81
  // instead of a bare "fetch failed".
@@ -101,10 +109,19 @@ export async function callTool(cwd, tool, args = {}) {
101
109
  `check the URL (RAFA_MCP_URL / credentials.json / .mcp.json) and that the platform is deployed and reachable from here.`,
102
110
  );
103
111
  }
104
- if (res.status === 401)
112
+ if (res.status === 401) {
113
+ let host = mcpUrl;
114
+ try {
115
+ host = new URL(mcpUrl).host;
116
+ } catch {
117
+ /* keep the raw url */
118
+ }
105
119
  throw new Error(
106
- "agent key rejected (401) — revoked or wrong platform; mint a new one (repo Agent access)",
120
+ `agent key rejected (401) by ${host} the key (from ${keySource}) is not valid on that platform ` +
121
+ `(url from ${urlSource}): revoked, or minted on a DIFFERENT deployment. Keys are per-platform — ` +
122
+ `mint one on THIS platform (repo → Agent access), or point the url back at the platform that minted yours.`,
107
123
  );
124
+ }
108
125
  if (res.status === 429)
109
126
  throw new Error("rate limited (429) — back off and retry");
110
127
  if (!res.ok) throw new Error(`MCP endpoint returned HTTP ${res.status}`);
package/lib/pull.mjs CHANGED
@@ -35,8 +35,8 @@ export default async function pull(args = []) {
35
35
  // Every working copy gets the M5 checkpoint boundary (.git/hooks is per-clone —
36
36
  // pull IS the teammate path, so this is where a fresh clone gains the sensor).
37
37
  {
38
- const { installPrePush, reportGitHook } = await import("./githook.mjs");
39
- reportGitHook(installPrePush(ROOT));
38
+ const { installGitHooks, reportGitHooks } = await import("./githook.mjs");
39
+ reportGitHooks(installGitHooks(ROOT));
40
40
  }
41
41
 
42
42
  // Reachability + default branch — loud if the remote can't be reached.
package/lib/push.mjs CHANGED
@@ -23,6 +23,33 @@ export default async function push(args = []) {
23
23
  const { rafaDir } = ensureBrainRepo(ROOT);
24
24
  const inRafa = inDir(rafaDir);
25
25
 
26
+ // Mirrored-branch era (capture-engine P1): push is a MAIN-branch act and the
27
+ // brain DEFAULT branch is its only target. If the working copy sits on a
28
+ // mirrored session branch (post-checkout lockstep), carry dirty surfaces
29
+ // there and switch first — a session branch's candidates must never ride an
30
+ // org-brain push.
31
+ const defaultBranch = remoteDefaultBranch(rafaDir);
32
+ try {
33
+ const cur = inRafa("git rev-parse --abbrev-ref HEAD");
34
+ if (cur !== defaultBranch) {
35
+ try {
36
+ inRafa("git add -A");
37
+ inRafa(
38
+ `git commit -q -m "brain(switch-carryover)" -m "switch-carryover-from: ${cur}"`,
39
+ );
40
+ } catch {
41
+ /* nothing dirty */
42
+ }
43
+ try {
44
+ inRafa(`git checkout -q ${defaultBranch}`);
45
+ } catch {
46
+ inRafa(`git checkout -q -b ${defaultBranch}`);
47
+ }
48
+ }
49
+ } catch {
50
+ /* unborn HEAD (fresh brain repo) — the branch -M below names it */
51
+ }
52
+
26
53
  // Anchor the brain to the current code HEAD.
27
54
  let codeSha = "unknown";
28
55
  try {
@@ -104,12 +131,50 @@ export default async function push(args = []) {
104
131
  );
105
132
 
106
133
  inRafa("git add -A");
134
+ // Descriptive commit (owner catch 2026-07-17: the history was a wall of
135
+ // identical "brain: scan"). Everything needed is already in scope — the
136
+ // manifest compile just wrote, the staged diff, the verb from the caller
137
+ // (--verb=scan|improve|build|distill; "update" when unstated). The
138
+ // "brain-for:" trailer paragraph is unchanged — provenance consumers key
139
+ // on it.
140
+ const verbArg = args.find((a) => a.startsWith("--verb="));
141
+ const verb = verbArg ? verbArg.slice("--verb=".length) : "update";
142
+ const subjectParts = [];
143
+ try {
144
+ const m = JSON.parse(readFileSync(join(rafaDir, "manifest.json"), "utf8"));
145
+ if (Array.isArray(m.notes)) subjectParts.push(`${m.notes.length} notes`);
146
+ if (Array.isArray(m.improvements)) {
147
+ const open = m.improvements.filter((i) => i.status === "open").length;
148
+ subjectParts.push(`${m.improvements.length} improvements (${open} open)`);
149
+ }
150
+ const h = m.health ?? {};
151
+ if (h.verdict) subjectParts.push(`health ${h.verdict}${typeof h.score === "number" ? ` ${h.score}` : ""}`);
152
+ } catch {
153
+ /* manifest unreadable — the verb + trailer still beat "brain: scan" */
154
+ }
155
+ let stagedStat = "";
107
156
  try {
108
- inRafa(`git commit -m "brain: scan" -m "brain-for: ${codeSha}"`);
157
+ const lines = inRafa("git diff --cached --name-status")
158
+ .split("\n")
159
+ .filter(Boolean);
160
+ if (lines.length) {
161
+ const n = (p) => lines.filter((l) => l.startsWith(p)).length;
162
+ stagedStat = `files +${n("A")} ~${n("M")} -${n("D")}`;
163
+ }
164
+ } catch {
165
+ /* stat is garnish */
166
+ }
167
+ const subject = `brain(${verb}): ${
168
+ subjectParts.length ? subjectParts.join(" · ") : "state sync"
169
+ } · brain-for: ${codeSha}`.replace(/"/g, "'");
170
+ try {
171
+ inRafa(
172
+ `git commit -m "${subject}" -m "brain-for: ${codeSha}${stagedStat ? ` · ${stagedStat}` : ""} · cli: ${CLI_VERSION}"`,
173
+ );
109
174
  } catch {
110
175
  console.log("• nothing new to commit — pushing current state");
111
176
  }
112
- const branch = remoteDefaultBranch(rafaDir);
177
+ const branch = defaultBranch;
113
178
  inRafa(`git branch -M ${branch}`);
114
179
  try {
115
180
  inRafa(`git push -u origin ${branch}`);
package/lib/releases.mjs CHANGED
@@ -216,6 +216,19 @@ export const RELEASES = [
216
216
  ".rafa; standalone exits loud). @rafinery/okf 0.1.1: validateBundle gains the " +
217
217
  "additive `empty` flag so library consumers can make the same distinction.",
218
218
  },
219
+ {
220
+ version: "0.8.2",
221
+ contract: 1,
222
+ plans: 2,
223
+ requires: "update",
224
+ summary:
225
+ "MCP scope hygiene, fleet-wide. Server: `repo` is now OPTIONAL on every tool — " +
226
+ "derived from the per-repo key (an authenticated fact); explicit mismatch still " +
227
+ "fails loudly with guidance. Cards: every agent (atlas/bloom/prism/sage/compass/" +
228
+ "distiller) + the conductor now carry the calling convention — omit repo; where a " +
229
+ "value is needed it is rafa.json → repoId, never a folder-name guess. `rafa " +
230
+ "update` re-vendors the cards.",
231
+ },
219
232
  ];
220
233
 
221
234
  // The release this CLI build ships (last entry).
@@ -0,0 +1,91 @@
1
+ // Sensor-health inventory (capture-engine P0) — the same wired∧exists truth
2
+ // `rafa status` prints, shaped for the platform heartbeat. Hooks fail SILENT
3
+ // by design (never block a session); the heartbeat makes the ABSENCE visible
4
+ // to the platform instead of only to a dev who thinks to ask.
5
+
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ const queueLife = (file) => {
10
+ try {
11
+ const rows = readFileSync(file, "utf8").split("\n").filter(Boolean);
12
+ const last = rows.length ? JSON.parse(rows[rows.length - 1]) : null;
13
+ const t = last?.t ?? last?.at ?? null;
14
+ return { entries: rows.length, ...(typeof t === "number" ? { lastFireAt: t } : {}) };
15
+ } catch {
16
+ return { entries: 0 };
17
+ }
18
+ };
19
+
20
+ export function collectSensorHealth(root) {
21
+ const rafaDir = join(root, ".rafa");
22
+ let settings = null;
23
+ try {
24
+ settings = JSON.parse(
25
+ readFileSync(join(root, ".claude", "settings.json"), "utf8"),
26
+ );
27
+ } catch {
28
+ /* unwired repo — every hook reports wired: false below */
29
+ }
30
+ const wired = (event, script) => {
31
+ const entries = settings?.hooks?.[event] ?? [];
32
+ return (
33
+ JSON.stringify(entries).includes(`rafa/hooks/${script}`) &&
34
+ existsSync(join(root, ".claude", "rafa", "hooks", script))
35
+ );
36
+ };
37
+ // The git hooks are per-clone (installed by init/pull/update), so their
38
+ // wiring is checked in .git/hooks, not settings.json.
39
+ const gitHookWired = (name) => {
40
+ try {
41
+ return readFileSync(join(root, ".git", "hooks", name), "utf8").includes("rafa");
42
+ } catch {
43
+ return false;
44
+ }
45
+ };
46
+ // Swallowed failures the wrappers recorded (non-blocking by design, but the
47
+ // heartbeat still carries them — the platform sees what the terminal ate).
48
+ const lastErrorFor = (() => {
49
+ const byHook = new Map();
50
+ try {
51
+ for (const line of readFileSync(join(rafaDir, "sensor-errors.jsonl"), "utf8")
52
+ .split("\n")
53
+ .filter(Boolean)) {
54
+ const e = JSON.parse(line);
55
+ if (e?.hook) byHook.set(e.hook, e);
56
+ }
57
+ } catch {
58
+ /* no recorded errors */
59
+ }
60
+ return (name) => {
61
+ const e = byHook.get(name);
62
+ return e
63
+ ? {
64
+ ...(typeof e.error === "string" ? { lastError: e.error } : {}),
65
+ ...(typeof e.t === "number" ? { lastErrorAt: e.t } : {}),
66
+ }
67
+ : {};
68
+ };
69
+ })();
70
+ return {
71
+ reportedAt: Date.now(),
72
+ hooks: [
73
+ { name: "session-start", wired: wired("SessionStart", "session-start.mjs") },
74
+ {
75
+ name: "post-tool",
76
+ wired: wired("PostToolUse", "post-tool.mjs"),
77
+ ...queueLife(join(rafaDir, "dirty.jsonl")),
78
+ },
79
+ {
80
+ name: "user-prompt-submit",
81
+ wired: wired("UserPromptSubmit", "user-prompt-submit.mjs"),
82
+ ...queueLife(join(rafaDir, "reflex.jsonl")),
83
+ },
84
+ { name: "pre-push", wired: gitHookWired("pre-push"), ...lastErrorFor("pre-push") },
85
+ // Capture-engine P1 transport hooks — the brain's 1-1 mirror machinery.
86
+ { name: "post-commit", wired: gitHookWired("post-commit"), ...lastErrorFor("post-commit") },
87
+ { name: "post-checkout", wired: gitHookWired("post-checkout"), ...lastErrorFor("post-checkout") },
88
+ { name: "post-rewrite", wired: gitHookWired("post-rewrite"), ...lastErrorFor("post-rewrite") },
89
+ ],
90
+ };
91
+ }
package/lib/status.mjs CHANGED
@@ -105,7 +105,40 @@ export function computeStatus(root = process.cwd()) {
105
105
  }
106
106
  }
107
107
 
108
- return { provisioned, plan, dirty, reflex, conflicts };
108
+ // Sensor health hooks fail SILENT by design (never block a session), which
109
+ // means a broken sensor is invisible until someone asks. This makes asking
110
+ // one command (live-catch 2026-07-17: "it proves our hooks are not listening"
111
+ // was unfalsifiable without it). wired = settings.json carries the hook AND
112
+ // its script exists; lastWrite = the queue's own evidence of life.
113
+ const sensors = (() => {
114
+ const settings = (() => {
115
+ try {
116
+ return JSON.parse(readFileSync(join(root, ".claude", "settings.json"), "utf8"));
117
+ } catch {
118
+ return null;
119
+ }
120
+ })();
121
+ const hookWired = (event, script) => {
122
+ const entries = settings?.hooks?.[event] ?? [];
123
+ const inSettings = JSON.stringify(entries).includes(`rafa/hooks/${script}`);
124
+ return {
125
+ wired: inSettings && existsSync(join(root, ".claude", "rafa", "hooks", script)),
126
+ };
127
+ };
128
+ const lastLine = (file) => {
129
+ const rows = readJsonl(join(rafaDir, file));
130
+ const t = rows.length ? rows[rows.length - 1].t ?? rows[rows.length - 1].at ?? null : null;
131
+ return { entries: rows.length, lastWrite: t ?? null };
132
+ };
133
+ return {
134
+ settingsFound: settings !== null,
135
+ sessionStart: hookWired("SessionStart", "session-start.mjs"),
136
+ postTool: { ...hookWired("PostToolUse", "post-tool.mjs"), ...lastLine("dirty.jsonl") },
137
+ promptSubmit: { ...hookWired("UserPromptSubmit", "user-prompt-submit.mjs"), ...lastLine("reflex.jsonl") },
138
+ };
139
+ })();
140
+
141
+ return { provisioned, plan, dirty, reflex, conflicts, sensors };
109
142
  }
110
143
 
111
144
  // The ambient one-liner — identical semantics wherever it renders.
@@ -149,4 +182,17 @@ export default async function status(args = []) {
149
182
  if (s.conflicts) console.log(` conflicts: ${s.conflicts} .theirs.md awaiting your decision`);
150
183
  if (!s.plan && !s.dirty && !s.reflex && !s.conflicts)
151
184
  console.log(" nothing pending — the loop is closed.");
185
+ // Sensors: broken hooks are silent by design — say their state out loud here.
186
+ const sen = s.sensors;
187
+ const mark = (h) => (h.wired ? "✓" : "✗ NOT WIRED");
188
+ console.log(
189
+ ` sensors: digest ${mark(sen.sessionStart)} · dirty-marker ${mark(sen.postTool)}` +
190
+ (sen.postTool.entries ? ` (${sen.postTool.entries} entries, last ${sen.postTool.lastWrite ?? "?"})` : " (queue empty)") +
191
+ ` · reflex ${mark(sen.promptSubmit)}` +
192
+ (sen.promptSubmit.entries ? ` (${sen.promptSubmit.entries})` : ""),
193
+ );
194
+ if (!sen.settingsFound || !sen.sessionStart.wired || !sen.postTool.wired || !sen.promptSubmit.wired)
195
+ console.log(
196
+ " ⚠ sensor(s) not listening — edits/corrections are NOT being captured. `rafa update` re-merges the hooks into .claude/settings.json.",
197
+ );
152
198
  }
package/lib/update.mjs CHANGED
@@ -48,8 +48,8 @@ export default async function update(args = []) {
48
48
  const sl = mergeStatusLine(TARGET);
49
49
  if (sl.skipped) console.log(` ! statusline untouched — ${sl.skipped}`);
50
50
  else if (sl.added?.length) console.log(" ✓ loop-state statusline → .claude/settings.json");
51
- const { installPrePush, reportGitHook } = await import("./githook.mjs");
52
- reportGitHook(installPrePush(TARGET));
51
+ const { installGitHooks, reportGitHooks } = await import("./githook.mjs");
52
+ reportGitHooks(installGitHooks(TARGET));
53
53
  }
54
54
 
55
55
  // ── Blueprint-side migrations: mechanical/deterministic, run here in the CLI. ──
@@ -26,7 +26,25 @@ import {
26
26
  import { dirname, join } from "node:path";
27
27
 
28
28
  // The working-set scan roots inside .rafa/ — brain-shaped authoring surfaces.
29
- export const WORKING_DIRS = ["brain/rules", "brain/playbooks"];
29
+ // P2 typed candidates (capture-engine spec r2 §3): improvements ride as
30
+ // candidates (files only — improve/ledger.md is DERIVED, regenerated at push,
31
+ // never a candidate) and the post-commit hook's intent records travel as
32
+ // provenance. The platform stores paths untyped; kind derives from the path.
33
+ export const WORKING_DIRS = [
34
+ "brain/rules",
35
+ "brain/playbooks",
36
+ "improve/improvements",
37
+ "intent",
38
+ ];
39
+
40
+ // The candidate kind a working-set path carries — the distiller's judge seam
41
+ // and the fold's filters key on this, never on a parallel type field that
42
+ // could drift from the path.
43
+ export function kindOfPath(path) {
44
+ if (path.startsWith("improve/improvements/")) return "improvement";
45
+ if (path.startsWith("intent/")) return "intent";
46
+ return "note";
47
+ }
30
48
 
31
49
  export const hashOf = (text) => createHash("sha256").update(text).digest("hex");
32
50
 
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@rafinery/cli",
3
- "version": "0.8.1",
4
- "description": "rafa the AI engineer CLI. Vendors the rafa blueprint into your repo (shadcn-style) and runs the deterministic contract gates.",
3
+ "version": "0.8.3",
4
+ "description": "rafa \u2014 the AI engineer CLI. Vendors the rafa blueprint into your repo (shadcn-style) and runs the deterministic contract gates.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "rafa": "bin/rafa.mjs"
7
+ "rafa": "bin/rafa.mjs",
8
+ "rafa-distiller": "bin/rafa-distiller.mjs"
8
9
  },
9
10
  "files": [
10
11
  "bin",
@@ -16,11 +17,16 @@
16
17
  "node": ">=18"
17
18
  },
18
19
  "dependencies": {
19
- "@rafinery/okf": "0.1.1"
20
+ "@rafinery/okf": "workspace:*"
20
21
  },
21
22
  "publishConfig": {
22
23
  "access": "public"
23
24
  },
25
+ "scripts": {
26
+ "bundle": "node scripts/bundle-blueprint.mjs",
27
+ "prepare": "node scripts/bundle-blueprint.mjs",
28
+ "test": "node --test"
29
+ },
24
30
  "keywords": [
25
31
  "rafa",
26
32
  "rafinery",
@@ -36,9 +42,5 @@
36
42
  "url": "git+https://github.com/rafinery-ai/rafinery.git",
37
43
  "directory": "packages/cli"
38
44
  },
39
- "homepage": "https://github.com/rafinery-ai/rafinery/tree/main/packages/cli",
40
- "scripts": {
41
- "bundle": "node scripts/bundle-blueprint.mjs",
42
- "test": "node --test"
43
- }
44
- }
45
+ "homepage": "https://github.com/rafinery-ai/rafinery/tree/main/packages/cli"
46
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- The MIT License
2
-
3
- Copyright (c) Atai Barkai
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.