@sleepy_panda_srl/portulan 0.1.2 → 0.1.3

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,590 @@
1
+ #!/usr/bin/env node
2
+ // The core-skill golden corpus — every mandate a core skill states, bound to the live artifacts it
3
+ // governs, and graded.
4
+ //
5
+ // Milestone 8, the row's **original first subject**: *Golden tasks per core skill.* Untouched by either
6
+ // amendment — the 2026-07-28 expansion says it "stands exactly as written", and clause (a) *widened its
7
+ // subject* to reach the gates "as well as the skills" without narrowing the subject it already had
8
+ // (`../docs/milestones/m08.md` line 18). The gates half landed at (a) as `./goldens.mjs`; this is the
9
+ // half that sentence always named and nobody had built.
10
+ //
11
+ // ## What a golden task IS here, and it was RULED rather than derived
12
+ //
13
+ // The criterion reads two ways — a deterministic corpus, or task specifications run against a live
14
+ // model — and `../.portulan/gate-map.md`'s *"Session-open runs `clarify` against the milestone row
15
+ // itself"* exists because milestone 4 guessed at exactly this shape of ambiguity and cost a
16
+ // session-blocking question. So it was put to the maintainer instead of argued. **His ruling: a
17
+ // deterministic corpus graded against LIVE ARTIFACTS in the tree.** The model-run sense belongs to the
18
+ // A/B clause, which is separately named in the same row.
19
+ //
20
+ // ## The object: a mandate, its live artifacts, and the carrier that enforces it
21
+ //
22
+ // A case binds one **numbered step** of one skill's `## The pass` to the artifact set it governs, and
23
+ // records which existing rail enforces it — or `null`, which is the interesting value.
24
+ //
25
+ // Four properties, and every one of them exists because a checkpoint found the version without it:
26
+ //
27
+ // 1. **The denominator is DERIVED, not chosen.** The runner enumerates the numbered steps under each
28
+ // skill's `## The pass` heading and requires every one to be either `bound` or `unbindable`. A
29
+ // fifteenth step is red. The first draft let the author pick which mandates to write cases for,
30
+ // and a census over a set its own author drew reports "5 of 5" and means nothing — the difference
31
+ // `./goldens.mjs` already draws by deriving its denominator from the yielded policy.
32
+ // 2. **The heading is PREFIX-matched, and an empty step list is could-not-run.** Three spellings exist
33
+ // — `## The pass`, `## The pass (bounded)`, `## The pass, in order` — so an exact match finds one
34
+ // skill of three and the other two get an empty denominator that satisfies "every step is bound"
35
+ // vacuously. That is `../evals/README.md`'s own sentence about clause (d): *"a check whose
36
+ // enumeration went empty … reports green and has stopped being a rail."* The match is also anchored
37
+ // at `The pass`, because a loose `/pass/` grabs `consolidate`'s `## The one move this pass may not
38
+ // make`.
39
+ // 3. **The quote must anchor EXACTLY ONCE** in the skill's own file, or the case is could-not-run —
40
+ // `./mutants.mjs`'s discipline, adopted rather than re-derived. A skill whose wording is reworded,
41
+ // softened or deleted reddens instead of drifting, and nothing in this tree did that before.
42
+ // 4. **The predicates use the carriers' own exported functions rather than re-spelling them.** This
43
+ // module imports `RETIRE_WHEN` and `parseProvenance` from `./doctor.mjs`, so the retire-when check
44
+ // is `doctor`'s regex and not a second one.
45
+ //
46
+ // **What that does NOT do, stated because three carriers of this file's prose once said it did:**
47
+ // it does not verify the `carrier` field. Nothing links `c.carrier.symbol` to an import — the
48
+ // pre-commit checkpoint rewrote every carrier in a corpus file to a module that does not exist and
49
+ // the corpus stayed green, and moved `.portulan/verify/index.sh` out of the tree with the same
50
+ // result. `carrier` is a **declared, reviewed field**: it records which rail a mandate's enforcement
51
+ // lives in, for the census, and a reviewer is what checks it. And a carrier that deleted an export
52
+ // would fail this module's own import with a stack trace and exit **1**, not the 2 the earlier
53
+ // sentence promised.
54
+ //
55
+ // ## What the carrier field does NOT establish, said here because a census implies more than it means
56
+ //
57
+ // An import proves the carrier **contains** the check. It does not prove the carrier **runs** it: a
58
+ // rail could keep `RETIRE_WHEN` exported, stop calling it, and this corpus would not notice, because
59
+ // `expect` is computed here over the artifacts rather than by asking the rail. Closing that needs a
60
+ // per-mandate drill — perturb a record, require the named rail to fire with its own tell — which is
61
+ // `./drills.mjs`'s shape at a finer grain and a second clause's work.
62
+ //
63
+ // ## `unbindable` is adjudicated, never asserted
64
+ //
65
+ // The dodge is obvious: call a mandate unbindable and it needs no case. So the reason takes a **closed
66
+ // vocabulary** rather than free prose, on `./mutants.mjs`'s rule that a `survives` record is admissible
67
+ // only as a proof and never as a standing note that a gap exists:
68
+ //
69
+ // * `judgement-only` — the mandate is about a human or agent decision and no artifact can witness it
70
+ // ("Get the answers from the human"). **Such a step may name no artifact path**:
71
+ // if you can name the artifacts, it is not judgement, it is unbuilt.
72
+ // * `no-artifact` — the mandate governs something that leaves no trace in this tree.
73
+ // * `cross-language` — a carrier exists but in another language, so binding it here would put two
74
+ // spellings of one rule across a language boundary.
75
+ // * `already-carried` — a carrier exists IN THIS LANGUAGE and already holds the mandate, so a case
76
+ // here would be a third carrier of a rail that is already drilled. Added at the
77
+ // pre-commit checkpoint, which found `consolidate` step 5 classed `bound` with
78
+ // a carrier the runner never reads: deleting `.portulan/verify/index.sh` left
79
+ // the corpus green. The row was inflating the headline, and the vocabulary had
80
+ // no honest term for what it actually was.
81
+ //
82
+ // **And the ratio is a finding, not bookkeeping.** How much of a core skill is artifact discipline and
83
+ // how much is agent judgement is a measurement about the engine, which is what this row exists to
84
+ // produce. It is printed as such.
85
+
86
+ import fs from "node:fs";
87
+ import path from "node:path";
88
+ import { pathToFileURL } from "node:url";
89
+
90
+ // The carriers, imported rather than grepped — property 4 above. A carrier that stops exporting these
91
+ // fails this module at load, which is the strongest form of the check available for free.
92
+ import { RETIRE_WHEN, parseProvenance } from "./doctor.mjs";
93
+
94
+ export const CASE_KINDS = ["load-bearing", "census"];
95
+ export const UNBINDABLE_REASONS = ["judgement-only", "no-artifact", "cross-language", "already-carried"];
96
+
97
+ // Anchored at `The pass` and prefix-matched, for the two reasons in property 2.
98
+ const PASS_HEADING = /^##\s+The pass\b/i;
99
+ // **Enumerated on the number, and the bold is read separately.** This was
100
+ // `/^(\d+)\.\s+\*\*(.+?)\*\*/`, which made an UNBOLDED step invisible: the pre-commit checkpoint
101
+ // added `5. A brand new fifth mandate, not bolded` to a pass and the corpus stayed green at 5 of 15.
102
+ // The empty-enumeration guard catches the all-or-nothing case; this catches the incremental one, which
103
+ // is the realistic one.
104
+ const STEP = /^(\d+)\.\s+(.*)$/;
105
+
106
+ export class CouldNotRun extends Error {}
107
+
108
+ /** Every core skill in the engine, derived from the tree. A skill with no corpus file is a finding. */
109
+ export function skillSet(repoRoot) {
110
+ const dir = path.join(repoRoot, "core/skills");
111
+ let entries;
112
+ try {
113
+ entries = fs.readdirSync(dir, { withFileTypes: true });
114
+ } catch (e) {
115
+ throw new CouldNotRun(`core/skills/ could not be read — ${e.message}`);
116
+ }
117
+ const skills = entries
118
+ .filter((d) => d.isDirectory() && fs.existsSync(path.join(dir, d.name, "SKILL.md")))
119
+ .map((d) => d.name)
120
+ .sort();
121
+ if (skills.length === 0) throw new CouldNotRun("core/skills/ holds no SKILL.md — nothing to grade");
122
+ return skills;
123
+ }
124
+
125
+ /** The numbered steps under a skill's `## The pass`. Zero is could-not-run, never a vacuous green. */
126
+ export function passSteps(repoRoot, skill) {
127
+ const file = path.join(repoRoot, "core/skills", skill, "SKILL.md");
128
+ let text;
129
+ try {
130
+ text = fs.readFileSync(file, "utf8");
131
+ } catch (e) {
132
+ throw new CouldNotRun(`${skill}: SKILL.md could not be read — ${e.message}`);
133
+ }
134
+ let inPass = false;
135
+ const steps = [];
136
+ for (const line of text.split("\n")) {
137
+ if (/^##\s/.test(line)) {
138
+ inPass = PASS_HEADING.test(line);
139
+ continue;
140
+ }
141
+ if (!inPass) continue;
142
+ const m = STEP.exec(line);
143
+ if (m) {
144
+ const bolded = /^\*\*(.+?)\*\*/.exec(m[2]);
145
+ steps.push({ n: Number(m[1]), title: bolded ? bolded[1] : m[2] });
146
+ }
147
+ }
148
+ if (steps.length === 0) {
149
+ throw new CouldNotRun(
150
+ `${skill}: no numbered steps found under a '## The pass' heading. ` +
151
+ "An empty denominator would satisfy every coverage rule vacuously, so it is refused.",
152
+ );
153
+ }
154
+ return steps;
155
+ }
156
+
157
+ /** A quote must place exactly once in the skill's own file, or the case cannot be graded. */
158
+ export function anchorQuote(repoRoot, skill, quote) {
159
+ const text = fs.readFileSync(path.join(repoRoot, "core/skills", skill, "SKILL.md"), "utf8");
160
+ let count = 0;
161
+ let i = text.indexOf(quote);
162
+ while (i !== -1) {
163
+ count += 1;
164
+ i = text.indexOf(quote, i + 1);
165
+ }
166
+ if (count !== 1) {
167
+ throw new CouldNotRun(
168
+ `${skill}: the mandate quote places ${count} time(s) in SKILL.md, not once — ` +
169
+ `${JSON.stringify(quote.slice(0, 60))}`,
170
+ );
171
+ }
172
+ }
173
+
174
+ /**
175
+ * The artifact set a case grades, resolved through the workspace's DECLARED slots rather than a
176
+ * hard-coded path. A slot rename must not silently empty a denominator, and an empty match is
177
+ * could-not-run for the same reason a zero-step list is.
178
+ */
179
+ export function slotFiles(workspaceDir, manifest, slot) {
180
+ const rel = manifest?.slots?.[slot];
181
+ if (typeof rel !== "string" || rel.length === 0) {
182
+ throw new CouldNotRun(`the workspace declares no '${slot}' slot — this case has nothing to grade`);
183
+ }
184
+ const dir = path.join(workspaceDir, rel);
185
+ let names;
186
+ try {
187
+ names = fs.readdirSync(dir);
188
+ } catch (e) {
189
+ throw new CouldNotRun(`slot '${slot}' points at ${rel}, which could not be read — ${e.message}`);
190
+ }
191
+ const files = names.filter((f) => f.endsWith(".md") && f.toLowerCase() !== "readme.md").sort();
192
+ if (files.length === 0) {
193
+ throw new CouldNotRun(`slot '${slot}' (${rel}) holds no records — a green over zero files is not a rail`);
194
+ }
195
+ return files.map((f) => ({ name: f, path: path.join(dir, f) }));
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------------------------
199
+ // The predicates. Each is named in its case, because a figure computed before its rule was decided is
200
+ // how both of this session's opening passes produced a different number for the same question.
201
+ // ---------------------------------------------------------------------------------------------
202
+
203
+ /** A bolded field's block: the `**Field.**` line, then lines until the next bolded field or heading. */
204
+ export function fieldBlock(text, field) {
205
+ const lines = text.split("\n");
206
+ const start = lines.findIndex((l) => new RegExp(`^\\s*\\*\\*${field}\\.?\\*\\*`, "i").test(l));
207
+ if (start === -1) return null;
208
+ const out = [];
209
+ for (let i = start + 1; i < lines.length; i += 1) {
210
+ if (/^\s*\*\*[A-Z]/.test(lines[i]) || /^##\s/.test(lines[i])) break;
211
+ out.push(lines[i]);
212
+ }
213
+ return out.join("\n");
214
+ }
215
+
216
+ /** Bullets with wrapped continuation lines rejoined — a wrapped criterion is one criterion. */
217
+ export function bulletsOf(block) {
218
+ const out = [];
219
+ for (const line of block.split("\n")) {
220
+ if (/^\s*[-*]\s/.test(line)) out.push(line.replace(/^\s*[-*]\s*(\[[ xX]\]\s*)?/, "").trim());
221
+ else if (out.length > 0 && line.trim()) out[out.length - 1] += ` ${line.trim()}`;
222
+ }
223
+ return out.filter(Boolean);
224
+ }
225
+
226
+ // EARS as `core/templates/task.md` states it. The shape is NOT restated in this module's prose: the
227
+ // template is the rule and this is a reduction of it, cited rather than re-spelled.
228
+ //
229
+ // **Three carriers state the shape** — that template, `core/operating/operating`-adjacent
230
+ // `core/operating/verification.md`, and `core/skills/clarify/SKILL.md` — all `core/` at tier `propose`.
231
+ // Reducing them to one is deferred on **budget** and on a real design question (making a CLI module the
232
+ // carrier that core doctrine cites inverts the cascade), filed as
233
+ // https://github.com/sleepy-panda-srl/portulan/issues/359. _(An earlier version of this comment said
234
+ // there were four, one of them `docs/vision.md` at tier `prohibited`, and concluded the reduction was
235
+ // impossible to an agent. `docs/vision.md` names EARS in a comparison-table row and nowhere states the
236
+ // shape; the count and the conclusion were both wrong, and the pre-commit checkpoint measured it.)_
237
+ export const isEars = (b) => /\bwhen\b/i.test(b) && /\bshall\b/i.test(b);
238
+
239
+ export const PREDICATES = {
240
+ "ears-acceptance-criteria": (text) => {
241
+ const block = fieldBlock(text, "Acceptance criteria");
242
+ if (block === null) return { ok: false, why: "no **Acceptance criteria.** section" };
243
+ const bs = bulletsOf(block);
244
+ if (bs.length === 0) return { ok: false, why: "an Acceptance criteria section with no criteria" };
245
+ const bad = bs.filter((b) => !isEars(b));
246
+ return bad.length === 0
247
+ ? { ok: true, total: bs.length }
248
+ : { ok: false, why: `${bad.length} of ${bs.length} criteria are not EARS-shaped`, total: bs.length };
249
+ },
250
+ // `codify` step 3 — *"Attach how it earns its place"*. core/templates/proposal.md mandates an
251
+ // **Enforcement.** field, and nothing checks it. The pre-commit checkpoint found this step classed
252
+ // judgement-only on the argument that presence-checking would be "the presence floor with none of
253
+ // the value" — an argument this module refutes in its own printed output, and which equally refutes
254
+ // the provenance binding it did ship. Same spelling tolerance, same reason.
255
+ "enforcement-present": (text) => {
256
+ const has = /^\s*\*\*Enforcement\.?\*\*/im.test(text) || /^##+\s+Enforcement/im.test(text);
257
+ return has ? { ok: true } : { ok: false, why: "no enforcement field in any spelling" };
258
+ },
259
+ // Any spelling of a provenance field. Deliberately shape-tolerant: the tree carries `**Provenance.**`
260
+ // and `## Provenance` and a literal predicate would report the renamed ones as absent — the false-red
261
+ // this session's own opening pass produced once against the task corpus.
262
+ "provenance-present": (text) => {
263
+ const has = /^\s*\*\*Provenance\.?\*\*/im.test(text) || /^##+\s+Provenance/im.test(text);
264
+ return has ? { ok: true } : { ok: false, why: "no provenance field in any spelling" };
265
+ },
266
+ // The two defined forms, answered by `doctor`'s own exported parser. Scoped to `type: rule`, which
267
+ // is the scope the mandate has and the scope `doctor` enforces at — a `decision` carrying prose
268
+ // provenance is reported by `doctor` and is not a violation of this mandate.
269
+ "provenance-two-form": (text) => {
270
+ const type = (/^\s*\*\*type:\*\*\s*(\w+)/im.exec(text) || [])[1];
271
+ if (type !== "rule") return { ok: true, skipped: "not a rule" };
272
+ const p = parseProvenance(text);
273
+ return p.present && p.fields ? { ok: true } : { ok: false, why: "a rule with no two-form provenance stamp" };
274
+ },
275
+ "retire-when-present": (text) =>
276
+ RETIRE_WHEN.test(text) ? { ok: true } : { ok: false, why: "no **Retire when:** line" },
277
+ };
278
+
279
+ /**
280
+ * The containment case: every budget id the Workspace Definition declares must be named in the
281
+ * routing sentence `consolidate` states. Derived from the schema on both sides — a fifth budget, or a
282
+ * rename, reddens rather than drifting.
283
+ */
284
+ export function budgetIds(repoRoot) {
285
+ const schema = JSON.parse(fs.readFileSync(path.join(repoRoot, "spec/workspace.schema.json"), "utf8"));
286
+ const mem = schema.properties?.memory?.properties ?? {};
287
+ const ids = [];
288
+ for (const group of ["index", "store"]) {
289
+ const props = mem[group]?.properties?.budget?.properties ?? {};
290
+ for (const id of Object.keys(props)) ids.push(id);
291
+ }
292
+ if (ids.length === 0) throw new CouldNotRun("the schema declares no memory budget ids — nothing to contain");
293
+ return ids.sort();
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------------------------
297
+ // Grading. Every case's `expect` is compared against what the predicate answers over the LIVE
298
+ // artifacts; nothing here is graded against a fixture the corpus invented.
299
+ // ---------------------------------------------------------------------------------------------
300
+
301
+ function gradeBound(skill, c, ctx) {
302
+ anchorQuote(ctx.repoRoot, skill, c.mandate.quote);
303
+
304
+ if (c.predicate === "schema-budget-containment") {
305
+ const ids = budgetIds(ctx.repoRoot);
306
+ const text = fs.readFileSync(path.join(ctx.repoRoot, "core/skills", skill, "SKILL.md"), "utf8");
307
+ const missing = ids.filter((id) => !text.includes(`\`${id}\``));
308
+ return { total: ids.length, failing: missing, detail: `${ids.length - missing.length}/${ids.length} budget id(s) named` };
309
+ }
310
+
311
+ const predicate = PREDICATES[c.predicate];
312
+ if (!predicate) throw new CouldNotRun(`${skill}: unknown predicate ${JSON.stringify(c.predicate)}`);
313
+ const files = slotFiles(ctx.workspaceDir, ctx.manifest, c.artifacts.slot);
314
+ const failing = [];
315
+ let considered = 0;
316
+ for (const f of files) {
317
+ const answer = predicate(fs.readFileSync(f.path, "utf8"));
318
+ if (answer.skipped) continue;
319
+ considered += 1;
320
+ if (!answer.ok) failing.push(`${f.name} — ${answer.why}`);
321
+ }
322
+ return { total: considered, failing, detail: `${considered - failing.length}/${considered} compliant` };
323
+ }
324
+
325
+ /**
326
+ * Grade one skill's corpus file against the tree.
327
+ *
328
+ * Every numbered step must be accounted for. `unbindable` is adjudicated: the reason comes from a
329
+ * closed vocabulary, and a `judgement-only` step may name no artifact — if you can name the artifacts
330
+ * the mandate governs, it is not judgement, it is unbuilt.
331
+ */
332
+ export function gradeSkill(skill, corpus, ctx) {
333
+ const findings = [];
334
+ const steps = passSteps(ctx.repoRoot, skill);
335
+ const declared = new Map();
336
+ for (const c of corpus.cases ?? []) {
337
+ if (declared.has(c.step)) findings.push(`${skill}: step ${c.step} is declared twice`);
338
+ declared.set(c.step, c);
339
+ }
340
+ for (const step of steps) {
341
+ if (!declared.has(step.n)) {
342
+ findings.push(
343
+ `${skill}: step ${step.n} — "${step.title.slice(0, 60)}" — is in the skill's pass and in no case. ` +
344
+ "Every step is bound or adjudicated unbindable; a new step is a finding, not a default.",
345
+ );
346
+ }
347
+ }
348
+ // Step 0 is reserved for a mandate outside `## The pass` — `consolidate`'s routing sentence lives
349
+ // under `## When to use it`. Numbered 0 so the pass accounting stays a clean 1..N and the row cannot
350
+ // be mistaken for a step. Any OTHER number the pass does not have is a finding.
351
+ for (const n of declared.keys()) {
352
+ if (n !== 0 && !steps.some((s) => s.n === n)) {
353
+ findings.push(`${skill}: case names step ${n}, which the pass does not have`);
354
+ }
355
+ }
356
+
357
+ const rows = [];
358
+ const graded = declared.has(0) ? [{ n: 0, title: "the routing sentence, outside `## The pass`" }, ...steps] : steps;
359
+ for (const step of graded) {
360
+ const c = declared.get(step.n);
361
+ if (!c) continue;
362
+ if (c.state === "unbindable") {
363
+ if (!UNBINDABLE_REASONS.includes(c.reason)) {
364
+ findings.push(`${skill}: step ${step.n} is unbindable for ${JSON.stringify(c.reason)}, which is not one of ${UNBINDABLE_REASONS.join(" | ")}`);
365
+ }
366
+ // **A reason without an argument is a label, not an adjudication.** `./mutants.mjs` refuses
367
+ // an operator carrying no `why` — *"a mutation nobody can read is not reviewable"* — and
368
+ // `./goldens.mjs` refuses a `documented-hole` naming no record. This carried neither until
369
+ // the pre-commit checkpoint stripped every `why` from all ten unbindable cases and watched
370
+ // the corpus stay green.
371
+ if (typeof c.why !== "string" || c.why.trim().length < 40) {
372
+ findings.push(
373
+ `${skill}: step ${step.n} is unbindable and carries no argument. ` +
374
+ "The reason is a vocabulary term; the `why` is what makes it an adjudication.",
375
+ );
376
+ }
377
+ if (c.reason === "judgement-only" && c.artifacts) {
378
+ findings.push(
379
+ `${skill}: step ${step.n} claims judgement-only and names artifacts. ` +
380
+ "A mandate whose artifacts you can name is unbuilt, not unjudgeable.",
381
+ );
382
+ }
383
+ rows.push({ step: step.n, state: "unbindable", reason: c.reason, kind: null });
384
+ continue;
385
+ }
386
+ if (!CASE_KINDS.includes(c.kind)) {
387
+ findings.push(`${skill}: step ${step.n} has kind ${JSON.stringify(c.kind)}, not one of ${CASE_KINDS.join(" | ")}`);
388
+ }
389
+ let result;
390
+ try {
391
+ result = gradeBound(skill, c, ctx);
392
+ } catch (e) {
393
+ if (e instanceof CouldNotRun) throw e;
394
+ throw new CouldNotRun(`${skill}: step ${step.n} could not be graded — ${e.message}`);
395
+ }
396
+ // **`expect.accepted` is the larger dodge, and it was entirely unadjudicated.** Any finding
397
+ // could be silenced by appending a bare filename — the pre-commit checkpoint created a
398
+ // non-compliant task file, listed it, and the corpus went green over eighteen silenced
399
+ // findings. So an entry is an object carrying its own `why`, on the same rule as `unbindable`
400
+ // above: the both-directions check stops a STALE entry, and this stops an UNJUSTIFIED one.
401
+ const acceptedEntries = c.expect?.accepted ?? [];
402
+ for (const a of acceptedEntries) {
403
+ if (typeof a !== "object" || typeof a.file !== "string") {
404
+ findings.push(`${skill}: step ${step.n} — an accepted-drift entry is not {file, why}: ${JSON.stringify(a)}`);
405
+ } else if (typeof a.why !== "string" || a.why.trim().length < 20) {
406
+ findings.push(
407
+ `${skill}: step ${step.n} — accepted drift ${a.file} carries no argument. ` +
408
+ "A silenced finding needs a reason a reviewer can refuse.",
409
+ );
410
+ }
411
+ }
412
+ const accepted = new Set(acceptedEntries.filter((a) => a && typeof a.file === "string").map((a) => a.file));
413
+ const unexpected = result.failing.filter((f) => !accepted.has(f.split(" — ")[0]));
414
+ const repaired = [...accepted].filter((a) => !result.failing.some((f) => f.split(" — ")[0] === a));
415
+ for (const u of unexpected) findings.push(`${skill}: step ${step.n} — ${u}`);
416
+ // Both directions, as `documented-hole` has on the gate side: an accepted drift that is
417
+ // REPAIRED is a finding too, or an accepted-drift list outlives the drift it records.
418
+ for (const r of repaired) {
419
+ findings.push(
420
+ `${skill}: step ${step.n} — ${r} now complies, and is still listed as accepted drift. ` +
421
+ "Remove it from `expect.accepted`; a drift list that outlives its drift is as wrong as one that hides it.",
422
+ );
423
+ }
424
+ rows.push({ step: step.n, state: "bound", kind: c.kind, carrier: c.carrier?.tool ?? null, detail: result.detail, accepted: accepted.size });
425
+ }
426
+ return { skill, rows, findings };
427
+ }
428
+
429
+ const USAGE = [
430
+ "usage: node cli/skill-goldens.mjs [--workspace <dir>] [--repo-root <dir>]",
431
+ "",
432
+ "Grades evals/goldens/skills/ against the core skills and the live artifacts they govern.",
433
+ "exit 0 green · 1 red · 2 could not run",
434
+ ].join("\n");
435
+
436
+ export function run(argv = process.argv.slice(2), io = console) {
437
+ try {
438
+ let repoRoot = ".";
439
+ let workspaceDir = null;
440
+ for (let i = 0; i < argv.length; i += 1) {
441
+ // **A missing option value must not swallow the next flag.** `argv[++i]` on a trailing
442
+ // `--repo-root` yields `undefined` and on `--repo-root --workspace x` yields `"--workspace"`,
443
+ // so the tool would grade a directory nobody named and say nothing. `./rule-carriers.mjs`
444
+ // treats a missing value as exit 2 and this now matches it. Copilot round 1 on #360.
445
+ const value = (flag) => {
446
+ const v = argv[i + 1];
447
+ if (v === undefined || v.startsWith("--")) throw new CouldNotRun(`${flag} needs a value`);
448
+ i += 1;
449
+ return v;
450
+ };
451
+ if (argv[i] === "--repo-root") repoRoot = value("--repo-root");
452
+ else if (argv[i] === "--workspace") workspaceDir = value("--workspace");
453
+ else if (argv[i] === "--help" || argv[i] === "-h") {
454
+ io.log(USAGE);
455
+ return 0;
456
+ } else {
457
+ io.error(`skill-goldens: unrecognised argument ${JSON.stringify(argv[i])}`);
458
+ io.error(USAGE);
459
+ return 2;
460
+ }
461
+ }
462
+ repoRoot = path.resolve(repoRoot);
463
+ workspaceDir = path.resolve(workspaceDir ?? path.join(repoRoot, ".portulan"));
464
+
465
+ let ctx;
466
+ let skills;
467
+ try {
468
+ ctx = { repoRoot, workspaceDir, manifest: JSON.parse(fs.readFileSync(path.join(workspaceDir, "workspace.json"), "utf8")) };
469
+ skills = skillSet(repoRoot);
470
+ } catch (e) {
471
+ io.error(`skill-goldens: ${e.message}`);
472
+ return 2;
473
+ }
474
+
475
+ const corpusDir = path.join(repoRoot, "evals/goldens/skills");
476
+ const findings = [];
477
+ const all = [];
478
+ for (const skill of skills) {
479
+ const file = path.join(corpusDir, `${skill}.json`);
480
+ if (!fs.existsSync(file)) {
481
+ // The derived skill set is what makes this a coverage rail: a new core skill cannot ship
482
+ // with no mandates accounted for.
483
+ findings.push(`${skill}: core/skills/${skill}/ exists and evals/goldens/skills/${skill}.json does not`);
484
+ continue;
485
+ }
486
+ let corpus;
487
+ try {
488
+ corpus = JSON.parse(fs.readFileSync(file, "utf8"));
489
+ } catch (e) {
490
+ io.error(`skill-goldens: ${skill}.json is not JSON — ${e.message}`);
491
+ return 2;
492
+ }
493
+ if (corpus.skill !== skill) {
494
+ // The filename is checked against the file's own field, the repair `../evals/README.md`
495
+ // records for the gate corpus after a misfiled fixture graded cleanly.
496
+ io.error(`skill-goldens: ${skill}.json declares skill ${JSON.stringify(corpus.skill)}`);
497
+ return 2;
498
+ }
499
+ try {
500
+ const graded = gradeSkill(skill, corpus, ctx);
501
+ all.push(graded);
502
+ findings.push(...graded.findings);
503
+ } catch (e) {
504
+ io.error(`skill-goldens: ${e.message}`);
505
+ return 2;
506
+ }
507
+ }
508
+
509
+ let bound = 0;
510
+ let unbindable = 0;
511
+ let loadBearing = 0;
512
+ let census = 0;
513
+ for (const g of all) {
514
+ io.log(`skill-goldens: ${g.skill}`);
515
+ for (const r of g.rows) {
516
+ if (r.state === "bound") {
517
+ bound += 1;
518
+ if (r.kind === "load-bearing") loadBearing += 1;
519
+ else census += 1;
520
+ const acc = r.accepted > 0 ? ` (+${r.accepted} accepted drift)` : "";
521
+ io.log(` step ${r.step} bound ${String(r.kind).padEnd(12)} carrier ${String(r.carrier ?? "none").padEnd(7)} ${r.detail}${acc}`);
522
+ } else {
523
+ unbindable += 1;
524
+ io.log(` step ${r.step} unbindable ${r.reason}`);
525
+ }
526
+ }
527
+ }
528
+
529
+ io.log("");
530
+ io.log(` ${bound} of ${bound + unbindable} mandate(s) bound to live artifacts — ${loadBearing} load-bearing, ${census} census`);
531
+ const reasons = {};
532
+ for (const g of all) for (const r of g.rows) if (r.state === "unbindable") reasons[r.reason] = (reasons[r.reason] ?? 0) + 1;
533
+ // **The split is printed, because the aggregate hides which unbindable means what.** Three prose
534
+ // carriers of this file's own output once said "two thirds of these mandates are judgement" when
535
+ // two thirds were merely UNBINDABLE and `cross-language` and `already-carried` mean the opposite —
536
+ // a carrier exists. Caught at the pre-commit checkpoint.
537
+ io.log(` ${unbindable} adjudicated unbindable — ${Object.entries(reasons).map(([k, v]) => `${v} ${k}`).join(", ")}`);
538
+ io.log("");
539
+ io.log(" THE RATIO IS A FINDING, not bookkeeping: it measures how much of a core skill is artifact");
540
+ io.log(" discipline and how much is agent judgement. Read the SPLIT, not the total — the");
541
+ io.log(" judgement-only rows are the A/B clause's FIRST subject; cross-language and already-carried");
542
+ io.log(" mean a carrier exists and is somewhere else.");
543
+ // **Widened 2026-08-28 on the maintainer's ruling**; this printed "only the judgement-only rows are
544
+ // the A/B clause's subject" until then. That reading could not stand, and the reason is measured:
545
+ // `vendor --host` carries `core/skills/` not at all, so all five judgement rows reach
546
+ // nothing in a vendored arm. The A/B clause's subject is the mandates core SHIPS that an arm
547
+ // actually receives. Four carriers held the narrower reading and all four moved together —
548
+ // ../evals/ab/corpus.md, and the claim is registered in ../.portulan/rule-carriers.json.
549
+ io.log(" A `census` row re-indexes a figure an existing recipe already prints; it is not a new check.");
550
+ io.log(" `unbindable` is the dodge, so its reason comes from a closed vocabulary and is adjudicated.");
551
+ io.log(" `carrier` is a DECLARED, reviewed field. Nothing links it to a check: a corpus naming a");
552
+ io.log(" module that does not exist still grades green, and a reviewer is what catches that.");
553
+ io.log(" Presence, not adequacy: a mandate can be bound and the binding trivial.");
554
+ io.log(" It grades a skill's MANDATES against the tree, never an agent's judgement in following one.");
555
+
556
+ if (findings.length > 0) {
557
+ io.error("");
558
+ for (const f of findings) io.error(` FAIL ${f}`);
559
+ io.error(`skill-goldens: ${findings.length} finding(s)`);
560
+ return 1;
561
+ }
562
+ io.log("");
563
+ io.log("GREEN — every core skill's mandates are accounted for and every binding holds.");
564
+ return 0;
565
+ } catch (e) {
566
+ if (e instanceof CouldNotRun) {
567
+ io.error(`skill-goldens: ${e.message}`);
568
+ io.error(USAGE);
569
+ return 2;
570
+ }
571
+ throw e;
572
+ }
573
+ }
574
+
575
+ // The entry guard, in the ONE form `./rule-carriers.mjs` designates: `file://${argv[1]}` percent-encodes
576
+ // differently from `import.meta.url` and this working copy lives under a path with spaces, so the naive
577
+ // spelling exits 0 having run nothing — met four times in this repository, once in the session before
578
+ // this one.
579
+ function isMain() {
580
+ const invoked = process.argv[1];
581
+ if (!invoked) return false;
582
+ if (import.meta.url === pathToFileURL(invoked).href) return true;
583
+ try {
584
+ return import.meta.url === pathToFileURL(fs.realpathSync(invoked)).href;
585
+ } catch {
586
+ return false;
587
+ }
588
+ }
589
+
590
+ if (isMain()) process.exitCode = run(process.argv.slice(2));