@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/bin/rafa.mjs +30 -5
  3. package/blueprint/.claude/agents/atlas.md +10 -5
  4. package/blueprint/.claude/agents/compass.md +3 -1
  5. package/blueprint/.claude/commands/rafa.md +103 -12
  6. package/blueprint/.claude/rafa/contract.md +111 -47
  7. package/blueprint/.claude/rafa/hooks/post-tool.mjs +62 -0
  8. package/blueprint/.claude/rafa/hooks/pre-push +24 -0
  9. package/blueprint/.claude/rafa/hooks/session-start.mjs +229 -0
  10. package/blueprint/.claude/rafa/hooks/statusline.mjs +113 -0
  11. package/blueprint/.claude/rafa/hooks/user-prompt-submit.mjs +87 -0
  12. package/blueprint/.claude/skills/rafa-build/SKILL.md +17 -5
  13. package/blueprint/.claude/skills/rafa-insights/SKILL.md +13 -2
  14. package/blueprint/.claude/skills/rafa-plan/SKILL.md +18 -3
  15. package/blueprint/.claude/skills/rafa-scan/SKILL.md +23 -3
  16. package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
  17. package/lib/blueprint.mjs +9 -1
  18. package/lib/brain-repo.mjs +10 -4
  19. package/lib/checkpoint.mjs +7 -0
  20. package/lib/ci-setup.mjs +2 -0
  21. package/lib/claude-config.mjs +113 -0
  22. package/lib/dirty.mjs +114 -0
  23. package/lib/distill.mjs +47 -2
  24. package/lib/gate/compile.mjs +145 -32
  25. package/lib/gate/verify-citations.mjs +214 -23
  26. package/lib/githook.mjs +54 -0
  27. package/lib/init.mjs +45 -0
  28. package/lib/leverage.mjs +13 -2
  29. package/lib/migrations/index.mjs +57 -2
  30. package/lib/pull.mjs +7 -0
  31. package/lib/push.mjs +21 -0
  32. package/lib/reflex.mjs +76 -0
  33. package/lib/releases.mjs +66 -0
  34. package/lib/status.mjs +152 -0
  35. package/lib/update.mjs +20 -0
  36. package/package.json +2 -2
package/lib/reflex.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // rafa reflex — the correction queue's query/consume surface (M5, agent-internal;
2
+ // the UserPromptSubmit sensor writes the queue, the conductor works it).
3
+ //
4
+ // rafa reflex list unprocessed corrections
5
+ // rafa reflex --json machine shape (bootstrap digest / spawn prompts)
6
+ // rafa reflex --consume <id> [reason] mark one processed — AFTER it was banked
7
+ // through the gates, or judged session-only
8
+ //
9
+ // Consume APPENDS a done-marker line ({id, done:true, verdict, at}) — the queue
10
+ // is append-only like every M5 sensor stream (monotonic, torn-line tolerant);
11
+ // readers treat the latest line per id as truth. Nothing here ever ships: the
12
+ // queue is transport-excluded; only distilled cited notes pass the gates.
13
+
14
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ export function readReflex(root = process.cwd()) {
18
+ const file = join(root, ".rafa", "reflex.jsonl");
19
+ const byId = new Map(); // id → latest entry (done-markers override)
20
+ if (!existsSync(file)) return { file, pending: [], done: 0 };
21
+ for (const line of readFileSync(file, "utf8").split("\n")) {
22
+ if (!line.trim()) continue;
23
+ try {
24
+ const e = JSON.parse(line);
25
+ if (e?.id) byId.set(e.id, { ...(byId.get(e.id) ?? {}), ...e });
26
+ } catch {
27
+ /* torn line — skip */
28
+ }
29
+ }
30
+ const all = [...byId.values()];
31
+ return {
32
+ file,
33
+ pending: all.filter((e) => !e.done),
34
+ done: all.filter((e) => e.done).length,
35
+ };
36
+ }
37
+
38
+ export default async function reflex(args = []) {
39
+ const ROOT = process.cwd();
40
+ const { file, pending, done } = readReflex(ROOT);
41
+
42
+ const ci = args.indexOf("--consume");
43
+ if (ci !== -1) {
44
+ const id = args[ci + 1];
45
+ if (!id || id.startsWith("-")) {
46
+ console.error("✗ usage: rafa reflex --consume <id> [banked|session-only|refuted]");
47
+ process.exit(1);
48
+ }
49
+ if (!pending.some((e) => e.id === id)) {
50
+ console.error(`✗ no unprocessed correction with id ${id} (see \`rafa reflex\`)`);
51
+ process.exit(1);
52
+ }
53
+ const verdict = args[ci + 2] && !args[ci + 2].startsWith("-") ? args[ci + 2] : "banked";
54
+ appendFileSync(
55
+ file,
56
+ JSON.stringify({ id, done: true, verdict, at: new Date().toISOString() }) + "\n",
57
+ );
58
+ console.log(`✓ correction ${id} consumed (${verdict})`);
59
+ return;
60
+ }
61
+
62
+ if (args.includes("--json")) {
63
+ console.log(JSON.stringify({ pending, done }, null, 2));
64
+ return;
65
+ }
66
+
67
+ if (pending.length === 0) {
68
+ console.log(`✓ reflex queue clean${done ? ` (${done} processed)` : ""} — no unbanked corrections`);
69
+ return;
70
+ }
71
+ console.log(`${pending.length} unprocessed correction(s):`);
72
+ for (const e of pending) console.log(` · ${e.id} [${e.t}] "${e.p}"`);
73
+ console.log(
74
+ "→ for each: bank it through the gates if it's durable repo knowledge (branch: note + checkpoint · main: full gates), then `rafa reflex --consume <id>` — or consume with `session-only` if it isn't.",
75
+ );
76
+ }
package/lib/releases.mjs CHANGED
@@ -102,6 +102,72 @@ export const RELEASES = [
102
102
  "the working-set scanner returned the directory path instead of each file's path " +
103
103
  "(variable shadowing in scanWorkingFiles). No schema change; no re-scan.",
104
104
  },
105
+ {
106
+ version: "0.4.2",
107
+ contract: 1,
108
+ plans: 1,
109
+ requires: "update",
110
+ summary:
111
+ "Owner E2E feedback round. `rafa init` now OFFERS CI reconciliation at provisioning " +
112
+ "(TTY only; skipping prints the session-fallback path — nothing is silently installed). " +
113
+ "`rafa distill --headless` streams the agent's narration and prints per-phase timings + " +
114
+ "cost (mirror · agent · gates · total), so CI logs show liveness and where time goes. " +
115
+ "Plans gain an optional parent-only `domains:` (contract §7) — the blast radius rides " +
116
+ "push_plan and the platform renders the plan's brain slice on its new detail page. " +
117
+ "No schema migration; no re-scan.",
118
+ },
119
+ {
120
+ version: "0.5.0",
121
+ contract: 1,
122
+ plans: 2,
123
+ requires: "migrate",
124
+ summary:
125
+ "Plans v2 — the work-item tree (contract §7 v2, first plans data-version bump). " +
126
+ "epic → task → subtask with vendor-blended vocabulary (checked against Linear/Jira/Asana " +
127
+ "APIs): title · description · approach · assignee · blocked_by (a dependency IS a " +
128
+ "blocker) · blocked_reason · priority 0–4 · estimate · external {provider,key,url} " +
129
+ "(read-only tracker identity). `blocked` is DERIVED, never stored — the status enum is " +
130
+ "todo|in-progress|done|superseded|abandoned. Decisions become first-class channel " +
131
+ "records (log_decision: at·actor·context·options·decision·rationale; paraphrase + " +
132
+ "pivotal quotes), mirrored as `## Decisions` body prose. `rafa migrate` converts v1 " +
133
+ "files mechanically (parent→epic, child→task, blocked→todo+blocked_reason). Brain data " +
134
+ "schema unchanged; no re-scan.",
135
+ },
136
+ {
137
+ version: "0.6.0",
138
+ contract: 1,
139
+ plans: 2,
140
+ requires: "update",
141
+ summary:
142
+ "The core-solidity release — checker v2 + the M5 capture engine. RATCHET: " +
143
+ "verify-citations gains the two 2026-06-08 catches as gates — ABSENCE (declared " +
144
+ "`absent:` tokens re-grepped every run; a stale absence claim fails at gate 1) and " +
145
+ "INVENTORY (coverage `inventory: <name> :: <glob> :: <count>` recomputed via git " +
146
+ "ls-files; surface drift fails) — plus a non-failing WARN heuristic for undeclared " +
147
+ "absence-shaped claims (prism's worklist). The checker records its run " +
148
+ "(citation-check.json) and compile folds it into manifest.citations {checkerVersion, " +
149
+ "pass, at} — the platform learns which gate level a brain passed. M5 SENSORS " +
150
+ "(deterministic capture moments — no more SOP-only checkpoints): SessionStart state " +
151
+ "digest (staleness · conflicts · active plan), PostToolUse dirty-marking " +
152
+ "(.rafa/dirty.jsonl, monotonic, no session-end dependency), git pre-push runs " +
153
+ "`rafa checkpoint` (non-blocking; installed per-clone at init/pull/update). New " +
154
+ "`rafa dirty [--json|--consume]` — the cite-graph invalidator's query surface: dirty " +
155
+ "files → citing notes → scoped refresh offer at boundaries; drift threshold → " +
156
+ "fire-alarm (/rafa scan --brain-only). Hooks vendor LOCKSTEP at .claude/rafa/hooks/ " +
157
+ "and are disabled in headless/CI runs (RAFA_HOOKS_DISABLED=1 — the recursion guard). " +
158
+ "THE CORRECTION REFLEX: correction-shaped prompts are queued (.rafa/reflex.jsonl) and " +
159
+ "steer the session to validate + bank them through the gates SAME-SESSION (rafa reflex " +
160
+ "--consume closes each with a verdict; ungroundable claims never bank). " +
161
+ "THE FRICTIONLESS LOOP: loop-state statusline (rafa ▸ plan 2/5 · 3 stale · 1 correction; " +
162
+ "never replaces a dev's own statusline; `rafa status --line|--json` is the harness-" +
163
+ "neutral core — Claude Code today, Cursor/Codex adapters later) · a deterministic " +
164
+ "'suggested next' line in the session digest · DIRECT-DO routing (small work = no plan " +
165
+ "files, no approval; escalates only when it grows) · brainstorm mode (grounded " +
166
+ "participant, one crystallization offer). " +
167
+ "`rafa push` now re-runs the checker itself (stale/hand-stamped check records can " +
168
+ "never ship). Brain data schema unchanged; no re-scan; adopt with `rafa update` " +
169
+ "(re-vendors hooks, merges settings + statusline, installs the pre-push boundary).",
170
+ },
105
171
  ];
106
172
 
107
173
  // The release this CLI build ships (last entry).
package/lib/status.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // rafa status — the loop-state read, HARNESS-NEUTRAL CORE (portability
2
+ // directive, 2026-07-13: built for Claude Code now, ships for Cursor/Codex
3
+ // later — the logic lives HERE; harness adapters stay thin mirrors).
4
+ //
5
+ // rafa status human view of where the loop stands
6
+ // rafa status --line the one-line ambient form (statuslines, prompts, TUIs)
7
+ // rafa status --json machine shape (adapters, conductors, other harnesses)
8
+ //
9
+ // Reads ONLY local state (the queues + plans + active pointer) — fast, offline,
10
+ // no network. The Claude Code statusline adapter (.claude/rafa/hooks/
11
+ // statusline.mjs) mirrors this computation as a self-contained script; any
12
+ // drift between the two is a bug against THIS file's semantics.
13
+
14
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ function readJsonl(file, pick) {
18
+ if (!existsSync(file)) return [];
19
+ const out = [];
20
+ for (const line of readFileSync(file, "utf8").split("\n")) {
21
+ if (!line.trim()) continue;
22
+ try {
23
+ const e = JSON.parse(line);
24
+ if (e) out.push(e);
25
+ } catch {
26
+ /* torn line */
27
+ }
28
+ }
29
+ return pick ? pick(out) : out;
30
+ }
31
+
32
+ function walk(dir, depth, hit, out = []) {
33
+ if (depth < 0 || !existsSync(dir)) return out;
34
+ for (const e of readdirSync(dir)) {
35
+ if (e === ".git") continue;
36
+ const p = join(dir, e);
37
+ try {
38
+ if (statSync(p).isDirectory()) walk(p, depth - 1, hit, out);
39
+ else if (hit(e)) out.push(p);
40
+ } catch {
41
+ /* races are fine */
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ // Frontmatter line-scan (the hooks' shared idiom — never a YAML parser).
48
+ function fmField(path, key) {
49
+ try {
50
+ const lines = readFileSync(path, "utf8").split("\n");
51
+ let fence = 0;
52
+ for (const line of lines) {
53
+ if (line.trim() === "---") { fence++; if (fence >= 2) break; continue; }
54
+ if (fence !== 1) continue;
55
+ const m = line.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`));
56
+ if (m) return m[1].replace(/\s+#.*$/, "").replace(/^["']|["']$/g, "").trim();
57
+ }
58
+ } catch {
59
+ /* unreadable = absent */
60
+ }
61
+ return null;
62
+ }
63
+
64
+ // The whole loop state, from local files only.
65
+ export function computeStatus(root = process.cwd()) {
66
+ const rafaDir = join(root, ".rafa");
67
+ const provisioned = existsSync(join(root, "rafa.json"));
68
+
69
+ // dirty: unique files
70
+ const dirty = new Set(
71
+ readJsonl(join(rafaDir, "dirty.jsonl")).filter((e) => typeof e.f === "string").map((e) => e.f),
72
+ ).size;
73
+
74
+ // reflex: pending = latest-per-id without a done marker
75
+ const byId = new Map();
76
+ for (const e of readJsonl(join(rafaDir, "reflex.jsonl"))) {
77
+ if (e.id) byId.set(e.id, { ...(byId.get(e.id) ?? {}), ...e });
78
+ }
79
+ const reflex = [...byId.values()].filter((e) => !e.done).length;
80
+
81
+ // checkpoint conflicts awaiting a human
82
+ const conflicts = walk(rafaDir, 4, (n) => n.endsWith(".theirs.md")).length;
83
+
84
+ // active plan + task progress (plans v2: kind task|subtask, status done|…)
85
+ let plan = null;
86
+ const activePath = join(rafaDir, "active.md");
87
+ if (existsSync(activePath)) {
88
+ const first = readFileSync(activePath, "utf8").split("\n")[0].trim();
89
+ const id = first.startsWith("# ") && first !== "# No active plan" ? first.slice(2).trim() : null;
90
+ if (id) {
91
+ let done = 0;
92
+ let total = 0;
93
+ let current = null;
94
+ for (const f of walk(join(rafaDir, "plans"), 3, (n) => n.endsWith(".md") && !n.startsWith("_"))) {
95
+ if (fmField(f, "plan") !== id) continue;
96
+ const kind = fmField(f, "kind");
97
+ if (kind !== "task" && kind !== "subtask") continue;
98
+ total++;
99
+ const status = fmField(f, "status");
100
+ if (status === "done") done++;
101
+ else if (status === "in-progress" && !current)
102
+ current = fmField(f, "title") ?? fmField(f, "id");
103
+ }
104
+ plan = { id, done, total, current };
105
+ }
106
+ }
107
+
108
+ return { provisioned, plan, dirty, reflex, conflicts };
109
+ }
110
+
111
+ // The ambient one-liner — identical semantics wherever it renders.
112
+ export function formatLine(s) {
113
+ if (!s.provisioned) return "";
114
+ const parts = [];
115
+ if (s.plan)
116
+ parts.push(
117
+ `plan ${s.plan.id} ${s.plan.done}/${s.plan.total}` +
118
+ (s.plan.current ? ` → ${s.plan.current}` : ""),
119
+ );
120
+ if (s.dirty) parts.push(`${s.dirty} stale`);
121
+ if (s.reflex) parts.push(`${s.reflex} correction${s.reflex === 1 ? "" : "s"}`);
122
+ if (s.conflicts) parts.push(`${s.conflicts} conflict${s.conflicts === 1 ? "" : "s"}`);
123
+ return parts.length ? `rafa ▸ ${parts.join(" · ")}` : "rafa ▸ in sync";
124
+ }
125
+
126
+ export default async function status(args = []) {
127
+ const s = computeStatus(process.cwd());
128
+ if (args.includes("--json")) {
129
+ console.log(JSON.stringify(s, null, 2));
130
+ return;
131
+ }
132
+ if (args.includes("--line")) {
133
+ const line = formatLine(s);
134
+ if (line) console.log(line);
135
+ return;
136
+ }
137
+ if (!s.provisioned) {
138
+ console.log("not a rafa repo (no rafa.json) — `npx @rafinery/cli init` to provision");
139
+ return;
140
+ }
141
+ console.log(formatLine(s));
142
+ if (s.plan)
143
+ console.log(
144
+ ` plan: ${s.plan.id} — ${s.plan.done}/${s.plan.total} tasks done` +
145
+ (s.plan.current ? ` · in progress: ${s.plan.current}` : ""),
146
+ );
147
+ if (s.dirty) console.log(` stale: ${s.dirty} code file(s) touched since last reconcile (rafa dirty)`);
148
+ if (s.reflex) console.log(` corrections: ${s.reflex} unprocessed (rafa reflex)`);
149
+ if (s.conflicts) console.log(` conflicts: ${s.conflicts} .theirs.md awaiting your decision`);
150
+ if (!s.plan && !s.dirty && !s.reflex && !s.conflicts)
151
+ console.log(" nothing pending — the loop is closed.");
152
+ }
package/lib/update.mjs CHANGED
@@ -31,6 +31,26 @@ export default async function update(args = []) {
31
31
  await copyBlueprint(blueprintSource(), TARGET, { onConflict: makeConflictResolver(args) }),
32
32
  );
33
33
  reportSettings(TARGET);
34
+ // Wire the repo's own MCP servers into the (owned) agent cards so subagents
35
+ // can actually reach them (zero-command orchestration, 2026-07-12).
36
+ {
37
+ const { mergeRepoMcpsIntoCards } = await import("./claude-config.mjs");
38
+ const r = mergeRepoMcpsIntoCards(TARGET);
39
+ for (const u of r.updated) console.log(` ✓ repo MCPs → ${u}`);
40
+ }
41
+ // M5 sensors: hooks config (merge, never clobber) + the per-clone pre-push
42
+ // boundary + the ambient loop-state statusline.
43
+ {
44
+ const { mergeClaudeHooks, mergeStatusLine } = await import("./claude-config.mjs");
45
+ const h = mergeClaudeHooks(TARGET);
46
+ if (h.skipped) console.log(` ! hooks untouched — ${h.skipped}`);
47
+ else if (h.added?.length) console.log(` ✓ sensor hooks → .claude/settings.json (${h.added.join(", ")})`);
48
+ const sl = mergeStatusLine(TARGET);
49
+ if (sl.skipped) console.log(` ! statusline untouched — ${sl.skipped}`);
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));
53
+ }
34
54
 
35
55
  // ── Blueprint-side migrations: mechanical/deterministic, run here in the CLI. ──
36
56
  // (Anything needing intelligence is left for the brain-side pass — /rafa update.)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rafinery/cli",
3
- "version": "0.4.1",
4
- "description": "rafa \u2014 the AI engineer CLI. Vendors the rafa blueprint into your repo (shadcn-style) and runs the deterministic contract gates.",
3
+ "version": "0.6.0",
4
+ "description": "rafa 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
7
  "rafa": "bin/rafa.mjs"