@rafinery/cli 0.7.0 → 0.8.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.
@@ -0,0 +1,323 @@
1
+ // The OKF surface emitter — makes the brain repo a conformant Open Knowledge
2
+ // Format v0.1 bundle (contract §11). Runs inside `rafa push` BEFORE compile, so
3
+ // everything it writes goes back through the gate like any authored file.
4
+ //
5
+ // The format machinery lives in @rafinery/okf (parser · concept primitives ·
6
+ // links · citations · indexes · validator · generator); this module is the thin
7
+ // orchestrator that applies the rafa PROFILE to a bundle:
8
+ //
9
+ // stamp · missing `type`/`title`/`description`/`timestamp`/`tags` where
10
+ // DERIVABLE from authored data or git history — authored values are
11
+ // never rewritten; a value we cannot derive is OMITTED, never guessed.
12
+ // render · a generated `# Citations` body section from the `cites:` DSL
13
+ // (sha-pinned GitHub URLs when the repo is known), inside markers so
14
+ // re-emits replace their own section and never touch prose.
15
+ // links · `[[wikilink]]` → `[Title](/brain/rules/<id>.md)` bundle-relative
16
+ // markdown links, ONLY when the id resolves (a dangling wikilink is
17
+ // not-yet-written knowledge — left for the checker's WARN lane).
18
+ // index · an `index.md` at the bundle root and in every concept directory
19
+ // (OKF §6) so a foreign agent reaches any concept from the root in
20
+ // ≤ 2 hops with no rafa tooling. Root index frontmatter carries
21
+ // `okf_version: "0.1"` + provenance (repo · codeSha) — the only
22
+ // index frontmatter OKF permits.
23
+ //
24
+ // Never touched: log.md (OKF-reserved trail), active.md (one-line pointer
25
+ // protocol — documented exception, surfaced by `rafa okf check`), citation-
26
+ // check.* (the checker's artifact), contract.md (push stamps it), plans/**
27
+ // (work objects — the plans channel).
28
+ //
29
+ // CLI: rafa okf [--root=.rafa] [--repo=owner/repo] [--codeSha=<sha>]
30
+
31
+ import {
32
+ readFileSync,
33
+ writeFileSync,
34
+ readdirSync,
35
+ existsSync,
36
+ statSync,
37
+ } from "node:fs";
38
+ import { join } from "node:path";
39
+ import { execSync } from "node:child_process";
40
+ import {
41
+ parseFrontmatter,
42
+ OKF_VERSION,
43
+ OKF_RESERVED,
44
+ splitConcept,
45
+ joinConcept,
46
+ missingStamps,
47
+ transpileWikilinks,
48
+ parseCites,
49
+ citationsSection,
50
+ upsertCitations,
51
+ indexBullet,
52
+ renderIndex,
53
+ renderRootIndex,
54
+ FILE_CLASSES,
55
+ derivedStamps,
56
+ } from "@rafinery/okf";
57
+
58
+ export function runOkfEmit(argv = []) {
59
+ const arg = (k, d) => {
60
+ const m = argv.find((a) => a.startsWith(`--${k}=`));
61
+ return m ? m.slice(k.length + 3) : d;
62
+ };
63
+ const ROOT = arg("root", ".rafa");
64
+ const gitOpts = (cwd) => ({
65
+ encoding: "utf8",
66
+ cwd,
67
+ stdio: ["ignore", "pipe", "ignore"],
68
+ });
69
+ const codeGit = (cmd, d = "") => {
70
+ try {
71
+ return execSync(cmd, gitOpts(process.cwd())).trim();
72
+ } catch {
73
+ return d;
74
+ }
75
+ };
76
+ const repo =
77
+ arg("repo", "") ||
78
+ (() => {
79
+ const origin = codeGit("git remote get-url origin");
80
+ const m = origin.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/);
81
+ return m ? `${m[1]}/${m[2]}` : "";
82
+ })();
83
+ const codeSha = arg("codeSha", "") || codeGit("git rev-parse HEAD");
84
+ const now = new Date().toISOString();
85
+
86
+ // Last meaningful change of a bundle file — real signals only (contract §11):
87
+ // dirty/untracked in the brain repo → it is changing in THIS emit → now;
88
+ // clean → its last commit time; no git at all → now (the file is being
89
+ // materialized into a fresh bundle). Never a placeholder.
90
+ function fileTimestamp(relPath) {
91
+ try {
92
+ const dirty = execSync(
93
+ `git status --porcelain -- ${JSON.stringify(relPath)}`,
94
+ gitOpts(ROOT),
95
+ ).trim();
96
+ if (dirty) return now;
97
+ const t = execSync(
98
+ `git log -1 --format=%cI -- ${JSON.stringify(relPath)}`,
99
+ gitOpts(ROOT),
100
+ ).trim();
101
+ return t || now;
102
+ } catch {
103
+ return now;
104
+ }
105
+ }
106
+
107
+ function walk(dir) {
108
+ if (!existsSync(dir)) return [];
109
+ return readdirSync(dir)
110
+ .sort()
111
+ .flatMap((e) => {
112
+ const p = join(dir, e);
113
+ return statSync(p).isDirectory()
114
+ ? walk(p)
115
+ : e.endsWith(".md") &&
116
+ !e.startsWith("_") &&
117
+ !e.endsWith(".theirs.md") && // pending human decision — compile gates it
118
+ !OKF_RESERVED.has(e)
119
+ ? [p]
120
+ : [];
121
+ });
122
+ }
123
+ const rel = (p) => p.slice(ROOT.length + 1);
124
+ const read = (p) => readFileSync(p, "utf8");
125
+
126
+ // ── collect the bundle's concept files (per the rafa profile registry) ────
127
+ const concepts = [];
128
+ const collect = (paths, kind) => {
129
+ for (const path of paths) {
130
+ const raw = read(path);
131
+ const split = splitConcept(raw);
132
+ const parsed = split ? parseFrontmatter(raw) : { error: "no frontmatter" };
133
+ if (!split || parsed.error) {
134
+ console.log(` ! ${rel(path)} — ${parsed.error} (skipped; compile will fail it)`);
135
+ continue;
136
+ }
137
+ concepts.push({ path, kind, raw, ...split, data: parsed.data });
138
+ }
139
+ };
140
+ for (const [kind, cls] of Object.entries(FILE_CLASSES)) {
141
+ if (cls.singleton) {
142
+ const p = join(ROOT, cls.path);
143
+ if (existsSync(p)) collect([p], kind);
144
+ } else {
145
+ collect(walk(join(ROOT, cls.dir)), kind);
146
+ }
147
+ }
148
+
149
+ // id → { title, bundlePath } for wikilink transpilation.
150
+ const resolve = new Map();
151
+ for (const c of concepts) {
152
+ if (!c.data.id) continue;
153
+ resolve.set(String(c.data.id), {
154
+ title: c.data.title ?? c.data.id,
155
+ bundlePath: "/" + rel(c.path),
156
+ });
157
+ }
158
+
159
+ // ── stamp + transform every concept ───────────────────────────────────────
160
+ let stamped = 0;
161
+ let transpiled = 0;
162
+ let citesSections = 0;
163
+ for (const c of concepts) {
164
+ const derived = derivedStamps(c.kind, c.data);
165
+ // timestamp: authored created/found verbatim → brain-repo git history → now.
166
+ if (!("timestamp" in c.data))
167
+ derived.timestamp = c.data.created ?? c.data.found ?? fileTimestamp(rel(c.path));
168
+ const stamps = missingStamps(c.data, derived);
169
+ let body = c.body;
170
+ const before = body;
171
+ body = transpileWikilinks(body, resolve);
172
+ if (body !== before) transpiled++;
173
+ const cites = parseCites(c.data.cites);
174
+ if (cites.length) {
175
+ body = upsertCitations(body, citationsSection(cites, { repo, sha: codeSha }));
176
+ citesSections++;
177
+ }
178
+ const next = joinConcept(c.fmLines, stamps, body);
179
+ if (next !== c.raw) {
180
+ writeFileSync(c.path, next);
181
+ if (stamps.length) stamped++;
182
+ }
183
+ }
184
+
185
+ // ── the index.md tree (OKF §6 progressive disclosure) ─────────────────────
186
+ const byKind = (k) => concepts.filter((c) => c.kind === k);
187
+ const meta = (c) => {
188
+ // post-stamp values: authored wins, then what emit just derived
189
+ const { data } = parseFrontmatter(read(c.path));
190
+ return {
191
+ title: data.title ?? data.id ?? rel(c.path),
192
+ description: data.description ?? data.summary ?? "",
193
+ name: c.path.split("/").pop(),
194
+ };
195
+ };
196
+ const bullets = (list) =>
197
+ list.map(meta).map((m) => indexBullet(m.title, m.name, m.description));
198
+
199
+ const indexes = [];
200
+ const writeIndex = (relPath, content) => {
201
+ writeFileSync(join(ROOT, relPath), content);
202
+ indexes.push(relPath);
203
+ };
204
+
205
+ for (const [kind, cls] of Object.entries(FILE_CLASSES)) {
206
+ if (cls.singleton || !cls.indexTitle) continue;
207
+ const list = byKind(kind);
208
+ if (!list.length) continue;
209
+ writeIndex(
210
+ join(cls.dir, "index.md"),
211
+ renderIndex([{ heading: cls.indexTitle, bullets: bullets(list) }]),
212
+ );
213
+ }
214
+
215
+ // Plans index — one section per epic, items listed beneath it (work items
216
+ // nest in subdirectories, so URLs are relative to plans/).
217
+ const planItems = byKind("plan");
218
+ if (planItems.length) {
219
+ const planUrl = (c) => rel(c.path).replace(/^plans\//, "");
220
+ writeIndex(
221
+ "plans/index.md",
222
+ renderIndex(
223
+ planItems
224
+ .filter((c) => c.data.kind === "epic")
225
+ .map((epic) => ({
226
+ heading: `${epic.data.title ?? epic.data.id} (${epic.data.id})`,
227
+ bullets: planItems
228
+ .filter((c) => c.data.plan === epic.data.id)
229
+ .map((c) => {
230
+ const m = meta(c);
231
+ return indexBullet(m.title, planUrl(c), m.description);
232
+ }),
233
+ })),
234
+ ),
235
+ );
236
+ }
237
+
238
+ const singleton = (kind, url) => {
239
+ const c = byKind(kind)[0];
240
+ return c ? indexBullet(meta(c).title, url, meta(c).description) : null;
241
+ };
242
+ if (existsSync(join(ROOT, "brain"))) {
243
+ const rules = byKind("rule");
244
+ const playbooks = byKind("playbook");
245
+ writeIndex(
246
+ "brain/index.md",
247
+ renderIndex([
248
+ {
249
+ heading: "Knowledge",
250
+ bullets: [
251
+ rules.length &&
252
+ indexBullet("Rules", "rules/", `${rules.length} cited invariants/contracts`),
253
+ playbooks.length &&
254
+ indexBullet("Playbooks", "playbooks/", `${playbooks.length} cited how-to/flow notes`),
255
+ ],
256
+ },
257
+ {
258
+ heading: "Reports",
259
+ bullets: [
260
+ singleton("coverage", "coverage.md"),
261
+ singleton("health", "checklist.md"),
262
+ existsSync(join(ROOT, "brain", "citation-check.md")) &&
263
+ indexBullet("Citation check", "citation-check.md", "generated gate report — every cite re-verified"),
264
+ existsSync(join(ROOT, "brain", "log.md")) &&
265
+ indexBullet("Scan log", "log.md", "chronological trail of scans and validations"),
266
+ ],
267
+ },
268
+ ]),
269
+ );
270
+ }
271
+ if (existsSync(join(ROOT, "improve"))) {
272
+ const improvements = byKind("improvement");
273
+ writeIndex(
274
+ "improve/index.md",
275
+ renderIndex([
276
+ {
277
+ heading: "Continuous improvement",
278
+ bullets: [
279
+ improvements.length &&
280
+ indexBullet("Improvements", "improvements/", `${improvements.length} items, leverage-ranked`),
281
+ singleton("ledger", "ledger.md"),
282
+ ],
283
+ },
284
+ ]),
285
+ );
286
+ }
287
+
288
+ // Root index — the bundle's front door (frontmatter: okf_version + provenance).
289
+ writeIndex(
290
+ "index.md",
291
+ renderRootIndex({
292
+ title: `${repo || "rafa brain"} — verified knowledge bundle`,
293
+ intro: [
294
+ `A rafa brain: every concept is cited into the code${codeSha ? ` at \`${codeSha.slice(0, 12)}\`` : ""} and`,
295
+ "mechanically re-verified on every push (cite resolution · anchor completeness ·",
296
+ `absence re-grep · coverage inventory). Conformant Open Knowledge Format v${OKF_VERSION} —`,
297
+ "any OKF consumer can read it; the verification layer is rafa's.",
298
+ ],
299
+ provenance: { ...(repo ? { repo } : {}), ...(codeSha ? { codeSha } : {}) },
300
+ sections: [
301
+ {
302
+ heading: "Contents",
303
+ bullets: [
304
+ existsSync(join(ROOT, "brain")) &&
305
+ indexBullet("Brain", "brain/", "the knowledge map — cited rules + playbooks, coverage, health"),
306
+ existsSync(join(ROOT, "improve")) &&
307
+ indexBullet("Improve", "improve/", "bloom's prioritized improvement ledger (P0–P3)"),
308
+ planItems.length > 0 &&
309
+ indexBullet("Plans", "plans/", "work-item trees (epic → task → subtask), per-epic index"),
310
+ existsSync(join(ROOT, "contract.md")) &&
311
+ indexBullet("Contract", "contract.md", "the file protocol this bundle conforms to (stamped copy)"),
312
+ ],
313
+ },
314
+ ],
315
+ }),
316
+ );
317
+
318
+ console.log(
319
+ `✓ rafa okf: ${concepts.length} concepts · ${stamped} stamped · ${transpiled} bodies transpiled · ` +
320
+ `${citesSections} citations sections · ${indexes.length} indexes → ${ROOT} (OKF v${OKF_VERSION} bundle)`,
321
+ );
322
+ return 0;
323
+ }
@@ -38,6 +38,11 @@ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdtemp
38
38
  import { execSync } from "node:child_process";
39
39
  import { join } from "node:path";
40
40
  import { tmpdir } from "node:os";
41
+ // Format utilities only (splitConcept/extractLinks/unresolvedLinks) — the five
42
+ // GATES above stay independently implemented; the LINKS lane below is a
43
+ // non-failing warn lane, so sharing the format package does not couple a gate
44
+ // to the producer it checks.
45
+ import { splitConcept, extractLinks, unresolvedLinks, OKF_RESERVED } from "@rafinery/okf";
41
46
 
42
47
  export const CHECKER_VERSION = 2; // v1 = the 3-gate era (resolution/completeness/policy)
43
48
 
@@ -60,7 +65,26 @@ function walk(dir) {
60
65
  if (!existsSync(dir)) return [];
61
66
  return readdirSync(dir).flatMap((e) => {
62
67
  const p = join(dir, e);
63
- return statSync(p).isDirectory() ? walk(p) : (e.endsWith(".md") && !e.startsWith("_")) ? [p] : []; // ignore _*.md scratch
68
+ if (statSync(p).isDirectory()) return walk(p);
69
+ // ignore _*.md scratch + OKF-reserved listings (index.md is generated, never
70
+ // a note) + *.theirs.md conflict copies (compile gates those with a typed error)
71
+ return e.endsWith(".md") &&
72
+ !e.startsWith("_") &&
73
+ !e.endsWith(".theirs.md") &&
74
+ !OKF_RESERVED.has(e)
75
+ ? [p]
76
+ : [];
77
+ });
78
+ }
79
+
80
+ // Every .md in the bundle (reserved included — they are legitimate mdlink
81
+ // targets), as "/bundle-relative" paths for the LINKS lane's resolution set.
82
+ function walkBundleMd(dir, base = dir) {
83
+ if (!existsSync(dir)) return [];
84
+ return readdirSync(dir).flatMap((e) => {
85
+ const p = join(dir, e);
86
+ if (statSync(p).isDirectory()) return e === ".git" ? [] : walkBundleMd(p, base);
87
+ return e.endsWith(".md") ? ["/" + p.slice(base.length + 1)] : [];
64
88
  });
65
89
  }
66
90
 
@@ -178,12 +202,24 @@ export function runVerifyCitations(argv = []) {
178
202
  return 0;
179
203
  }
180
204
 
205
+ // LINKS lane resolution sets — BUNDLE-wide (a note may link across dirs):
206
+ // ids = concept stems of every knowledge class; paths = every bundle .md as
207
+ // "/bundle-relative" (reserved files included — legitimate mdlink targets).
208
+ const BUNDLE_ROOT = join(ROOT, "..");
209
+ const bundleIds = new Set(
210
+ ["brain/rules", "brain/playbooks", "improve/improvements"]
211
+ .flatMap((d) => walk(join(BUNDLE_ROOT, d)))
212
+ .map((p) => p.split("/").pop().replace(/\.md$/, "")),
213
+ );
214
+ const bundlePaths = new Set(walkBundleMd(BUNDLE_ROOT));
215
+
181
216
  const resolution = []; // { note, loc, token, ok, reason }
182
217
  const completeness = []; // { note, anchor, site, ok, reason }
183
218
  const policy = []; // { note, ok, reason }
184
219
  const absence = []; // { note, token, site, ok, reason }
185
220
  const inventory = []; // { name, glob, declared, found, ok, reason, paths }
186
221
  const warns = []; // { note, phrase } — heuristic, never fails the gate
222
+ const linkWarns = []; // { note, kind, target } — dangling links (OKF §5.3), never fails the gate
187
223
 
188
224
  for (const note of notes) {
189
225
  const rel = note.replace(ROOT + "/", "");
@@ -256,6 +292,40 @@ export function runVerifyCitations(argv = []) {
256
292
  const m = (title + " · " + summary).match(ABSENCE_HEURISTIC);
257
293
  if (m) warns.push({ note: rel, phrase: m[0] });
258
294
  }
295
+
296
+ // LINKS lane (non-failing, OKF §5.3): every frontmatter `links:` id, body
297
+ // [[wikilink]], and body bundle-relative markdown link should resolve to a
298
+ // bundle concept/file. A dangling link may be not-yet-written knowledge —
299
+ // prism's worklist, never a gate failure.
300
+ {
301
+ const raw = lines.join("\n");
302
+ const split = splitConcept(raw);
303
+ const body = split ? split.body : raw;
304
+ const fmLinks = [];
305
+ let inLinks = false;
306
+ for (const l of split?.fmLines ?? []) {
307
+ const flow = l.match(/^links:\s*\[(.*)\]\s*$/);
308
+ if (flow) {
309
+ for (const item of flow[1].split(",")) {
310
+ const v = strip(item);
311
+ if (v) fmLinks.push(v);
312
+ }
313
+ inLinks = false;
314
+ continue;
315
+ }
316
+ if (/^links:\s*$/.test(l)) { inLinks = true; continue; }
317
+ if (inLinks) {
318
+ const item = l.match(/^\s*-\s+(.+?)\s*$/);
319
+ if (item) { fmLinks.push(strip(item[1])); continue; }
320
+ if (/^\S/.test(l)) inLinks = false;
321
+ }
322
+ }
323
+ const dangling = unresolvedLinks(extractLinks({ links: fmLinks }, body), {
324
+ ids: bundleIds,
325
+ paths: bundlePaths,
326
+ });
327
+ for (const d of dangling) linkWarns.push({ note: rel, kind: d.kind, target: d.target });
328
+ }
259
329
  }
260
330
 
261
331
  // INVENTORY — coverage.md declared counts vs tracked reality
@@ -309,10 +379,24 @@ export function runVerifyCitations(argv = []) {
309
379
  `## Warns (heuristic, non-failing — existence-shaped title/summary with no \`absent:\` declared): ${warns.length}`,
310
380
  ...warns.map((w) => `⚠ ${w.note} — reads as an absence claim ("${w.phrase}") — declare \`absent: <token>\` so the gate can re-grep it, or reword`),
311
381
  "",
382
+ `## Links (non-failing, OKF §5.3 — dangling cross-links, bundle-wide resolution): ${linkWarns.length}`,
383
+ ...linkWarns.map((w) => `⚠ ${w.note} — ${w.kind} → ${w.target} does not resolve in the bundle (not-yet-written knowledge, a typo, or a moved file)`),
384
+ "",
312
385
  fail ? `**${fail} FAILED.**` : "**All pass.**",
313
386
  "",
314
387
  ].join("\n");
315
- writeFileSync(join(ROOT, "citation-check.md"), md);
388
+ // The report is itself a generated concept (contract §11): it self-describes.
389
+ const at = new Date().toISOString();
390
+ const reportFm = [
391
+ "---",
392
+ 'type: "Citation Check"',
393
+ 'title: "Citation check report"',
394
+ `description: "checker v${CHECKER_VERSION} — ${fail ? `${fail} failed` : "all gates pass"} · ${warns.length + linkWarns.length} warn(s)"`,
395
+ `timestamp: ${at}`,
396
+ "---",
397
+ "",
398
+ ].join("\n");
399
+ writeFileSync(join(ROOT, "citation-check.md"), reportFm + md);
316
400
 
317
401
  // Machine record of THIS run — compile folds it into manifest.citations so the
318
402
  // platform knows the gate level a brain passed. Recorded, never assumed.
@@ -321,7 +405,7 @@ export function runVerifyCitations(argv = []) {
321
405
  JSON.stringify(
322
406
  {
323
407
  checkerVersion: CHECKER_VERSION,
324
- at: new Date().toISOString(),
408
+ at,
325
409
  pass: fail === 0,
326
410
  gates: {
327
411
  resolution: { pass: resolution.length - rFail.length, total: resolution.length },
@@ -331,6 +415,7 @@ export function runVerifyCitations(argv = []) {
331
415
  inventory: { pass: inventory.length - iFail.length, total: inventory.length },
332
416
  },
333
417
  warns: warns.length,
418
+ linkWarns: linkWarns.length, // additive optional — compile only reads { checkerVersion, pass, at }
334
419
  },
335
420
  null,
336
421
  2,
@@ -341,6 +426,7 @@ export function runVerifyCitations(argv = []) {
341
426
  console.log(
342
427
  `resolution ${resolution.length - rFail.length}/${resolution.length} · completeness ${completeness.length - cFail.length}/${completeness.length} · policy ${policy.length - pFail.length}/${policy.length} · absence ${absence.length - aFail.length}/${absence.length} · inventory ${inventory.length - iFail.length}/${inventory.length}` +
343
428
  (warns.length ? ` · ${warns.length} warn(s)` : "") +
429
+ (linkWarns.length ? ` · ${linkWarns.length} dangling link(s)` : "") +
344
430
  (fail ? ` · ${fail} FAILED` : " · all pass"),
345
431
  );
346
432
  return fail ? 1 : 0;
@@ -26,7 +26,9 @@ const readJson = (file) => {
26
26
  // The platform repo id this working copy is provisioned for.
27
27
  export function repoIdOf(cwd) {
28
28
  const stamp = readStamp(cwd);
29
- return typeof stamp.repoId === "string" && stamp.repoId !== "" ? stamp.repoId : null;
29
+ return typeof stamp.repoId === "string" && stamp.repoId !== ""
30
+ ? stamp.repoId
31
+ : null;
30
32
  }
31
33
 
32
34
  export function resolveMcp(cwd) {
@@ -66,26 +68,51 @@ let rpcId = 0;
66
68
  // message on transport or tool errors.
67
69
  export async function callTool(cwd, tool, args = {}) {
68
70
  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
- });
71
+ // A localhost platform is only reachable on the machine running the dev
72
+ // server — on a CI runner it is a guaranteed dead end; say so up front
73
+ // instead of a bare "fetch failed".
74
+ if (
75
+ process.env.CI &&
76
+ /\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)[:/]/.test(mcpUrl)
77
+ )
78
+ throw new Error(
79
+ `platform MCP url is ${mcpUrl} — a localhost dev server is not reachable from CI.\n` +
80
+ ` Set the RAFA_MCP_URL secret to your deployed platform's /api/mcp URL, or run the\n` +
81
+ ` session fallback instead (/rafa distill <branch> in Claude Code).`,
82
+ );
83
+ let res;
84
+ try {
85
+ res = await fetch(mcpUrl, {
86
+ method: "POST",
87
+ headers: {
88
+ "content-type": "application/json",
89
+ authorization: `Bearer ${key}`,
90
+ },
91
+ body: JSON.stringify({
92
+ jsonrpc: "2.0",
93
+ id: ++rpcId,
94
+ method: "tools/call",
95
+ params: { name: tool, arguments: { repo: repoId, ...args } },
96
+ }),
97
+ });
98
+ } catch (e) {
99
+ throw new Error(
100
+ `cannot reach the platform MCP at ${mcpUrl} (${e instanceof Error ? e.message : e}) — ` +
101
+ `check the URL (RAFA_MCP_URL / credentials.json / .mcp.json) and that the platform is deployed and reachable from here.`,
102
+ );
103
+ }
82
104
  if (res.status === 401)
83
- throw new Error("agent key rejected (401) — revoked or wrong platform; mint a new one (repo → Agent access)");
105
+ throw new Error(
106
+ "agent key rejected (401) — revoked or wrong platform; mint a new one (repo → Agent access)",
107
+ );
84
108
  if (res.status === 429)
85
109
  throw new Error("rate limited (429) — back off and retry");
86
110
  if (!res.ok) throw new Error(`MCP endpoint returned HTTP ${res.status}`);
87
111
  const body = await res.json();
88
- if (body.error) throw new Error(`MCP error: ${body.error.message ?? JSON.stringify(body.error)}`);
112
+ if (body.error)
113
+ throw new Error(
114
+ `MCP error: ${body.error.message ?? JSON.stringify(body.error)}`,
115
+ );
89
116
  const result = body.result ?? {};
90
117
  const payload = result.structuredContent ?? null;
91
118
  if (result.isError) {
package/lib/okf.mjs ADDED
@@ -0,0 +1,110 @@
1
+ // rafa okf — the OKF surface commands (contract §11). The format machinery is
2
+ // @rafinery/okf; the rafa profile decides where classes live and what stamps
3
+ // are derivable.
4
+ //
5
+ // rafa okf materialize the surface on .rafa (stamps · link
6
+ // transpile · citations sections · index tree).
7
+ // Runs automatically inside `rafa push`.
8
+ // rafa okf check OKF §9 conformance walk of the bundle — errors
9
+ // fail (exit 1), soft guidance warns. Exemptions
10
+ // (active.md) are explicit and surfaced.
11
+ // rafa okf new <class> <id> [--key=value …] [--cite="f:l :: tok" …] [--link=id …]
12
+ // mint a conformant concept skeleton in the class's
13
+ // directory. Nothing is invented: flags you omit are
14
+ // absent, and `rafa compile` fails required ones
15
+ // loudly — that is the validate-and-correct loop.
16
+
17
+ import { existsSync, writeFileSync, mkdirSync } from "node:fs";
18
+ import { join, dirname } from "node:path";
19
+ import { runOkfEmit } from "./gate/okf-emit.mjs";
20
+ import { SCHEMA_VERSION } from "./gate/compile.mjs";
21
+ import {
22
+ OKF_VERSION,
23
+ validateBundle,
24
+ newConcept,
25
+ FILE_CLASSES,
26
+ RAFA_EXEMPT,
27
+ } from "@rafinery/okf";
28
+
29
+ export default async function okf(args = []) {
30
+ const [sub, ...rest] = args[0]?.startsWith("--") ? [undefined, ...args] : args;
31
+ const ROOT =
32
+ (args.find((a) => a.startsWith("--root=")) ?? "").slice(7) || ".rafa";
33
+
34
+ if (sub === undefined || sub === "emit") {
35
+ process.exit(runOkfEmit(args.filter((a) => a.startsWith("--"))));
36
+ }
37
+
38
+ if (sub === "check") {
39
+ const { conformant, checked, errors, warnings } = validateBundle(ROOT, {
40
+ exempt: RAFA_EXEMPT,
41
+ });
42
+ for (const e of errors) console.log(`✗ ${e.path} — ${e.rule}`);
43
+ for (const w of warnings) console.log(`⚠ ${w.path} — ${w.rule}`);
44
+ console.log(
45
+ `${conformant ? "✓" : "✗"} rafa okf check: ${checked} files · ` +
46
+ `${errors.length} error(s) · ${warnings.length} warning(s) — ` +
47
+ (conformant ? `bundle conforms to OKF v${OKF_VERSION}` : "bundle is NOT conformant"),
48
+ );
49
+ process.exit(conformant ? 0 : 1);
50
+ }
51
+
52
+ if (sub === "new") {
53
+ const [cls, id] = rest.filter((a) => !a.startsWith("--"));
54
+ const registry = FILE_CLASSES[cls];
55
+ if (!registry || registry.singleton) {
56
+ console.error(
57
+ `✗ rafa okf new: <class> must be one of ${Object.entries(FILE_CLASSES)
58
+ .filter(([, c]) => !c.singleton)
59
+ .map(([k]) => k)
60
+ .join(" | ")} (singletons are authored at their fixed path)`,
61
+ );
62
+ process.exit(1);
63
+ }
64
+ if (!id) {
65
+ console.error("✗ rafa okf new: missing <id> (kebab-case, becomes the filename stem)");
66
+ process.exit(1);
67
+ }
68
+ const fields = { schemaVersion: SCHEMA_VERSION }; // never a literal — moves with the contract
69
+ const listFields = { cites: [], links: [] };
70
+ for (const a of rest.filter((a) => a.startsWith("--"))) {
71
+ const m = a.match(/^--([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
72
+ if (!m) continue;
73
+ const [, key, value] = m;
74
+ if (key === "cite") listFields.cites.push(value);
75
+ else if (key === "link") listFields.links.push(value);
76
+ else if (key !== "root") fields[key] = value;
77
+ }
78
+ // The improvement class's OKF type is constant; notes author their own
79
+ // contract enum via --type (compile owns the enum — no default here).
80
+ const type = fields.type ?? registry.okfType ?? "";
81
+ delete fields.type;
82
+ let content;
83
+ try {
84
+ content = newConcept({ type, id, fields, listFields });
85
+ } catch (e) {
86
+ console.error(`✗ rafa okf new: ${e instanceof Error ? e.message : e}`);
87
+ console.error(
88
+ cls === "improvement"
89
+ ? ""
90
+ : " notes require --type=<contract|convention|flow|how-to> (the compile enum)",
91
+ );
92
+ process.exit(1);
93
+ }
94
+ const path = join(ROOT, registry.dir, `${id}.md`);
95
+ if (existsSync(path)) {
96
+ console.error(`✗ rafa okf new: ${path} already exists — refusing to overwrite`);
97
+ process.exit(1);
98
+ }
99
+ mkdirSync(dirname(path), { recursive: true });
100
+ writeFileSync(path, content);
101
+ console.log(
102
+ `✓ rafa okf new: ${path} — now write the body + fill required fields, ` +
103
+ `then \`rafa verify-citations\` + \`rafa compile\` gate it.`,
104
+ );
105
+ process.exit(0);
106
+ }
107
+
108
+ console.error(`✗ rafa okf: unknown subcommand "${sub}" (emit | check | new)`);
109
+ process.exit(1);
110
+ }