@rafinery/cli 0.3.0 → 0.4.1
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 +94 -0
- package/README.md +17 -7
- package/bin/rafa.mjs +68 -20
- 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 +151 -26
- package/blueprint/{.rafa → .claude/rafa}/contract.md +108 -22
- 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/{.rafa/capabilities/plan.md → .claude/skills/rafa-plan/SKILL.md} +30 -3
- 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 +15 -30
- package/lib/leverage/adapters/claude-code.mjs +5 -4
- package/lib/mcp-client.mjs +97 -0
- package/lib/mcp-config.mjs +1 -1
- package/lib/migrations/index.mjs +39 -3
- package/lib/pull.mjs +129 -0
- package/lib/push.mjs +93 -14
- package/lib/releases.mjs +33 -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 -478
- 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 -39
|
@@ -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,13 +1,7 @@
|
|
|
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";
|
|
@@ -77,8 +71,6 @@ const die = (m) => {
|
|
|
77
71
|
console.error(`✗ ${m}`);
|
|
78
72
|
process.exit(1);
|
|
79
73
|
};
|
|
80
|
-
const run = (cmd, cwd) =>
|
|
81
|
-
execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }).trim();
|
|
82
74
|
|
|
83
75
|
export default async function init(args) {
|
|
84
76
|
const url = args.find((a) => !a.startsWith("-"));
|
|
@@ -104,14 +96,14 @@ export default async function init(args) {
|
|
|
104
96
|
console.log("rafa init — vendoring the blueprint (no setup URL)");
|
|
105
97
|
}
|
|
106
98
|
|
|
107
|
-
// Vendor the blueprint into .claude/
|
|
108
|
-
//
|
|
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`.
|
|
109
103
|
reportBlueprint(
|
|
110
104
|
await copyBlueprint(blueprintSource(), TARGET, { onConflict: makeConflictResolver(args) }),
|
|
111
105
|
);
|
|
112
|
-
|
|
113
|
-
writeFileSync(join(TARGET, ".rafa", "active.md"), "# No active plan\n");
|
|
114
|
-
// Grant rafa's vendored tools their permissions (merge, never overwrite).
|
|
106
|
+
// Grant rafa's tools their permissions (merge, never overwrite).
|
|
115
107
|
reportSettings(TARGET);
|
|
116
108
|
|
|
117
109
|
// Keep the brain out of the code repo's diffs; keep the per-dev MCP key out of git.
|
|
@@ -174,27 +166,20 @@ export default async function init(args) {
|
|
|
174
166
|
: {},
|
|
175
167
|
);
|
|
176
168
|
|
|
177
|
-
if (p) {
|
|
178
|
-
const rafaDir = join(TARGET, ".rafa");
|
|
179
|
-
if (!existsSync(join(rafaDir, ".git"))) run("git init -b main", rafaDir);
|
|
180
|
-
try {
|
|
181
|
-
run("git remote get-url origin", rafaDir);
|
|
182
|
-
run(`git remote set-url origin ${p.brainRepoUrl}`, rafaDir);
|
|
183
|
-
} catch {
|
|
184
|
-
run(`git remote add origin ${p.brainRepoUrl}`, rafaDir);
|
|
185
|
-
}
|
|
186
|
-
console.log(" ✓ .rafa/ is its own git repo → origin = brain remote");
|
|
187
|
-
}
|
|
188
|
-
|
|
189
169
|
console.log(
|
|
190
170
|
`\n✓ Provisioned.\n\nNext steps:\n` +
|
|
191
|
-
" 1.
|
|
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" +
|
|
192
175
|
(p?.mcpUrl ? " + the rafinery MCP server" : "") +
|
|
193
176
|
").\n" +
|
|
194
|
-
"
|
|
195
|
-
"
|
|
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." +
|
|
196
181
|
(p?.mcpUrl
|
|
197
|
-
? "\n
|
|
182
|
+
? "\n 5. After the first push, any MCP client with a key can query this repo's knowledge\n" +
|
|
198
183
|
` (endpoint: ${p.mcpUrl} · keys: repo → Agent access on the platform).`
|
|
199
184
|
: ""),
|
|
200
185
|
);
|
|
@@ -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
|
+
}
|
package/lib/mcp-config.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// MCP wiring for `rafa init` — connect the client repo to the platform's
|
|
2
|
-
// knowledge MCP (.rafa/contract.md §9) with zero secrets in committed files:
|
|
2
|
+
// knowledge MCP (.claude/rafa/contract.md §9) with zero secrets in committed files:
|
|
3
3
|
//
|
|
4
4
|
// .mcp.json committed · server entry, key via ${RAFA_MCP_KEY}
|
|
5
5
|
// .claude/settings.local.json gitignored · env.RAFA_MCP_KEY = the raw key
|
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) {
|
package/lib/pull.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// rafa pull — the teammate path: make this clone brain-ready.
|
|
2
|
+
//
|
|
3
|
+
// Blueprint split (0.4.0): `.rafa/` is a LAZY, gitignored, per-working-copy
|
|
4
|
+
// instance — day-to-day branch work needs NO full local brain (recall serves
|
|
5
|
+
// the org brain via the platform MCP and faults files in as hydrations). So:
|
|
6
|
+
//
|
|
7
|
+
// rafa pull bootstrap the lazy skeleton from the committed
|
|
8
|
+
// rafa.json (git wiring + active pointer), verify the
|
|
9
|
+
// brain remote is reachable, and report how knowledge
|
|
10
|
+
// will be served. Clone → `npx rafa pull` just works.
|
|
11
|
+
// rafa pull --full ALSO mirror the whole brain repo into `.rafa/`
|
|
12
|
+
// (offline work / file-reading fallback). Fast-forward
|
|
13
|
+
// only; divergence stops loudly; --force adopts the
|
|
14
|
+
// remote (the remote is canonical; local is a cache).
|
|
15
|
+
//
|
|
16
|
+
// Never re-scan to obtain knowledge that already exists. Uses YOUR git auth.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
ensureBrainRepo,
|
|
20
|
+
remoteDefaultBranch,
|
|
21
|
+
ensureActivePointer,
|
|
22
|
+
inDir,
|
|
23
|
+
die,
|
|
24
|
+
} from "./brain-repo.mjs";
|
|
25
|
+
|
|
26
|
+
export default async function pull(args = []) {
|
|
27
|
+
const ROOT = process.cwd();
|
|
28
|
+
const FULL = args.includes("--full");
|
|
29
|
+
const FORCE = args.includes("--force");
|
|
30
|
+
|
|
31
|
+
const { rafaDir, remote, bootstrapped } = ensureBrainRepo(ROOT);
|
|
32
|
+
const inRafa = inDir(rafaDir);
|
|
33
|
+
if (bootstrapped) console.log(`• bootstrapped lazy .rafa/ from rafa.json (remote: ${remote})`);
|
|
34
|
+
|
|
35
|
+
// Reachability + default branch — loud if the remote can't be reached.
|
|
36
|
+
let branch = "main";
|
|
37
|
+
try {
|
|
38
|
+
branch = remoteDefaultBranch(rafaDir);
|
|
39
|
+
inRafa(`git ls-remote origin ${branch}`);
|
|
40
|
+
} catch {
|
|
41
|
+
die("Could not reach the brain remote — check your network/auth and retry.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
ensureActivePointer(rafaDir);
|
|
45
|
+
|
|
46
|
+
if (!FULL) {
|
|
47
|
+
console.log(`✓ .rafa/ ready (lazy instance · brain remote: origin/${branch})`);
|
|
48
|
+
console.log(
|
|
49
|
+
" Knowledge is served via the platform MCP as you work; files hydrate in on demand.\n" +
|
|
50
|
+
" Want a full local mirror (offline/fallback reads)? rafa pull --full",
|
|
51
|
+
);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── --full: mirror the brain repo (the pre-0.4.0 behavior) ──
|
|
56
|
+
console.log(`• fetching brain (origin/${branch}) …`);
|
|
57
|
+
inRafa(`git fetch origin ${branch}`);
|
|
58
|
+
|
|
59
|
+
let remoteSha = "";
|
|
60
|
+
try {
|
|
61
|
+
remoteSha = inRafa(`git rev-parse origin/${branch}`);
|
|
62
|
+
} catch {
|
|
63
|
+
die(
|
|
64
|
+
`The brain remote has no "${branch}" branch yet — nothing to pull. ` +
|
|
65
|
+
"Run /rafa scan and `rafa push` first (once, on any machine).",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Dirty working tree → loud stop (the cache never silently loses local work).
|
|
70
|
+
const dirty = inRafa("git status --porcelain");
|
|
71
|
+
if (dirty && !FORCE)
|
|
72
|
+
die(
|
|
73
|
+
"`.rafa/` has local changes that a pull would clobber:\n" +
|
|
74
|
+
dirty
|
|
75
|
+
.split("\n")
|
|
76
|
+
.map((l) => ` ${l}`)
|
|
77
|
+
.join("\n") +
|
|
78
|
+
"\n Checkpoint/push them first, or discard them (`rafa pull --full --force`).",
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
if (FORCE) {
|
|
83
|
+
inRafa(`git checkout -B ${branch}`);
|
|
84
|
+
inRafa(`git reset --hard origin/${branch}`);
|
|
85
|
+
} else {
|
|
86
|
+
// ff-only from a possibly-unborn local branch: -B onto the remote tip is a
|
|
87
|
+
// fast-forward when clean-and-behind, and fails loudly on divergence.
|
|
88
|
+
const localSha = (() => {
|
|
89
|
+
try {
|
|
90
|
+
return inRafa("git rev-parse HEAD");
|
|
91
|
+
} catch {
|
|
92
|
+
return ""; // unborn (fresh bootstrap) — safe to adopt the remote tip
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
if (localSha && localSha !== remoteSha) {
|
|
96
|
+
const isAncestor = (() => {
|
|
97
|
+
try {
|
|
98
|
+
inRafa(`git merge-base --is-ancestor ${localSha} origin/${branch}`);
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
if (!isAncestor)
|
|
105
|
+
die(
|
|
106
|
+
"Local brain has diverged from the remote (unpushed local commits). " +
|
|
107
|
+
"The remote is canonical — `rafa pull --full --force` to adopt it, or push yours first.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
inRafa(`git checkout -B ${branch} origin/${branch}`);
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
die(`pull failed: ${e.message ?? e}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Report the anchor so the dev knows how current the cache is.
|
|
117
|
+
let stamp = "";
|
|
118
|
+
try {
|
|
119
|
+
stamp = inRafa("git log -1 --pretty=%s");
|
|
120
|
+
} catch {
|
|
121
|
+
/* fine */
|
|
122
|
+
}
|
|
123
|
+
console.log(
|
|
124
|
+
`✓ brain mirrored → .rafa/ @ ${remoteSha.slice(0, 7)}${stamp ? ` (${stamp})` : ""}`,
|
|
125
|
+
);
|
|
126
|
+
console.log(
|
|
127
|
+
" Local mirror is current. Recall also works with zero local files when the repo is platform-connected (MCP).",
|
|
128
|
+
);
|
|
129
|
+
}
|