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