@sleepy_panda_srl/portulan 0.1.2 → 0.2.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/README.md +7 -6
- package/cli/README.md +142 -291
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1443 -1210
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +225 -1071
- package/cli/drills.mjs +1107 -0
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +595 -0
- package/cli/gate.mjs +7 -198
- package/cli/goldens.mjs +311 -0
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +244 -564
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +778 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +764 -0
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +6 -68
- package/cli/pack-version.mjs +13 -248
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +5 -124
- package/cli/recipe-set.mjs +16 -240
- package/cli/release-eval.mjs +748 -0
- package/cli/review-meter.mjs +500 -0
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +424 -0
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +126 -454
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +582 -0
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +9 -74
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +183 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/consolidate/SKILL.md +4 -2
- package/core/templates/gate-policy.md +27 -8
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +320 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/README.md +32 -0
- package/package.json +9 -2
- package/packs/rituals/checkpoints/README.md +9 -2
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/spec/README.md +112 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +359 -23
- package/spec/workspace.schema.json +145 -4
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The core-skill golden corpus — every mandate a core skill states, bound to the live artifacts it governs, and graded.
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
// The carriers' own exports, never a second spelling of their checks.
|
|
9
|
+
import { RETIRE_WHEN, parseProvenance } from "./doctor.mjs";
|
|
10
|
+
|
|
11
|
+
export const CASE_KINDS = ["load-bearing", "census"];
|
|
12
|
+
export const UNBINDABLE_REASONS = ["judgement-only", "no-artifact", "cross-language", "already-carried"];
|
|
13
|
+
|
|
14
|
+
// Prefix-matched, as three spellings exist; anchored, as a bare `pass` matches another `consolidate` heading.
|
|
15
|
+
const PASS_HEADING = /^##\s+The pass\b/i;
|
|
16
|
+
// On the number alone, the bold read separately, so an unbolded step still counts.
|
|
17
|
+
const STEP = /^(\d+)\.\s+(.*)$/;
|
|
18
|
+
|
|
19
|
+
export class CouldNotRun extends Error {}
|
|
20
|
+
|
|
21
|
+
export function skillSet(repoRoot) {
|
|
22
|
+
const dir = path.join(repoRoot, "core/skills");
|
|
23
|
+
let entries;
|
|
24
|
+
try {
|
|
25
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
throw new CouldNotRun(`core/skills/ could not be read — ${e.message}`);
|
|
28
|
+
}
|
|
29
|
+
const skills = entries
|
|
30
|
+
.filter((d) => d.isDirectory() && fs.existsSync(path.join(dir, d.name, "SKILL.md")))
|
|
31
|
+
.map((d) => d.name)
|
|
32
|
+
.sort();
|
|
33
|
+
if (skills.length === 0) throw new CouldNotRun("core/skills/ holds no SKILL.md — nothing to grade");
|
|
34
|
+
return skills;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function passSteps(repoRoot, skill) {
|
|
38
|
+
const file = path.join(repoRoot, "core/skills", skill, "SKILL.md");
|
|
39
|
+
let text;
|
|
40
|
+
try {
|
|
41
|
+
text = fs.readFileSync(file, "utf8");
|
|
42
|
+
} catch (e) {
|
|
43
|
+
throw new CouldNotRun(`${skill}: SKILL.md could not be read — ${e.message}`);
|
|
44
|
+
}
|
|
45
|
+
let inPass = false;
|
|
46
|
+
const steps = [];
|
|
47
|
+
for (const line of text.split("\n")) {
|
|
48
|
+
if (/^##\s/.test(line)) {
|
|
49
|
+
inPass = PASS_HEADING.test(line);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!inPass) continue;
|
|
53
|
+
const m = STEP.exec(line);
|
|
54
|
+
if (m) {
|
|
55
|
+
const bolded = /^\*\*(.+?)\*\*/.exec(m[2]);
|
|
56
|
+
steps.push({ n: Number(m[1]), title: bolded ? bolded[1] : m[2] });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (steps.length === 0) {
|
|
60
|
+
throw new CouldNotRun(
|
|
61
|
+
`${skill}: no numbered steps found under a '## The pass' heading. ` +
|
|
62
|
+
"An empty denominator would satisfy every coverage rule vacuously, so it is refused.",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return steps;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function anchorQuote(repoRoot, skill, quote) {
|
|
69
|
+
const text = fs.readFileSync(path.join(repoRoot, "core/skills", skill, "SKILL.md"), "utf8");
|
|
70
|
+
let count = 0;
|
|
71
|
+
let i = text.indexOf(quote);
|
|
72
|
+
while (i !== -1) {
|
|
73
|
+
count += 1;
|
|
74
|
+
i = text.indexOf(quote, i + 1);
|
|
75
|
+
}
|
|
76
|
+
if (count !== 1) {
|
|
77
|
+
throw new CouldNotRun(
|
|
78
|
+
`${skill}: the mandate quote places ${count} time(s) in SKILL.md, not once — ` +
|
|
79
|
+
`${JSON.stringify(quote.slice(0, 60))}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function slotFiles(workspaceDir, manifest, slot) {
|
|
85
|
+
const rel = manifest?.slots?.[slot];
|
|
86
|
+
if (typeof rel !== "string" || rel.length === 0) {
|
|
87
|
+
throw new CouldNotRun(`the workspace declares no '${slot}' slot — this case has nothing to grade`);
|
|
88
|
+
}
|
|
89
|
+
const dir = path.join(workspaceDir, rel);
|
|
90
|
+
let names;
|
|
91
|
+
try {
|
|
92
|
+
names = fs.readdirSync(dir);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
throw new CouldNotRun(`slot '${slot}' points at ${rel}, which could not be read — ${e.message}`);
|
|
95
|
+
}
|
|
96
|
+
const files = names.filter((f) => f.endsWith(".md") && f.toLowerCase() !== "readme.md").sort();
|
|
97
|
+
if (files.length === 0) {
|
|
98
|
+
throw new CouldNotRun(`slot '${slot}' (${rel}) holds no records — a green over zero files is not a rail`);
|
|
99
|
+
}
|
|
100
|
+
return files.map((f) => ({ name: f, path: path.join(dir, f) }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------------------------
|
|
104
|
+
// The predicates
|
|
105
|
+
// ---------------------------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export function fieldBlock(text, field) {
|
|
108
|
+
const lines = text.split("\n");
|
|
109
|
+
const start = lines.findIndex((l) => new RegExp(`^\\s*\\*\\*${field}\\.?\\*\\*`, "i").test(l));
|
|
110
|
+
if (start === -1) return null;
|
|
111
|
+
const out = [];
|
|
112
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
113
|
+
if (/^\s*\*\*[A-Z]/.test(lines[i]) || /^##\s/.test(lines[i])) break;
|
|
114
|
+
out.push(lines[i]);
|
|
115
|
+
}
|
|
116
|
+
return out.join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function bulletsOf(block) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const line of block.split("\n")) {
|
|
122
|
+
if (/^\s*[-*]\s/.test(line)) out.push(line.replace(/^\s*[-*]\s*(\[[ xX]\]\s*)?/, "").trim());
|
|
123
|
+
else if (out.length > 0 && line.trim()) out[out.length - 1] += ` ${line.trim()}`;
|
|
124
|
+
}
|
|
125
|
+
return out.filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A reduction of EARS as `core/templates/task.md` states it; the template is the rule.
|
|
129
|
+
export const isEars = (b) => /\bwhen\b/i.test(b) && /\bshall\b/i.test(b);
|
|
130
|
+
|
|
131
|
+
export const PREDICATES = {
|
|
132
|
+
"ears-acceptance-criteria": (text) => {
|
|
133
|
+
const block = fieldBlock(text, "Acceptance criteria");
|
|
134
|
+
if (block === null) return { ok: false, why: "no **Acceptance criteria.** section" };
|
|
135
|
+
const bs = bulletsOf(block);
|
|
136
|
+
if (bs.length === 0) return { ok: false, why: "an Acceptance criteria section with no criteria" };
|
|
137
|
+
const bad = bs.filter((b) => !isEars(b));
|
|
138
|
+
return bad.length === 0
|
|
139
|
+
? { ok: true, total: bs.length }
|
|
140
|
+
: { ok: false, why: `${bad.length} of ${bs.length} criteria are not EARS-shaped`, total: bs.length };
|
|
141
|
+
},
|
|
142
|
+
"enforcement-present": (text) => {
|
|
143
|
+
const has = /^\s*\*\*Enforcement\.?\*\*/im.test(text) || /^##+\s+Enforcement/im.test(text);
|
|
144
|
+
return has ? { ok: true } : { ok: false, why: "no enforcement field in any spelling" };
|
|
145
|
+
},
|
|
146
|
+
"provenance-present": (text) => {
|
|
147
|
+
const has = /^\s*\*\*Provenance\.?\*\*/im.test(text) || /^##+\s+Provenance/im.test(text);
|
|
148
|
+
return has ? { ok: true } : { ok: false, why: "no provenance field in any spelling" };
|
|
149
|
+
},
|
|
150
|
+
// Scoped to `type: rule`, the scope `doctor` enforces the two forms at.
|
|
151
|
+
"provenance-two-form": (text) => {
|
|
152
|
+
const type = (/^\s*\*\*type:\*\*\s*(\w+)/im.exec(text) || [])[1];
|
|
153
|
+
if (type !== "rule") return { ok: true, skipped: "not a rule" };
|
|
154
|
+
const p = parseProvenance(text);
|
|
155
|
+
return p.present && p.fields ? { ok: true } : { ok: false, why: "a rule with no two-form provenance stamp" };
|
|
156
|
+
},
|
|
157
|
+
"retire-when-present": (text) =>
|
|
158
|
+
RETIRE_WHEN.test(text) ? { ok: true } : { ok: false, why: "no **Retire when:** line" },
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export function budgetIds(repoRoot) {
|
|
162
|
+
const schema = JSON.parse(fs.readFileSync(path.join(repoRoot, "spec/workspace.schema.json"), "utf8"));
|
|
163
|
+
const mem = schema.properties?.memory?.properties ?? {};
|
|
164
|
+
const ids = [];
|
|
165
|
+
for (const group of ["index", "store"]) {
|
|
166
|
+
const props = mem[group]?.properties?.budget?.properties ?? {};
|
|
167
|
+
for (const id of Object.keys(props)) ids.push(id);
|
|
168
|
+
}
|
|
169
|
+
if (ids.length === 0) throw new CouldNotRun("the schema declares no memory budget ids — nothing to contain");
|
|
170
|
+
return ids.sort();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------------------------
|
|
174
|
+
// Grading
|
|
175
|
+
// ---------------------------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
function gradeBound(skill, c, ctx) {
|
|
178
|
+
anchorQuote(ctx.repoRoot, skill, c.mandate.quote);
|
|
179
|
+
|
|
180
|
+
if (c.predicate === "schema-budget-containment") {
|
|
181
|
+
const ids = budgetIds(ctx.repoRoot);
|
|
182
|
+
const text = fs.readFileSync(path.join(ctx.repoRoot, "core/skills", skill, "SKILL.md"), "utf8");
|
|
183
|
+
const missing = ids.filter((id) => !text.includes(`\`${id}\``));
|
|
184
|
+
return { total: ids.length, failing: missing, detail: `${ids.length - missing.length}/${ids.length} budget id(s) named` };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const predicate = PREDICATES[c.predicate];
|
|
188
|
+
if (!predicate) throw new CouldNotRun(`${skill}: unknown predicate ${JSON.stringify(c.predicate)}`);
|
|
189
|
+
const files = slotFiles(ctx.workspaceDir, ctx.manifest, c.artifacts.slot);
|
|
190
|
+
const failing = [];
|
|
191
|
+
let considered = 0;
|
|
192
|
+
for (const f of files) {
|
|
193
|
+
const answer = predicate(fs.readFileSync(f.path, "utf8"));
|
|
194
|
+
if (answer.skipped) continue;
|
|
195
|
+
considered += 1;
|
|
196
|
+
if (!answer.ok) failing.push(`${f.name} — ${answer.why}`);
|
|
197
|
+
}
|
|
198
|
+
return { total: considered, failing, detail: `${considered - failing.length}/${considered} compliant` };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function gradeSkill(skill, corpus, ctx) {
|
|
202
|
+
const findings = [];
|
|
203
|
+
const steps = passSteps(ctx.repoRoot, skill);
|
|
204
|
+
const declared = new Map();
|
|
205
|
+
for (const c of corpus.cases ?? []) {
|
|
206
|
+
if (declared.has(c.step)) findings.push(`${skill}: step ${c.step} is declared twice`);
|
|
207
|
+
declared.set(c.step, c);
|
|
208
|
+
}
|
|
209
|
+
for (const step of steps) {
|
|
210
|
+
if (!declared.has(step.n)) {
|
|
211
|
+
findings.push(
|
|
212
|
+
`${skill}: step ${step.n} — "${step.title.slice(0, 60)}" — is in the skill's pass and in no case. ` +
|
|
213
|
+
"Every step is bound or adjudicated unbindable; a new step is a finding, not a default.",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Step 0 is a mandate outside `## The pass`, such as `consolidate`'s routing sentence.
|
|
218
|
+
for (const n of declared.keys()) {
|
|
219
|
+
if (n !== 0 && !steps.some((s) => s.n === n)) {
|
|
220
|
+
findings.push(`${skill}: case names step ${n}, which the pass does not have`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const rows = [];
|
|
225
|
+
const graded = declared.has(0) ? [{ n: 0, title: "the routing sentence, outside `## The pass`" }, ...steps] : steps;
|
|
226
|
+
for (const step of graded) {
|
|
227
|
+
const c = declared.get(step.n);
|
|
228
|
+
if (!c) continue;
|
|
229
|
+
if (c.state === "unbindable") {
|
|
230
|
+
if (!UNBINDABLE_REASONS.includes(c.reason)) {
|
|
231
|
+
findings.push(`${skill}: step ${step.n} is unbindable for ${JSON.stringify(c.reason)}, which is not one of ${UNBINDABLE_REASONS.join(" | ")}`);
|
|
232
|
+
}
|
|
233
|
+
if (typeof c.why !== "string" || c.why.trim().length < 40) {
|
|
234
|
+
findings.push(
|
|
235
|
+
`${skill}: step ${step.n} is unbindable and carries no argument. ` +
|
|
236
|
+
"The reason is a vocabulary term; the `why` is what makes it an adjudication.",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (c.reason === "judgement-only" && c.artifacts) {
|
|
240
|
+
findings.push(
|
|
241
|
+
`${skill}: step ${step.n} claims judgement-only and names artifacts. ` +
|
|
242
|
+
"A mandate whose artifacts you can name is unbuilt, not unjudgeable.",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
rows.push({ step: step.n, state: "unbindable", reason: c.reason, kind: null });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!CASE_KINDS.includes(c.kind)) {
|
|
249
|
+
findings.push(`${skill}: step ${step.n} has kind ${JSON.stringify(c.kind)}, not one of ${CASE_KINDS.join(" | ")}`);
|
|
250
|
+
}
|
|
251
|
+
let result;
|
|
252
|
+
try {
|
|
253
|
+
result = gradeBound(skill, c, ctx);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
if (e instanceof CouldNotRun) throw e;
|
|
256
|
+
throw new CouldNotRun(`${skill}: step ${step.n} could not be graded — ${e.message}`);
|
|
257
|
+
}
|
|
258
|
+
const acceptedEntries = c.expect?.accepted ?? [];
|
|
259
|
+
for (const a of acceptedEntries) {
|
|
260
|
+
if (typeof a !== "object" || typeof a.file !== "string") {
|
|
261
|
+
findings.push(`${skill}: step ${step.n} — an accepted-drift entry is not {file, why}: ${JSON.stringify(a)}`);
|
|
262
|
+
} else if (typeof a.why !== "string" || a.why.trim().length < 20) {
|
|
263
|
+
findings.push(
|
|
264
|
+
`${skill}: step ${step.n} — accepted drift ${a.file} carries no argument. ` +
|
|
265
|
+
"A silenced finding needs a reason a reviewer can refuse.",
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const accepted = new Set(acceptedEntries.filter((a) => a && typeof a.file === "string").map((a) => a.file));
|
|
270
|
+
const unexpected = result.failing.filter((f) => !accepted.has(f.split(" — ")[0]));
|
|
271
|
+
const repaired = [...accepted].filter((a) => !result.failing.some((f) => f.split(" — ")[0] === a));
|
|
272
|
+
for (const u of unexpected) findings.push(`${skill}: step ${step.n} — ${u}`);
|
|
273
|
+
for (const r of repaired) {
|
|
274
|
+
findings.push(
|
|
275
|
+
`${skill}: step ${step.n} — ${r} now complies, and is still listed as accepted drift. ` +
|
|
276
|
+
"Remove it from `expect.accepted`; a drift list that outlives its drift is as wrong as one that hides it.",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
rows.push({ step: step.n, state: "bound", kind: c.kind, carrier: c.carrier?.tool ?? null, detail: result.detail, accepted: accepted.size });
|
|
280
|
+
}
|
|
281
|
+
return { skill, rows, findings };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const USAGE = [
|
|
285
|
+
"usage: node cli/skill-goldens.mjs [--workspace <dir>] [--repo-root <dir>]",
|
|
286
|
+
"",
|
|
287
|
+
"Grades evals/goldens/skills/ against the core skills and the live artifacts they govern.",
|
|
288
|
+
"exit 0 green · 1 red · 2 could not run",
|
|
289
|
+
].join("\n");
|
|
290
|
+
|
|
291
|
+
export function run(argv = process.argv.slice(2), io = console) {
|
|
292
|
+
try {
|
|
293
|
+
let repoRoot = ".";
|
|
294
|
+
let workspaceDir = null;
|
|
295
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
296
|
+
const value = (flag) => {
|
|
297
|
+
const v = argv[i + 1];
|
|
298
|
+
if (v === undefined || v.startsWith("--")) throw new CouldNotRun(`${flag} needs a value`);
|
|
299
|
+
i += 1;
|
|
300
|
+
return v;
|
|
301
|
+
};
|
|
302
|
+
if (argv[i] === "--repo-root") repoRoot = value("--repo-root");
|
|
303
|
+
else if (argv[i] === "--workspace") workspaceDir = value("--workspace");
|
|
304
|
+
else if (argv[i] === "--help" || argv[i] === "-h") {
|
|
305
|
+
io.log(USAGE);
|
|
306
|
+
return 0;
|
|
307
|
+
} else {
|
|
308
|
+
io.error(`skill-goldens: unrecognised argument ${JSON.stringify(argv[i])}`);
|
|
309
|
+
io.error(USAGE);
|
|
310
|
+
return 2;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
repoRoot = path.resolve(repoRoot);
|
|
314
|
+
workspaceDir = path.resolve(workspaceDir ?? path.join(repoRoot, ".portulan"));
|
|
315
|
+
|
|
316
|
+
let ctx;
|
|
317
|
+
let skills;
|
|
318
|
+
try {
|
|
319
|
+
ctx = { repoRoot, workspaceDir, manifest: JSON.parse(fs.readFileSync(path.join(workspaceDir, "workspace.json"), "utf8")) };
|
|
320
|
+
skills = skillSet(repoRoot);
|
|
321
|
+
} catch (e) {
|
|
322
|
+
io.error(`skill-goldens: ${e.message}`);
|
|
323
|
+
return 2;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const corpusDir = path.join(repoRoot, "evals/goldens/skills");
|
|
327
|
+
const findings = [];
|
|
328
|
+
const all = [];
|
|
329
|
+
for (const skill of skills) {
|
|
330
|
+
const file = path.join(corpusDir, `${skill}.json`);
|
|
331
|
+
if (!fs.existsSync(file)) {
|
|
332
|
+
findings.push(`${skill}: core/skills/${skill}/ exists and evals/goldens/skills/${skill}.json does not`);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
let corpus;
|
|
336
|
+
try {
|
|
337
|
+
corpus = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
338
|
+
} catch (e) {
|
|
339
|
+
io.error(`skill-goldens: ${skill}.json is not JSON — ${e.message}`);
|
|
340
|
+
return 2;
|
|
341
|
+
}
|
|
342
|
+
if (corpus.skill !== skill) {
|
|
343
|
+
io.error(`skill-goldens: ${skill}.json declares skill ${JSON.stringify(corpus.skill)}`);
|
|
344
|
+
return 2;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const graded = gradeSkill(skill, corpus, ctx);
|
|
348
|
+
all.push(graded);
|
|
349
|
+
findings.push(...graded.findings);
|
|
350
|
+
} catch (e) {
|
|
351
|
+
io.error(`skill-goldens: ${e.message}`);
|
|
352
|
+
return 2;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let bound = 0;
|
|
357
|
+
let unbindable = 0;
|
|
358
|
+
let loadBearing = 0;
|
|
359
|
+
let census = 0;
|
|
360
|
+
for (const g of all) {
|
|
361
|
+
io.log(`skill-goldens: ${g.skill}`);
|
|
362
|
+
for (const r of g.rows) {
|
|
363
|
+
if (r.state === "bound") {
|
|
364
|
+
bound += 1;
|
|
365
|
+
if (r.kind === "load-bearing") loadBearing += 1;
|
|
366
|
+
else census += 1;
|
|
367
|
+
const acc = r.accepted > 0 ? ` (+${r.accepted} accepted drift)` : "";
|
|
368
|
+
io.log(` step ${r.step} bound ${String(r.kind).padEnd(12)} carrier ${String(r.carrier ?? "none").padEnd(7)} ${r.detail}${acc}`);
|
|
369
|
+
} else {
|
|
370
|
+
unbindable += 1;
|
|
371
|
+
io.log(` step ${r.step} unbindable ${r.reason}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
io.log("");
|
|
377
|
+
io.log(` ${bound} of ${bound + unbindable} mandate(s) bound to live artifacts — ${loadBearing} load-bearing, ${census} census`);
|
|
378
|
+
const reasons = {};
|
|
379
|
+
for (const g of all) for (const r of g.rows) if (r.state === "unbindable") reasons[r.reason] = (reasons[r.reason] ?? 0) + 1;
|
|
380
|
+
io.log(` ${unbindable} adjudicated unbindable — ${Object.entries(reasons).map(([k, v]) => `${v} ${k}`).join(", ")}`);
|
|
381
|
+
io.log("");
|
|
382
|
+
io.log(" THE RATIO IS A FINDING, not bookkeeping: it measures how much of a core skill is artifact");
|
|
383
|
+
io.log(" discipline and how much is agent judgement. Read the SPLIT, not the total — the");
|
|
384
|
+
io.log(" judgement-only rows are the A/B clause's FIRST subject; cross-language and already-carried");
|
|
385
|
+
io.log(" mean a carrier exists and is somewhere else.");
|
|
386
|
+
io.log(" A `census` row re-indexes a figure an existing recipe already prints; it is not a new check.");
|
|
387
|
+
io.log(" `unbindable` is the dodge, so its reason comes from a closed vocabulary and is adjudicated.");
|
|
388
|
+
io.log(" `carrier` is a DECLARED, reviewed field. Nothing links it to a check: a corpus naming a");
|
|
389
|
+
io.log(" module that does not exist still grades green, and a reviewer is what catches that.");
|
|
390
|
+
io.log(" Presence, not adequacy: a mandate can be bound and the binding trivial.");
|
|
391
|
+
io.log(" It grades a skill's MANDATES against the tree, never an agent's judgement in following one.");
|
|
392
|
+
|
|
393
|
+
if (findings.length > 0) {
|
|
394
|
+
io.error("");
|
|
395
|
+
for (const f of findings) io.error(` FAIL ${f}`);
|
|
396
|
+
io.error(`skill-goldens: ${findings.length} finding(s)`);
|
|
397
|
+
return 1;
|
|
398
|
+
}
|
|
399
|
+
io.log("");
|
|
400
|
+
io.log("GREEN — every core skill's mandates are accounted for and every binding holds.");
|
|
401
|
+
return 0;
|
|
402
|
+
} catch (e) {
|
|
403
|
+
if (e instanceof CouldNotRun) {
|
|
404
|
+
io.error(`skill-goldens: ${e.message}`);
|
|
405
|
+
io.error(USAGE);
|
|
406
|
+
return 2;
|
|
407
|
+
}
|
|
408
|
+
throw e;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Not `file://${argv[1]}`: `import.meta.url` percent-encodes, so a path with a space would never match.
|
|
413
|
+
function isMain() {
|
|
414
|
+
const invoked = process.argv[1];
|
|
415
|
+
if (!invoked) return false;
|
|
416
|
+
if (import.meta.url === pathToFileURL(invoked).href) return true;
|
|
417
|
+
try {
|
|
418
|
+
return import.meta.url === pathToFileURL(fs.realpathSync(invoked)).href;
|
|
419
|
+
} catch {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (isMain()) process.exitCode = run(process.argv.slice(2));
|