@sleepy_panda_srl/portulan 0.1.3 → 0.3.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.
Files changed (89) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +145 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1719 -1699
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +269 -1080
  10. package/cli/drills.mjs +92 -504
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +882 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +6 -125
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +29 -265
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +82 -505
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +258 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/README.md +33 -0
  52. package/core/personas/librarian.md +2 -2
  53. package/core/skills/clarify/SKILL.md +1 -1
  54. package/core/skills/consolidate/SKILL.md +5 -3
  55. package/core/templates/handoff.md +28 -15
  56. package/core/templates/memory-entry.md +1 -0
  57. package/evals/README.md +271 -845
  58. package/evals/releases/0.2.0.json +146 -0
  59. package/evals/releases/0.2.0.md +67 -0
  60. package/evals/releases/0.3.0.json +150 -0
  61. package/evals/releases/0.3.0.md +68 -0
  62. package/evals/releases/README.md +3 -3
  63. package/package.json +5 -2
  64. package/packs/.claude-plugin/plugin.json +2 -1
  65. package/packs/README.md +7 -4
  66. package/packs/rituals/checkpoints/README.md +1 -1
  67. package/packs/tools/README.md +8 -8
  68. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  69. package/packs/tools/local-llm/README.md +136 -0
  70. package/packs/tools/local-llm/pack.json +32 -0
  71. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  72. package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
  73. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
  74. package/packs/tools/local-llm/verify/README.md +18 -0
  75. package/spec/README.md +113 -6
  76. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  77. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  78. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  79. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  80. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  81. package/spec/migrations/0006-boot-card.mjs +77 -0
  82. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  83. package/spec/migrations/0008-card-reading.mjs +55 -0
  84. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  85. package/spec/migrations/0010-comment-rail.mjs +57 -0
  86. package/spec/migrations/README.md +45 -10
  87. package/spec/pack.schema.json +1 -1
  88. package/spec/slots.md +343 -27
  89. package/spec/workspace.schema.json +145 -4
@@ -1,86 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Portulan — the eval result a release carries.
3
3
  //
4
- // Milestone 8's ninth clause: *a release carries an eval result*. `../docs/plan.md`'s Protocol →
5
- // Versioning has carried the obligation since the plan was locked — *"from milestone 8, releases carry
6
- // an eval result"* — and the maintainer's ruling of 2026-08-24 made it bind row 8's close. The
7
- // legislative history is `../docs/milestones/m08.md`; the criterion itself is the row, and this file
8
- // deliberately restates neither.
9
- //
10
- // ## The three things the amendment left open, and what this module settles
11
- //
12
- // The 2026-08-24 amendment named what it did **not** settle: *"what an eval result attached to a
13
- // release consists of, where it is carried (the changelog entry, the release body, a file in the
14
- // payload), and whether a release with no eval result is refused by a rail or by a person. Those are
15
- // this row's remaining work, not this amendment's content."*
16
- //
17
- // 1. **What it consists of.** The verdict every recipe the workspace YIELDS returned, measured by
18
- // running them, plus the identity of the A/B baseline the release ships against. Not a summary
19
- // anybody types.
20
- // 2. **Where it is carried.** One file per version, `evals/releases/<version>.md`, rendered from
21
- // `evals/releases/<version>.json`. `CHANGELOG.md` and the release body CITE it and restate no
22
- // figure — a rule with two carriers is obeyed at the narrower one.
23
- // 3. **Rail or person — it is BOTH, and the split is the honest answer.** A rail reaches the tree: a
24
- // governed version with no record, a record that disagrees with its capture, a record listing a red
25
- // rail. A rail reaches **no tag and no published release body** — `../.portulan/gate-map.md` keeps
26
- // `tag-a-release` and `publish-a-release` Gated, and those happen outside any tree a check can
27
- // read. Answering "a rail" flat would have claimed a reach this has not got. The split is the shape
28
- // the drill calendar already uses for its own two halves.
29
- //
30
- // ## The subject is EVERY governed release, not the tree's current cut state — two wrong designs first
31
- //
32
- // The obvious cut-detector is `CHANGELOG.md`'s **top** heading: rename `## Unreleased` to a version and
33
- // the tree is a cut. **It never fires.** The cut commit RE-SEEDS the accumulator — `b410c020` renamed
34
- // the heading to `## 0.1.2 — 2026-08-20` and put a fresh `## Unreleased` back above it in the same
35
- // commit, which `CHANGELOG.md`'s own header requires so the tagged tree contains its own entry. So the
36
- // top heading is `Unreleased` on every commit including the cut, and a rail keyed to it would have
37
- // reported *no cut in this tree* on precisely the tree it was built to grade.
38
- //
39
- // The second design keyed off `package.json`'s `version` alone — what actually moves at a cut, and what
40
- // `./version-carriers.mjs` grades the prose against. It fires, and it **protects one release at a
41
- // time**: once `0.1.4` is declared, `0.1.3`'s record can be deleted or corrupted and nothing reds. A
42
- // rail over a record layer that only ever grades the newest record is not a rail over the record layer.
43
- //
44
- // So the subject is **every version `CHANGELOG.md` records**, from `FIRST_GOVERNED_VERSION` onward,
45
- // permanently — plus one cut-integrity check that holds between cuts as well as at one: the newest
46
- // version heading must be the version `package.json` declares. Both findings were the second opinion's,
47
- // caught before a line of the rail was written and recorded here rather than in a later repair.
48
-
49
- // ## What this cannot reach, and the one surface that can
50
- //
51
- // The heading rename happens *"in a change merged before the tag is created"* — `CHANGELOG.md`'s own
52
- // header — and that ordering is human-owned prose in a file that says of its neighbouring rule *"nothing
53
- // checks this"*. A maintainer who tags a commit whose accumulator was never renamed produces a release
54
- // no tree state ever flagged **where nothing else moved either** — `--verify` is green on such a tree,
55
- // since `package.json` and the heading still agree with each other. Where the version WAS bumped and a
56
- // record written, `--verify` reds it here on the pull request; what no rail sees is that same tree
57
- // reached by a tag rather than by a merge, since `--tagged` below never reads `CHANGELOG.md`.
58
- //
59
- // `../.github/workflows/publish-github-packages.yml` fires on `release: published` and checks out the
60
- // tag — the one mechanical surface at the release act itself — and it runs `--tagged` below against that
61
- // checkout. That is the half a rail can reach at the moment a person acts; the rest stays Gated and is
62
- // carried in `../.portulan/gate-map.md`.
63
- //
64
- // ## Read the WORKTREE, where `./version-carriers.mjs` reads the index — and the difference is the point
65
- //
66
- // That module reads `package.json` and the prose it compares **both** from the git index, because
67
- // comparing worktree prose against an index version compares two trees. Its recorded residual is that an
68
- // **unstaged** cut is therefore invisible to it: run against a fully-edited worktree it reported a green
69
- // about a tree that no longer existed.
70
- //
71
- // This module reads the worktree, uniformly, both the version and the record. `../.portulan/dod.md`
72
- // condition 1 asks that every recipe ran green *in this working copy*, and there is no cross-tree
73
- // comparison here to protect — the record and the version are read from one tree either way. Inheriting
74
- // the index read would have inherited that residual for nothing.
75
- //
76
- // ## `0.1.0`–`0.1.2` are out of scope, by the Protocol's own words
77
- //
78
- // *"**From milestone 8**, releases carry an eval result."* Three releases predate the clause and carry
79
- // none. `FIRST_GOVERNED_VERSION` is the boundary, and it is a **historical constant** rather than a
80
- // hand-maintained figure of a moving subject: which releases predate a ruling cannot change. The
81
- // distinction matters because this repository deletes hand-maintained figures on sight, and the reason
82
- // it does is that their subject moves. This one's does not.
83
- //
84
4
  // Exit 0 green · 1 a finding · 2 could not run.
85
5
 
86
6
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
@@ -90,46 +10,25 @@ import { pathToFileURL } from "node:url";
90
10
 
91
11
  import { recipeSet, resolverFor } from "./recipe-set.mjs";
92
12
 
93
- /** Raised when a precondition fails. Always exit 2 — never a finding about a release. */
13
+ /** A failed precondition: always exit 2, never a finding about a release. */
94
14
  export class CouldNotRun extends Error {}
95
15
 
96
- /** Where a version's record lives. Both halves, because one carrier is a pair here as it is for the baseline. */
97
16
  export const RECORD_DIR = "evals/releases";
98
17
  export const snapshotPath = (version) => `${RECORD_DIR}/${version}.json`;
99
18
  export const registerPath = (version) => `${RECORD_DIR}/${version}.md`;
100
19
 
101
- /**
102
- * The first release the clause governs.
103
- *
104
- * `../docs/plan.md`'s Protocol → Versioning: *"from milestone 8, releases carry an eval result"*.
105
- * `v0.1.0`, `v0.1.1` and `v0.1.2` were cut before that clause had an owner — it belonged to no row
106
- * until 2026-08-24 — so they carry no record and this rail does not invent one for them. A retro-fitted
107
- * record would be a capture nobody ran, which is the one thing an eval result may not be.
108
- */
109
20
  export const FIRST_GOVERNED_VERSION = "0.1.3";
110
21
 
111
- /**
112
- * This module's own recipe id, excluded from every capture it writes.
113
- *
114
- * **The capture cannot be accurate about itself and the exclusion is the only honest resolution.**
115
- * `release-eval` is a yielded recipe, so its verdict would enter the record it lives inside: captured
116
- * before the record is written it is red, because the governed version has no record; captured after,
117
- * the capture is stale the moment the register is rendered from it. Every ordering is wrong. So its row
118
- * is excluded and the exclusion is PRINTED in the register — a dropped row that says nothing is how a
119
- * record starts implying a green nobody measured.
120
- */
22
+ // The payload does not carry `evals/ab/`, so from this version a register cites it at the release's tag.
23
+ // Earlier registers keep rendering the relative link npm froze into their tarballs.
24
+ export const PINNED_FROM = "0.2.0";
25
+ export const REPOSITORY = "https://github.com/sleepy-panda-srl/portulan";
26
+
121
27
  export const SELF = "release-eval";
122
28
 
123
- /** `X.Y.Z` and nothing else. A prerelease or a build tag is could-not-run rather than a guess at ordering. */
124
29
  const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
125
30
 
126
- /**
127
- * Compare two `X.Y.Z` versions. Throws `CouldNotRun` on anything else.
128
- *
129
- * Deliberately not a general semver comparator: this repository ships `X.Y.Z` and nothing more, and a
130
- * partial implementation that silently mis-orders a prerelease would decide whether a release is
131
- * governed. Refusing is the answer that cannot be quietly wrong.
132
- */
31
+ /** Throws `CouldNotRun` on anything but `X.Y.Z` rather than guess where a prerelease falls. */
133
32
  export function compareVersions(a, b) {
134
33
  const pa = SEMVER.exec(a);
135
34
  const pb = SEMVER.exec(b);
@@ -142,12 +41,11 @@ export function compareVersions(a, b) {
142
41
  return 0;
143
42
  }
144
43
 
145
- /** Whether the clause governs this version. */
146
44
  export function isGoverned(version) {
147
45
  return compareVersions(version, FIRST_GOVERNED_VERSION) >= 0;
148
46
  }
149
47
 
150
- /** `package.json`'s version, from the worktree. See the header for why not the index. */
48
+ /** Read from the worktree, not the index, so an unstaged cut is graded too. */
151
49
  export function declaredVersion(root) {
152
50
  let raw;
153
51
  try {
@@ -165,17 +63,7 @@ export function declaredVersion(root) {
165
63
  return v.trim();
166
64
  }
167
65
 
168
- /**
169
- * Every version `CHANGELOG.md` records, newest first.
170
- *
171
- * **This is the released set, and it is the rail's subject.** `../CHANGELOG.md` carries one `## X.Y.Z —
172
- * DATE` heading per release and one `## Unreleased` accumulator that is re-seeded at every cut, so the
173
- * version headings are the releases and the accumulator is never one. A heading is matched only in
174
- * column 0 at depth 2, because the entries below quote their own retired headings — and **fenced
175
- * regions are skipped**, because a worked example of a cut belongs in one and would otherwise enter the
176
- * released set as a phantom release. A version-shaped heading that is not `X.Y.Z` is refused rather than
177
- * skipped, so the subject list cannot decide by omission what `compareVersions` refuses out loud.
178
- */
66
+ /** Every `## X.Y.Z` heading in file order, which is newest first. */
179
67
  export function changelogVersions(root) {
180
68
  let text;
181
69
  try {
@@ -186,10 +74,7 @@ export function changelogVersions(root) {
186
74
  const out = [];
187
75
  let fenced = false;
188
76
  for (const line of text.split("\n")) {
189
- // **Fenced regions are skipped**, because a column-0 fence is exactly where this file would show
190
- // a reader what a cut looks like — and a worked example naming `## 9.9.9` would enter the
191
- // released set as a phantom, producing a false red on both the cut-integrity arm and the
192
- // missing-record arm. Latent today; the docstring above claimed a robustness the fence broke.
77
+ // A fence may hold a worked example of a cut, and its heading is no release.
193
78
  if (/^(```|~~~)/.test(line)) {
194
79
  fenced = !fenced;
195
80
  continue;
@@ -200,10 +85,6 @@ export function changelogVersions(root) {
200
85
  out.push(m[1]);
201
86
  continue;
202
87
  }
203
- // **A version-shaped heading this cannot order is REFUSED, never skipped.** `compareVersions`
204
- // refuses a prerelease loudly on the ground that a partial ordering would silently decide whether
205
- // a release is governed — and the subject-list builder was dropping `## 0.1.3-rc.1` without a
206
- // word, which decides the same thing by omission. The two halves now agree.
207
88
  if (/^## \d/.test(line)) {
208
89
  throw new CouldNotRun(
209
90
  `CHANGELOG.md carries the release heading ${JSON.stringify(line.trim())}, which is not \`## X.Y.Z\` — this rail ` +
@@ -215,7 +96,6 @@ export function changelogVersions(root) {
215
96
  return out;
216
97
  }
217
98
 
218
- /** The commit this capture was taken at, and whether the tree was clean when it was. */
219
99
  export function sourceOf(root) {
220
100
  let commit;
221
101
  try {
@@ -232,12 +112,7 @@ export function sourceOf(root) {
232
112
  return { commit, clean };
233
113
  }
234
114
 
235
- /**
236
- * The recipe set this workspace yields, pinned.
237
- *
238
- * The root is **named**, so it replaces every other source and this answer is about the tree rather than
239
- * about the machine — the same reason `./drills.mjs`'s recipe and `./goldens.mjs`'s pin theirs.
240
- */
115
+ /** A named pack root wins outright over discovery, so the set depends on the tree, never on the machine. */
241
116
  export function yieldedRecipes({ root, workspace = ".portulan", packRoot = "packs" }) {
242
117
  const workspaceDir = path.join(root, workspace);
243
118
  let manifest;
@@ -257,19 +132,7 @@ export function yieldedRecipes({ root, workspace = ".portulan", packRoot = "pack
257
132
  return set.recipes.map((r) => ({ id: r.id, run: r.run }));
258
133
  }
259
134
 
260
- /**
261
- * The A/B baseline this release ships against — its IDENTITY, never its figures.
262
- *
263
- * **Restating the cells here would be the defect this whole module is arranged against.** The figures
264
- * have a carrier — `evals/ab/baseline.json`, and `evals/ab/baseline.md` rendered from it and
265
- * byte-compared by the `ab-run` recipe — and a second copy of them in a per-release record is a
266
- * hand-maintained figure of a moving subject wearing a machine-written costume. What a release record
267
- * owes its reader is *which* baseline it shipped against and where to read it, so that is what this
268
- * returns: path, capture date, the commit it was taken at, and whether that tree was clean.
269
- *
270
- * Returns `null` where no baseline is committed, which a release may legitimately be in — and the
271
- * register then says so rather than rendering a hole.
272
- */
135
+ /** The A/B baseline's identity alone, since its figures have their own carrier; `null` when none is committed. */
273
136
  export function abBaselineIdentity(root) {
274
137
  const snapshot = "evals/ab/baseline.json";
275
138
  const abs = path.join(root, snapshot);
@@ -280,11 +143,7 @@ export function abBaselineIdentity(root) {
280
143
  } catch (e) {
281
144
  throw new CouldNotRun(`the A/B baseline at ${snapshot} is not valid JSON: ${e.message}`);
282
145
  }
283
- // **Read through, and coerce nothing.** A first cut mapped a missing field to `null`, which the
284
- // renderer then printed as `<undated>` / `<uncommitted>` — so a baseline whose capture lacked those
285
- // fields produced a perfectly well-formed register naming a placeholder, and the derived probe below
286
- // saw no hole because a fallback had filled it. Reading through means an absent field arrives as
287
- // `undefined`, renders as `undefined`, and is refused before anything is written. Fail closed.
146
+ // No fallbacks: an absent field stays `undefined` so that `verifyShape` refuses it.
288
147
  return {
289
148
  snapshot,
290
149
  register: "evals/ab/baseline.md",
@@ -294,32 +153,17 @@ export function abBaselineIdentity(root) {
294
153
  };
295
154
  }
296
155
 
297
- /**
298
- * The limitations, DERIVED from the capture rather than fixed.
299
- *
300
- * 6d round 2's finding, and it is this milestone's signature defect: a limitation asserted flat about a
301
- * field the capture may or may not hold is a false sentence waiting for its first counterexample. That
302
- * block asserted *"the model is not recorded"* while the harness had just begun recording one.
303
- */
304
156
  export function limitationsFor(snap) {
305
157
  const out = [
306
158
  "**A green says this record agrees with its own capture — never that the release is good.** Every " +
307
159
  "line below is what the recipes returned at one commit, and a recipe's own green establishes only what " +
308
160
  "that recipe's documentation says it establishes.",
309
- // No `??` here either — the fallback survived one round of removing them, in the block whose
310
- // subject is claims a capture never made, so a null-commit record printed `null` in its table and
311
- // `<commit>` in its limitations. `verifyShape` refuses such a capture before this is reached.
312
161
  `**The recipes were not run at the tag.** They ran at \`${snap?.source?.commit}\`, and this record is ` +
313
162
  "committed *in* the cut change — so the tagged tree is this commit plus whatever that change still adds " +
314
163
  "after the capture, the record itself at minimum. A record cannot be captured at a commit that does not " +
315
164
  "exist yet, and printing the one it was captured at is the only honest form.",
316
165
  `**\`${SELF}\` is excluded from the rows above**, because a capture cannot be accurate about the record it is inside. ` +
317
166
  "Its verdict for this release is the rail's own run on the pull request that carries this file.",
318
- // **The record ships inside the npm payload, so it is read where nothing around it says what it
319
- // is about.** In a consumer's `node_modules` a table headed *"N of M recipes this workspace
320
- // yielded"* invites being read as a claim about THEIR workspace. It is a claim about this
321
- // repository's own build and nothing else, and the sentence saying so travels with the document
322
- // rather than living in a README that does not.
323
167
  "**This record measures the Portulan repository's own build**, at the commit named above — never the workspace, " +
324
168
  "project or package this release is installed into. Nothing here has looked at your tree.",
325
169
  ];
@@ -338,7 +182,7 @@ export function limitationsFor(snap) {
338
182
  return out;
339
183
  }
340
184
 
341
- /** The register, rendered from the capture. The one place a release's eval result is written for a reader. */
185
+ /** No fallbacks, here or in `limitationsFor`: a missing field must render as a hole that `verifyShape` refuses. */
342
186
  export function renderRegister(snap) {
343
187
  const L = [];
344
188
  L.push(`# Eval result — Portulan ${snap.version}`);
@@ -383,11 +227,10 @@ export function renderRegister(snap) {
383
227
  if (snap.abBaseline === null) {
384
228
  L.push("None is committed in this tree.");
385
229
  } else {
386
- // No `??` fallbacks: a missing field must render as a hole the derived check refuses, never as a
387
- // placeholder that reads like a measurement. `clean` is the exception by nature — it renders as a
388
- // branch — and is therefore checked by name in `verifyShape`.
230
+ const pinned = SEMVER.test(String(snap.version)) && compareVersions(snap.version, PINNED_FROM) >= 0;
231
+ const href = pinned ? `${REPOSITORY}/blob/v${snap.version}/${snap.abBaseline.register}` : `../../${snap.abBaseline.register}`;
389
232
  L.push(
390
- `[\`${snap.abBaseline.register}\`](../../${snap.abBaseline.register}), rendered from ` +
233
+ `[\`${snap.abBaseline.register}\`](${href}), rendered from ` +
391
234
  `\`${snap.abBaseline.snapshot}\` — captured ${snap.abBaseline.captured} at ` +
392
235
  `\`${snap.abBaseline.commit}\`, over a tree that was ` +
393
236
  `${snap.abBaseline.clean === true ? "clean" : "**not clean**"}.`,
@@ -403,73 +246,17 @@ export function renderRegister(snap) {
403
246
  return L.join("\n");
404
247
  }
405
248
 
406
- /**
407
- * The shape check, DERIVED from the renderer — and it runs before any render a caller trusts.
408
- *
409
- * 6d rounds 3, 4, 6 and 7 arrived at this shape over five rounds and it is adopted rather than
410
- * re-derived: report rather than throw; check **before** rendering rather than after; check what is
411
- * dereferenced rather than what is typed; and derive the list from the renderer instead of hand-writing
412
- * another function's reads, because a hand-written one goes stale the moment the renderer reads one more
413
- * thing.
414
- *
415
- * **The derived check is blind to anything the renderer supplies a value for**, and that is the whole of
416
- * its limit rather than "one boolean". A first cut of this module claimed exactly one blind spot and had
417
- * three: `abBaseline.captured` and `abBaseline.commit` were read through `??` fallbacks, so absence
418
- * printed `<undated>` and `<uncommitted>` — placeholders that read like measurements — and
419
- * `abBaseline.clean: null` was *explicitly permitted* by the check whose message said its absence must
420
- * not publish an unmeasured claim, then rendered as `**not clean**`. Caught by a fresh context deleting
421
- * every field the renderer reads, one at a time, which is the only way this class is ever found.
422
- *
423
- * So there are **four** rules, and each after the first was a further round of this same defect — which
424
- * is the honest way to carry the count, since a docblock claiming totality is what the last two rounds
425
- * caught this file doing. (1) The
426
- * renderer supplies no fallback, so absence renders as a hole. (2) Every field that renders as a
427
- * **branch** rather than a value is checked here by name, because no hole appears for those — only the
428
- * meaning is invented. (3) **No leaf may be `null` and no string leaf may be blank**, because a
429
- * present-degenerate value is neither absent nor a branch: `${null}` renders the four characters `null`
430
- * and `""` renders nothing at all, so the document comes out well-formed and the probe below — which
431
- * looks for `undefined` and `NaN` — sees a clean page. Six of these passed a check that had just been
432
- * hardened against exactly this class and whose comment claimed two rules were the whole of it.
433
- *
434
- * Rule 3 is **structural rather than a list of fields**: it walks the capture's own leaves. A
435
- * hand-written roster of "fields that must be non-empty" is the defect this file has now met at three
436
- * depths, and it would go stale the moment the renderer read one more. (4) **A field that names a commit
437
- * must look like one** — rule 3 is a floor under every leaf and a *name* check for none, so
438
- * `source.commit: "banana"` passed it, and `"HEAD"` passed while reading like an answer. Rules 3 and 4
439
- * are different questions about the same leaf: *is anything there* and *is it the kind of thing it
440
- * claims to be*.
441
- */
442
- /** `YYYY-MM-DD`. A capture that stamps itself from anything else cannot be reproduced, and it is printed. */
443
249
  const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
444
250
 
445
- /**
446
- * A full object name — 40 hex for SHA-1, 64 for a SHA-256 repository.
447
- *
448
- * **A commit field is a NAME, and a name-shaped check is the only one that can tell a commit from a
449
- * word.** `source.commit: "banana"` passed shape, the render probe and the non-blank walk alike, because
450
- * it is a present, non-empty string that renders perfectly — and `"HEAD"` passed too, which is worse,
451
- * since it reads like an answer. The record's central claim is that it was measured *at a named commit*;
452
- * without this the claim is satisfied by any prose at all. Copilot's round 1 on
453
- * [#381](https://github.com/sleepy-panda-srl/portulan/pull/381), and it is the degenerate-value class one
454
- * field further in than the round before it reached: rule 3 asks whether a leaf is *present and
455
- * non-blank*, which is a floor under every field and a name check for none.
456
- *
457
- * Abbreviated shas are refused. Nothing here writes one — `sourceOf` runs `git rev-parse HEAD` — and a
458
- * short name is ambiguous in exactly the way a record meant to be re-derived cannot afford.
459
- */
251
+ /** A full SHA-1 or SHA-256 name: an abbreviated one can become ambiguous. */
460
252
  const OBJECT_NAME = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
461
253
 
462
- /**
463
- * Every leaf of the capture, as a dotted path — the subject of rule 3 above.
464
- *
465
- * `abBaseline: null` is the one legitimate null and is skipped by the caller rather than exempted here,
466
- * because an exemption inside a total walk is how a second one gets added later without argument.
467
- */
468
254
  function leafPaths(value, prefix = []) {
469
255
  if (value === null || typeof value !== "object") return [[prefix, value]];
470
256
  return Object.entries(value).flatMap(([k, v]) => leafPaths(v, [...prefix, k]));
471
257
  }
472
258
 
259
+ /** Every reason this capture cannot be rendered truthfully; empty when it can. */
473
260
  export function verifyShape(snap) {
474
261
  const red = [];
475
262
  if (snap?.portulan?.releaseEval !== "1") red.push("the record does not declare `portulan.releaseEval: \"1\"` — this is not a release eval capture");
@@ -495,11 +282,6 @@ export function verifyShape(snap) {
495
282
  for (const field of ["source", "host"]) {
496
283
  if (snap?.[field] === undefined || snap[field] === null) red.push(`the record has no \`${field}\`, which the register prints among the release's conditions`);
497
284
  }
498
- // **The host's two fields are printed as conditions, so they are strings or they are a claim nobody
499
- // measured.** Found by sweeping rule 4's *class* — a type-conditional guard — across this file rather
500
- // than by fixing the site the note named: `host.node: 22` rendered `` `22` `` and passed everything.
501
- // `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md` is the reason the sweep
502
- // happened at all, and this is the site it turned up that round 4 did not name.
503
285
  if (snap?.host !== undefined && snap?.host !== null) {
504
286
  for (const k of ["node", "platform"]) {
505
287
  if (typeof snap.host[k] !== "string") {
@@ -507,28 +289,15 @@ export function verifyShape(snap) {
507
289
  }
508
290
  }
509
291
  }
510
- // **The booleans, explicitly** — the derived check's one blind spot, measured rather than reasoned.
511
292
  if (snap?.source !== undefined && snap?.source !== null && typeof snap.source.clean !== "boolean") {
512
293
  red.push("the record's `source.clean` is not a boolean — it renders as a branch, so its absence would publish a claim about the tree that the capture never made");
513
294
  }
514
- // **`null` was permitted here and it renders as `**not clean**`** — a claim about the baseline's tree
515
- // that the capture never made, published by the check whose own message said absence must not do
516
- // that. Permitting the third state defeated the check in the same line that stated it. Strict.
517
295
  if (snap?.abBaseline !== null && snap?.abBaseline !== undefined && typeof snap.abBaseline.clean !== "boolean") {
518
296
  red.push("the record's `abBaseline.clean` is not a boolean — it renders as a branch, so anything else would assert a dirty baseline tree the capture never recorded");
519
297
  }
520
298
  if (snap?.abBaseline === undefined) {
521
299
  red.push("the record has no `abBaseline` — `null` is how *no baseline* is recorded, and an absent field renders as one without ever having been measured");
522
300
  }
523
- // **Rule 4: a field that names a commit must BE a string that names one.** See `OBJECT_NAME`.
524
- //
525
- // **The guard's shape is the finding, not the pattern.** Written as *"if it is a string, validate
526
- // it"*, the check skipped every value that was not a string — so `source.commit: 1234567890` rendered
527
- // `` `1234567890` ``, `true` rendered `` `true` ``, and `["a"]` rendered `` `a` ``, each a plausible
528
- // measurement passing a check whose whole subject is that field. A type-conditional guard is not a
529
- // guard over the field; it is a guard over the field *when someone already used the right type*.
530
- // Copilot round 4, and the same shape as rule 3's own origin: `is anything there` and `is it the kind
531
- // of thing it claims to be` have to be asked of the value, never of the value's type.
532
301
  const commitFields = [
533
302
  ["source.commit", snap?.source, "the record's central claim is that it was measured at a named commit"],
534
303
  ["abBaseline.commit", snap?.abBaseline, "a baseline cited by a name nothing can resolve is cited by nothing"],
@@ -540,18 +309,11 @@ export function verifyShape(snap) {
540
309
  red.push(`the record's \`${label}\` is ${JSON.stringify(value)}, which is not a full object name — ${why}`);
541
310
  }
542
311
  }
543
- // **No `.trim()`**, and its presence here was the degenerate-value class once more. Trimming before
544
- // the test accepted `"2026-09-01 "` and the renderer then printed the padding into the table —
545
- // a value that passes a format check and violates the format it was checked against. A check that
546
- // normalises its input is checking something other than what will be published. Copilot round 3.
312
+ // Not trimmed: the value is printed as is, so it is checked as is.
547
313
  if (typeof snap?.captured === "string" && !ISO_DATE.test(snap.captured)) {
548
314
  red.push(`the record's \`captured\` is ${JSON.stringify(snap.captured)}, which is not a \`YYYY-MM-DD\` date — it is printed as one`);
549
315
  }
550
316
 
551
- // **Rule 3: no leaf is `null`, no string leaf is blank.** A present-degenerate value renders as a
552
- // value, so neither the fallback rule nor the branch checks nor the probe below can see it: `null`
553
- // prints the text `null` into a table cell and `""` prints nothing, and the register comes out
554
- // looking measured. Structural rather than a field list, for the reason the docblock gives.
555
317
  if (snap !== null && typeof snap === "object") {
556
318
  const skip = snap.abBaseline === null ? "abBaseline" : null;
557
319
  for (const [pathParts, leaf] of leafPaths(snap)) {
@@ -567,9 +329,6 @@ export function verifyShape(snap) {
567
329
 
568
330
  if (red.length > 0) return red;
569
331
 
570
- // **The list above is not the check; this is.** Render, and refuse a document that came out carrying
571
- // `undefined` or `NaN`. Anything the renderer reads and the capture lacks reds here without anybody
572
- // listing it, including reads added later.
573
332
  let rendered;
574
333
  try {
575
334
  rendered = renderRegister(snap);
@@ -585,24 +344,11 @@ export function verifyShape(snap) {
585
344
  return red;
586
345
  }
587
346
 
588
- /**
589
- * The rail's verdict over one record.
590
- *
591
- * **Totality against the LIVE recipe set is deliberately not checked**, and the reason is that a record
592
- * is a historical fact. A recipe added after `0.1.3` was cut does not make `0.1.3`'s record wrong; it
593
- * makes it a record of an earlier tree, which is what it is for. Totality is a **capture-time** property
594
- * — `--capture` runs whatever the workspace yields at that moment — and the register prints how many
595
- * that was. A verify-time totality check would turn every past release red on the commit that adds a
596
- * rail.
597
- */
347
+ /** Checks a record against its own capture, never the live recipe set: a recipe added later does not make it wrong. */
598
348
  export function verifyRecord(snap, { version, keyedBy = "the file it is named for" }) {
599
349
  const red = verifyShape(snap);
600
350
  if (red.length > 0) return red;
601
351
  if (snap.version !== version) {
602
- // The source of `version` is named rather than assumed: `--verify` keys every release but the
603
- // newest from `CHANGELOG.md`, so a message hard-coding *package.json declares* would have been
604
- // false for all of them — a diagnostic pointing at the wrong file is a diagnostic that sends the
605
- // next reader to the wrong place.
606
352
  red.push(`the record declares version \`${snap.version}\` where ${keyedBy} says \`${version}\` — a record keyed to another release cannot answer for this one`);
607
353
  }
608
354
  for (const r of snap.recipes) {
@@ -610,13 +356,6 @@ export function verifyRecord(snap, { version, keyedBy = "the file it is named fo
610
356
  red.push(`the record shows \`${r.id}\` at exit ${r.exit} — a release may not carry an eval result that records a rail it did not pass`);
611
357
  }
612
358
  }
613
- // **EXACTLY `SELF`, never merely including it.** Requiring presence let a red rail be *relocated*:
614
- // move `tests` out of `recipes` into `excluded` with any reason string and the record is green, the
615
- // register prints a smaller denominator and a principled-looking exclusion, and the recipe's own
616
- // headline — *no record shows a rail at a non-zero exit* — is satisfied by not recording it. A
617
- // verdict laundered into the exclusion list is worse than a red, because it reads as rigour.
618
- // `--capture` only ever writes `SELF`, so requiring exactly that costs nothing. Found by a
619
- // fresh-context reviewer attacking the record layer rather than the code.
620
359
  const excludedIds = snap.excluded.map((e) => e.id).sort();
621
360
  if (excludedIds.length !== 1 || excludedIds[0] !== SELF) {
622
361
  red.push(
@@ -627,7 +366,6 @@ export function verifyRecord(snap, { version, keyedBy = "the file it is named fo
627
366
  return red;
628
367
  }
629
368
 
630
- /** Run every yielded recipe except this module's own, and return the rows. */
631
369
  function measure(root, recipes, { stdout }) {
632
370
  const rows = [];
633
371
  for (const r of recipes) {
@@ -674,12 +412,6 @@ Exit 0 green · 1 a finding · 2 could not run.`;
674
412
  const MODES = ["--capture", "--write", "--verify", "--tagged"];
675
413
 
676
414
  function parse(argv) {
677
- // **`tagged` and `version` are SEPARATE fields, and sharing one was a real defeat of the rail.**
678
- // Both meanings once lived in `out.version`: `--tagged`'s argument — the tag being published — and
679
- // `--write`'s optional target. So `--tagged v0.1.3 --version 0.1.2` silently overwrote the tag with
680
- // the payload's own version, and the tag-versus-payload refusal that `--tagged` exists for became
681
- // `exit 0`; reversing the two arguments gave the opposite answer, which is the tell that one slot was
682
- // carrying two meanings. Two names cannot collide. Copilot round 5.
683
415
  const out = { mode: null, root: ".", date: null, version: null, tagged: null };
684
416
  for (let i = 0; i < argv.length; i += 1) {
685
417
  const a = argv[i];
@@ -692,12 +424,8 @@ function parse(argv) {
692
424
  if (MODES.includes(a)) {
693
425
  if (out.mode !== null) throw new CouldNotRun(`pass exactly one of ${MODES.join(", ")}`);
694
426
  out.mode = a.slice(2);
695
- // `--tagged` names the version being published; the others take it optionally.
696
427
  if (a === "--tagged") {
697
- // Swept with `--version` rather than left because the note named only one of them: the
698
- // tag also reaches `snapshotPath()` after its `v` is stripped. `isGoverned` would refuse
699
- // a malformed tag downstream, but a value that reaches a path builder before it reaches
700
- // a validator is the same shape twice, and only one of the two was reported.
428
+ // Validated here, before the tag reaches a record path.
701
429
  const t = need("the tag being published, for example `v0.1.3`");
702
430
  if (!SEMVER.test(t.replace(/^v/, ""))) throw new CouldNotRun(`\`--tagged\` takes a tag naming an \`X.Y.Z\` version, not \`${t}\``);
703
431
  out.tagged = t;
@@ -709,26 +437,13 @@ function parse(argv) {
709
437
  out.root = need("a directory");
710
438
  break;
711
439
  case "--date": {
712
- // Validated here rather than trusted: `--date banana` satisfied the parser and the shape
713
- // check alike, and the usage below promises `YYYY-MM-DD`. The front door was the one
714
- // reachable route into the degenerate-value class.
715
440
  const v = need("a YYYY-MM-DD date");
716
441
  if (!ISO_DATE.test(v)) throw new CouldNotRun(`\`--date\` takes a \`YYYY-MM-DD\` date, not \`${v}\``);
717
442
  out.date = v;
718
443
  break;
719
444
  }
720
445
  case "--version": {
721
- // Meaningful for `--write` alone: it names which committed record to re-render. Passing
722
- // it anywhere else is a refusal rather than a silently ignored argument, because an
723
- // argument that looks accepted and does nothing is how the collision above went unseen.
724
- //
725
- // **And it is validated HERE, because it reaches `path.join` as a path segment.**
726
- // Unvalidated, `--write --version ../../pwned` rendered into
727
- // `evals/releases/../../pwned.md` — outside the record directory entirely, overwriting a
728
- // file two levels up. Demonstrated rather than reasoned about. `X.Y.Z` contains no `/`
729
- // and no `..`, so validating the shape closes the traversal as a side effect of
730
- // validating the meaning, which is the better order: the value is refused for not being
731
- // a version, not for looking like an attack. Copilot round 6.
446
+ // Validated here because it becomes a path segment, and `X.Y.Z` admits no `/` or `..`.
732
447
  const v = need("an `X.Y.Z` version");
733
448
  if (!SEMVER.test(v)) throw new CouldNotRun(`\`--version\` takes an \`X.Y.Z\` version, not \`${v}\` — it names a record file, so anything else is a path rather than a version`);
734
449
  out.version = v;
@@ -780,11 +495,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
780
495
  }
781
496
 
782
497
  if (parsed.mode === "capture") {
783
- // **The governance refusal runs FIRST, before any git or workspace read.** It sat after
784
- // `sourceOf()`, which throws on a tree that is not a git repository — so on the one fixture that
785
- // exercised it the refusal was never reached, and the case guarding it passed on a
786
- // `could not read HEAD` it had accidentally allowed through an alternation. A precondition that
787
- // needs nothing should be checked before the ones that need something.
498
+ // Before any git or workspace read, so this refusal is reached outside a repository too.
788
499
  if (!governed) {
789
500
  stderr.write(
790
501
  `release-eval: package.json declares \`${version}\`, which predates \`${FIRST_GOVERNED_VERSION}\` — ` +
@@ -804,8 +515,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
804
515
  stderr.write(`release-eval: ${e.message}\n`);
805
516
  return 2;
806
517
  }
807
- // **The date is an argument, never `new Date()`.** A capture that stamps itself from the clock
808
- // cannot be reproduced, and this record is byte-compared.
518
+ // Never the clock: the record is byte-compared, so its date must be reproducible.
809
519
  const captured = parsed.date ?? execFileSync("git", ["-C", root, "log", "-1", "--format=%cs"], { encoding: "utf8" }).trim();
810
520
  const rows = measure(root, recipes, { stdout });
811
521
  const snap = {
@@ -864,18 +574,8 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
864
574
  }
865
575
 
866
576
  // ---------------------------------------------------------------- `--tagged`, the release act
867
- //
868
- // **The one surface a check reaches at the moment a person publishes.** It reads the tag's own
869
- // checkout and asks only its own question — does the tree being published carry a record for the
870
- // version being published — because everything else about that tree was already graded on the pull
871
- // request that made it.
872
577
  if (parsed.mode === "tagged") {
873
- // **The argument is the TAG's version, and reading it from `package.json` instead was a hole that
874
- // made this check green in every reachable variant of the one case it exists for.** Its motivating
875
- // scenario is a tag created from a tree whose `## Unreleased` accumulator was never renamed — and
876
- // in that tree `package.json` still declares the PREVIOUS version, so a step that asked
877
- // `--tagged $(package.json version)` asked about `0.1.2`, got *predates the clause*, and published.
878
- // The tag is the only thing in that scenario that names what is being released.
578
+ // The tag's version, never package.json's: a tag cut from an un-bumped tree still names what it releases.
879
579
  const tagged = parsed.tagged.replace(/^v/, "");
880
580
  let taggedGoverned;
881
581
  try {
@@ -884,9 +584,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
884
584
  stderr.write(`release-eval: ${e.message}\n`);
885
585
  return 2;
886
586
  }
887
- // **A disagreement is a FINDING, not a note.** It is the scenario itself: the tag says one release
888
- // and the payload declares another, so one of the two was never moved. Reporting it and continuing
889
- // would publish the thing this check exists to stop.
890
587
  if (tagged !== version) {
891
588
  stdout.write(
892
589
  `release-eval: the tag names \`${tagged}\` while this tree's package.json declares \`${version}\` — one of the ` +
@@ -932,9 +629,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
932
629
  }
933
630
  const red = [];
934
631
 
935
- // **Cut integrity, and it holds between cuts as well as at one.** The newest heading is the last
936
- // release cut, and `package.json` is bumped in the same change that renames it — so a disagreement
937
- // means one of the two moved alone.
938
632
  if (released[0] !== version) {
939
633
  red.push(
940
634
  `CHANGELOG.md's newest release heading is \`${released[0]}\` where package.json declares \`${version}\` — one ` +
@@ -966,18 +660,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
966
660
  red.push(...gradeOne(root, v, read.snap, v === version ? "package.json" : "CHANGELOG.md"));
967
661
  }
968
662
 
969
- // **Everything ON DISK is graded, in both extensions, governed or not.** Three holes closed here,
970
- // all found by a fresh context attacking the record layer rather than the code:
971
- //
972
- // * a record for a version the clause does NOT govern was ungraded entirely — `--capture` refuses
973
- // to write one, so nothing could have produced it honestly, and a hand-written
974
- // `evals/releases/0.1.2.json` full of garbage beside a register reading *"all 25 recipes green"*
975
- // was green. A version this rail declines to demand a record for is not a version it permits an
976
- // unexamined record for;
977
- // * the sweep enumerated `.json` only, so a fabricated `<version>.md` with no capture beside it
978
- // was invisible — and the register is the reader-facing half, the one place a release's eval
979
- // result is written for a reader;
980
- // * a record for a version `CHANGELOG.md` never cut sits in the tree looking exactly like evidence.
981
663
  let present;
982
664
  try {
983
665
  present = existsSync(path.join(root, RECORD_DIR))
@@ -1001,10 +683,9 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
1001
683
  );
1002
684
  continue;
1003
685
  }
1004
- if (governedReleases.includes(v)) continue; // already graded above
686
+ if (governedReleases.includes(v)) continue;
1005
687
  red.push(`${RECORD_DIR}/${v}.* is present but was not graded — this is a bug in the rail, not in the tree`);
1006
688
  }
1007
- // A register with no capture beside it: the reader-facing half standing alone.
1008
689
  for (const v of governedReleases) {
1009
690
  if (existsSync(path.join(root, registerPath(v))) && !existsSync(path.join(root, snapshotPath(v)))) {
1010
691
  red.push(`${registerPath(v)} stands with no capture beside it — the register is what a reader reads, and nothing holds it to anything`);
@@ -1031,7 +712,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
1031
712
  return 0;
1032
713
  }
1033
714
 
1034
- /** Read one version's capture. `snap: null` is *no record*; `couldNotRun` is *a record nothing could read*. */
715
+ /** `snap: null` is no record; `couldNotRun` is a record nothing could read. */
1035
716
  function readRecord(root, version) {
1036
717
  const abs = path.join(root, snapshotPath(version));
1037
718
  if (!existsSync(abs)) return { snap: null };
@@ -1042,7 +723,6 @@ function readRecord(root, version) {
1042
723
  }
1043
724
  }
1044
725
 
1045
- /** One record's full verdict: its contents, and its register byte-compared. */
1046
726
  function gradeOne(root, version, snap, keyedBy = "CHANGELOG.md") {
1047
727
  const red = verifyRecord(snap, { version, keyedBy });
1048
728
  if (red.length > 0) return red;