@sleepy_panda_srl/portulan 0.1.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 (73) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +87 -0
  4. package/cli/README.md +337 -0
  5. package/cli/compile.mjs +2209 -0
  6. package/cli/control-chars.mjs +570 -0
  7. package/cli/discover.mjs +985 -0
  8. package/cli/doctor.mjs +2803 -0
  9. package/cli/feedback.mjs +970 -0
  10. package/cli/gate.mjs +271 -0
  11. package/cli/index.mjs +1390 -0
  12. package/cli/init.mjs +1729 -0
  13. package/cli/librarian.mjs +1384 -0
  14. package/cli/manifest.mjs +59 -0
  15. package/cli/new.mjs +803 -0
  16. package/cli/pack-version.mjs +543 -0
  17. package/cli/plugin-lint.mjs +1566 -0
  18. package/cli/portulan.mjs +266 -0
  19. package/cli/recipe-set.mjs +425 -0
  20. package/cli/rule-carriers.mjs +489 -0
  21. package/cli/skills-set.mjs +715 -0
  22. package/cli/stop-gate.mjs +434 -0
  23. package/cli/upgrade.mjs +897 -0
  24. package/cli/vendor.mjs +1504 -0
  25. package/core/README.md +46 -0
  26. package/core/engine.md +47 -0
  27. package/core/operating/autonomy.md +100 -0
  28. package/core/operating/evolution.md +84 -0
  29. package/core/operating/loop.md +83 -0
  30. package/core/operating/memory.md +161 -0
  31. package/core/operating/safety.md +42 -0
  32. package/core/operating/verification.md +76 -0
  33. package/core/personas/README.md +40 -0
  34. package/core/personas/implementer.md +51 -0
  35. package/core/personas/librarian.md +73 -0
  36. package/core/personas/reviewer.md +52 -0
  37. package/core/skills/README.md +52 -0
  38. package/core/skills/clarify/SKILL.md +42 -0
  39. package/core/skills/codify/SKILL.md +65 -0
  40. package/core/skills/consolidate/SKILL.md +121 -0
  41. package/core/templates/gate-policy.md +76 -0
  42. package/core/templates/handoff.md +29 -0
  43. package/core/templates/memory-entry.md +44 -0
  44. package/core/templates/pack.md +94 -0
  45. package/core/templates/persona.md +69 -0
  46. package/core/templates/proposal.md +27 -0
  47. package/core/templates/repo-card.md +28 -0
  48. package/core/templates/skill.md +57 -0
  49. package/core/templates/task.md +26 -0
  50. package/core/templates/workspace.md +81 -0
  51. package/package.json +41 -0
  52. package/packs/.claude-plugin/plugin.json +22 -0
  53. package/packs/README.md +61 -0
  54. package/packs/rituals/README.md +13 -0
  55. package/packs/rituals/checkpoints/README.md +105 -0
  56. package/packs/rituals/checkpoints/pack.json +36 -0
  57. package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
  58. package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
  59. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
  60. package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
  61. package/packs/stacks/README.md +8 -0
  62. package/packs/tools/README.md +17 -0
  63. package/packs/tools/github/README.md +65 -0
  64. package/packs/tools/github/pack.json +29 -0
  65. package/packs/tools/github/verify/README.md +51 -0
  66. package/packs/tools/github/verify/actions-pinned.sh +132 -0
  67. package/spec/README.md +399 -0
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
  70. package/spec/migrations/README.md +95 -0
  71. package/spec/pack.schema.json +201 -0
  72. package/spec/slots.md +796 -0
  73. package/spec/workspace.schema.json +438 -0
@@ -0,0 +1,543 @@
1
+ #!/usr/bin/env node
2
+ // A change to a pack's `contributes` must move that pack's `portulan.version`.
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`.
65
+
66
+ import fs from "node:fs";
67
+ import path from "node:path";
68
+ import { execFileSync } from "node:child_process";
69
+ import { pathToFileURL } from "node:url";
70
+
71
+ /** Raised when the check cannot run, or cannot judge honestly. Always exit 2, never 1. */
72
+ export class CannotRun extends Error {}
73
+
74
+ const DEFAULT_BASE = "origin/main";
75
+
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
+ function git(root, args, what) {
83
+ try {
84
+ return execFileSync("git", ["-C", root, ...args], {
85
+ encoding: "utf8",
86
+ stdio: ["ignore", "pipe", "pipe"],
87
+ });
88
+ } catch (cause) {
89
+ throw new CannotRun(`git could not ${what} — ${cause.stderr?.toString().trim() || cause.message}`);
90
+ }
91
+ }
92
+
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.)_
123
+ const END = "--end-of-options";
124
+
125
+ export function mergeBase(root, base = DEFAULT_BASE) {
126
+ const shallowHint =
127
+ "`actions/checkout` is shallow by default — `../cli/librarian.mjs` calls that the normal clone rather than a " +
128
+ "theoretical one — so the usual repair is `fetch-depth: 0` on the job running this check; see .github/workflows/verify.yml.";
129
+ try {
130
+ git(root, ["rev-parse", "--verify", END, `${base}^{commit}`], `resolve ${base}`);
131
+ } 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
+ throw new CannotRun(
143
+ `could not resolve base ref \`${base}\` — refusing to report a verdict against a ref nothing could read. ` +
144
+ `The usual cause is a SHALLOW single-branch clone, which does not fetch it at all; a typo or an ` +
145
+ `unfetched remote look the same from here, and so does a repository that cannot be read at all. ` +
146
+ `${shallowHint} — git said: ${cause.message}`,
147
+ );
148
+ }
149
+ try {
150
+ return git(root, ["merge-base", END, base, "HEAD"], `find the merge-base of ${base} and HEAD`).trim();
151
+ } catch (cause) {
152
+ throw new CannotRun(
153
+ `no merge-base between \`${base}\` and HEAD, though the ref itself resolved — a SHALLOW clone that fetched ` +
154
+ `the ref and truncated its history looks exactly like this, as do genuinely unrelated histories. ` +
155
+ `${shallowHint} — git said: ${cause.message}`,
156
+ );
157
+ }
158
+ }
159
+
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
+ */
168
+ function presentOnDisk(file) {
169
+ try {
170
+ fs.lstatSync(file);
171
+ return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
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.
198
+ export function insideRepo(value) {
199
+ if (path.isAbsolute(value)) {
200
+ throw new CannotRun(
201
+ `--packs ${value} is an absolute path — this flag names a directory RELATIVE to the repository root, and ` +
202
+ `joining an absolute path would silently re-root it under the repository instead of refusing.`,
203
+ );
204
+ }
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
+ const canonical = path.normalize(value).split(path.sep).join("/").replace(/\/+$/, "");
209
+ if (canonical === "" || canonical === "." || canonical === ".." || canonical.startsWith("../")) {
210
+ throw new CannotRun(
211
+ `--packs ${value} resolves to ${JSON.stringify(canonical)}, which is not a directory inside the repository — ` +
212
+ `refusing to scan somewhere this check has no business reading.`,
213
+ );
214
+ }
215
+ return canonical;
216
+ }
217
+
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
+ export function packManifests(root, packsDir = "packs") {
230
+ const base = path.join(root, packsDir);
231
+ const found = [];
232
+ const read = (dir, what) => {
233
+ try {
234
+ return fs.readdirSync(dir, { withFileTypes: true });
235
+ } catch (cause) {
236
+ if (cause.code === "ENOENT") return null;
237
+ throw new CannotRun(
238
+ `cannot read ${what} at ${dir} — ${cause.code ?? cause.message}. Refusing to report an empty set of packs ` +
239
+ `as a clean one: an enumeration that could not run is not a finding that nothing changed.`,
240
+ );
241
+ }
242
+ };
243
+ const categories = read(base, `the packs directory`);
244
+ if (categories === null) return [];
245
+ for (const category of categories) {
246
+ if (!category.isDirectory()) continue;
247
+ const packs = read(path.join(base, category.name), `the pack category \`${category.name}\``);
248
+ if (packs === null) continue;
249
+ for (const pack of packs) {
250
+ if (!pack.isDirectory()) continue;
251
+ const rel = [packsDir, category.name, pack.name, "pack.json"].join("/");
252
+ if (presentOnDisk(path.join(root, rel))) found.push(rel);
253
+ }
254
+ }
255
+ return found.sort();
256
+ }
257
+
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
+ */
273
+ export function manifestAt(root, commit, rel, present) {
274
+ if (!present) return null;
275
+ const raw = git(root, ["show", `${commit}:${rel}`], `read ${rel} at ${commit.slice(0, 7)}`);
276
+ try {
277
+ return JSON.parse(raw);
278
+ } catch (cause) {
279
+ throw new CannotRun(
280
+ `${rel} at ${commit.slice(0, 7)} is not valid JSON — ${cause.message}. Refusing a verdict on a manifest ` +
281
+ `this check cannot read; \`json\` and \`doctor\` are the tools that judge manifest validity.`,
282
+ );
283
+ }
284
+ }
285
+
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
+ export function manifestHere(root, rel) {
302
+ const file = path.join(root, rel);
303
+ 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.
306
+ fs.lstatSync(file);
307
+ } catch (cause) {
308
+ if (cause.code === "ENOENT") return null;
309
+ throw new CannotRun(
310
+ `cannot stat ${rel} — ${cause.code ?? cause.message}. Refusing to report it as removed: this is a fact ` +
311
+ `about the filesystem, not about the pack.`,
312
+ );
313
+ }
314
+ let raw;
315
+ try {
316
+ raw = fs.readFileSync(file, "utf8");
317
+ } 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
+ throw new CannotRun(
321
+ `cannot read ${rel} — ${cause.code ?? cause.message}. The path exists, so this is not a removal: a ` +
322
+ `dangling symlink, an unreadable mode or a directory in its place all land here.`,
323
+ );
324
+ }
325
+ try {
326
+ return JSON.parse(raw);
327
+ } catch (cause) {
328
+ throw new CannotRun(`${rel} is not valid JSON — ${cause.message}. Refusing a verdict on a manifest this check cannot read.`);
329
+ }
330
+ }
331
+
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
+ */
339
+ export function sameValue(a, b) {
340
+ if (a === b) return true;
341
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
342
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
343
+ 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.
346
+ return a.length === b.length && a.every((item, i) => sameValue(item, b[i]));
347
+ }
348
+ const ka = Object.keys(a).sort();
349
+ const kb = Object.keys(b).sort();
350
+ return ka.length === kb.length && ka.every((k, i) => k === kb[i]) && ka.every((k) => sameValue(a[k], b[k]));
351
+ }
352
+
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
+ export function judge(rel, before, after) {
361
+ if (after === null) return { rel, verdict: "deleted", why: "the pack was removed" };
362
+ 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
+ return { rel, verdict: "added", why: "the pack is new — nothing to bump from" };
368
+ }
369
+ if (sameValue(before?.contributes, after?.contributes)) {
370
+ return { rel, verdict: "unchanged", why: "`contributes` is unchanged" };
371
+ }
372
+ const was = before?.portulan?.version;
373
+ const now = after?.portulan?.version;
374
+ if (typeof now !== "string" || now === "") {
375
+ return {
376
+ rel,
377
+ verdict: "unversioned",
378
+ why:
379
+ "`contributes` changed and this pack declares no `portulan.version` to move. The field is optional in " +
380
+ "`spec/pack.schema.json`, and its own description calls it how independent versioning is expressed — " +
381
+ "declare one, because a consumer pinning this pack has nothing else to pin on",
382
+ };
383
+ }
384
+ if (now === was) {
385
+ return {
386
+ rel,
387
+ verdict: "stale",
388
+ why: `\`contributes\` changed and \`portulan.version\` stayed at \`${now}\`. A prose-only edit to a fragment's \`reason\` counts — it is the sentence the gate runner shows a human`,
389
+ };
390
+ }
391
+ return { rel, verdict: "ok", why: `\`contributes\` changed and \`portulan.version\` moved \`${was ?? "(absent)"}\` → \`${now}\`` };
392
+ }
393
+
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
+ */
407
+ export function compare(root, { base = DEFAULT_BASE, packsDir = "packs", packsNamed = false } = {}) {
408
+ 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
+ const here = packManifests(root, packsDir);
413
+ const there = git(root, ["ls-tree", "-r", "--name-only", at, "--", `${packsDir}/`], `list packs at ${at.slice(0, 7)}`)
414
+ .split("\n")
415
+ .filter((line) => line.endsWith("/pack.json"));
416
+ if (packsNamed && here.length === 0 && there.length === 0 && !presentOnDisk(path.join(root, packsDir))) {
417
+ throw new CannotRun(
418
+ `--packs ${packsDir} names a directory that exists neither in the working tree nor at the merge-base. ` +
419
+ `Refusing to report green having examined nothing: an empty set is a verdict about packs, and this is a ` +
420
+ `verdict about the path you typed. (The DEFAULT \`packs/\` being absent is different — that is a ` +
421
+ `workspace which composes nothing, and it passes.)`,
422
+ );
423
+ }
424
+ const atBase = new Set(there);
425
+ const all = [...new Set([...here, ...there])].sort();
426
+ return {
427
+ at,
428
+ results: all.map((rel) => judge(rel, manifestAt(root, at, rel, atBase.has(rel)), manifestHere(root, rel))),
429
+ };
430
+ }
431
+
432
+ function usage() {
433
+ 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
+ "pack-version — a change to a pack's `contributes` must move its `portulan.version`",
439
+ "",
440
+ " node cli/pack-version.mjs [--base <ref>] [--packs <dir>] [<repository-root>]",
441
+ "",
442
+ " --base what to compare against; default `origin/main`. Compared THREE-DOT: the merge-base",
443
+ " of this ref and HEAD, which is what a pull request shows",
444
+ " --packs the packs directory, relative to the repository root; default `packs`",
445
+ "",
446
+ "Ruled on #265: the whole `contributes` block, and a prose-only edit to a `reason` counts.",
447
+ "",
448
+ "Exit codes: 0 green · 1 a red verdict · 2 could not run.",
449
+ ].join("\n");
450
+ }
451
+
452
+ 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.
455
+ if (argv.includes("--help") || argv.includes("-h")) {
456
+ stdout.write(`${usage()}\n`);
457
+ return 0;
458
+ }
459
+ let base = DEFAULT_BASE;
460
+ let packsDir = "packs";
461
+ let packsNamed = false;
462
+ let root = null;
463
+ try {
464
+ for (let i = 0; i < argv.length; i += 1) {
465
+ if (argv[i] === "--base" || argv[i] === "--packs") {
466
+ const which = argv[i];
467
+ const value = argv[i + 1];
468
+ 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
+ if (value === undefined || value.startsWith("-")) throw new CannotRun(`${which} needs a value`);
476
+ if (which === "--base") base = value;
477
+ else {
478
+ packsDir = insideRepo(value);
479
+ packsNamed = true;
480
+ }
481
+ } else if (argv[i].startsWith("-")) {
482
+ throw new CannotRun(`unknown argument ${JSON.stringify(argv[i])}`);
483
+ } else if (root === null) {
484
+ root = argv[i];
485
+ } else {
486
+ throw new CannotRun(`unexpected second repository root ${JSON.stringify(argv[i])}`);
487
+ }
488
+ }
489
+ 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
+ const top = git(where, ["rev-parse", "--show-toplevel"], `find a git repository at ${where}`).trim();
494
+
495
+ const { at, results } = compare(top, { base, packsDir, packsNamed });
496
+ const red = results.filter((r) => r.verdict === "stale" || r.verdict === "unversioned");
497
+ const moved = results.filter((r) => r.verdict === "ok");
498
+
499
+ 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.
504
+ for (const r of results) {
505
+ stdout.write(` ${r.verdict.padEnd(12)} ${r.rel}${r.verdict === "ok" ? ` — ${r.why}` : ""}\n`);
506
+ }
507
+ if (results.length === 0) stdout.write(" (no pack manifests here or at the merge-base — nothing to check)\n");
508
+
509
+ if (red.length === 0) {
510
+ stdout.write(`\nok ${moved.length} pack(s) changed \`contributes\` and moved their version; ${results.length} examined\n`);
511
+ return 0;
512
+ }
513
+ stderr.write("\n");
514
+ for (const r of red) stderr.write(`RED ${r.rel} — ${r.why}\n`);
515
+ stderr.write(`\n${red.length} pack(s) changed \`contributes\` without moving \`portulan.version\` (#265).\n`);
516
+ return 1;
517
+ } catch (error) {
518
+ if (error instanceof CannotRun) {
519
+ stderr.write(`pack-version: ${error.message}\n`);
520
+ return 2;
521
+ }
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
+ stderr.write(`pack-version: CRASHED — ${error?.stack ?? error}\n`);
532
+ 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
+ return 2;
534
+ }
535
+ }
536
+
537
+ function isMain() {
538
+ return import.meta.url === pathToFileURL(process.argv[1] ?? "").href;
539
+ }
540
+
541
+ if (isMain()) {
542
+ process.exitCode = run(process.argv.slice(2));
543
+ }