@rafinery/cli 0.2.2 → 0.4.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 (45) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +17 -7
  3. package/bin/rafa.mjs +78 -8
  4. package/blueprint/.claude/agents/atlas.md +28 -20
  5. package/blueprint/.claude/agents/bloom.md +15 -8
  6. package/blueprint/.claude/agents/compass.md +46 -0
  7. package/blueprint/.claude/agents/prism.md +42 -25
  8. package/blueprint/.claude/commands/rafa.md +190 -13
  9. package/blueprint/.claude/rafa/contract.md +482 -0
  10. package/blueprint/.claude/skills/rafa-build/SKILL.md +76 -0
  11. package/blueprint/.claude/skills/rafa-distill/SKILL.md +87 -0
  12. package/blueprint/{.rafa/capabilities/improve.md → .claude/skills/rafa-improve/SKILL.md} +10 -5
  13. package/blueprint/.claude/skills/rafa-insights/SKILL.md +71 -0
  14. package/blueprint/{.rafa/capabilities/leverage.md → .claude/skills/rafa-leverage/SKILL.md} +9 -4
  15. package/blueprint/.claude/skills/rafa-plan/SKILL.md +74 -0
  16. package/blueprint/{.rafa/capabilities/scan.md → .claude/skills/rafa-scan/SKILL.md} +14 -9
  17. package/blueprint/{.rafa/capabilities/validate.md → .claude/skills/rafa-validate/SKILL.md} +14 -5
  18. package/lib/blueprint.mjs +22 -12
  19. package/lib/brain-repo.mjs +100 -0
  20. package/lib/checkpoint.mjs +138 -0
  21. package/lib/ci-setup.mjs +109 -0
  22. package/lib/claude-config.mjs +5 -5
  23. package/lib/compile.mjs +6 -21
  24. package/lib/distill.mjs +237 -0
  25. package/lib/fold.mjs +53 -0
  26. package/lib/gate/compile.mjs +549 -0
  27. package/lib/gate/verify-citations.mjs +156 -0
  28. package/lib/hydrate.mjs +96 -0
  29. package/lib/init.mjs +69 -35
  30. package/lib/leverage/adapters/claude-code.mjs +5 -4
  31. package/lib/mcp-client.mjs +97 -0
  32. package/lib/mcp-config.mjs +93 -0
  33. package/lib/migrations/index.mjs +39 -3
  34. package/lib/pull.mjs +129 -0
  35. package/lib/push.mjs +93 -14
  36. package/lib/releases.mjs +36 -0
  37. package/lib/verify-citations.mjs +9 -0
  38. package/lib/working-set.mjs +113 -0
  39. package/package.json +2 -2
  40. package/blueprint/.rafa/bin/rafa-compile.mjs +0 -390
  41. package/blueprint/.rafa/bin/rafa-push.mjs +0 -75
  42. package/blueprint/.rafa/bin/verify-citations.mjs +0 -153
  43. package/blueprint/.rafa/capabilities/build.md +0 -38
  44. package/blueprint/.rafa/capabilities/plan.md +0 -38
  45. package/blueprint/.rafa/contract.md +0 -303
@@ -0,0 +1,156 @@
1
+ // Deterministic citation checker for the atlas scan output. Lives INSIDE
2
+ // @rafinery/cli (blueprint split, 0.4.0) — run as `rafa verify-citations`.
3
+ // Three gates:
4
+ //
5
+ // RESOLUTION (B1) — every cite `- <file>:<line>[-<end>] :: <token>` points at a line
6
+ // that actually contains <token>. Catches off-by-N / wrong-file.
7
+ // COMPLETENESS (B2) — a note may declare `anchor: <token>`; the checker greps it repo-wide
8
+ // (code only, docs excluded) and asserts EVERY hit is a cited site.
9
+ // Catches the "3-place vs 5-place" omission bug.
10
+ // POLICY — every `type: contract` MUST declare `anchor:` — a greppable token,
11
+ // or `anchor: none` for composition/ordering contracts that don't grep
12
+ // as one token. So completeness can't be silently skipped (closes F1:
13
+ // the guarantee is mandatory, not opt-in; exemptions are explicit).
14
+ //
15
+ // Writes a generated report to <root>/citation-check.md (don't hand-paste it).
16
+ // Returns exit code 1 on any failure. Run from repo root: rafa verify-citations
17
+
18
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdtempSync, rmSync } from "node:fs";
19
+ import { execSync } from "node:child_process";
20
+ import { join } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+
23
+ const CITE = /^\s*-\s+(.+?):(\d+)(?:-(\d+))?\s*::\s*(.+?)\s*$/; // - file:start[-end] :: token
24
+ const ANCHOR = /^anchor:\s*(.+?)\s*$/;
25
+ const TYPE = /^type:\s*(.+?)\s*$/;
26
+
27
+ function walk(dir) {
28
+ if (!existsSync(dir)) return [];
29
+ return readdirSync(dir).flatMap((e) => {
30
+ const p = join(dir, e);
31
+ return statSync(p).isDirectory() ? walk(p) : (e.endsWith(".md") && !e.startsWith("_")) ? [p] : []; // ignore _*.md scratch
32
+ });
33
+ }
34
+
35
+ function gitGrep(token) {
36
+ try {
37
+ const out = execSync(`git grep -nF -e ${JSON.stringify(token)}`, { encoding: "utf8" });
38
+ return out.split("\n").filter(Boolean).map((l) => {
39
+ const m = l.match(/^(.+?):(\d+):/);
40
+ return m ? { file: m[1], line: +m[2] } : null;
41
+ }).filter(Boolean);
42
+ } catch (e) {
43
+ if (e.status === 1) return []; // git grep: no matches
44
+ throw e;
45
+ }
46
+ }
47
+
48
+ function citeResolves(file, start, end, token) {
49
+ if (!existsSync(file)) return { ok: false, reason: "file missing" };
50
+ const src = readFileSync(file, "utf8").split("\n");
51
+ if (start < 1 || end > src.length) return { ok: false, reason: `lines ${start}-${end} out of range (${src.length})` };
52
+ if (src.slice(start - 1, end).some((l) => l.includes(token))) return { ok: true, reason: "" };
53
+ return { ok: false, reason: `token not on ${start}${end !== start ? "-" + end : ""}` };
54
+ }
55
+
56
+ export function runVerifyCitations(argv = []) {
57
+ const arg = (k, d) => { const m = argv.find((a) => a.startsWith(`--${k}=`)); return m ? m.slice(k.length + 3) : d; };
58
+ const ROOT = arg("root", ".rafa/brain"); // --root=.rafa/improve for the improve ledger
59
+ const NOTE_DIRS = arg("dirs", "rules,playbooks").split(",").filter(Boolean); // --dirs=improvements
60
+
61
+ // --selftest: prove the resolution logic fails on a bad cite and passes a good one, on a
62
+ // throwaway temp file (no brain/repo pollution). prism runs this as its mutation probe —
63
+ // re-running the checker is not proof it still works; this is.
64
+ if (argv.includes("--selftest")) {
65
+ const dir = mkdtempSync(join(tmpdir(), "vc-selftest-"));
66
+ const f = join(dir, "sample.txt");
67
+ writeFileSync(f, "alpha\nTOKEN beta\ngamma\n");
68
+ const good = citeResolves(f, 2, 2, "TOKEN").ok; // expect true
69
+ const bad = citeResolves(f, 1, 1, "TOKEN").ok; // expect false (off-by-one)
70
+ rmSync(dir, { recursive: true, force: true });
71
+ const sane = good === true && bad === false;
72
+ console.log(`checker self-test: ${sane ? "PASS" : "FAIL"} — good cite=${good} (expect true), bad cite=${bad} (expect false)`);
73
+ return sane ? 0 : 1;
74
+ }
75
+
76
+ const notes = NOTE_DIRS.flatMap((d) => walk(join(ROOT, d)));
77
+ if (notes.length === 0) {
78
+ console.log(`No notes under ${ROOT}/{${NOTE_DIRS.join(",")}}/ — run a scan first.`);
79
+ return 0;
80
+ }
81
+
82
+ const resolution = []; // { note, loc, token, ok, reason }
83
+ const completeness = []; // { note, anchor, site, ok, reason }
84
+ const policy = []; // { note, ok, reason }
85
+
86
+ for (const note of notes) {
87
+ const rel = note.replace(ROOT + "/", "");
88
+ const lines = readFileSync(note, "utf8").split("\n");
89
+ const cites = [];
90
+ const anchors = [];
91
+ let noteType = "";
92
+ let fence = 0;
93
+ for (const line of lines) {
94
+ if (line.trim() === "---") { fence++; if (fence >= 2) break; continue; }
95
+ if (fence !== 1) continue;
96
+ const c = line.match(CITE);
97
+ if (c) { cites.push({ file: c[1], start: +c[2], end: c[3] ? +c[3] : +c[2], token: c[4] }); continue; }
98
+ const a = line.match(ANCHOR);
99
+ if (a) { anchors.push(a[1].replace(/\s+#.*$/, "").replace(/^["']|["']$/g, "").trim()); continue; }
100
+ const t = line.match(TYPE);
101
+ if (t) noteType = t[1].replace(/\s+#.*$/, "").trim();
102
+ }
103
+
104
+ // POLICY — every contract must declare an anchor (token or explicit `none`)
105
+ if (noteType === "contract") {
106
+ const ok = anchors.length > 0;
107
+ policy.push({ note: rel, ok, reason: ok ? "" : "type: contract must declare `anchor: <token>` (or `anchor: none` for composition/ordering contracts)" });
108
+ }
109
+
110
+ // RESOLUTION
111
+ for (const ct of cites) {
112
+ const { ok, reason } = citeResolves(ct.file, ct.start, ct.end, ct.token);
113
+ resolution.push({ note: rel, loc: `${ct.file}:${ct.start}${ct.end !== ct.start ? "-" + ct.end : ""}`, token: ct.token, ok, reason });
114
+ }
115
+
116
+ // COMPLETENESS
117
+ for (const anchor of anchors) {
118
+ if (anchor.toLowerCase() === "none") continue; // explicit, visible exemption
119
+ const hits = gitGrep(anchor).filter((h) => !h.file.endsWith(".md")); // docs excluded
120
+ if (hits.length === 0) {
121
+ completeness.push({ note: rel, anchor, site: "(none)", ok: false, reason: "anchor found nowhere in code — wrong token?" });
122
+ continue;
123
+ }
124
+ for (const h of hits) {
125
+ const covered = cites.some((ct) => ct.file === h.file && h.line >= ct.start && h.line <= ct.end);
126
+ completeness.push({ note: rel, anchor, site: `${h.file}:${h.line}`, ok: covered, reason: covered ? "" : "grep hit not cited — site omitted" });
127
+ }
128
+ }
129
+ }
130
+
131
+ const rFail = resolution.filter((r) => !r.ok);
132
+ const cFail = completeness.filter((r) => !r.ok);
133
+ const pFail = policy.filter((r) => !r.ok);
134
+ const fail = rFail.length + cFail.length + pFail.length;
135
+
136
+ const md = [
137
+ "# Citation check (generated — do not hand-edit)",
138
+ "",
139
+ `## Resolution (B1): ${resolution.length - rFail.length}/${resolution.length} ✓`,
140
+ ...resolution.map((r) => `${r.ok ? "✓" : "✗"} ${r.loc} :: ${r.token}${r.ok ? "" : " — " + r.reason + " [" + r.note + "]"}`),
141
+ "",
142
+ `## Completeness (B2): ${completeness.length - cFail.length}/${completeness.length} ✓ (${new Set(completeness.map((c) => c.anchor)).size} anchors)`,
143
+ ...completeness.map((r) => `${r.ok ? "✓" : "✗"} anchor '${r.anchor}' → ${r.site}${r.ok ? "" : " — " + r.reason + " [" + r.note + "]"}`),
144
+ "",
145
+ `## Policy (contract → anchor declared): ${policy.length - pFail.length}/${policy.length} ✓`,
146
+ ...policy.map((r) => `${r.ok ? "✓" : "✗"} ${r.note}${r.ok ? "" : " — " + r.reason}`),
147
+ "",
148
+ fail ? `**${fail} FAILED.**` : "**All pass.**",
149
+ "",
150
+ ].join("\n");
151
+ writeFileSync(join(ROOT, "citation-check.md"), md);
152
+
153
+ console.log(md);
154
+ console.log(`resolution ${resolution.length - rFail.length}/${resolution.length} · completeness ${completeness.length - cFail.length}/${completeness.length} · policy ${policy.length - pFail.length}/${policy.length}` + (fail ? ` · ${fail} FAILED` : " · all pass"));
155
+ return fail ? 1 : 0;
156
+ }
@@ -0,0 +1,96 @@
1
+ // rafa hydrate — fault knowledge INTO the lazy .rafa/ instance (working-set
2
+ // architecture, ratified 2026-07-10). Two sources, one bookkeeping rule:
3
+ //
4
+ // rafa hydrate rule <id> one org-brain note → .rafa/brain/rules/<id>.md
5
+ // rafa hydrate playbook <id> one org-brain note → .rafa/brain/playbooks/<id>.md
6
+ // Stamped in the sidecar with its main-brain base version (envelope
7
+ // brainForSha) + content hash — an UNEDITED hydration is a disposable
8
+ // cache `rafa checkpoint` will never push; editing it makes it working set.
9
+ //
10
+ // rafa hydrate --working-set this branch's working set (teammates' synced
11
+ // files) → .rafa/brain/** with sidecar sync records, so a fresh session
12
+ // starts exactly where the branch's knowledge stands.
13
+ //
14
+ // Hydration reconstructs note files from served fields (the MCP serves fields,
15
+ // not raw files). `anchor:` is not served — a contract note edited after
16
+ // hydration must re-declare it before distillation; the policy gate is loud.
17
+
18
+ import { execSync } from "node:child_process";
19
+ import { mkdirSync, writeFileSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { callTool } from "./mcp-client.mjs";
22
+ import {
23
+ hashOf,
24
+ materializeNote,
25
+ readSidecar,
26
+ writeSidecar,
27
+ } from "./working-set.mjs";
28
+
29
+ const die = (m) => {
30
+ console.error(`✗ ${m}`);
31
+ process.exit(1);
32
+ };
33
+
34
+ export default async function hydrate(args = []) {
35
+ const ROOT = process.cwd();
36
+
37
+ if (args.includes("--working-set")) {
38
+ let branch = "";
39
+ try {
40
+ branch = execSync("git rev-parse --abbrev-ref HEAD", {
41
+ cwd: ROOT,
42
+ encoding: "utf8",
43
+ stdio: ["ignore", "pipe", "ignore"],
44
+ }).trim();
45
+ } catch {
46
+ die("not a git repo — run from your code repo root");
47
+ }
48
+ let payload;
49
+ try {
50
+ payload = await callTool(ROOT, "get_working_set", { branch, status: "active" });
51
+ } catch (e) {
52
+ die(e instanceof Error ? e.message : String(e));
53
+ }
54
+ const files = payload.files ?? [];
55
+ if (files.length === 0) {
56
+ console.log(`✓ no active working set on ${branch} — nothing to hydrate`);
57
+ return;
58
+ }
59
+ const sidecar = readSidecar(ROOT);
60
+ const syncBranch = (sidecar.sync[branch] = sidecar.sync[branch] ?? {});
61
+ for (const f of files) {
62
+ const abs = join(ROOT, ".rafa", f.path);
63
+ mkdirSync(dirname(abs), { recursive: true });
64
+ writeFileSync(abs, f.content);
65
+ syncBranch[f.path] = { version: f.version, hash: hashOf(f.content) };
66
+ if (f.baseBrainSha)
67
+ sidecar.files[f.path] = {
68
+ baseBrainSha: f.baseBrainSha,
69
+ hash: sidecar.files[f.path]?.hash ?? "",
70
+ };
71
+ console.log(` ✓ ${f.path} (v${f.version} · ${f.lastAuthor})`);
72
+ }
73
+ writeSidecar(ROOT, sidecar);
74
+ console.log(`✓ hydrated ${files.length} working-set file(s) on ${branch}`);
75
+ return;
76
+ }
77
+
78
+ const [kind, id] = args.filter((a) => !a.startsWith("-"));
79
+ if ((kind !== "rule" && kind !== "playbook") || !id)
80
+ die("usage: rafa hydrate <rule|playbook> <id> · rafa hydrate --working-set");
81
+
82
+ let payload;
83
+ try {
84
+ payload = await callTool(ROOT, kind === "rule" ? "get_rule" : "get_playbook", { id });
85
+ } catch (e) {
86
+ die(e instanceof Error ? e.message : String(e));
87
+ }
88
+ const brainForSha = payload.envelope?.brainForSha ?? null;
89
+ const rel = materializeNote(ROOT, kind, payload, brainForSha);
90
+ console.log(
91
+ `✓ hydrated ${kind} "${id}" → .rafa/${rel}` +
92
+ (brainForSha ? ` (base: ${String(brainForSha).slice(0, 7)})` : "") +
93
+ "\n Unedited = disposable cache (never pushed). Edit it and it becomes working set" +
94
+ "\n — `rafa checkpoint` syncs it. Contract notes: re-declare `anchor:` before distilling an edit.",
95
+ );
96
+ }
package/lib/init.mjs CHANGED
@@ -1,16 +1,16 @@
1
1
  // rafa init — provision a repo: vendor the blueprint, and (with a platform setup
2
2
  // URL) wire the brain remote. Run inside your code repo's root.
3
3
 
4
- import {
5
- existsSync,
6
- writeFileSync,
7
- readFileSync,
8
- appendFileSync,
9
- } from "node:fs";
10
- import { execSync } from "node:child_process";
4
+ import { existsSync, readFileSync, appendFileSync } from "node:fs";
11
5
  import { join } from "node:path";
12
6
  import { blueprintSource, copyBlueprint } from "./blueprint.mjs";
13
7
  import { mergeClaudeSettings } from "./claude-config.mjs";
8
+ import {
9
+ credentialsPath,
10
+ mergeLocalEnv,
11
+ mergeMcpJson,
12
+ saveCredentials,
13
+ } from "./mcp-config.mjs";
14
14
  import { stampCurrent } from "./stamp.mjs";
15
15
  import { notifyIfBehind } from "./version-check.mjs";
16
16
  import { isInteractive, ask } from "./prompt.mjs";
@@ -71,8 +71,6 @@ const die = (m) => {
71
71
  console.error(`✗ ${m}`);
72
72
  process.exit(1);
73
73
  };
74
- const run = (cmd, cwd) =>
75
- execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }).trim();
76
74
 
77
75
  export default async function init(args) {
78
76
  const url = args.find((a) => !a.startsWith("-"));
@@ -98,22 +96,59 @@ export default async function init(args) {
98
96
  console.log("rafa init — vendoring the blueprint (no setup URL)");
99
97
  }
100
98
 
101
- // Vendor the blueprint into .claude/ + .rafa/ (you own these copies) — non-destructive:
102
- // tools/contract move in lockstep; owned files (agents, capabilities) prompt before overwrite.
99
+ // Vendor the blueprint into .claude/ (you own these copies) — non-destructive:
100
+ // the contract moves in lockstep; owned files (agents, command, skills) prompt
101
+ // before overwrite. `.rafa/` itself is NOT created here — it is lazy, gitignored
102
+ // knowledge+state that bootstraps from rafa.json on first `rafa pull`/`push`.
103
103
  reportBlueprint(
104
104
  await copyBlueprint(blueprintSource(), TARGET, { onConflict: makeConflictResolver(args) }),
105
105
  );
106
- if (!existsSync(join(TARGET, ".rafa", "active.md")))
107
- writeFileSync(join(TARGET, ".rafa", "active.md"), "# No active plan\n");
108
- // Grant rafa's vendored tools their permissions (merge, never overwrite).
106
+ // Grant rafa's tools their permissions (merge, never overwrite).
109
107
  reportSettings(TARGET);
110
108
 
111
- // Keep the brain out of the code repo's diffs.
112
- const gi = join(TARGET, ".gitignore");
113
- const body = existsSync(gi) ? readFileSync(gi, "utf8") : "";
114
- if (!/^\.rafa\/?\s*$/m.test(body)) {
115
- appendFileSync(gi, `${body.endsWith("\n") || body === "" ? "" : "\n"}.rafa/\n`);
116
- console.log(" ✓ .rafa/ → .gitignore");
109
+ // Keep the brain out of the code repo's diffs; keep the per-dev MCP key out of git.
110
+ const ensureIgnored = (line, label) => {
111
+ const gi = join(TARGET, ".gitignore");
112
+ const body = existsSync(gi) ? readFileSync(gi, "utf8") : "";
113
+ const re = new RegExp(`^${line.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/?\\s*$`, "m");
114
+ if (!re.test(body)) {
115
+ appendFileSync(gi, `${body.endsWith("\n") || body === "" ? "" : "\n"}${line}\n`);
116
+ console.log(` ✓ ${label} → .gitignore`);
117
+ }
118
+ };
119
+ ensureIgnored(".rafa/", ".rafa/");
120
+
121
+ // Wire the platform's knowledge MCP (contract §9): committed .mcp.json entry
122
+ // (secret-free, ${RAFA_MCP_KEY} expansion) + the key in gitignored local config
123
+ // + the per-machine canonical copy. The key was minted at setup generation and
124
+ // is consume-once: a re-fetched setup arrives keyless and we say so loudly.
125
+ if (p?.mcpUrl) {
126
+ if (p.agentKey) {
127
+ const cred = saveCredentials(p.repoId, p.agentKey, p.mcpUrl);
128
+ console.log(
129
+ cred.skipped
130
+ ? ` ! MCP key NOT saved to ${credentialsPath()} — ${cred.skipped}`
131
+ : ` ✓ MCP key → ${cred.file} (0600)`,
132
+ );
133
+ const env = mergeLocalEnv(TARGET, p.agentKey);
134
+ console.log(
135
+ env.skipped
136
+ ? ` ! ${env.skipped} — export RAFA_MCP_KEY yourself (key is in ${credentialsPath()})`
137
+ : " ✓ RAFA_MCP_KEY → .claude/settings.local.json (gitignored)",
138
+ );
139
+ ensureIgnored(".claude/settings.local.json", ".claude/settings.local.json");
140
+ } else {
141
+ console.log(
142
+ ` ! this setup's MCP key was already consumed${p.agentKeyConsumed ? "" : " or absent"} — ` +
143
+ "mint one on the platform (repo → Agent access) and set RAFA_MCP_KEY in .claude/settings.local.json",
144
+ );
145
+ }
146
+ const mcp = mergeMcpJson(TARGET, p.mcpUrl);
147
+ console.log(
148
+ mcp.skipped
149
+ ? ` ! ${mcp.skipped} — add the "rafinery" server pointing at ${p.mcpUrl} yourself`
150
+ : ` ✓ knowledge MCP → .mcp.json (rafinery · ${p.mcpUrl})`,
151
+ );
117
152
  }
118
153
 
119
154
  // Stamp the versions this repo was provisioned at — the compat anchor `update`/`migrate`
@@ -131,23 +166,22 @@ export default async function init(args) {
131
166
  : {},
132
167
  );
133
168
 
134
- if (p) {
135
- const rafaDir = join(TARGET, ".rafa");
136
- if (!existsSync(join(rafaDir, ".git"))) run("git init -b main", rafaDir);
137
- try {
138
- run("git remote get-url origin", rafaDir);
139
- run(`git remote set-url origin ${p.brainRepoUrl}`, rafaDir);
140
- } catch {
141
- run(`git remote add origin ${p.brainRepoUrl}`, rafaDir);
142
- }
143
- console.log(" ✓ .rafa/ is its own git repo → origin = brain remote");
144
- }
145
-
146
169
  console.log(
147
170
  `\n✓ Provisioned.\n\nNext steps:\n` +
148
- " 1. Restart Claude Code in this repo (reload the /rafa command + agents).\n" +
149
- " 2. Run /rafa scan — build + validate the brain, then rafa push to sync it.\n" +
150
- " 3. Open the repo on the rafinery platform its Overview shows the brain once pushed.",
171
+ " 1. Commit the vendored files (agents, /rafa command, skills, contract, rafa.json)\n" +
172
+ " protected-main teams: via a normal MR. The brain remote lives in rafa.json,\n" +
173
+ " so any teammate's clone bootstraps with npx rafa pull (no init needed).\n" +
174
+ " 2. Restart Claude Code in this repo (reload the /rafa command + agents + skills" +
175
+ (p?.mcpUrl ? " + the rafinery MCP server" : "") +
176
+ ").\n" +
177
+ " 3. First machine on this repo: run /rafa scan then rafa push (from a main\n" +
178
+ " checkout — rafa push only touches the BRAIN repo, never the code repo).\n" +
179
+ " Joining an ALREADY-scanned repo: run rafa pull — never re-scan for existing knowledge.\n" +
180
+ " 4. Open the repo on the rafinery platform — its Overview shows the brain once pushed." +
181
+ (p?.mcpUrl
182
+ ? "\n 5. After the first push, any MCP client with a key can query this repo's knowledge\n" +
183
+ ` (endpoint: ${p.mcpUrl} · keys: repo → Agent access on the platform).`
184
+ : ""),
151
185
  );
152
186
 
153
187
  await notifyIfBehind();
@@ -50,14 +50,15 @@ const adapter = {
50
50
  });
51
51
  }
52
52
 
53
- // rafa's own tools should run without a prompt each time.
54
- if (existsSync(join(cwd, ".rafa", "bin"))) {
53
+ // rafa's own tools should run without a prompt each time. Provisioned =
54
+ // the /rafa command is vendored (the gates live inside the CLI since 0.4.0).
55
+ if (existsSync(join(cwd, ".claude", "commands", "rafa.md"))) {
55
56
  if (cap.settingsState === "missing") {
56
57
  tips.push({
57
58
  id: "no-settings",
58
59
  priority: P2,
59
60
  title: "No `.claude/settings.json` — every rafa tool call prompts",
60
- detail: "rafa vendored gates into .rafa/bin but nothing grants them.",
61
+ detail: "rafa is provisioned but nothing grants its CLI commands.",
61
62
  fix: "run `rafa init` (or `rafa update`) — it merges the permissions in",
62
63
  });
63
64
  } else if (cap.settingsState === "ok") {
@@ -67,7 +68,7 @@ const adapter = {
67
68
  id: "perms-missing",
68
69
  priority: P2,
69
70
  title: `${missing.length} rafa tool permission(s) not allow-listed`,
70
- detail: "the vendored gates will prompt until granted.",
71
+ detail: "the rafa CLI commands will prompt until granted.",
71
72
  fix: "run `rafa update` — it unions them into settings.json without clobbering yours",
72
73
  });
73
74
  }
@@ -0,0 +1,97 @@
1
+ // Minimal MCP-over-HTTP client for the platform's knowledge/state endpoint
2
+ // (JSON-RPC 2.0 tools/call — the same surface any MCP client speaks).
3
+ //
4
+ // Auth + endpoint resolution, in order of truth:
5
+ // key — $RAFA_MCP_KEY, else ~/.config/rafinery/credentials.json (repos[repoId].key)
6
+ // mcpUrl — $RAFA_MCP_URL, else credentials.json, else the committed .mcp.json
7
+ // "rafinery" entry (secret-free)
8
+ // repoId — the committed rafa.json (stamped at init)
9
+ //
10
+ // Errors are LOUD: a missing key/url/repoId names exactly what to do; a tool
11
+ // error is thrown with the server's message, never swallowed.
12
+
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { credentialsPath } from "./mcp-config.mjs";
16
+ import { readStamp } from "./stamp.mjs";
17
+
18
+ const readJson = (file) => {
19
+ try {
20
+ return JSON.parse(readFileSync(file, "utf8"));
21
+ } catch {
22
+ return null;
23
+ }
24
+ };
25
+
26
+ // The platform repo id this working copy is provisioned for.
27
+ export function repoIdOf(cwd) {
28
+ const stamp = readStamp(cwd);
29
+ return typeof stamp.repoId === "string" && stamp.repoId !== "" ? stamp.repoId : null;
30
+ }
31
+
32
+ export function resolveMcp(cwd) {
33
+ const repoId = repoIdOf(cwd);
34
+ if (!repoId)
35
+ throw new Error(
36
+ "no repoId in rafa.json — this repo isn't platform-provisioned (run `rafa init <setup-url>`)",
37
+ );
38
+
39
+ const creds = readJson(credentialsPath());
40
+ const repoCreds = creds?.repos?.[repoId] ?? null;
41
+
42
+ const key = process.env.RAFA_MCP_KEY || repoCreds?.key || null;
43
+ if (!key)
44
+ throw new Error(
45
+ `no agent key — set RAFA_MCP_KEY, or mint one on the platform (repo → Agent access) ` +
46
+ `and save it to ${credentialsPath()} (rafa init does this automatically)`,
47
+ );
48
+
49
+ let mcpUrl = process.env.RAFA_MCP_URL || repoCreds?.mcpUrl || null;
50
+ if (!mcpUrl) {
51
+ const mcpJson = readJson(join(cwd, ".mcp.json"));
52
+ const url = mcpJson?.mcpServers?.rafinery?.url;
53
+ if (typeof url === "string" && url !== "") mcpUrl = url;
54
+ }
55
+ if (!mcpUrl)
56
+ throw new Error(
57
+ "no MCP endpoint — set RAFA_MCP_URL, or ensure .mcp.json carries the `rafinery` server entry",
58
+ );
59
+
60
+ return { repoId, key, mcpUrl };
61
+ }
62
+
63
+ let rpcId = 0;
64
+
65
+ // Call one tool; returns the structured payload. Throws with the server's
66
+ // message on transport or tool errors.
67
+ export async function callTool(cwd, tool, args = {}) {
68
+ const { repoId, key, mcpUrl } = resolveMcp(cwd);
69
+ const res = await fetch(mcpUrl, {
70
+ method: "POST",
71
+ headers: {
72
+ "content-type": "application/json",
73
+ authorization: `Bearer ${key}`,
74
+ },
75
+ body: JSON.stringify({
76
+ jsonrpc: "2.0",
77
+ id: ++rpcId,
78
+ method: "tools/call",
79
+ params: { name: tool, arguments: { repo: repoId, ...args } },
80
+ }),
81
+ });
82
+ if (res.status === 401)
83
+ throw new Error("agent key rejected (401) — revoked or wrong platform; mint a new one (repo → Agent access)");
84
+ if (res.status === 429)
85
+ throw new Error("rate limited (429) — back off and retry");
86
+ if (!res.ok) throw new Error(`MCP endpoint returned HTTP ${res.status}`);
87
+ const body = await res.json();
88
+ if (body.error) throw new Error(`MCP error: ${body.error.message ?? JSON.stringify(body.error)}`);
89
+ const result = body.result ?? {};
90
+ const payload = result.structuredContent ?? null;
91
+ if (result.isError) {
92
+ const msg = payload?.error ?? result.content?.[0]?.text ?? "tool error";
93
+ throw new Error(`${tool}: ${msg}`);
94
+ }
95
+ if (payload === null) throw new Error(`${tool}: empty response`);
96
+ return payload;
97
+ }
@@ -0,0 +1,93 @@
1
+ // MCP wiring for `rafa init` — connect the client repo to the platform's
2
+ // knowledge MCP (.claude/rafa/contract.md §9) with zero secrets in committed files:
3
+ //
4
+ // .mcp.json committed · server entry, key via ${RAFA_MCP_KEY}
5
+ // .claude/settings.local.json gitignored · env.RAFA_MCP_KEY = the raw key
6
+ // ~/.config/rafinery/credentials.json per-machine canonical copy (0600)
7
+ //
8
+ // Merge, never clobber: unparseable files are left alone (reported), other
9
+ // servers/env keys are preserved; only the `rafinery` entry is ours to replace.
10
+
11
+ import {
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ writeFileSync,
16
+ chmodSync,
17
+ } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join, dirname } from "node:path";
20
+
21
+ const readJson = (file) => {
22
+ if (!existsSync(file)) return null;
23
+ try {
24
+ return JSON.parse(readFileSync(file, "utf8"));
25
+ } catch (e) {
26
+ return { error: e instanceof Error ? e.message : String(e) };
27
+ }
28
+ };
29
+ const writeJson = (file, data) => {
30
+ mkdirSync(dirname(file), { recursive: true });
31
+ writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
32
+ };
33
+
34
+ export function credentialsPath() {
35
+ return join(homedir(), ".config", "rafinery", "credentials.json");
36
+ }
37
+
38
+ // Canonical per-machine store: { repos: { <repoId>: { key, mcpUrl, savedAt } } }.
39
+ // Never inside the code repo or the brain repo — nothing to gitignore, nothing
40
+ // for `rafa push` to publish.
41
+ export function saveCredentials(repoId, key, mcpUrl) {
42
+ const file = credentialsPath();
43
+ const existing = readJson(file);
44
+ if (existing?.error)
45
+ return { skipped: `credentials.json is not valid JSON (${existing.error})` };
46
+ const data = existing ?? {};
47
+ data.repos = { ...(data.repos ?? {}) };
48
+ data.repos[repoId] = { key, mcpUrl, savedAt: new Date().toISOString() };
49
+ writeJson(file, data);
50
+ try {
51
+ chmodSync(file, 0o600);
52
+ } catch {
53
+ /* best-effort on non-POSIX */
54
+ }
55
+ return { file };
56
+ }
57
+
58
+ // .mcp.json — the committed, shareable server registration. The Authorization
59
+ // header uses ${RAFA_MCP_KEY} env expansion, so the file carries NO secret; each
60
+ // teammate's key comes from their own settings.local.json / shell env.
61
+ export function mergeMcpJson(targetDir, mcpUrl) {
62
+ const file = join(targetDir, ".mcp.json");
63
+ const existing = readJson(file);
64
+ if (existing?.error)
65
+ return { skipped: `.mcp.json is not valid JSON (${existing.error})` };
66
+ const data = existing ?? {};
67
+ data.mcpServers = { ...(data.mcpServers ?? {}) };
68
+ const entry = {
69
+ type: "http",
70
+ url: mcpUrl,
71
+ headers: { Authorization: "Bearer ${RAFA_MCP_KEY}" },
72
+ };
73
+ const same = JSON.stringify(data.mcpServers.rafinery) === JSON.stringify(entry);
74
+ data.mcpServers.rafinery = entry;
75
+ if (!same) writeJson(file, data);
76
+ return { file, changed: !same };
77
+ }
78
+
79
+ // .claude/settings.local.json — Claude Code's gitignored per-checkout settings;
80
+ // its `env` map is applied to the session, which is exactly where a per-dev
81
+ // secret belongs. Everything else in the file is preserved.
82
+ export function mergeLocalEnv(targetDir, key) {
83
+ const file = join(targetDir, ".claude", "settings.local.json");
84
+ const existing = readJson(file);
85
+ if (existing?.error)
86
+ return {
87
+ skipped: `.claude/settings.local.json is not valid JSON (${existing.error})`,
88
+ };
89
+ const data = existing ?? {};
90
+ data.env = { ...(data.env ?? {}), RAFA_MCP_KEY: key };
91
+ writeJson(file, data);
92
+ return { file };
93
+ }
@@ -18,8 +18,6 @@
18
18
  // can't do this correctly; `run` is a no-op that flags it, and `/rafa migrate`
19
19
  // (the LLM) rewrites the files against the contract. Intelligence lives there.
20
20
  //
21
- // Empty by design until a release actually changes a shape — the SEAM is what matters,
22
- // so the first breaking change ships its migration here and `rafa migrate` just works.
23
21
  // Example (kept as a reference, not registered):
24
22
  //
25
23
  // const plans_v1_to_v2 = {
@@ -30,7 +28,45 @@
30
28
  // run: (cwd) => backfillPlanField(cwd, "owner", ownerOf(cwd)),
31
29
  // };
32
30
 
33
- export const MIGRATIONS = [];
31
+ import { existsSync, rmSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { cmpSemver } from "../releases.mjs";
34
+
35
+ // 0.4.0 blueprint split: gate tools moved INSIDE the CLI, capabilities became
36
+ // committed skills (.claude/skills/rafa-*), the contract went canonical in the
37
+ // code repo (.claude/rafa/contract.md). The old vendored copies under .rafa/
38
+ // were LOCKSTEP files (always overwritten by us, never dev-tuned), so removing
39
+ // them mechanically is safe — the update that runs this has already vendored
40
+ // the new locations.
41
+ const blueprint_split_0_4_0 = {
42
+ id: "blueprint-split-0.4.0",
43
+ kind: "config",
44
+ mode: "mechanical",
45
+ summary:
46
+ "remove the pre-0.4.0 vendored gate tools + capabilities + contract from .rafa/ " +
47
+ "(tools now live inside @rafinery/cli; capabilities are .claude/skills/rafa-*; " +
48
+ "contract is .claude/rafa/contract.md)",
49
+ appliesTo: (from, to) =>
50
+ cmpSemver(from.cli, "0.4.0") < 0 && cmpSemver(to.version, "0.4.0") >= 0,
51
+ run: (cwd) => {
52
+ const changed = [];
53
+ for (const rel of [".rafa/bin", ".rafa/capabilities", ".rafa/contract.md"]) {
54
+ const p = join(cwd, rel);
55
+ if (existsSync(p)) {
56
+ rmSync(p, { recursive: true, force: true });
57
+ changed.push(rel);
58
+ }
59
+ }
60
+ return {
61
+ changed,
62
+ notes: changed.length
63
+ ? ["gate tools: `npx rafa <cmd>` · SOPs: .claude/skills/rafa-* · contract: .claude/rafa/contract.md"]
64
+ : ["nothing to clean — repo already on the split layout"],
65
+ };
66
+ },
67
+ };
68
+
69
+ export const MIGRATIONS = [blueprint_split_0_4_0];
34
70
 
35
71
  // The migrations that apply to a given move (stamped `from` → shipped `to`), in order.
36
72
  export function pendingMigrations(from, to) {