@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.
- package/CHANGELOG.md +105 -0
- package/README.md +17 -7
- package/bin/rafa.mjs +78 -8
- package/blueprint/.claude/agents/atlas.md +28 -20
- package/blueprint/.claude/agents/bloom.md +15 -8
- package/blueprint/.claude/agents/compass.md +46 -0
- package/blueprint/.claude/agents/prism.md +42 -25
- package/blueprint/.claude/commands/rafa.md +190 -13
- package/blueprint/.claude/rafa/contract.md +482 -0
- package/blueprint/.claude/skills/rafa-build/SKILL.md +76 -0
- package/blueprint/.claude/skills/rafa-distill/SKILL.md +87 -0
- package/blueprint/{.rafa/capabilities/improve.md → .claude/skills/rafa-improve/SKILL.md} +10 -5
- package/blueprint/.claude/skills/rafa-insights/SKILL.md +71 -0
- package/blueprint/{.rafa/capabilities/leverage.md → .claude/skills/rafa-leverage/SKILL.md} +9 -4
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +74 -0
- package/blueprint/{.rafa/capabilities/scan.md → .claude/skills/rafa-scan/SKILL.md} +14 -9
- package/blueprint/{.rafa/capabilities/validate.md → .claude/skills/rafa-validate/SKILL.md} +14 -5
- package/lib/blueprint.mjs +22 -12
- package/lib/brain-repo.mjs +100 -0
- package/lib/checkpoint.mjs +138 -0
- package/lib/ci-setup.mjs +109 -0
- package/lib/claude-config.mjs +5 -5
- package/lib/compile.mjs +6 -21
- package/lib/distill.mjs +237 -0
- package/lib/fold.mjs +53 -0
- package/lib/gate/compile.mjs +549 -0
- package/lib/gate/verify-citations.mjs +156 -0
- package/lib/hydrate.mjs +96 -0
- package/lib/init.mjs +69 -35
- package/lib/leverage/adapters/claude-code.mjs +5 -4
- package/lib/mcp-client.mjs +97 -0
- package/lib/mcp-config.mjs +93 -0
- package/lib/migrations/index.mjs +39 -3
- package/lib/pull.mjs +129 -0
- package/lib/push.mjs +93 -14
- package/lib/releases.mjs +36 -0
- package/lib/verify-citations.mjs +9 -0
- package/lib/working-set.mjs +113 -0
- package/package.json +2 -2
- package/blueprint/.rafa/bin/rafa-compile.mjs +0 -390
- package/blueprint/.rafa/bin/rafa-push.mjs +0 -75
- package/blueprint/.rafa/bin/verify-citations.mjs +0 -153
- package/blueprint/.rafa/capabilities/build.md +0 -38
- package/blueprint/.rafa/capabilities/plan.md +0 -38
- 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
|
+
}
|
package/lib/hydrate.mjs
ADDED
|
@@ -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/
|
|
102
|
-
//
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
|
149
|
-
"
|
|
150
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/lib/migrations/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|