@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.
Files changed (81) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +142 -291
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1443 -1210
  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 +225 -1071
  10. package/cli/drills.mjs +1107 -0
  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 +595 -0
  15. package/cli/gate.mjs +7 -198
  16. package/cli/goldens.mjs +311 -0
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +244 -564
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +764 -0
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +6 -68
  27. package/cli/pack-version.mjs +13 -248
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +16 -240
  31. package/cli/release-eval.mjs +748 -0
  32. package/cli/review-meter.mjs +500 -0
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +424 -0
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +126 -454
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +582 -0
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +9 -74
  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 +183 -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/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/gate-policy.md +27 -8
  54. package/core/templates/handoff.md +28 -15
  55. package/core/templates/memory-entry.md +1 -0
  56. package/evals/README.md +320 -0
  57. package/evals/releases/0.1.3.json +134 -0
  58. package/evals/releases/0.1.3.md +64 -0
  59. package/evals/releases/0.2.0.json +146 -0
  60. package/evals/releases/0.2.0.md +67 -0
  61. package/evals/releases/README.md +32 -0
  62. package/package.json +9 -2
  63. package/packs/rituals/checkpoints/README.md +9 -2
  64. package/packs/rituals/checkpoints/pack.json +1 -1
  65. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
  66. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  67. package/spec/README.md +112 -6
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  70. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  71. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  72. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  73. package/spec/migrations/0006-boot-card.mjs +77 -0
  74. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  75. package/spec/migrations/0008-card-reading.mjs +55 -0
  76. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  77. package/spec/migrations/0010-comment-rail.mjs +57 -0
  78. package/spec/migrations/README.md +45 -10
  79. package/spec/pack.schema.json +1 -1
  80. package/spec/slots.md +359 -23
  81. package/spec/workspace.schema.json +145 -4
@@ -1,67 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // A change to a pack's `contributes` must move that pack's `portulan.version`.
3
3
  //
4
- // Ruled by the maintainer on `#265`, 2026-08-14, in three parts: **arm 3** — a rail rather than a
5
- // convention; **a prose-only edit counts**, so a fragment whose `reason` alone changes owes the bump;
6
- // and the field is **`pack.json`'s `portulan.version`**, not the packs plugin's bundle version.
7
- //
8
- // ## Why the field is the pack's own
9
- //
10
- // `packs/.claude-plugin/plugin.json` versions the *bundle*. Measured on `c93a819`: it reads `0.2.1`,
11
- // `rituals/checkpoints` reads `0.2.1` and `tools/github` reads `0.1.0` — so the bundle agrees with one
12
- // pack by coincidence and cannot track both, and two packs changing in one pull request would contend
13
- // for a single field. `portulan.version` is per-pack, so it cannot contend. Tying the bundle to the pack
14
- // manifests is arm 4 of the issue and is deliberately NOT here.
15
- //
16
- // ## Why the whole `contributes` block, not only `gates`
17
- //
18
- // The issue's title asks about gate fragments; arm 3's own text says *"touching a pack's `contributes`
19
- // block"*, and arm 3 is what was ruled — confirmed by the maintainer when the two readings were put to
20
- // him. Skills, personas and verify recipes reach every composing workspace by the same route a gate
21
- // fragment does.
22
- //
23
- // ## THREE-DOT, and it is not a detail
24
- //
25
- // The comparison is `base...head` — from the **merge-base** to the head — never `base..head`.
26
- // Demonstrated rather than asserted, and pinned in `./pack-version.test.mjs`: with a branch that changes
27
- // `pack.json` while the base branch independently changes another file, two-dot reports BOTH files and
28
- // three-dot reports only the branch's. A rail on two-dot would red a pull request because somebody else
29
- // bumped a version upstream, and would read a `contributes` change the pull request never made.
30
- //
31
- // `git diff A...B` is exactly `git diff $(git merge-base A B) B`, so this resolves the merge-base once
32
- // and reads each base manifest at that commit. No diff is parsed: the manifests are compared as VALUES,
33
- // which is what makes a reformatting-only edit — whitespace, key order — correctly *not* a change.
34
- //
35
- // ## Exit codes, and the middle one is the point
36
- //
37
- // 0 every changed pack moved its version
38
- // 1 a pack changed `contributes` and did not move `portulan.version`
39
- // 2 could not run — no repository, no base ref, no merge-base, or a manifest that will not parse
40
- //
41
- // **2 is never 1.** A rail that cannot read its input must not report green, and must not report red
42
- // either: a red verdict is a claim about the work, and "I could not look" is a claim about the check.
43
- // That is `../.portulan/memory/verify-preconditions-fail-closed.md`'s rule and the doctrine `#208` is
44
- // open about for the sibling scanner. It matters most here because `actions/checkout` is shallow by
45
- // default — `../cli/librarian.mjs` calls that "not a theoretical clone; it is the *normal* one" — so a
46
- // missing merge-base is the ordinary case on an unprepared runner, not an exotic one.
47
- //
48
- // ## What "moved" means, stated because the ruling's word was "moves"
49
- //
50
- // **Any change to the string counts, including absent → present.** It is deliberately NOT "the SemVer
51
- // increased": this rail enforces the maintainer's word, and reading an ordering into it would be the
52
- // rail deciding a policy it was not given — `0.2.0` → `0.1.0` is a different question, and a bad answer
53
- // to it, but it is not this one. Absent → present has to count, or a pack red for having no version
54
- // could never discharge that red. Present → absent is the `unversioned` red, from the head side.
55
- //
56
- // ## What this rail does NOT see, named rather than left to be discovered
57
- //
58
- // It compares the `contributes` **block**. Content the block merely *points at* — a skill's `SKILL.md`
59
- // body, a persona file, the shell script a contributed verify recipe names — reaches every adopting
60
- // workspace and can change without touching `pack.json` at all. Inline material is covered: gate
61
- // fragments live in the manifest, and so do recipe objects. Pointed-at files are not. That is `#259`'s
62
- // class — a rail narrower than the sentence around it — and it is stated here rather than closed,
63
- // because widening it is a different change with a different argument. A rename, or a delete-plus-add
64
- // under a new name, likewise passes as `deleted` + `added`.
4
+ // Exit 0 every changed pack moved its version · 1 one did not · 2 could not run.
65
5
 
66
6
  import fs from "node:fs";
67
7
  import path from "node:path";
@@ -73,12 +13,6 @@ export class CannotRun extends Error {}
73
13
 
74
14
  const DEFAULT_BASE = "origin/main";
75
15
 
76
- /**
77
- * Run git, or say why it could not.
78
- *
79
- * `stdio` pins stderr to a pipe so a failure's own message rides into the exception rather than
80
- * leaking to the terminal and leaving the thrown error to say only "command failed".
81
- */
82
16
  function git(root, args, what) {
83
17
  try {
84
18
  return execFileSync("git", ["-C", root, ...args], {
@@ -90,38 +24,10 @@ function git(root, args, what) {
90
24
  }
91
25
  }
92
26
 
93
- /**
94
- * The merge-base of `base` and `HEAD`, which IS the three-dot comparison point.
95
- *
96
- * **A shallow clone reaches this function by TWO different routes, and an earlier draft of these
97
- * messages knew about only one.** Measured on git, both shapes built as fixtures:
98
- *
99
- * - `--depth 1` alone is also `--single-branch`, so `origin/main` is **not fetched at all** and the
100
- * failure is an unknown ref. This is the common CI shape.
101
- * - `--depth 1 --no-single-branch` fetches the ref and truncates its history, so the ref resolves and
102
- * **no merge-base exists**.
103
- *
104
- * So both messages name `fetch-depth: 0` — a reader who lands on either one is most likely looking at a
105
- * shallow checkout — while staying distinguishable, because the *other* causes differ: an unknown ref
106
- * can also be a typo or an unfetched remote, and a missing merge-base can also be genuinely unrelated
107
- * histories. Naming only one of the two would send half of the readers to the wrong repair.
108
- */
109
- // **`--end-of-options` on every call that takes the caller's ref**, because `base` is user-supplied —
110
- // `PORTULAN_BASE_REF`, or `--base` — and git will otherwise read a value that starts with `-` as a FLAG.
111
- // Measured on git 2.50.1: `git merge-base --is-ancestor HEAD` is parsed as the `--is-ancestor` option and
112
- // exits 129, so the command silently stops being a merge-base lookup and becomes an ancestry test. With
113
- // the guard it exits 128, refused as a ref, which is what a bad ref should do.
114
- //
115
- // `rev-parse` was already shielded by accident rather than by design: the `^{commit}` suffix makes
116
- // `--help^{commit}` an unparseable revision rather than a flag. Guarded anyway, so the protection is
117
- // stated instead of resting on a suffix somebody could reasonably remove.
118
- //
119
- // _(Copilot proposed `--` for both, and it is the wrong separator here: measured,
120
- // `git rev-parse --verify -- HEAD^{commit}` exits **128** — `--` marks the start of PATHS for rev-parse,
121
- // so the fix would have broken every call. Raised on #274, round 2; the concern was right and the
122
- // mechanism was not.)_
27
+ // `base` is user-supplied; `--end-of-options` keeps a leading `-` from reading as a flag (`--` means paths).
123
28
  const END = "--end-of-options";
124
29
 
30
+ /** The merge-base of `base` and HEAD: comparing from it, never from `base`, leaves upstream changes out. */
125
31
  export function mergeBase(root, base = DEFAULT_BASE) {
126
32
  const shallowHint =
127
33
  "`actions/checkout` is shallow by default — `../cli/librarian.mjs` calls that the normal clone rather than a " +
@@ -129,16 +35,6 @@ export function mergeBase(root, base = DEFAULT_BASE) {
129
35
  try {
130
36
  git(root, ["rev-parse", "--verify", END, `${base}^{commit}`], `resolve ${base}`);
131
37
  } catch (cause) {
132
- // The cause rides along. A bare `catch {}` here reported EVERY failure of this command as an
133
- // unknown ref — including a corrupt object store, a permissions failure, or a git that would not
134
- // start — so the message named the one cause it could think of and discarded the one git gave.
135
- // `git()` has already captured that stderr; throwing it away and substituting a guess is the
136
- // wrong-mechanism-in-a-user-facing-string class this repository keeps finding. Raised by Copilot
137
- // on #274, round 4 — and its round 6 caught that the FIX was half-done: the cause rode along
138
- // while the leading clause still asserted `is not in this repository`, so the sentence stated one
139
- // cause as fact and listed the alternatives underneath it. It now says what HAPPENED (could not
140
- // resolve) and ranks the causes as causes. The defect class named in this very comment, surviving
141
- // one round inside the repair for it.
142
38
  throw new CannotRun(
143
39
  `could not resolve base ref \`${base}\` — refusing to report a verdict against a ref nothing could read. ` +
144
40
  `The usual cause is a SHALLOW single-branch clone, which does not fetch it at all; a typo or an ` +
@@ -157,14 +53,7 @@ export function mergeBase(root, base = DEFAULT_BASE) {
157
53
  }
158
54
  }
159
55
 
160
- /**
161
- * Is this path THERE — as a directory entry, not as something openable?
162
- *
163
- * `fs.existsSync` follows symlinks, so it answers **false** for a dangling link and the entry disappears
164
- * from every listing built on it. That is the same absent-versus-unreadable confusion this file has now
165
- * repaired at three sites, and this helper is the sweep `0020` asks for *at the first fix* rather than
166
- * after a third round found the third one: existence is `lstat`, readability is whoever opens the file.
167
- */
56
+ /** `lstat`, because `existsSync` follows a symlink and answers false for a dangling one. */
168
57
  function presentOnDisk(file) {
169
58
  try {
170
59
  fs.lstatSync(file);
@@ -174,27 +63,7 @@ function presentOnDisk(file) {
174
63
  }
175
64
  }
176
65
 
177
- /**
178
- * `--packs` as a repository-relative path, or a refusal.
179
- *
180
- * The flag is documented as *relative to the repository root* and, until #274's round 2, the parser took
181
- * that on trust. Measured: `path.join("/repo", "../../etc")` is `/etc`, so `--packs ../..` sent the
182
- * filesystem scan **outside the repository** — and the escape was invisible in the result, because
183
- * `git ls-tree` then refused the same path and the run ended at exit 2 for a reason that named the wrong
184
- * thing. Containment is checked after resolution rather than by pattern, since a `..` chain satisfies any
185
- * regex and still escapes; the same shape `../compile.mjs`'s `policyPath` uses for the same reason.
186
- *
187
- * An absolute path is refused outright rather than silently re-rooted: `path.join` turns `/etc` into
188
- * `<root>/etc`, which is *contained* and is not what the caller asked for, and a flag that quietly means
189
- * something else is worse than one that refuses.
190
- */
191
- // **It returns the CANONICAL path, and the first draft returned the caller's spelling.** That was a
192
- // correctness defect rather than untidiness, and it produced a false green. `--packs packs/../packs`
193
- // validated fine — it resolves inside — and then the two halves of `compare` disagreed about what a pack
194
- // is called: the filesystem scan joined the raw value (`packs/../packs/tools/github/pack.json`) while
195
- // `git ls-tree` canonicalised its pathspec (`packs/tools/github/pack.json`). Measured: every pack was
196
- // reported TWICE, once `added` from the uncanonical spelling and once `unchanged` from git's — and
197
- // `added` is exempt, so a real unbumped change would have passed. Raised by Copilot on #274, round 3.
66
+ /** `--packs`, canonical and repository-relative, so the disk scan and `git ls-tree` spell a pack alike. */
198
67
  export function insideRepo(value) {
199
68
  if (path.isAbsolute(value)) {
200
69
  throw new CannotRun(
@@ -202,9 +71,6 @@ export function insideRepo(value) {
202
71
  `joining an absolute path would silently re-root it under the repository instead of refusing.`,
203
72
  );
204
73
  }
205
- // Normalised, then checked — a `..` chain satisfies any pattern and still escapes, so containment is
206
- // decided on the resolved form. Separators are forced to `/` because every downstream consumer is a
207
- // git pathspec or a repository-relative id, neither of which is a platform path.
208
74
  const canonical = path.normalize(value).split(path.sep).join("/").replace(/\/+$/, "");
209
75
  if (canonical === "" || canonical === "." || canonical === ".." || canonical.startsWith("../")) {
210
76
  throw new CannotRun(
@@ -215,17 +81,6 @@ export function insideRepo(value) {
215
81
  return canonical;
216
82
  }
217
83
 
218
- /**
219
- * Every pack manifest in the tree, as repository-relative paths, sorted so output is stable.
220
- *
221
- * **ENOENT is the only benign failure, and an earlier draft of this function treated every failure as
222
- * benign.** A bare `catch { return [] }` turns an unreadable `packs/` — wrong permissions, a broken
223
- * symlink, a mount that went away — into *the empty set*, and an empty set is a **green**. That is the
224
- * shape `../.portulan/memory/verify-preconditions-fail-closed.md` exists about: a recipe that enumerates
225
- * nothing and reports success has described the machine rather than the work. So the absent directory is
226
- * an answer (a workspace that composes nothing has no manifests to check) and everything else is a
227
- * refusal. Found by the session-open checkpoint, which asked what guards the enumeration.
228
- */
229
84
  export function packManifests(root, packsDir = "packs") {
230
85
  const base = path.join(root, packsDir);
231
86
  const found = [];
@@ -255,21 +110,7 @@ export function packManifests(root, packsDir = "packs") {
255
110
  return found.sort();
256
111
  }
257
112
 
258
- // **ABSENCE IS A FACT FROM THE LISTING, NEVER AN INFERENCE FROM A FAILED READ.** Both readers below got
259
- // this wrong in the same way, one function apart from the `readdir` guard that got it right — a fix that
260
- // missed its own siblings inside a single change, which is `0020`'s shape at its smallest. Demonstrated:
261
- // `chmod 000` on a tracked `pack.json` made this report the pack **deleted** and exit **0**, so a
262
- // permissions accident read as an intentional removal and the whole rail went quiet. Found by the
263
- // pre-commit checkpoint, which attacked the readers after the enumeration guard was added rather than
264
- // assuming the class had been swept.
265
-
266
- /**
267
- * A manifest as it stood at `commit`, or `null` where it genuinely did not exist there.
268
- *
269
- * `present` is the caller's listing from `ls-tree` at that same commit, and it is what decides absence —
270
- * `git show` failing is then always a refusal, never a shrug. Matching on git's stderr would be the
271
- * other way to tell the two apart, and it would be a string comparison against another tool's prose.
272
- */
113
+ /** A manifest at `commit`, or `null` where the caller's `ls-tree` listing lacks it; a failed read throws. */
273
114
  export function manifestAt(root, commit, rel, present) {
274
115
  if (!present) return null;
275
116
  const raw = git(root, ["show", `${commit}:${rel}`], `read ${rel} at ${commit.slice(0, 7)}`);
@@ -283,26 +124,10 @@ export function manifestAt(root, commit, rel, present) {
283
124
  }
284
125
  }
285
126
 
286
- /**
287
- * The manifest in the working tree, or `null` where the pack has genuinely been deleted.
288
- *
289
- * **`lstat` decides whether the file is there; `readFileSync`'s ENOENT does not.** Third instance of one
290
- * class in this file, and the sharpest: a **dangling symlink** at `pack.json` makes `readFileSync` throw
291
- * ENOENT while the path itself plainly exists — measured, `lstat` succeeds and reports
292
- * `isSymbolicLink: true` — so an ENOENT-only carve-out reported a present-but-unreadable manifest as a
293
- * pack somebody deleted, at exit 0. The same shape as the `readdir` guard and the absent-vs-unreadable
294
- * repair before it, at a site both of those left alone.
295
- *
296
- * The lstat-first pattern is not invented here: `./control-chars.mjs`'s `bytesOf` already does exactly
297
- * this, for exactly this reason, down to the sentence — *"Refusing to report it clean: this is a fact
298
- * about the filesystem, not about the file."* Raised by Copilot on #274, round 7, which also supplied the
299
- * precedent.
300
- */
301
127
  export function manifestHere(root, rel) {
302
128
  const file = path.join(root, rel);
303
129
  try {
304
- // `lstat`, not `stat`: `stat` follows the link and would throw ENOENT for the dangling case,
305
- // which is the very confusion this guard exists to end.
130
+ // `lstat`, not `stat`: a dangling symlink is there and unreadable, never a deleted pack.
306
131
  fs.lstatSync(file);
307
132
  } catch (cause) {
308
133
  if (cause.code === "ENOENT") return null;
@@ -315,8 +140,6 @@ export function manifestHere(root, rel) {
315
140
  try {
316
141
  raw = fs.readFileSync(file, "utf8");
317
142
  } catch (cause) {
318
- // The path EXISTS — `lstat` just said so — so every failure here is the check unable to look:
319
- // a dangling symlink, EACCES, EISDIR, a symlink loop. None of them is a deletion.
320
143
  throw new CannotRun(
321
144
  `cannot read ${rel} — ${cause.code ?? cause.message}. The path exists, so this is not a removal: a ` +
322
145
  `dangling symlink, an unreadable mode or a directory in its place all land here.`,
@@ -329,20 +152,13 @@ export function manifestHere(root, rel) {
329
152
  }
330
153
  }
331
154
 
332
- /**
333
- * Deep value equality over parsed JSON, which is deliberately NOT a text comparison.
334
- *
335
- * Reformatting a manifest — reindenting, reordering keys — changes its bytes and changes nothing a
336
- * composing workspace yields, so it must not owe a version bump. Comparing values is what makes that
337
- * true, and it is why this reads the manifests rather than parsing `git diff`.
338
- */
155
+ /** Deep equality over parsed JSON, so a reformatted manifest is not a changed one. */
339
156
  export function sameValue(a, b) {
340
157
  if (a === b) return true;
341
158
  if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
342
159
  if (Array.isArray(a) !== Array.isArray(b)) return false;
343
160
  if (Array.isArray(a)) {
344
- // Order IS significant in an array: `contributes.gates` is an ordered list, and `composeFragments`
345
- // applies fragments in the order it finds them, so a reordering can change what a workspace yields.
161
+ // Order is significant: gate fragments apply in the order they are listed.
346
162
  return a.length === b.length && a.every((item, i) => sameValue(item, b[i]));
347
163
  }
348
164
  const ka = Object.keys(a).sort();
@@ -350,22 +166,12 @@ export function sameValue(a, b) {
350
166
  return ka.length === kb.length && ka.every((k, i) => k === kb[i]) && ka.every((k) => sameValue(a[k], b[k]));
351
167
  }
352
168
 
353
- /**
354
- * Judge one pack. Returns `{ rel, verdict, why }` with verdict `ok` | `unchanged` | `added` | `deleted`
355
- * | `stale` | `unversioned`.
356
- *
357
- * The two reds are told apart because they need different sentences: `stale` has a version and did not
358
- * move it, `unversioned` has no version to move.
359
- */
360
169
  export function judge(rel, before, after) {
361
170
  if (after === null) return { rel, verdict: "deleted", why: "the pack was removed" };
362
171
  if (before === null) {
363
- // An added pack has no prior state to bump FROM, so no bump is owed. Whether a new pack must
364
- // declare a version at all is a Pack Definition question — `spec/pack.schema.json` currently
365
- // makes `portulan.version` optional — and annexing it here would be this rail deciding a schema
366
- // rule it was not given. Named in `#265` as a hole with a shape, left to the schema.
367
172
  return { rel, verdict: "added", why: "the pack is new — nothing to bump from" };
368
173
  }
174
+ // The block alone: a file it points at, such as a skill's `SKILL.md`, can change unseen.
369
175
  if (sameValue(before?.contributes, after?.contributes)) {
370
176
  return { rel, verdict: "unchanged", why: "`contributes` is unchanged" };
371
177
  }
@@ -391,24 +197,9 @@ export function judge(rel, before, after) {
391
197
  return { rel, verdict: "ok", why: `\`contributes\` changed and \`portulan.version\` moved \`${was ?? "(absent)"}\` → \`${now}\`` };
392
198
  }
393
199
 
394
- /**
395
- * Compare every pack in the tree against the merge-base.
396
- *
397
- * `packsNamed` says whether the caller *chose* `packsDir`, and it decides what an empty result means.
398
- * **A directory that is absent is an answer only when nobody named it.** The default `packs/` missing is
399
- * a workspace that composes nothing — green, correctly. But `--packs paks`, a typo, found nothing at head
400
- * and nothing at the merge-base and reported *"nothing to check"* at exit **0**: a false green, and a
401
- * bypass anyone experimenting locally could reach by accident. Measured. So a named directory that exists
402
- * in neither tree is a refusal, and the ENOENT carve-out in `packManifests` keeps its narrower job.
403
- * Raised by Copilot on #274, round 5 — on the `Copilot` login of the comments endpoint rather than the
404
- * `copilot-pull-request-reviewer[bot]` login of `/reviews`, which is the two-logins gotcha this
405
- * repository already records.
406
- */
200
+ /** Every pack against the merge-base; a `packsDir` the caller named that neither tree holds is refused. */
407
201
  export function compare(root, { base = DEFAULT_BASE, packsDir = "packs", packsNamed = false } = {}) {
408
202
  const at = mergeBase(root, base);
409
- // The union of what exists NOW and what existed at the merge-base — a pack deleted in this change
410
- // has no working-tree manifest and would otherwise never be looked at. It is green either way, but
411
- // being green by having been considered is not the same as being green by being invisible.
412
203
  const here = packManifests(root, packsDir);
413
204
  const there = git(root, ["ls-tree", "-r", "--name-only", at, "--", `${packsDir}/`], `list packs at ${at.slice(0, 7)}`)
414
205
  .split("\n")
@@ -431,10 +222,6 @@ export function compare(root, { base = DEFAULT_BASE, packsDir = "packs", packsNa
431
222
 
432
223
  function usage() {
433
224
  return [
434
- // NOT `portulan pack-version` — `./portulan.mjs` has eight subcommands and this is not one of
435
- // them (measured: it answers "unknown subcommand"), and its own header forbids minting a ninth.
436
- // The sibling checkers carry no `portulan <name>` banner either. This is the form the verify
437
- // recipe actually runs, which is the only invocation that exists.
438
225
  "pack-version — a change to a pack's `contributes` must move its `portulan.version`",
439
226
  "",
440
227
  " node cli/pack-version.mjs [--base <ref>] [--packs <dir>] [<repository-root>]",
@@ -450,8 +237,7 @@ function usage() {
450
237
  }
451
238
 
452
239
  export function run(argv = [], { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd() } = {}) {
453
- // Before every other argument decision, so asking for help cannot be outranked by a complaint about
454
- // the rest of the command line — the contract `./portulan.mjs` states.
240
+ // First, so a request for help is never outranked by a complaint about the other arguments.
455
241
  if (argv.includes("--help") || argv.includes("-h")) {
456
242
  stdout.write(`${usage()}\n`);
457
243
  return 0;
@@ -466,12 +252,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
466
252
  const which = argv[i];
467
253
  const value = argv[i + 1];
468
254
  i += 1;
469
- // A single leading `-` is refused too, not just `--`: `--base --packs` would otherwise
470
- // consume the next flag as a ref and fail later as an unreadable one. The three tools in
471
- // `cli/` that take a directory already read it this way; this aligns with them rather
472
- // than inventing. _(A draft used `--base -h` as the example, which never reaches here:
473
- // `--help` is answered before any other argument decision, so that spelling exits 0 on
474
- // the help path. Measured.)_
475
255
  if (value === undefined || value.startsWith("-")) throw new CannotRun(`${which} needs a value`);
476
256
  if (which === "--base") base = value;
477
257
  else {
@@ -487,9 +267,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
487
267
  }
488
268
  }
489
269
  const where = path.resolve(root ?? cwd);
490
- // Resolved to the repository top level rather than trusting the argument: a caller invoking this
491
- // from a subdirectory would otherwise have every `packs/…` path resolved against the wrong root,
492
- // which is #131's class — paths against the author's layout.
493
270
  const top = git(where, ["rev-parse", "--show-toplevel"], `find a git repository at ${where}`).trim();
494
271
 
495
272
  const { at, results } = compare(top, { base, packsDir, packsNamed });
@@ -497,10 +274,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
497
274
  const moved = results.filter((r) => r.verdict === "ok");
498
275
 
499
276
  stdout.write(`pack-version: comparing against \`${base}\` at merge-base ${at.slice(0, 7)} (three-dot)\n`);
500
- // Every pack is printed, not only the failures. A rail that prints only reds cannot be told from
501
- // one that examined nothing — the same reason `compile` prints its refusals on every run. The
502
- // `ok` rows carry their `why` too, so a green states WHICH version moved rather than leaving a
503
- // reader to diff the manifest to find out what the rail approved.
277
+ // Every pack is printed, green included, so a green shows what was examined.
504
278
  for (const r of results) {
505
279
  stdout.write(` ${r.verdict.padEnd(12)} ${r.rel}${r.verdict === "ok" ? ` — ${r.why}` : ""}\n`);
506
280
  }
@@ -519,15 +293,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
519
293
  stderr.write(`pack-version: ${error.message}\n`);
520
294
  return 2;
521
295
  }
522
- // **A CRASH IS A COULD-NOT-RUN, NOT A RED**, and this file's own header says so two screens up
523
- // while an earlier draft did the opposite: an uncaught throw left node exiting **1**, which the
524
- // recipe faithfully printed as *"RED — verify recipe failed"*. So a TypeError in this checker
525
- // accused the pull request of a policy breach it had not committed — the exact defect `#208` is
526
- // open about in the sibling scanner, shipped new in the change that cites it. Demonstrated by
527
- // the pre-commit checkpoint with an injected throw.
528
- //
529
- // The stack is printed rather than swallowed: a crash must stay loud, it just must not wear a
530
- // verdict it did not earn.
531
296
  stderr.write(`pack-version: CRASHED — ${error?.stack ?? error}\n`);
532
297
  stderr.write("pack-version: reporting could-not-run (2) rather than a red verdict — a defect in this checker is not a finding about the work.\n");
533
298
  return 2;