@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/lib/mcp-client.mjs
CHANGED
|
@@ -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 !== ""
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
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)
|
|
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
|
+
}
|