@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.
package/lib/ci-setup.mjs CHANGED
@@ -46,6 +46,7 @@ jobs:
46
46
  - name: Mechanical fold (no LLM — rigor lives at main)
47
47
  env:
48
48
  RAFA_MCP_KEY: \${{ secrets.RAFA_MCP_KEY }}
49
+ RAFA_MCP_URL: \${{ secrets.RAFA_MCP_URL }} # deployed platform /api/mcp — localhost never works from CI
49
50
  RAFA_HOOKS_DISABLED: "1" # headless — the M5 sensors are session instruments
50
51
  run: npx -y @rafinery/cli fold --from "\${{ github.event.pull_request.head.ref }}" --to "\${{ github.event.pull_request.base.ref }}"
51
52
 
@@ -60,13 +61,20 @@ jobs:
60
61
  - uses: actions/setup-node@v4
61
62
  with:
62
63
  node-version: 20
63
- - name: Install the Agent SDK (runs on the org's own key)
64
- run: npm i --no-save @anthropic-ai/claude-agent-sdk
64
+ # ISOLATED install never \`npm i\` inside the client checkout: a pnpm
65
+ # workspace repo has "workspace:*" deps npm cannot parse (EUNSUPPORTEDPROTOCOL).
66
+ - name: Install the Agent SDK (isolated dir; runs on the org's own key)
67
+ run: |
68
+ mkdir -p "\$RUNNER_TEMP/rafa-sdk" && cd "\$RUNNER_TEMP/rafa-sdk"
69
+ npm init -y > /dev/null
70
+ npm i @anthropic-ai/claude-agent-sdk
65
71
  - name: Distill into the org brain (prism-validated, compile-gated)
66
72
  env:
67
73
  ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
68
74
  RAFA_MCP_KEY: \${{ secrets.RAFA_MCP_KEY }}
75
+ RAFA_MCP_URL: \${{ secrets.RAFA_MCP_URL }} # deployed platform /api/mcp — localhost never works from CI
69
76
  RAFA_BRAIN_TOKEN: \${{ secrets.RAFA_BRAIN_TOKEN }}
77
+ RAFA_AGENT_SDK_DIR: \${{ runner.temp }}/rafa-sdk
70
78
  RAFA_HOOKS_DISABLED: "1" # headless — the M5 sensors are session instruments
71
79
  run: npx -y @rafinery/cli distill --headless "\${{ github.event.pull_request.head.ref }}"
72
80
  `;
@@ -97,12 +105,14 @@ export default async function ciSetup(args = []) {
97
105
  console.log(`
98
106
  Next steps (repo Settings → Secrets and variables → Actions):
99
107
  1. RAFA_MCP_KEY — mint on the platform (repo → Agent access); scope: this repo.
100
- 2. ANTHROPIC_API_KEY the ORG'S OWN LLM key. Used only inside YOUR CI for
108
+ 2. RAFA_MCP_URL your DEPLOYED platform's MCP endpoint (https://<host>/api/mcp).
109
+ A localhost dev URL can never work from a CI runner.
110
+ 3. ANTHROPIC_API_KEY — the ORG'S OWN LLM key. Used only inside YOUR CI for
101
111
  merge-to-main distillation; never stored on the rafinery
102
112
  platform (hard rule).
103
- 3. RAFA_BRAIN_TOKEN — a token with push access to the BRAIN repo (the distill
113
+ 4. RAFA_BRAIN_TOKEN — a token with push access to the BRAIN repo (the distill
104
114
  job pushes validated knowledge there).
105
- 4. Commit the workflow via your normal MR flow.
115
+ 5. Commit the workflow via your normal MR flow.
106
116
 
107
117
  The rigor gradient this wires: dev↔dev = CAS + session prompt · branch↔branch =
108
118
  free mechanical fold · branch→main = full distillation + gates. Cost tracks
package/lib/distill.mjs CHANGED
@@ -17,7 +17,13 @@
17
17
  // Fallback chain when this can't run (no key, no CI): the dev-session flow —
18
18
  // /rafa distill <branch> in Claude Code (the bootstrap digest offers it).
19
19
 
20
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ readFileSync,
24
+ rmSync,
25
+ writeFileSync,
26
+ } from "node:fs";
21
27
  import { createRequire } from "node:module";
22
28
  import { dirname, join } from "node:path";
23
29
  import { pathToFileURL } from "node:url";
@@ -35,27 +41,35 @@ const die = (m) => {
35
41
  // A brain-relative path from the platform must stay inside .rafa/ — a row that
36
42
  // would escape is refused loudly (never written, never guessed).
37
43
  const safeRel = (p) => {
38
- if (p.startsWith("/") || p.split("/").includes("..")) die(`unsafe working-set path: ${p}`);
44
+ if (p.startsWith("/") || p.split("/").includes(".."))
45
+ die(`unsafe working-set path: ${p}`);
39
46
  return p;
40
47
  };
41
48
 
42
49
  // The Agent SDK is deliberately NOT a dependency of this CLI (it is heavy and
43
- // only CI distillation needs it). Resolution order: normal import → the host
44
- // repo's node_modules (the workflow installs it with `npm i --no-save`).
50
+ // only CI distillation needs it). Resolution order: $RAFA_AGENT_SDK_DIR (the
51
+ // workflow's ISOLATED install `npm i` inside the client checkout breaks on
52
+ // pnpm workspaces, "workspace:*" is not an npm protocol) → normal import →
53
+ // the host repo's node_modules.
45
54
  async function loadAgentSdk(cwd) {
46
- try {
47
- return await import("@anthropic-ai/claude-agent-sdk");
48
- } catch {
55
+ const roots = [process.env.RAFA_AGENT_SDK_DIR, null, cwd];
56
+ for (const root of roots) {
49
57
  try {
50
- const req = createRequire(join(cwd, "package.json"));
51
- return await import(pathToFileURL(req.resolve("@anthropic-ai/claude-agent-sdk")).href);
52
- } catch {
53
- die(
54
- "@anthropic-ai/claude-agent-sdk is not installed — the reconcile workflow runs\n" +
55
- " `npm i --no-save @anthropic-ai/claude-agent-sdk` before this command (see rafa ci-setup).",
58
+ if (root === null) return await import("@anthropic-ai/claude-agent-sdk");
59
+ if (!root) continue;
60
+ const req = createRequire(join(root, "package.json"));
61
+ return await import(
62
+ pathToFileURL(req.resolve("@anthropic-ai/claude-agent-sdk")).href
56
63
  );
64
+ } catch {
65
+ // try the next root
57
66
  }
58
67
  }
68
+ die(
69
+ "@anthropic-ai/claude-agent-sdk is not installed — the reconcile workflow installs it\n" +
70
+ " into an isolated dir and points RAFA_AGENT_SDK_DIR at it (see rafa ci-setup;\n" +
71
+ " re-run `npx @rafinery/cli ci-setup --overwrite` if your workflow predates this).",
72
+ );
59
73
  }
60
74
 
61
75
  const VERDICTS = ["distilled", "refuted", "needs-adjudication"];
@@ -76,7 +90,8 @@ export default async function distill(args = []) {
76
90
  die(
77
91
  "ANTHROPIC_API_KEY is not set — CI distillation runs on the ORG'S OWN LLM key from CI\n" +
78
92
  " secrets (never stored on the rafinery platform). Add the secret (rafa ci-setup names\n" +
79
- " it), or fall back to the dev session: /rafa distill " + branch,
93
+ " it), or fall back to the dev session: /rafa distill " +
94
+ branch,
80
95
  );
81
96
 
82
97
  const ROOT = process.cwd();
@@ -90,10 +105,16 @@ export default async function distill(args = []) {
90
105
  console.log(`• collecting working set for ${branch} …`);
91
106
  let active, flagged;
92
107
  try {
93
- active = (await callTool(ROOT, "get_working_set", { branch, status: "active" })).files ?? [];
94
- flagged =
95
- (await callTool(ROOT, "get_working_set", { branch, status: "needs-adjudication" }))
108
+ active =
109
+ (await callTool(ROOT, "get_working_set", { branch, status: "active" }))
96
110
  .files ?? [];
111
+ flagged =
112
+ (
113
+ await callTool(ROOT, "get_working_set", {
114
+ branch,
115
+ status: "needs-adjudication",
116
+ })
117
+ ).files ?? [];
97
118
  } catch (e) {
98
119
  die(e instanceof Error ? e.message : String(e));
99
120
  }
@@ -131,7 +152,9 @@ export default async function distill(args = []) {
131
152
 
132
153
  // 4 · the judgment pass (org's own key; gates stay deterministic below).
133
154
  const sdk = await loadAgentSdk(ROOT);
134
- const fileList = active.map((f) => `- ${f.path} (author: ${f.lastAuthor})`).join("\n");
155
+ const fileList = active
156
+ .map((f) => `- ${f.path} (author: ${f.lastAuthor})`)
157
+ .join("\n");
135
158
  const prompt = `You are running rafa's CI DISTILLATION — merge-time reconciliation of branch "${branch}"'s
136
159
  working set into the org brain. The checked-out repo IS merged main (the ground truth).
137
160
  The current org brain is mirrored at .rafa/brain/. The incoming candidate-grade files are
@@ -162,7 +185,9 @@ with EXACTLY one entry per staged file listed below. Do not modify anything outs
162
185
  Staged files:
163
186
  ${fileList}`;
164
187
 
165
- console.log("• running the distillation agent (org's own ANTHROPIC_API_KEY) …");
188
+ console.log(
189
+ "• running the distillation agent (org's own ANTHROPIC_API_KEY) …",
190
+ );
166
191
  const tAgent = Date.now();
167
192
  const run = sdk.query({
168
193
  prompt,
@@ -179,7 +204,10 @@ ${fileList}`;
179
204
  let lastLine = "";
180
205
  let agentModel = "unreported";
181
206
  for await (const message of run) {
182
- if (message.type === "assistant" && typeof message.message?.model === "string")
207
+ if (
208
+ message.type === "assistant" &&
209
+ typeof message.message?.model === "string"
210
+ )
183
211
  agentModel = message.message.model;
184
212
  if (message.type === "assistant") {
185
213
  const blocks = message.message?.content ?? [];
@@ -194,7 +222,9 @@ ${fileList}`;
194
222
  }
195
223
  if (message.type === "result") {
196
224
  if (message.subtype !== "success")
197
- die(`distillation agent did not complete (${message.subtype}) — nothing resolved, nothing pushed`);
225
+ die(
226
+ `distillation agent did not complete (${message.subtype}) — nothing resolved, nothing pushed`,
227
+ );
198
228
  console.log(
199
229
  ` ⏱ agent: ${secs(tAgent)}s (${message.num_turns ?? "?"} turns · ` +
200
230
  `$${(message.total_cost_usd ?? 0).toFixed(4)} on the org's key)`,
@@ -206,32 +236,46 @@ ${fileList}`;
206
236
  model: agentModel,
207
237
  inputTokens: message.usage?.input_tokens ?? 0,
208
238
  outputTokens: message.usage?.output_tokens ?? 0,
209
- ...(typeof message.total_cost_usd === "number" ? { costUsd: message.total_cost_usd } : {}),
239
+ ...(typeof message.total_cost_usd === "number"
240
+ ? { costUsd: message.total_cost_usd }
241
+ : {}),
210
242
  purpose: "distill",
211
243
  runner: "ci",
212
244
  });
213
245
  } catch (e) {
214
- console.log(` ! usage not reported: ${e instanceof Error ? e.message : e}`);
246
+ console.log(
247
+ ` ! usage not reported: ${e instanceof Error ? e.message : e}`,
248
+ );
215
249
  }
216
250
  }
217
251
  }
218
252
 
219
253
  // 5 · verdicts must be complete — a missing entry is a hard stop, never a guess.
220
254
  if (!existsSync(verdictsPath))
221
- die("agent produced no .rafa/distill-verdicts.json — nothing resolved, nothing pushed; working set left intact");
255
+ die(
256
+ "agent produced no .rafa/distill-verdicts.json — nothing resolved, nothing pushed; working set left intact",
257
+ );
222
258
  let verdicts;
223
259
  try {
224
260
  verdicts = JSON.parse(readFileSync(verdictsPath, "utf8")).verdicts;
225
261
  } catch {
226
- die("distill-verdicts.json is not valid JSON — nothing resolved, nothing pushed");
262
+ die(
263
+ "distill-verdicts.json is not valid JSON — nothing resolved, nothing pushed",
264
+ );
227
265
  }
228
- if (!Array.isArray(verdicts)) die("distill-verdicts.json has no verdicts[] — aborting");
266
+ if (!Array.isArray(verdicts))
267
+ die("distill-verdicts.json has no verdicts[] — aborting");
229
268
  const byPath = new Map(verdicts.map((v) => [v.path, v]));
230
269
  for (const f of active) {
231
270
  const v = byPath.get(f.path);
232
271
  if (!v || !VERDICTS.includes(v.verdict))
233
- die(`no valid verdict for ${f.path} — nothing resolved, nothing pushed; working set left intact`);
234
- if (v.verdict !== "distilled" && (typeof v.note !== "string" || v.note.trim() === ""))
272
+ die(
273
+ `no valid verdict for ${f.path} nothing resolved, nothing pushed; working set left intact`,
274
+ );
275
+ if (
276
+ v.verdict !== "distilled" &&
277
+ (typeof v.note !== "string" || v.note.trim() === "")
278
+ )
235
279
  die(`${f.path}: verdict "${v.verdict}" requires a cited note — aborting`);
236
280
  }
237
281
 
@@ -239,14 +283,20 @@ ${fileList}`;
239
283
  console.log("• re-running the gates (trust-but-verify) …");
240
284
  const tGates = Date.now();
241
285
  if (runVerifyCitations([]) !== 0)
242
- die("citation checker failed on the authored brain — nothing resolved, nothing pushed");
286
+ die(
287
+ "citation checker failed on the authored brain — nothing resolved, nothing pushed",
288
+ );
243
289
  if (runCompile([]) !== 0)
244
- die("compile gate failed on the authored brain — nothing resolved, nothing pushed");
290
+ die(
291
+ "compile gate failed on the authored brain — nothing resolved, nothing pushed",
292
+ );
245
293
  console.log(` ⏱ gates: ${secs(tGates)}s`);
246
294
 
247
295
  // 7 · ship: brain repo push (webhook → ingest), staging cleaned first.
248
296
  rmSync(stagingRoot, { recursive: true, force: true });
249
- const distilledCount = active.filter((f) => byPath.get(f.path).verdict === "distilled").length;
297
+ const distilledCount = active.filter(
298
+ (f) => byPath.get(f.path).verdict === "distilled",
299
+ ).length;
250
300
  if (distilledCount > 0) {
251
301
  await push([]);
252
302
  } else {
@@ -268,9 +318,13 @@ ${fileList}`;
268
318
  actorMeta: { agent: "rafa-distill", runner: "ci", model: agentModel },
269
319
  ...(v.note ? { note: v.note } : {}),
270
320
  });
271
- console.log(` ${v.verdict === "distilled" ? "✓" : "!"} ${f.path} → ${v.verdict}${v.note ? ` (${v.note})` : ""}`);
321
+ console.log(
322
+ ` ${v.verdict === "distilled" ? "✓" : "!"} ${f.path} → ${v.verdict}${v.note ? ` (${v.note})` : ""}`,
323
+ );
272
324
  } catch (e) {
273
- console.log(` ✗ ${f.path} — resolve failed: ${e instanceof Error ? e.message : e}`);
325
+ console.log(
326
+ ` ✗ ${f.path} — resolve failed: ${e instanceof Error ? e.message : e}`,
327
+ );
274
328
  }
275
329
  }
276
330
  rmSync(verdictsPath, { force: true });
@@ -21,91 +21,14 @@ import {
21
21
  import { join } from "node:path";
22
22
  import { execSync } from "node:child_process";
23
23
 
24
- const SCHEMA_VERSION = 1;
24
+ export const SCHEMA_VERSION = 1; // the contract version — single numeric source; the generator imports it
25
25
 
26
- // ── strict frontmatter parser (contract §0 grammar) ─────────────────────────
27
- // Legal value forms only: scalar | "quoted" | [flow,list] | { flow: map }.
28
- // Plus `key:` followed by ` - item` block lists and folded scalars. Anything
29
- // else error.
30
- function parseScalar(raw) {
31
- const s = raw.trim();
32
- if (s === "") return "";
33
- if (/^".*"$/.test(s) || /^'.*'$/.test(s)) return s.slice(1, -1);
34
- if (s === "true") return true;
35
- if (s === "false") return false;
36
- if (/^-?\d+$/.test(s)) return Number(s);
37
- return s;
38
- }
39
- // Strip a trailing YAML comment ( whitespace + # … ) from an UNQUOTED top-level
40
- // scalar. Not applied to block-list items (cites/links are exact strings that may
41
- // legitimately contain `#`).
42
- function stripComment(s) {
43
- if (/^["']/.test(s.trim())) return s;
44
- const i = s.search(/\s#/);
45
- return i === -1 ? s : s.slice(0, i);
46
- }
47
- function parseFlowList(raw) {
48
- const inner = raw.trim().slice(1, -1).trim();
49
- if (inner === "") return [];
50
- return inner.split(",").map((x) => parseScalar(x));
51
- }
52
- function parseFlowMap(raw) {
53
- const inner = raw.trim().slice(1, -1).trim();
54
- const out = {};
55
- if (inner === "") return out;
56
- for (const pair of inner.split(",")) {
57
- const i = pair.indexOf(":");
58
- if (i === -1) return null; // malformed
59
- out[pair.slice(0, i).trim()] = parseScalar(pair.slice(i + 1));
60
- }
61
- return out;
62
- }
63
- // Returns { data } or { error: "reason" }.
64
- export function parseFrontmatter(text) {
65
- if (!text.startsWith("---")) return { error: "no frontmatter block" };
66
- const end = text.indexOf("\n---", text.indexOf("\n"));
67
- if (end === -1) return { error: "unterminated frontmatter" };
68
- const lines = text.slice(text.indexOf("\n") + 1, end).split("\n");
69
- const data = {};
70
- for (let i = 0; i < lines.length; i++) {
71
- const line = lines[i];
72
- if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
73
- const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*):(.*)$/);
74
- if (!m) return { error: `illegal line: ${JSON.stringify(line)}` };
75
- const key = m[1];
76
- // Strip a trailing comment first, so `cites: # note` reads as an empty value
77
- // (block list follows), not as the comment being the value.
78
- const rest = stripComment(m[2]).trim();
79
- if (rest === "") {
80
- // block list follows
81
- const items = [];
82
- while (i + 1 < lines.length && /^\s*-\s+/.test(lines[i + 1])) {
83
- items.push(parseScalar(lines[++i].replace(/^\s*-\s+/, "")));
84
- }
85
- data[key] = items;
86
- } else if (rest === ">-" || rest === ">") {
87
- // folded scalar (contract §0): indented continuation lines join with one space
88
- const parts = [];
89
- while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) {
90
- parts.push(lines[++i].trim());
91
- }
92
- data[key] = parts.join(" ");
93
- } else if (rest.startsWith("[")) {
94
- const close = rest.lastIndexOf("]"); // ignore any trailing comment after ]
95
- if (close === -1) return { error: `unclosed flow list at ${key}` };
96
- data[key] = parseFlowList(rest.slice(0, close + 1));
97
- } else if (rest.startsWith("{")) {
98
- const close = rest.lastIndexOf("}");
99
- if (close === -1) return { error: `unclosed flow map at ${key}` };
100
- const fm = parseFlowMap(rest.slice(0, close + 1));
101
- if (fm === null) return { error: `malformed flow map at ${key}` };
102
- data[key] = fm;
103
- } else {
104
- data[key] = parseScalar(rest);
105
- }
106
- }
107
- return { data };
108
- }
26
+ // The strict frontmatter parser (contract §0 grammar) lives in @rafinery/okf
27
+ // (2026-07-15, the OKF surface arc) so the gate, the emitter, the validator,
28
+ // and the platform all parse knowledge files identically. Re-exported here
29
+ // existing consumers keep importing it from the gate.
30
+ import { parseFrontmatter } from "@rafinery/okf";
31
+ export { parseFrontmatter };
109
32
 
110
33
  const CITE_RE = /^(.+):(\d+(?:-\d+)?)\s*::\s*(.+)$/;
111
34
  const DUTY_RE = /^(\S[^:]*?)\s+::\s+(\S+)\s+::\s+(\S.*)$/;
@@ -201,18 +124,73 @@ export function runCompile(argv = []) {
201
124
  if (d.id !== stem) fail(path, "id", `must equal filename stem "${stem}"`);
202
125
  return stem;
203
126
  }
127
+ // OKF surface fields (contract §11) — OPTIONAL everywhere, validated when
128
+ // present so the emitted bundle never carries a malformed value. `rafa okf`
129
+ // stamps them where derivable; a brain without them still compiles (additive,
130
+ // schemaVersion unchanged per §8). `typed` also validates the stamped
131
+ // `type`/`title` on singleton files (notes/improvements validate their own).
132
+ function checkOkfFields(d, path, { typed = false } = {}) {
133
+ if (typed) {
134
+ for (const key of ["type", "title"])
135
+ if (key in d && (typeof d[key] !== "string" || d[key].trim() === ""))
136
+ fail(path, key, "optional · non-empty string (OKF concept surface)");
137
+ }
138
+ if (
139
+ "description" in d &&
140
+ (typeof d.description !== "string" || d.description.trim() === "")
141
+ )
142
+ fail(path, "description", "optional · non-empty string");
143
+ if (
144
+ "timestamp" in d &&
145
+ !/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(
146
+ String(d.timestamp),
147
+ )
148
+ )
149
+ fail(path, "timestamp", "optional · ISO 8601 date or datetime");
150
+ if (
151
+ "tags" in d &&
152
+ (!Array.isArray(d.tags) ||
153
+ d.tags.some((t) => typeof t !== "string" || t.trim() === ""))
154
+ )
155
+ fail(path, "tags", "optional · flow list of non-empty strings");
156
+ }
204
157
 
158
+ // index.md + log.md are OKF-reserved (contract §11): listings/trails at any
159
+ // level, never concept documents — the gate never parses them as data files.
160
+ // `*.theirs.md` checkpoint-conflict copies are excluded here and flagged as a
161
+ // TYPED error by sweepConflicts below (never a cryptic id mismatch).
162
+ const OKF_RESERVED = new Set(["index.md", "log.md"]);
205
163
  function walk(dir) {
206
164
  if (!existsSync(dir)) return [];
207
165
  return readdirSync(dir).flatMap((e) => {
208
166
  const p = join(dir, e);
209
167
  return statSync(p).isDirectory()
210
168
  ? walk(p)
211
- : e.endsWith(".md") && !e.startsWith("_")
169
+ : e.endsWith(".md") &&
170
+ !e.startsWith("_") &&
171
+ !e.endsWith(".theirs.md") &&
172
+ !OKF_RESERVED.has(e)
212
173
  ? [p]
213
174
  : [];
214
175
  });
215
176
  }
177
+ // Unresolved checkpoint conflicts BLOCK the gate with an explicit rule — a
178
+ // brain with a pending human decision must never compile past it silently.
179
+ function sweepConflicts(dir) {
180
+ if (!existsSync(dir)) return;
181
+ for (const e of readdirSync(dir)) {
182
+ const p = join(dir, e);
183
+ if (statSync(p).isDirectory()) {
184
+ if (e !== ".git") sweepConflicts(p);
185
+ } else if (e.endsWith(".theirs.md")) {
186
+ fail(
187
+ p,
188
+ "conflict",
189
+ "unresolved checkpoint conflict — decide (keep yours or adopt theirs), delete the .theirs.md copy, re-run",
190
+ );
191
+ }
192
+ }
193
+ }
216
194
  const read = (p) => readFileSync(p, "utf8");
217
195
 
218
196
  function compileNotes(kind, dir) {
@@ -235,6 +213,7 @@ export function runCompile(argv = []) {
235
213
  (typeof data[key] !== "string" || data[key].trim() === "")
236
214
  )
237
215
  fail(path, key, "optional · non-empty token (or `none`)");
216
+ checkOkfFields(data, path); // note `type`/`title` have their own required checks
238
217
  // Size stamps (additive optional): body cost + per-cite target-file cost. A
239
218
  // target we can't read → the cite simply carries no `targetTokens` (omitted).
240
219
  const cites = parseCites(data.cites, path);
@@ -277,6 +256,7 @@ export function runCompile(argv = []) {
277
256
  }
278
257
  checkVersion(data, path);
279
258
  const id = checkId(data, path, name);
259
+ checkOkfFields(data, path, { typed: true }); // stamped `type: Improvement` etc.
280
260
  const lev = data.leverage;
281
261
  if (
282
262
  typeof lev !== "object" ||
@@ -336,6 +316,7 @@ export function runCompile(argv = []) {
336
316
  return null;
337
317
  }
338
318
  checkVersion(data, path);
319
+ checkOkfFields(data, path, { typed: true }); // stamped OKF surface (§11)
339
320
  const g = data.gates,
340
321
  c = data.counts;
341
322
  if (
@@ -375,6 +356,7 @@ export function runCompile(argv = []) {
375
356
  return null;
376
357
  }
377
358
  checkVersion(data, path);
359
+ checkOkfFields(data, path, { typed: true }); // stamped OKF surface (§11)
378
360
  const bp = data.by_priority;
379
361
  const okBp =
380
362
  bp && ["P0", "P1", "P2", "P3"].every((k) => typeof bp[k] === "number");
@@ -400,6 +382,7 @@ export function runCompile(argv = []) {
400
382
  return null;
401
383
  }
402
384
  checkVersion(data, path);
385
+ checkOkfFields(data, path, { typed: true }); // stamped OKF surface (§11)
403
386
  const d = data.domains;
404
387
  if (typeof d !== "object" || Array.isArray(d)) {
405
388
  fail(path, "domains", "required · { domain: status } flow map");
@@ -489,6 +472,7 @@ export function runCompile(argv = []) {
489
472
  }
490
473
  checkVersion(data, path);
491
474
  const id = checkId(data, path, name);
475
+ checkOkfFields(data, path, { typed: true }); // stamped `type: Plan Epic|Task|Subtask` etc.
492
476
  if (seen.has(id))
493
477
  fail(
494
478
  path,
@@ -779,6 +763,64 @@ export function runCompile(argv = []) {
779
763
  return count;
780
764
  }
781
765
 
766
+ // Shipped SOPs are contract-governed like agent cards (§10/§11 — local gate,
767
+ // never in the manifest): the rafa-* skills the blueprint vendors and the
768
+ // conductor command. A workforce whose procedures don't parse doesn't ship.
769
+ function compileShippedSops() {
770
+ const repoRoot = join(ROOT, "..");
771
+ let count = 0;
772
+ const skillsDir = join(repoRoot, ".claude", "skills");
773
+ if (existsSync(skillsDir))
774
+ for (const e of readdirSync(skillsDir)) {
775
+ if (!e.startsWith("rafa-")) continue; // the dev's own skills are theirs
776
+ const path = join(skillsDir, e, "SKILL.md");
777
+ if (!existsSync(path)) continue;
778
+ const { data, error } = parseFrontmatter(read(path));
779
+ if (error) {
780
+ fail(path, "frontmatter", error);
781
+ continue;
782
+ }
783
+ count++;
784
+ if (data.name !== e)
785
+ fail(path, "name", `must equal skill directory "${e}"`);
786
+ reqStr(data, "description", path);
787
+ }
788
+ const conductor = join(repoRoot, ".claude", "commands", "rafa.md");
789
+ if (existsSync(conductor)) {
790
+ const { data, error } = parseFrontmatter(read(conductor));
791
+ if (error) fail(conductor, "frontmatter", error);
792
+ else {
793
+ count++;
794
+ if (!/^\d+\.\d+\.\d+$/.test(String(data.version ?? "")))
795
+ fail(conductor, "version", "required · semver MAJOR.MINOR.PATCH");
796
+ reqStr(data, "description", conductor);
797
+ }
798
+ }
799
+ return count;
800
+ }
801
+
802
+ // sage's learnings (.claude/rafa/learnings/*.md) — the same self-describing
803
+ // protocol outside the bundle (§11), mechanically gated: OKF quartet + stable
804
+ // id. ledger.md there is sage's generated index (skipped like a listing).
805
+ function compileLearnings() {
806
+ const dir = join(ROOT, "..", ".claude", "rafa", "learnings");
807
+ let count = 0;
808
+ for (const path of walk(dir)) {
809
+ const name = path.split("/").pop();
810
+ if (name === "ledger.md") continue;
811
+ const { data, error } = parseFrontmatter(read(path));
812
+ if (error) {
813
+ fail(path, "frontmatter", error);
814
+ continue;
815
+ }
816
+ count++;
817
+ checkId(data, path, name);
818
+ for (const key of ["type", "title", "description"])
819
+ reqStr(data, key, path);
820
+ }
821
+ return count;
822
+ }
823
+
782
824
  // ── assemble ─────────────────────────────────────────────────────────────
783
825
  const gitOpts = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }; // no stderr leak
784
826
  function gitSha() {
@@ -810,6 +852,9 @@ export function runCompile(argv = []) {
810
852
  const plans = compilePlans();
811
853
  const activePlanId = compileActivePlanId(plans);
812
854
  const agentCards = compileAgentCards();
855
+ const shippedSops = compileShippedSops();
856
+ const learnings = compileLearnings();
857
+ sweepConflicts(ROOT);
813
858
 
814
859
  // Cross-check: ledger.open / by_priority must match the actual open improvements.
815
860
  if (ledger) {
@@ -865,7 +910,7 @@ export function runCompile(argv = []) {
865
910
  `${plans.length} plans validated${activePlanId ? ` (active: ${activePlanId})` : ""} (plans travel via the plans channel, not the manifest) · ` +
866
911
  `health ${health ? "ok" : "—"} · ledger ${ledger ? "ok" : "—"} · ` +
867
912
  `coverage ${coverage ? `${coverage.domains.length} domains` : "—"} · ` +
868
- `${agentCards} agent cards (local gate) → ${ROOT}/manifest.json`,
913
+ `${agentCards} agent cards + ${shippedSops} SOPs${learnings ? ` + ${learnings} learnings` : ""} (local gate) → ${ROOT}/manifest.json`,
869
914
  );
870
915
  return 0;
871
916
  }