@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.
- package/README.md +2 -2
- package/cli/README.md +69 -2
- package/cli/compile.mjs +555 -37
- package/cli/doctor.mjs +5 -1
- package/cli/drills.mjs +1541 -0
- package/cli/fuzz-shell.mjs +880 -0
- package/cli/gate.mjs +3 -2
- package/cli/goldens.mjs +469 -0
- package/cli/init.mjs +4 -1
- package/cli/mutants.mjs +925 -0
- package/cli/pack-identity.mjs +9 -2
- package/cli/pack-version.mjs +7 -0
- package/cli/recipe-set.mjs +6 -0
- package/cli/release-eval.mjs +1068 -0
- package/cli/review-meter.mjs +741 -0
- package/cli/skill-goldens.mjs +590 -0
- package/cli/stop-gate.mjs +107 -4
- package/cli/telemetry.mjs +1047 -0
- package/cli/version-carriers.mjs +13 -0
- package/core/templates/gate-policy.md +27 -8
- package/evals/README.md +910 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/README.md +32 -0
- package/package.json +7 -2
- package/packs/rituals/checkpoints/README.md +8 -1
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- package/spec/slots.md +26 -2
|
@@ -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));
|