@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/CHANGELOG.md +58 -0
- package/LICENSE +21 -0
- package/bin/rafa.mjs +18 -3
- package/blueprint/.claude/agents/atlas.md +4 -3
- package/blueprint/.claude/agents/bloom.md +2 -1
- package/blueprint/.claude/agents/compass.md +2 -1
- package/blueprint/.claude/agents/prism.md +2 -1
- package/blueprint/.claude/agents/sage.md +2 -1
- package/blueprint/.claude/commands/rafa.md +4 -0
- package/blueprint/.claude/rafa/contract.md +86 -4
- package/blueprint/.claude/skills/rafa-build/SKILL.md +2 -1
- package/blueprint/.claude/skills/rafa-distill/SKILL.md +2 -1
- package/blueprint/.claude/skills/rafa-improve/SKILL.md +1 -1
- package/blueprint/.claude/skills/rafa-okf/SKILL.md +101 -0
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +1 -0
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +12 -7
- package/blueprint/.claude/skills/rafa-validate/SKILL.md +2 -1
- package/lib/blueprint.mjs +1 -0
- package/lib/ci-setup.mjs +15 -5
- package/lib/distill.mjs +87 -33
- package/lib/gate/compile.mjs +131 -86
- package/lib/gate/okf-emit.mjs +323 -0
- package/lib/gate/verify-citations.mjs +89 -3
- package/lib/mcp-client.mjs +43 -16
- package/lib/okf.mjs +110 -0
- package/lib/push.mjs +33 -17
- package/lib/releases.mjs +35 -0
- package/package.json +10 -7
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
|
-
|
|
64
|
-
|
|
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.
|
|
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
|
-
|
|
113
|
+
4. RAFA_BRAIN_TOKEN — a token with push access to the BRAIN repo (the distill
|
|
104
114
|
job pushes validated knowledge there).
|
|
105
|
-
|
|
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 {
|
|
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(".."))
|
|
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:
|
|
44
|
-
//
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
} catch {
|
|
55
|
+
const roots = [process.env.RAFA_AGENT_SDK_DIR, null, cwd];
|
|
56
|
+
for (const root of roots) {
|
|
49
57
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"@anthropic-ai/claude-agent-sdk
|
|
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 " +
|
|
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 =
|
|
94
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
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(
|
|
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"
|
|
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(
|
|
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(
|
|
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(
|
|
262
|
+
die(
|
|
263
|
+
"distill-verdicts.json is not valid JSON — nothing resolved, nothing pushed",
|
|
264
|
+
);
|
|
227
265
|
}
|
|
228
|
-
if (!Array.isArray(verdicts))
|
|
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(
|
|
234
|
-
|
|
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(
|
|
286
|
+
die(
|
|
287
|
+
"citation checker failed on the authored brain — nothing resolved, nothing pushed",
|
|
288
|
+
);
|
|
243
289
|
if (runCompile([]) !== 0)
|
|
244
|
-
die(
|
|
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(
|
|
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(
|
|
321
|
+
console.log(
|
|
322
|
+
` ${v.verdict === "distilled" ? "✓" : "!"} ${f.path} → ${v.verdict}${v.note ? ` (${v.note})` : ""}`,
|
|
323
|
+
);
|
|
272
324
|
} catch (e) {
|
|
273
|
-
console.log(
|
|
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 });
|
package/lib/gate/compile.mjs
CHANGED
|
@@ -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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
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") &&
|
|
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
|
}
|