@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.
- package/CHANGELOG.md +144 -0
- package/bin/rafa.mjs +30 -5
- package/blueprint/.claude/agents/atlas.md +10 -5
- package/blueprint/.claude/agents/compass.md +3 -1
- package/blueprint/.claude/commands/rafa.md +103 -12
- package/blueprint/.claude/rafa/contract.md +111 -47
- 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 +17 -5
- package/blueprint/.claude/skills/rafa-insights/SKILL.md +13 -2
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +18 -3
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +23 -3
- package/blueprint/.claude/skills/rafa-validate/SKILL.md +15 -2
- package/lib/blueprint.mjs +9 -1
- package/lib/brain-repo.mjs +10 -4
- package/lib/checkpoint.mjs +7 -0
- package/lib/ci-setup.mjs +2 -0
- package/lib/claude-config.mjs +113 -0
- package/lib/dirty.mjs +114 -0
- package/lib/distill.mjs +47 -2
- package/lib/gate/compile.mjs +145 -32
- package/lib/gate/verify-citations.mjs +214 -23
- package/lib/githook.mjs +54 -0
- package/lib/init.mjs +45 -0
- package/lib/leverage.mjs +13 -2
- package/lib/migrations/index.mjs +57 -2
- package/lib/pull.mjs +7 -0
- package/lib/push.mjs +21 -0
- package/lib/reflex.mjs +76 -0
- package/lib/releases.mjs +66 -0
- package/lib/status.mjs +152 -0
- package/lib/update.mjs +20 -0
- package/package.json +2 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// rafa · statusline — the CLAUDE CODE ADAPTER for the loop-state line.
|
|
3
|
+
//
|
|
4
|
+
// HARNESS ADAPTER, deliberately thin (portability directive 2026-07-13: built
|
|
5
|
+
// for Claude Code now, Cursor/Codex later). The semantics are owned by the
|
|
6
|
+
// harness-neutral core — `rafa status` (packages/cli/lib/status.mjs); this
|
|
7
|
+
// script mirrors that computation as a self-contained, dependency-free file
|
|
8
|
+
// because statuslines re-render often (an npx call here would lag the UI).
|
|
9
|
+
// Any drift between this and `rafa status --line` is a bug against the core.
|
|
10
|
+
//
|
|
11
|
+
// Claude Code invokes it with session JSON on stdin; whatever one line it
|
|
12
|
+
// prints becomes the statusline. Ambient guidance, zero keystrokes, zero nags:
|
|
13
|
+
// rafa ▸ plan auth-rate-limit 2/5 → add limiter to route · 3 stale · 1 correction
|
|
14
|
+
// rafa ▸ in sync
|
|
15
|
+
// Outside a rafa repo it prints nothing (silent — never hijacks the statusline).
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
function readJsonl(file) {
|
|
21
|
+
if (!existsSync(file)) return [];
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
24
|
+
if (!line.trim()) continue;
|
|
25
|
+
try {
|
|
26
|
+
const e = JSON.parse(line);
|
|
27
|
+
if (e) out.push(e);
|
|
28
|
+
} catch {
|
|
29
|
+
/* torn line */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function walk(dir, depth, hit, out = []) {
|
|
36
|
+
if (depth < 0 || !existsSync(dir)) return out;
|
|
37
|
+
for (const e of readdirSync(dir)) {
|
|
38
|
+
if (e === ".git") continue;
|
|
39
|
+
const p = join(dir, e);
|
|
40
|
+
try {
|
|
41
|
+
if (statSync(p).isDirectory()) walk(p, depth - 1, hit, out);
|
|
42
|
+
else if (hit(e)) out.push(p);
|
|
43
|
+
} catch {
|
|
44
|
+
/* races are fine */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fmField(path, key) {
|
|
51
|
+
try {
|
|
52
|
+
let fence = 0;
|
|
53
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
54
|
+
if (line.trim() === "---") { fence++; if (fence >= 2) break; continue; }
|
|
55
|
+
if (fence !== 1) continue;
|
|
56
|
+
const m = line.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`));
|
|
57
|
+
if (m) return m[1].replace(/\s+#.*$/, "").replace(/^["']|["']$/g, "").trim();
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
/* unreadable = absent */
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
let root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
67
|
+
try {
|
|
68
|
+
const evt = JSON.parse(readFileSync(0, "utf8"));
|
|
69
|
+
root = evt?.workspace?.current_dir || evt?.workspace?.project_dir || evt?.cwd || root;
|
|
70
|
+
} catch {
|
|
71
|
+
/* no/bad stdin — fall back to env/cwd */
|
|
72
|
+
}
|
|
73
|
+
if (!existsSync(join(root, "rafa.json"))) process.exit(0); // silent outside rafa repos
|
|
74
|
+
|
|
75
|
+
const rafaDir = join(root, ".rafa");
|
|
76
|
+
const dirty = new Set(
|
|
77
|
+
readJsonl(join(rafaDir, "dirty.jsonl")).filter((e) => typeof e.f === "string").map((e) => e.f),
|
|
78
|
+
).size;
|
|
79
|
+
const byId = new Map();
|
|
80
|
+
for (const e of readJsonl(join(rafaDir, "reflex.jsonl"))) if (e.id) byId.set(e.id, { ...(byId.get(e.id) ?? {}), ...e });
|
|
81
|
+
const reflex = [...byId.values()].filter((e) => !e.done).length;
|
|
82
|
+
const conflicts = walk(rafaDir, 4, (n) => n.endsWith(".theirs.md")).length;
|
|
83
|
+
|
|
84
|
+
let plan = null;
|
|
85
|
+
const activePath = join(rafaDir, "active.md");
|
|
86
|
+
if (existsSync(activePath)) {
|
|
87
|
+
const first = readFileSync(activePath, "utf8").split("\n")[0].trim();
|
|
88
|
+
const id = first.startsWith("# ") && first !== "# No active plan" ? first.slice(2).trim() : null;
|
|
89
|
+
if (id) {
|
|
90
|
+
let done = 0, total = 0, current = null;
|
|
91
|
+
for (const f of walk(join(rafaDir, "plans"), 3, (n) => n.endsWith(".md") && !n.startsWith("_"))) {
|
|
92
|
+
if (fmField(f, "plan") !== id) continue;
|
|
93
|
+
const kind = fmField(f, "kind");
|
|
94
|
+
if (kind !== "task" && kind !== "subtask") continue;
|
|
95
|
+
total++;
|
|
96
|
+
const status = fmField(f, "status");
|
|
97
|
+
if (status === "done") done++;
|
|
98
|
+
else if (status === "in-progress" && !current) current = fmField(f, "title") ?? fmField(f, "id");
|
|
99
|
+
}
|
|
100
|
+
plan = { id, done, total, current };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const parts = [];
|
|
105
|
+
if (plan) parts.push(`plan ${plan.id} ${plan.done}/${plan.total}` + (plan.current ? ` → ${plan.current}` : ""));
|
|
106
|
+
if (dirty) parts.push(`${dirty} stale`);
|
|
107
|
+
if (reflex) parts.push(`${reflex} correction${reflex === 1 ? "" : "s"}`);
|
|
108
|
+
if (conflicts) parts.push(`${conflicts} conflict${conflicts === 1 ? "" : "s"}`);
|
|
109
|
+
process.stdout.write(parts.length ? `rafa ▸ ${parts.join(" · ")}` : "rafa ▸ in sync");
|
|
110
|
+
} catch {
|
|
111
|
+
/* a statusline must never error the UI */
|
|
112
|
+
}
|
|
113
|
+
process.exit(0);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// rafa hook · UserPromptSubmit — the correction reflex, detect half (M5 sensor #4).
|
|
3
|
+
//
|
|
4
|
+
// A dev correcting the agent is the highest-value knowledge signal the system
|
|
5
|
+
// sees. This hook makes the MOMENT deterministic: a correction-shaped prompt is
|
|
6
|
+
// (a) queued to .rafa/reflex.jsonl (monotonic — an abandoned session loses
|
|
7
|
+
// nothing; the SessionStart digest resurfaces unprocessed corrections), and
|
|
8
|
+
// (b) answered with a steering injection so THIS session validates and banks it
|
|
9
|
+
// through the normal gates right now — same session, not next scan.
|
|
10
|
+
//
|
|
11
|
+
// The hook only detects CANDIDATES (narrow patterns, cheap, no LLM); judging
|
|
12
|
+
// whether it's durable repo knowledge is model work downstream (capture is
|
|
13
|
+
// cheap, distillation is rigorous). False positive cost: one injected line.
|
|
14
|
+
// Privacy: the queue stores a short prompt snippet + a LOCAL transcript path —
|
|
15
|
+
// nothing here ever ships; only a distilled, cited note passes the gates.
|
|
16
|
+
//
|
|
17
|
+
// Hard rules: never block (exit 0 everywhere), no network, no LLM.
|
|
18
|
+
// Honors RAFA_HOOKS_DISABLED=1 (headless/CI recursion guard).
|
|
19
|
+
|
|
20
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
// Narrow, strong correction shapes — leading negation/contradiction, or an
|
|
24
|
+
// explicit never/don't-use instruction. Deliberately NOT matching every "no"
|
|
25
|
+
// mid-sentence; missed captures still have the dev's "bank that" as a path.
|
|
26
|
+
const CORRECTION = [
|
|
27
|
+
/^(no\b|nope\b|wrong\b|incorrect\b|that'?s (wrong|incorrect|not right)|not (quite|right)\b|actually[,\s])/i,
|
|
28
|
+
/\b(never|don'?t|do not|stop) (use|do|call|import|write|put|add|touch)\b/i,
|
|
29
|
+
/\buse\s+\S[^.!?]{0,60}\binstead\b/i,
|
|
30
|
+
/\binstead of\b[^.!?]{0,60}\buse\b/i,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
if (process.env.RAFA_HOOKS_DISABLED === "1") process.exit(0);
|
|
35
|
+
|
|
36
|
+
let evt = null;
|
|
37
|
+
try {
|
|
38
|
+
evt = JSON.parse(readFileSync(0, "utf8"));
|
|
39
|
+
} catch {
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
const root = process.env.CLAUDE_PROJECT_DIR || evt?.cwd || process.cwd();
|
|
43
|
+
if (!existsSync(join(root, "rafa.json"))) process.exit(0);
|
|
44
|
+
|
|
45
|
+
const prompt = typeof evt?.prompt === "string" ? evt.prompt.trim() : "";
|
|
46
|
+
if (!prompt || prompt.startsWith("/")) process.exit(0); // commands are not corrections
|
|
47
|
+
if (!CORRECTION.some((re) => re.test(prompt))) process.exit(0);
|
|
48
|
+
|
|
49
|
+
const rafaDir = join(root, ".rafa");
|
|
50
|
+
mkdirSync(rafaDir, { recursive: true });
|
|
51
|
+
// Transport exclusion (same belt-and-braces as the dirty queue).
|
|
52
|
+
const gi = join(rafaDir, ".gitignore");
|
|
53
|
+
const body = existsSync(gi) ? readFileSync(gi, "utf8") : "";
|
|
54
|
+
if (!/^reflex\.jsonl$/m.test(body))
|
|
55
|
+
writeFileSync(gi, body + (body === "" || body.endsWith("\n") ? "" : "\n") + "reflex.jsonl\n");
|
|
56
|
+
|
|
57
|
+
const id = `rx_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
58
|
+
appendFileSync(
|
|
59
|
+
join(rafaDir, "reflex.jsonl"),
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
id,
|
|
62
|
+
p: prompt.slice(0, 300),
|
|
63
|
+
t: new Date().toISOString(),
|
|
64
|
+
tp: typeof evt?.transcript_path === "string" ? evt.transcript_path : null, // LOCAL pointer, never ships
|
|
65
|
+
}) + "\n",
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// The steering injection — arrives WITH the correction itself, so the session
|
|
69
|
+
// handles it at exactly the right moment (no SOP memory involved).
|
|
70
|
+
process.stdout.write(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
hookSpecificOutput: {
|
|
73
|
+
hookEventName: "UserPromptSubmit",
|
|
74
|
+
additionalContext:
|
|
75
|
+
`[rafa · reflex ${id}] This prompt reads as a CORRECTION of prior agent output. ` +
|
|
76
|
+
`After addressing it: judge whether it encodes DURABLE repo knowledge (code-groundable — a convention, contract, or fact a future session would need). ` +
|
|
77
|
+
`If yes: bank it NOW through the gates — on a branch: author/edit the note under .rafa/brain/** with ≥1 resolving cite + \`rafa checkpoint\`; on main: full gates (verify-citations · compile · push). ` +
|
|
78
|
+
`Then confirm to the dev in ONE line (note id + where it landed) and run \`rafa reflex --consume ${id}\`. ` +
|
|
79
|
+
`If it is NOT durable repo knowledge (one-off steer, preference, out-of-code claim): say it stays session-only, and still \`rafa reflex --consume ${id}\` with reason "session-only". ` +
|
|
80
|
+
`Never bank an ungroundable claim; never quote the transcript into any shared store.`,
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
/* a sensor must never take down the session */
|
|
86
|
+
}
|
|
87
|
+
process.exit(0);
|
|
@@ -17,8 +17,8 @@ platform MCP (one read path — the same surface any third-party agent uses).
|
|
|
17
17
|
| Role | Agent | Job per task |
|
|
18
18
|
|---|---|---|
|
|
19
19
|
| **Executor** | atlas | RECALL the task's brain slice via MCP (`search_knowledge` + `get_rule`/`get_playbook`; honor non-exemplars) → implement, convention-adherent |
|
|
20
|
-
| **Validator** | prism | validate the execution against the child's `## Done-check` — strict, unbiased, against code + brain, never against atlas's claims. **`status: done` only on prism PASS**; FAIL → atlas corrects (validate-and-correct at work time).
|
|
21
|
-
| **Improver** | bloom | **push**: new improvement opportunities spotted during execution → new ledger files. **close**: improvements fixed in passing → `status: fixed
|
|
20
|
+
| **Validator** | prism | validate the execution against the child's `## Done-check` — strict, unbiased, against code + brain, never against atlas's claims. **`status: done` only on prism PASS**; FAIL → atlas corrects (validate-and-correct at work time). Plan-done adds one line to the verdict: **working set reviewed — captured, or clean-with-reason** (a build that learned nothing SAYS so; a build that learned something SHOWS the files) |
|
|
21
|
+
| **Improver** | bloom | **push**: new improvement opportunities spotted during execution → new ledger files. **close**: improvements fixed in passing → `status: fixed` in the ledger file + `report_improvement_status(id, fixed)` so the platform shows it LIVE as pending-reconciliation (the ledger row itself changes only at the next brain push — K1). **nudge**: top-leverage open item in the task's blast radius — opt-in, never blocking |
|
|
22
22
|
|
|
23
23
|
## Procedure
|
|
24
24
|
|
|
@@ -40,11 +40,19 @@ platform MCP (one read path — the same surface any third-party agent uses).
|
|
|
40
40
|
parsed; the plan files at `.rafa/plans/<plan>/` ARE the local cache) → at the
|
|
41
41
|
task-done CHECKPOINT (under the session consent): `push_plan` /
|
|
42
42
|
`update_plan_status` (the plans channel — statuses + Log journals) +
|
|
43
|
+
**`log_decision` for each deliberation since the last checkpoint** — what
|
|
44
|
+
came up, what was considered, what the DEV chose, why (actor = the dev for
|
|
45
|
+
steering, the agent for its own proposals; PARAPHRASE + short verbatim
|
|
46
|
+
quotes only where the wording carries the decision — transcripts never land
|
|
47
|
+
in shared stores; mirror each into the item's `## Decisions` section) +
|
|
43
48
|
`rafa checkpoint` (the branch working set), so the platform and every MCP
|
|
44
49
|
consumer reflect live progress. Checkpoint moments: task done · plan
|
|
45
|
-
approved · explicit ask · cadence · git push/pull — never session-end.
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
approved · explicit ask · cadence · git push/pull — never session-end. The
|
|
51
|
+
git-push boundary is MECHANICAL (M5): the pre-push hook runs `rafa checkpoint`
|
|
52
|
+
itself, non-blocking — the session still owns the task-done/plan-approved
|
|
53
|
+
moments. A checkpoint CONFLICT (a teammate's newer copy of the same file) is
|
|
54
|
+
decided IN THIS SESSION: read the `.theirs.md` copy, merge/adopt/keep,
|
|
55
|
+
re-checkpoint.
|
|
48
56
|
3. **Brain changes mid-build — WHERE you are decides WHERE it goes.**
|
|
49
57
|
- **On the default branch (main):** run a full `/rafa scan` (regenerate →
|
|
50
58
|
prism → compile → push); the brain re-stamps at the new sha, so
|
|
@@ -61,6 +69,10 @@ platform MCP (one read path — the same surface any third-party agent uses).
|
|
|
61
69
|
4. **Verify** (prism-style) before declaring the plan done; final `push_plan` +
|
|
62
70
|
`set_active_plan` (clear) + `rafa checkpoint`. A plan that stops being worth
|
|
63
71
|
finishing closes honestly: `superseded` or `abandoned`, never fake-`done`.
|
|
72
|
+
Plan-done is also a **staleness boundary**: read `rafa dirty --json` — if the
|
|
73
|
+
build's edits dirtied notes this session didn't already refresh, surface the
|
|
74
|
+
scoped-refresh offer NOW (on main: refresh → gates → push; on a branch:
|
|
75
|
+
working-set edit → checkpoint), and `rafa dirty --consume` only after it ships.
|
|
64
76
|
|
|
65
77
|
**Lite plans** (single-child, from plan-lite) run the same per-task loop — the
|
|
66
78
|
Done-check gate never relaxes; only the ceremony around it shrinks (no bloom nudge,
|
|
@@ -58,9 +58,20 @@ code-level → the branch working set (not yours — rafa.md §capture routes it
|
|
|
58
58
|
existing insight (`put_dev_insight` with its id) rather than accreting near-
|
|
59
59
|
copies.
|
|
60
60
|
|
|
61
|
+
### 2b. Personal tooling (ratified 2026-07-12 — the user-plane toolbox)
|
|
62
|
+
Under the dev's STANDING insights consent, inventory the tooling they carry
|
|
63
|
+
across repos — `~/.claude/skills` + user-level MCPs — and bank each as a
|
|
64
|
+
`kind: tooling` insight (NAME + DESCRIPTION only, never file contents). Update
|
|
65
|
+
beats duplicate; tombstones honored. This is what lets rafa orchestrate a
|
|
66
|
+
dev's personal migration skill in a repo that has never seen it. The REPO
|
|
67
|
+
toolbox is atlas's plane (scan's toolbox domain), never yours.
|
|
68
|
+
|
|
61
69
|
### 3. Steering (the payoff — propose, the dev disposes)
|
|
62
|
-
At natural boundaries only, at most one nudge
|
|
63
|
-
|
|
70
|
+
At natural boundaries only, at most one nudge — and ALWAYS have a candidate
|
|
71
|
+
ready (leverage-coaching: cross the repo's toolbox inventory with the dev's
|
|
72
|
+
patterns + tooling insights — "you've hand-run this migration three times; the
|
|
73
|
+
repo ships a skill for it"). Availability is always; frequency never rises.
|
|
74
|
+
Surface the insight that changes what the dev does next — "you've corrected X three times: want it banked as a
|
|
64
75
|
rule?" · "this decomposition worked before, reuse it?" · "you never use plan
|
|
65
76
|
mode on big changes; it would have caught this." Dismissal is final for the
|
|
66
77
|
session. Never rank, never compare devs, never nag.
|
|
@@ -37,9 +37,17 @@ Planning is a choreography, not one agent (spec: knowledge-mcp-build-agent):
|
|
|
37
37
|
1. **Staleness check** — compare the platform envelope's `brainForSha` against the
|
|
38
38
|
local brain stamp; if the platform is behind, surface "run `rafa push`" (never
|
|
39
39
|
proceed silently on knowledge you know is stale — never block either).
|
|
40
|
-
2. **Recall** (atlas, via MCP) → **decompose**
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
2. **Recall** (atlas, via MCP) → **decompose** into the WORK-ITEM TREE (contract
|
|
41
|
+
§7 v2): one epic → tasks → subtasks (three ranks, never deeper). Every item
|
|
42
|
+
carries the glimpse fields — `title` (what) · `description` (why) ·
|
|
43
|
+
`approach` (how, one line) · `assignee` when known · `blocked_by` for
|
|
44
|
+
intra-plan dependencies (a dependency IS a blocker; blocked is DERIVED,
|
|
45
|
+
never a status) · optional `priority` 0–4 / `estimate`. Every LEAF carries a
|
|
46
|
+
`## Done-check`. The blast radius goes on the EPIC's `domains:` — it rides
|
|
47
|
+
`push_plan` and the platform renders the plan's brain slice beside it.
|
|
48
|
+
ADR material (alternatives, risks, non-goals) lives in the epic body AND
|
|
49
|
+
the pivotal choices are logged as DECISIONS at approval (`log_decision`:
|
|
50
|
+
context · options · decision · rationale; actor = the dev for their calls).
|
|
43
51
|
3. **Ledger pull** (bloom) → optional leverage tasks in the blast radius.
|
|
44
52
|
4. **Leverage-match** — recommend existing skills/tools/MCP that fit the tasks;
|
|
45
53
|
never plan to hand-roll what a capability already does.
|
|
@@ -59,6 +67,13 @@ The full choreography earns its weight on cross-cutting work; a one-file change
|
|
|
59
67
|
through five steps teaches devs to route around rafa — and a route-around is a product
|
|
60
68
|
failure. So the conductor weighs the **blast radius** (from coverage at recall time):
|
|
61
69
|
|
|
70
|
+
- **Below planning entirely — DIRECT-DO (conductor 1.8.0):** radius ≤ 1 domain, no
|
|
71
|
+
contract surface, fits one sitting → NO plan files are created at all. The
|
|
72
|
+
conductor acts (recall → implement → verify) and the sensors carry the loop
|
|
73
|
+
(dirty-mark · reflex · checkpoint-at-push · capture if knowledge emerged). Plans
|
|
74
|
+
begin where RESUMABILITY or COORDINATION begins — not before. If direct-do work
|
|
75
|
+
GROWS (2nd domain, contract surface, multi-session), the conductor escalates to
|
|
76
|
+
lite with one announce line and creates the files THEN.
|
|
62
77
|
- **Lite** (radius ≤ 2 domains and no contract/schema surface touched): ONE parent +
|
|
63
78
|
ONE child file, recall scoped to the touched domains, bloom pull skipped, prism gate
|
|
64
79
|
collapses to the two invariants that never relax — every task grounded, the child
|
|
@@ -81,9 +81,14 @@ file, finds nothing, and stops trusting the brain. So fidelity is non-negotiable
|
|
|
81
81
|
- **One docs rule, stated once.** Contract site lists count **code** occurrences; exclude
|
|
82
82
|
docs/markdown/comments. Apply this identically to every contract — never "the full
|
|
83
83
|
surface" for one and "the rest are docs" for another. State the exclusion once per note.
|
|
84
|
-
- **Never assert absence without an exhaustive grep
|
|
85
|
-
anywhere" require a repo-wide
|
|
86
|
-
|
|
84
|
+
- **Never assert absence without an exhaustive grep — and DECLARE it so the gate re-greps
|
|
85
|
+
it forever.** "none yet", "greenfield", "not used anywhere" require a repo-wide
|
|
86
|
+
`git grep` of the pattern first; a sampled read is not evidence of absence. Then declare
|
|
87
|
+
the token in frontmatter — `absent: <token>` (repeatable, one per line) — so
|
|
88
|
+
`verify-citations` gate **B3** re-greps it on every run and the claim can never silently
|
|
89
|
+
go stale (the 2026-06-08 blocker class: code grew the thing the note said was missing).
|
|
90
|
+
An absence-shaped title/summary with no `absent:` declared is flagged as a checker WARN —
|
|
91
|
+
resolve every WARN before hand-off (declare the token, or reword the claim).
|
|
87
92
|
|
|
88
93
|
---
|
|
89
94
|
|
|
@@ -94,6 +99,12 @@ file, finds nothing, and stops trusting the brain. So fidelity is non-negotiable
|
|
|
94
99
|
- `.rafa/brain/coverage.md` — the coverage report. **Machine-read frontmatter** per
|
|
95
100
|
[`.claude/rafa/contract.md`](../../rafa/contract.md) §6: `domains: { <domain>: mapped|thin|stubbed|empty, … }`
|
|
96
101
|
— one entry per domain from Step 1. The body keeps the per-criterion PASS/FAIL narrative.
|
|
102
|
+
**Declare `inventory:` entries** (`- <name> :: <glob> :: <count>`) for each framework
|
|
103
|
+
surface the scan counted — route pages, API routes, agent graphs, whatever is
|
|
104
|
+
load-bearing in THIS repo. The checker recomputes each via `git ls-files ':(glob)…'`
|
|
105
|
+
every run and fails on drift, so coverage can never silently claim an inventory the
|
|
106
|
+
repo has outgrown. Compute the count FROM the same `git ls-files` command — never from
|
|
107
|
+
memory of the tree.
|
|
97
108
|
|
|
98
109
|
**Note format.** The **strict contract is [`.claude/rafa/contract.md`](../../rafa/contract.md) §2** — every
|
|
99
110
|
required field there is mandatory and `rafa compile` (Step 7) **rejects** any violation with a
|
|
@@ -138,6 +149,15 @@ retrieval index. Bodies read like a senior engineer explaining that one concept
|
|
|
138
149
|
the internal node/relationship model the next steps reason over. (Not emitted as a
|
|
139
150
|
file; it feeds the notes.)
|
|
140
151
|
|
|
152
|
+
3b. **Toolbox inventory** [deterministic] — ratified 2026-07-12: the REPO toolbox is a
|
|
153
|
+
first-class brain domain (`toolbox`). Run `npx @rafinery/cli leverage --json` (the
|
|
154
|
+
deterministic extractor) and author cited notes for the committed toolbox — skills
|
|
155
|
+
(`.claude/skills/*/SKILL.md` name+description), commands, `.mcp.json` servers,
|
|
156
|
+
granted permissions — cites into the config files themselves (contract §2; they are
|
|
157
|
+
citable file:line). This makes the toolbox recallable through the SAME MCP surface
|
|
158
|
+
as all knowledge and refreshable like any note. Personal `~/.claude/` is NEVER
|
|
159
|
+
inventoried here (user-profile plane, compass's job, standing consent).
|
|
160
|
+
|
|
141
161
|
4. **Convention detection** [deterministic + light LLM] — for **each** domain, the local
|
|
142
162
|
idiom (design tokens, component export pattern + any server/client split such as RSC,
|
|
143
163
|
data-op shape, naming).
|
|
@@ -32,8 +32,11 @@ agent. So:
|
|
|
32
32
|
## Procedure (run in order; ground everything in code)
|
|
33
33
|
|
|
34
34
|
1. **Re-run the checker yourself** — `npx @rafinery/cli verify-citations`. Record exit
|
|
35
|
-
code + counts (resolution / completeness / policy
|
|
36
|
-
|
|
35
|
+
code + counts (resolution / completeness / policy / **absence / inventory** — checker
|
|
36
|
+
v2 mechanizes the 2026-06-08 ratchet: declared `absent:` tokens re-grepped, coverage
|
|
37
|
+
`inventory:` counts re-computed). Exit ≠ 0 → **blocker(s)**. Never trust a pasted
|
|
38
|
+
table; confirm `citation-check.json` (checkerVersion · pass · at) matches the run you
|
|
39
|
+
just made — a stale record riding a push is itself a **blocker**.
|
|
37
40
|
2. **Trust-but-verify the checker** —
|
|
38
41
|
(a) independently re-verify a sample (~10) of cites against the raw files by a *different*
|
|
39
42
|
method (hand grep / read), to confirm the checker isn't lying.
|
|
@@ -44,6 +47,16 @@ agent. So:
|
|
|
44
47
|
A mismatch in (a), or a non-zero `--selftest` in (b) → **blocker** (the verifier is broken).
|
|
45
48
|
3. **Adversarial completeness probe** — for each contract `anchor:`, run `git grep` yourself
|
|
46
49
|
and confirm the cited sites equal the grep hits. Hunt for an omitted site.
|
|
50
|
+
|
|
51
|
+
3b. **Absence audit — work the WARN list.** The checker's report ends with heuristic WARNs
|
|
52
|
+
(absence-shaped title/summary with no `absent:` declared). For each: decide whether the
|
|
53
|
+
claim is truly existence-dependent → if yes, it MUST declare `absent: <token>` (finding:
|
|
54
|
+
major — an undeclared absence claim is exactly the class that went stale in 2026-06-08);
|
|
55
|
+
if the wording is just loose, note it as minor. Then hunt for absence claims the
|
|
56
|
+
heuristic missed: any note whose truth depends on something NOT existing, with no
|
|
57
|
+
`absent:` gate behind it. The mechanized gate only covers what is declared — YOUR job
|
|
58
|
+
is the undeclared remainder (the ratchet's disposition protocol: every catch here
|
|
59
|
+
should end as a new declaration, an eval case, or a recorded judgment).
|
|
47
60
|
4. **Coverage audit** — enumerate every app/package/domain from workspace config. Confirm
|
|
48
61
|
each has a *substantive* note (not a token stub). Tunneling / imbalance → **major**.
|
|
49
62
|
5. **Load-bearing test (the core score)** — pick ONE real feature + ONE real bug. Using ONLY
|
package/lib/blueprint.mjs
CHANGED
|
@@ -43,6 +43,10 @@ export const BLUEPRINT = {
|
|
|
43
43
|
".claude/skills/rafa-leverage",
|
|
44
44
|
],
|
|
45
45
|
lockstep: [".claude/rafa/contract.md"],
|
|
46
|
+
// The M5 sensor hooks — deterministic protocol machinery (like the contract):
|
|
47
|
+
// they move with the CLI version, clients don't tune them. NOT knowledge —
|
|
48
|
+
// the brain (.rafa/) stays executable-free; these live in the code repo.
|
|
49
|
+
lockstepDirs: [".claude/rafa/hooks"],
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
const HERE = dirname(fileURLToPath(import.meta.url)); // …/packages/cli/lib
|
|
@@ -96,7 +100,11 @@ export function blueprintFiles(from) {
|
|
|
96
100
|
...BLUEPRINT.owned,
|
|
97
101
|
...BLUEPRINT.ownedDirs.flatMap((d) => filesUnder(from, d)),
|
|
98
102
|
];
|
|
99
|
-
|
|
103
|
+
const lockstep = [
|
|
104
|
+
...BLUEPRINT.lockstep,
|
|
105
|
+
...(BLUEPRINT.lockstepDirs ?? []).flatMap((d) => filesUnder(from, d)),
|
|
106
|
+
];
|
|
107
|
+
return { owned, lockstep };
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
function sameBytes(a, b) {
|
package/lib/brain-repo.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// which materializes the skeleton + git wiring from the committed rafa.json so
|
|
7
7
|
// `clone → npx rafa <cmd>` just works with no init step on the machine.
|
|
8
8
|
|
|
9
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { execSync } from "node:child_process";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { readStamp } from "./stamp.mjs";
|
|
@@ -73,10 +73,16 @@ export function ensureBrainRepo(cwd, { requireRemote = true } = {}) {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
// Session/state debris never rides a brain push: the sidecar, conflict
|
|
76
|
-
// copies,
|
|
76
|
+
// copies, distill staging, and the M5 dirty queue are transport-excluded here
|
|
77
|
+
// (not knowledge). MERGED line-by-line so clones ignored under an older CLI
|
|
78
|
+
// still gain new exclusions.
|
|
77
79
|
const ignore = join(rafaDir, ".gitignore");
|
|
78
|
-
const IGNORES = "hydration.json
|
|
79
|
-
|
|
80
|
+
const IGNORES = ["hydration.json", "*.theirs.md", "distill-incoming/", "distill-verdicts.json", "dirty.jsonl", "reflex.jsonl"];
|
|
81
|
+
const cur = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
|
|
82
|
+
const have = new Set(cur.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
83
|
+
const missing = IGNORES.filter((l) => !have.has(l));
|
|
84
|
+
if (missing.length)
|
|
85
|
+
writeFileSync(ignore, cur + (cur === "" || cur.endsWith("\n") ? "" : "\n") + missing.join("\n") + "\n");
|
|
80
86
|
|
|
81
87
|
return { rafaDir, remote, bootstrapped };
|
|
82
88
|
}
|
package/lib/checkpoint.mjs
CHANGED
|
@@ -84,6 +84,13 @@ export default async function checkpoint() {
|
|
|
84
84
|
try {
|
|
85
85
|
payload = await callTool(ROOT, "checkpoint_sync", {
|
|
86
86
|
branch,
|
|
87
|
+
// WHO/WHAT executed — the CLI knows itself; the MODEL is the session's to
|
|
88
|
+
// report (RAFA_ACTOR_MODEL env, set by the conductor) — never guessed.
|
|
89
|
+
actorMeta: {
|
|
90
|
+
agent: "rafa-cli@" + (process.env.npm_package_version ?? "0.5.0"),
|
|
91
|
+
runner: process.env.CI ? "ci" : "session",
|
|
92
|
+
model: process.env.RAFA_ACTOR_MODEL ?? "unreported",
|
|
93
|
+
},
|
|
87
94
|
files: candidates.map(({ path, content, baseVersion, baseBrainSha }) => ({
|
|
88
95
|
path,
|
|
89
96
|
content,
|
package/lib/ci-setup.mjs
CHANGED
|
@@ -46,6 +46,7 @@ jobs:
|
|
|
46
46
|
- name: Mechanical fold (no LLM — rigor lives at main)
|
|
47
47
|
env:
|
|
48
48
|
RAFA_MCP_KEY: \${{ secrets.RAFA_MCP_KEY }}
|
|
49
|
+
RAFA_HOOKS_DISABLED: "1" # headless — the M5 sensors are session instruments
|
|
49
50
|
run: npx -y @rafinery/cli fold --from "\${{ github.event.pull_request.head.ref }}" --to "\${{ github.event.pull_request.base.ref }}"
|
|
50
51
|
|
|
51
52
|
distill:
|
|
@@ -66,6 +67,7 @@ jobs:
|
|
|
66
67
|
ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
|
|
67
68
|
RAFA_MCP_KEY: \${{ secrets.RAFA_MCP_KEY }}
|
|
68
69
|
RAFA_BRAIN_TOKEN: \${{ secrets.RAFA_BRAIN_TOKEN }}
|
|
70
|
+
RAFA_HOOKS_DISABLED: "1" # headless — the M5 sensors are session instruments
|
|
69
71
|
run: npx -y @rafinery/cli distill --headless "\${{ github.event.pull_request.head.ref }}"
|
|
70
72
|
`;
|
|
71
73
|
|
package/lib/claude-config.mjs
CHANGED
|
@@ -13,6 +13,119 @@ 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
|
+
|
|
93
|
+
// Merge the repo's .mcp.json server names into the OWNED agent cards' tools
|
|
94
|
+
// lines (ratified 2026-07-12: subagents must be able to reach the repo's MCPs
|
|
95
|
+
// — a Convex repo's mcp__convex tools were structurally invisible to atlas).
|
|
96
|
+
// Owned-file edit, idempotent, reviewable in git diff; blueprint stays generic.
|
|
97
|
+
export function mergeRepoMcpsIntoCards(targetDir) {
|
|
98
|
+
const mcp = readClaudeSettingsFile(join(targetDir, ".mcp.json"));
|
|
99
|
+
const servers = Object.keys(mcp?.mcpServers ?? {}).filter((n) => n !== "rafinery");
|
|
100
|
+
if (servers.length === 0) return { updated: [] };
|
|
101
|
+
const updated = [];
|
|
102
|
+
const dir = join(targetDir, ".claude", "agents");
|
|
103
|
+
if (!existsSync(dir)) return { updated };
|
|
104
|
+
for (const card of ["atlas.md", "bloom.md", "prism.md", "compass.md"]) {
|
|
105
|
+
const file = join(dir, card);
|
|
106
|
+
if (!existsSync(file)) continue;
|
|
107
|
+
let text = readFileSync(file, "utf8");
|
|
108
|
+
const m = text.match(/^tools: (.+)$/m);
|
|
109
|
+
if (!m) continue;
|
|
110
|
+
const have = m[1].split(",").map((t) => t.trim());
|
|
111
|
+
const missing = servers.map((s2) => `mcp__${s2}`).filter((t) => !have.includes(t));
|
|
112
|
+
if (missing.length === 0) continue;
|
|
113
|
+
text = text.replace(/^tools: .+$/m, `tools: ${[...have, ...missing].join(", ")}`);
|
|
114
|
+
writeFileSync(file, text);
|
|
115
|
+
updated.push(`${card} (+${missing.join(", ")})`);
|
|
116
|
+
}
|
|
117
|
+
return { updated };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readClaudeSettingsFile(file) {
|
|
121
|
+
if (!existsSync(file)) return null;
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
16
129
|
export function settingsPath(targetDir) {
|
|
17
130
|
return join(targetDir, ".claude", "settings.json");
|
|
18
131
|
}
|