@rafinery/cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/bin/rafa.mjs +37 -5
  3. package/blueprint/.claude/agents/atlas.md +2 -1
  4. package/blueprint/.claude/agents/sage.md +66 -0
  5. package/blueprint/.claude/commands/rafa.md +214 -269
  6. package/blueprint/.claude/rafa/contract.md +204 -115
  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 +20 -5
  13. package/blueprint/.claude/skills/rafa-distill/SKILL.md +6 -1
  14. package/blueprint/.claude/skills/rafa-plan/SKILL.md +7 -0
  15. package/blueprint/.claude/skills/rafa-sage/SKILL.md +201 -0
  16. package/blueprint/.claude/skills/rafa-scan/SKILL.md +55 -5
  17. package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
  18. package/lib/benchmark.mjs +573 -0
  19. package/lib/blueprint.mjs +11 -1
  20. package/lib/brain-repo.mjs +10 -4
  21. package/lib/ci-setup.mjs +2 -0
  22. package/lib/claude-config.mjs +77 -0
  23. package/lib/dirty.mjs +114 -0
  24. package/lib/distill.mjs +4 -0
  25. package/lib/gate/compile.mjs +293 -44
  26. package/lib/gate/verify-citations.mjs +214 -23
  27. package/lib/githook.mjs +54 -0
  28. package/lib/init.mjs +18 -0
  29. package/lib/pull.mjs +7 -0
  30. package/lib/push.mjs +21 -0
  31. package/lib/reflex.mjs +76 -0
  32. package/lib/releases.mjs +35 -0
  33. package/lib/status.mjs +152 -0
  34. package/lib/update.mjs +13 -0
  35. package/package.json +1 -1
@@ -13,6 +13,83 @@ export const RAFA_PERMISSIONS = [
13
13
  "Bash(rafa:*)",
14
14
  ];
15
15
 
16
+ // The M5 sensor hooks (capture engine): SessionStart injects the state digest
17
+ // (staleness · conflicts · active plan), PostToolUse dirty-marks edited code
18
+ // files into .rafa/dirty.jsonl. Both scripts are vendored LOCKSTEP at
19
+ // .claude/rafa/hooks/ and are no-ops outside a rafa-provisioned repo.
20
+ export const RAFA_HOOKS = {
21
+ SessionStart: {
22
+ matcher: "startup|resume|clear",
23
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/rafa/hooks/session-start.mjs"',
24
+ },
25
+ PostToolUse: {
26
+ matcher: "Edit|Write|MultiEdit|NotebookEdit",
27
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/rafa/hooks/post-tool.mjs"',
28
+ },
29
+ // The correction reflex (M5 sensor #4): detect correction-shaped prompts,
30
+ // queue them, and inject the bank-it-now steering at exactly that moment.
31
+ UserPromptSubmit: {
32
+ matcher: "*",
33
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/rafa/hooks/user-prompt-submit.mjs"',
34
+ },
35
+ };
36
+
37
+ // The ambient loop-state statusline (frictionless loop, 2026-07-13). Set ONLY
38
+ // when the dev has no statusline of their own, or theirs is already ours —
39
+ // a configured statusline is the dev's turf, never replaced (merge, don't own).
40
+ export const RAFA_STATUSLINE = {
41
+ type: "command",
42
+ command: 'node "$CLAUDE_PROJECT_DIR/.claude/rafa/hooks/statusline.mjs"',
43
+ };
44
+
45
+ export function mergeStatusLine(targetDir) {
46
+ const file = settingsPath(targetDir);
47
+ const existing = readClaudeSettings(targetDir);
48
+ if (existing && existing.error) {
49
+ return { skipped: `.claude/settings.json is not valid JSON (${existing.error})` };
50
+ }
51
+ const settings = existing ?? {};
52
+ const cur = settings.statusLine;
53
+ const ours = typeof cur?.command === "string" && cur.command.includes(".claude/rafa/hooks/statusline.mjs");
54
+ if (cur && !ours) return { skipped: "you already have a statusline — keeping yours (rafa's shows via `rafa status --line`)" };
55
+ if (ours && cur.command === RAFA_STATUSLINE.command) return { added: [] };
56
+ const next = { ...settings, statusLine: { ...RAFA_STATUSLINE } };
57
+ mkdirSync(dirname(file), { recursive: true });
58
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
59
+ return { added: ["statusLine"] };
60
+ }
61
+
62
+ // Merge rafa's sensor hooks into .claude/settings.json WITHOUT clobbering the
63
+ // dev's own hook config: a rafa hook is recognized by its script path — if any
64
+ // handler for that event already points at .claude/rafa/hooks/<script>, the
65
+ // event is left untouched (matcher/command tuning survives updates). Returns
66
+ // { added: [eventNames] } or { skipped: "reason" }.
67
+ export function mergeClaudeHooks(targetDir, defs = RAFA_HOOKS) {
68
+ const file = settingsPath(targetDir);
69
+ const existing = readClaudeSettings(targetDir);
70
+ if (existing && existing.error) {
71
+ return { skipped: `.claude/settings.json is not valid JSON (${existing.error})` };
72
+ }
73
+ const settings = existing ?? {};
74
+ const hooks = { ...(settings.hooks ?? {}) };
75
+ const added = [];
76
+ for (const [event, def] of Object.entries(defs)) {
77
+ const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
78
+ const marker = def.command.match(/\.claude\/rafa\/hooks\/[\w.-]+/)?.[0] ?? def.command;
79
+ const present = groups.some((g) =>
80
+ (g?.hooks ?? []).some((h) => typeof h?.command === "string" && h.command.includes(marker)),
81
+ );
82
+ if (present) continue;
83
+ hooks[event] = [...groups, { matcher: def.matcher, hooks: [{ type: "command", command: def.command }] }];
84
+ added.push(event);
85
+ }
86
+ if (added.length === 0 && existing) return { added: [] };
87
+ const next = { ...settings, hooks };
88
+ mkdirSync(dirname(file), { recursive: true });
89
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
90
+ return { added };
91
+ }
92
+
16
93
  // Merge the repo's .mcp.json server names into the OWNED agent cards' tools
17
94
  // lines (ratified 2026-07-12: subagents must be able to reach the repo's MCPs
18
95
  // — a Convex repo's mcp__convex tools were structurally invisible to atlas).
package/lib/dirty.mjs ADDED
@@ -0,0 +1,114 @@
1
+ // rafa dirty — the cite-graph invalidator's query surface (M5, agent-internal:
2
+ // the conductor runs this at boundaries; devs never need to remember it).
3
+ //
4
+ // rafa dirty human view: dirty code files + the brain notes citing them
5
+ // rafa dirty --json the same, machine-shaped (conductor bootstrap / spawn prompts)
6
+ // rafa dirty --consume clear the queue AFTER a completed scoped refresh — the
7
+ // refresh must have shipped (gates + checkpoint/push) first
8
+ //
9
+ // The queue (.rafa/dirty.jsonl) is written by the PostToolUse hook the moment an
10
+ // edit happens (monotonic, no session-end dependency). Citing notes resolve from
11
+ // the LOCAL manifest when one exists (full pull / prior compile); otherwise the
12
+ // mapping is honestly "unresolved here" — counts still serve, and the platform's
13
+ // get_code_context covers work-time recall. Never guesses.
14
+
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ export const DIRTY_FILES_ALARM = 15; // ≥ → recommend /rafa scan --brain-only
19
+ export const CITED_NOTES_ALARM = 10;
20
+
21
+ export function readDirty(root = process.cwd()) {
22
+ const file = join(root, ".rafa", "dirty.jsonl");
23
+ const files = new Map(); // path → last touch
24
+ if (!existsSync(file)) return { file, files: [] };
25
+ for (const line of readFileSync(file, "utf8").split("\n")) {
26
+ if (!line.trim()) continue;
27
+ try {
28
+ const e = JSON.parse(line);
29
+ if (e && typeof e.f === "string") files.set(e.f, e.t ?? "");
30
+ } catch {
31
+ /* torn line — skip, never fail the query */
32
+ }
33
+ }
34
+ return { file, files: [...files.entries()].map(([f, t]) => ({ f, t })) };
35
+ }
36
+
37
+ // file → citing notes from the local manifest; null = no manifest here (lazy .rafa).
38
+ export function resolveCiters(root, dirtyFiles) {
39
+ const path = join(root, ".rafa", "manifest.json");
40
+ if (!existsSync(path)) return null;
41
+ let m;
42
+ try {
43
+ m = JSON.parse(readFileSync(path, "utf8"));
44
+ } catch {
45
+ return null;
46
+ }
47
+ const want = new Set(dirtyFiles);
48
+ const notes = [];
49
+ for (const group of [m.notes ?? [], m.improvements ?? []]) {
50
+ for (const n of group) {
51
+ const files = [...new Set((n.cites ?? []).filter((c) => want.has(c.file)).map((c) => c.file))];
52
+ if (files.length) notes.push({ id: n.id, path: n.path, files });
53
+ }
54
+ }
55
+ return notes;
56
+ }
57
+
58
+ export default async function dirty(args = []) {
59
+ const ROOT = process.cwd();
60
+ const { file, files } = readDirty(ROOT);
61
+
62
+ if (args.includes("--consume")) {
63
+ if (files.length === 0) {
64
+ console.log("✓ dirty queue already clean");
65
+ return;
66
+ }
67
+ writeFileSync(file, "");
68
+ console.log(
69
+ `✓ consumed ${files.length} dirty file(s) — do this ONLY after the scoped refresh shipped (gates + checkpoint/push)`,
70
+ );
71
+ return;
72
+ }
73
+
74
+ const notes = resolveCiters(ROOT, files.map((e) => e.f));
75
+ const alarm =
76
+ files.length >= DIRTY_FILES_ALARM || (notes !== null && notes.length >= CITED_NOTES_ALARM);
77
+
78
+ if (args.includes("--json")) {
79
+ console.log(
80
+ JSON.stringify(
81
+ {
82
+ files: files.map((e) => e.f),
83
+ notes, // null = unresolved here (no local manifest) — an honest unknown, not zero
84
+ thresholds: { files: DIRTY_FILES_ALARM, notes: CITED_NOTES_ALARM },
85
+ alarm,
86
+ },
87
+ null,
88
+ 2,
89
+ ),
90
+ );
91
+ return;
92
+ }
93
+
94
+ if (files.length === 0) {
95
+ console.log("✓ dirty queue clean — no code edits recorded since the last brain reconcile");
96
+ return;
97
+ }
98
+ console.log(`${files.length} code file(s) edited since the last brain reconcile:`);
99
+ for (const e of files) console.log(` · ${e.f}`);
100
+ if (notes === null) {
101
+ console.log("citing notes: unresolved here (lazy .rafa, no local manifest) — recall still");
102
+ console.log("serves fresh knowledge via the platform; run a scoped refresh from a session.");
103
+ } else if (notes.length === 0) {
104
+ console.log("citing notes: none — nothing in the brain cites these files (nothing to refresh).");
105
+ } else {
106
+ console.log(`${notes.length} brain note(s) cite them:`);
107
+ for (const n of notes) console.log(` · ${n.id} ← ${n.files.join(", ")}`);
108
+ }
109
+ console.log(
110
+ alarm
111
+ ? `⚠ drift past threshold — recommend a brain refresh from main (/rafa scan --brain-only), then \`rafa dirty --consume\`.`
112
+ : `→ at the next boundary: scoped refresh of the citing notes (atlas re-derives ONLY those → gates → checkpoint), then \`rafa dirty --consume\`.`,
113
+ );
114
+ }
package/lib/distill.mjs CHANGED
@@ -61,6 +61,10 @@ async function loadAgentSdk(cwd) {
61
61
  const VERDICTS = ["distilled", "refuted", "needs-adjudication"];
62
62
 
63
63
  export default async function distill(args = []) {
64
+ // U7 recursion guard, belt-and-braces: the Agent SDK worker must never fire
65
+ // the M5 session sensors (a worker's own tool calls re-triggering hooks would
66
+ // loop). The ci-setup workflow sets this too; this covers ad-hoc invocations.
67
+ process.env.RAFA_HOOKS_DISABLED = "1";
64
68
  const branch = args.filter((a) => !a.startsWith("-"))[0];
65
69
  if (!args.includes("--headless"))
66
70
  die(