@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.
- package/CHANGELOG.md +97 -0
- package/bin/rafa.mjs +37 -5
- package/blueprint/.claude/agents/atlas.md +2 -1
- package/blueprint/.claude/agents/sage.md +66 -0
- package/blueprint/.claude/commands/rafa.md +214 -269
- package/blueprint/.claude/rafa/contract.md +204 -115
- 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 +20 -5
- package/blueprint/.claude/skills/rafa-distill/SKILL.md +6 -1
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +7 -0
- package/blueprint/.claude/skills/rafa-sage/SKILL.md +201 -0
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +55 -5
- package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
- package/lib/benchmark.mjs +573 -0
- package/lib/blueprint.mjs +11 -1
- package/lib/brain-repo.mjs +10 -4
- package/lib/ci-setup.mjs +2 -0
- package/lib/claude-config.mjs +77 -0
- package/lib/dirty.mjs +114 -0
- package/lib/distill.mjs +4 -0
- package/lib/gate/compile.mjs +293 -44
- package/lib/gate/verify-citations.mjs +214 -23
- package/lib/githook.mjs +54 -0
- package/lib/init.mjs +18 -0
- package/lib/pull.mjs +7 -0
- package/lib/push.mjs +21 -0
- package/lib/reflex.mjs +76 -0
- package/lib/releases.mjs +35 -0
- package/lib/status.mjs +152 -0
- package/lib/update.mjs +13 -0
- package/package.json +1 -1
package/lib/claude-config.mjs
CHANGED
|
@@ -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(
|