@sleepy_panda_srl/portulan 0.1.3 → 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 (76) hide show
  1. package/README.md +6 -5
  2. package/cli/README.md +142 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1322 -1607
  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 +224 -1074
  10. package/cli/drills.mjs +68 -502
  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 +778 -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 +5 -124
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +17 -258
  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 +27 -492
  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 +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/handoff.md +28 -15
  54. package/core/templates/memory-entry.md +1 -0
  55. package/evals/README.md +255 -845
  56. package/evals/releases/0.2.0.json +146 -0
  57. package/evals/releases/0.2.0.md +67 -0
  58. package/evals/releases/README.md +3 -3
  59. package/package.json +4 -2
  60. package/packs/rituals/checkpoints/README.md +1 -1
  61. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  62. package/spec/README.md +112 -6
  63. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  64. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  65. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  66. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  67. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  68. package/spec/migrations/0006-boot-card.mjs +77 -0
  69. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  70. package/spec/migrations/0008-card-reading.mjs +55 -0
  71. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  72. package/spec/migrations/0010-comment-rail.mjs +57 -0
  73. package/spec/migrations/README.md +45 -10
  74. package/spec/pack.schema.json +1 -1
  75. package/spec/slots.md +333 -21
  76. package/spec/workspace.schema.json +145 -4
package/cli/new.mjs CHANGED
@@ -1,43 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // `new` — scaffold a skill · persona · pack · workspace · gate-policy · repo-card from a core template,
3
- // into the user's OWN layer, never into `core/`.
2
+ // `portulan new`: scaffold a skill, persona, pack, workspace, gate policy or repo card, never into `core/`.
4
3
  //
5
- // Milestone 7, row 7 of docs/plan.md. Zero dependencies, ESM, no build step (the session-0 ruling).
6
- //
7
- // ## The one property this tool states in the imperative
8
- //
9
- // **Never into `core/`.** Row 7 says it, and it is the reason this tool exists at all rather than being
10
- // advice: the cascade runs core < pack < workspace < repo card < task, and a team that answers "where do
11
- // I put my own skill?" by editing `core/` has merged their specifics into the layer that ships to
12
- // everybody. Thesis 6 of the constitution — storage follows ownership.
13
- //
14
- // The refusal is **resolution-based, not pattern-based**, because every interesting escape parses fine:
15
- // `packs/../core` matches any path rule you could write, and a symlinked destination matches nothing at
16
- // all until you follow it. So the check resolves first and compares after — the same conclusion
17
- // `spec/pack.schema.json`'s `$defs/filePath` reaches from the other side, and the one `cli/compile.mjs`
18
- // already acts on for its policy path.
19
- //
20
- // ## Two exit codes, deliberately
21
- //
22
- // **0 wrote · 2 could not run.** There is no exit 1, for the same reason `init` has none: this tool
23
- // renders no verdict about anybody's workspace, so it has no red to report. `doctor` is what says
24
- // whether what landed is any good, and conflating "I could not write this" with "what I wrote is wrong"
25
- // would make a caller unable to tell a missing template from a bad scaffold.
26
- //
27
- // ## What it refuses, ahead of the first byte
28
- //
29
- // A file it would overwrite — **any** file, never only a manifest. Session 1 shipped a residence check
30
- // keyed on `workspace.json` and treated it as an answer to *is it safe to write here?*; a hand-written
31
- // `gate-map.md` was silently overwritten. Two questions, one key: that was the whole defect. Here there
32
- // is only one question and it is asked about every path.
33
- //
34
- // A symlink anywhere on the destination chain — resolved links are how a write leaves the tree it was
35
- // meant to stay inside, measured on `init` at nine files written into an unrelated directory and
36
- // reported as success.
37
- //
38
- // And an `lstat` failure that is **not** `ENOENT`, because only `ENOENT` means absent. `EACCES` means
39
- // the question could not be answered, and answering *nothing there* to an unanswerable question is
40
- // "nothing looked" recorded as "nothing wrong".
4
+ // Exit 0 wrote · 2 could not run; never 1, since it renders no verdict on a workspace.
41
5
 
42
6
  import fs from "node:fs";
43
7
  import path from "node:path";
@@ -46,36 +10,20 @@ import { fileURLToPath, pathToFileURL } from "node:url";
46
10
  const HERE = path.dirname(fileURLToPath(import.meta.url));
47
11
  const CORE = path.resolve(HERE, "..", "core");
48
12
 
49
- /** Raised for anything that means `new` could not run. Carries no verdict about a workspace. */
50
13
  export class NewError extends Error {}
51
14
 
52
- /**
53
- * The six kinds row 7 names. A Set rather than an array so membership is the question asked, and
54
- * exported so the suite compares against the row rather than against a count that passes while a name
55
- * is wrong.
56
- */
57
15
  export const KINDS = new Set(["skill", "persona", "pack", "workspace", "gate-policy", "repo-card"]);
58
16
 
59
- /** A lowercase hyphenated identifier — the same shape `$defs/slug` fixes in both schemas. */
17
+ /** `$defs/slug`, as both schemas hold it. */
60
18
  export const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
61
19
 
62
- /** Kinds whose artifact is JSON, extracted from the template's first fenced `json` block. */
63
20
  const JSON_KINDS = new Set(["pack", "workspace", "gate-policy"]);
64
21
 
65
- /** Where a kind's core template lives. One place, so a missing template is one failure and not six. */
66
22
  export function template(kind) {
67
23
  return path.join(CORE, "templates", `${kind}.md`);
68
24
  }
69
25
 
70
- /**
71
- * The skeleton inside a template — everything after the first standalone `---`, or, for the JSON kinds,
72
- * the first fenced ```json block after it.
73
- *
74
- * The separator is the first `---` at column 0 rather than the last, which matters for the skill
75
- * template specifically: its skeleton BEGINS with YAML frontmatter, so the file legitimately contains
76
- * two `---` lines in a row and taking the last one would swallow the frontmatter the skeleton exists to
77
- * provide.
78
- */
26
+ /** After the first `---`, not the last: the skill template's skeleton opens with frontmatter of its own. */
79
27
  export function skeleton(kind, source) {
80
28
  const lines = source.split("\n");
81
29
  const at = lines.findIndex((line) => line.trimEnd() === "---");
@@ -91,14 +39,6 @@ export function skeleton(kind, source) {
91
39
  return `${body.slice(start, close).trim()}\n`;
92
40
  }
93
41
 
94
- /**
95
- * Where a scaffold lands, as a pure function of kind, name and target.
96
- *
97
- * A repo card resolves through the workspace's **declared** `repos` slot rather than through a
98
- * convention, because deriving a path a manifest does not declare is the tool inventing a slot — and a
99
- * workspace that disagrees with its own manifest on the day it was scaffolded is exactly the drift the
100
- * claims lint exists to catch.
101
- */
102
42
  export function destination(kind, name, target) {
103
43
  switch (kind) {
104
44
  case "skill":
@@ -109,12 +49,6 @@ export function destination(kind, name, target) {
109
49
  return path.join(target, `${name}.json`);
110
50
  case "repo-card": {
111
51
  const slot = declaredSlot(target, "repos");
112
- // The slot value is the WORKSPACE's, not this tool's, and a `..` in it escapes:
113
- // `path.join("/ws/sub", "../../evil")` is `/evil`, measured. (An *absolute* slot does not
114
- // escape `join` — that is `resolve`'s behaviour, not `join`'s — so only the `..` half of the
115
- // review that found this was right, and the fix is written against what was measured rather
116
- // than against what was reported.) Checked after resolution, like every other containment
117
- // question here. Copilot, round 3 on #156.
118
52
  const landing = path.resolve(target, slot, `${name}.md`);
119
53
  const root = path.resolve(target);
120
54
  if (landing !== root && !landing.startsWith(`${root}${path.sep}`)) {
@@ -131,7 +65,6 @@ export function destination(kind, name, target) {
131
65
  }
132
66
  }
133
67
 
134
- /** Reads one slot out of a workspace manifest, refusing rather than guessing when it is not declared. */
135
68
  function declaredSlot(workspaceDir, slot) {
136
69
  const manifestPath = path.join(workspaceDir, "workspace.json");
137
70
  let manifest;
@@ -157,18 +90,6 @@ function declaredSlot(workspaceDir, slot) {
157
90
  return declared;
158
91
  }
159
92
 
160
- /**
161
- * Which of the destination paths already exist, and which sit behind a symlink.
162
- *
163
- * `lstatSync`, never `existsSync`/`statSync` — both of those follow links, which is how `init` wrote
164
- * nine files outside a repository and reported success. A symlink is reported as its own state rather
165
- * than resolved and permitted: deciding whether a link's target is "really" inside the intended tree is
166
- * a containment judgement with a bad failure mode, where refusing has none.
167
- *
168
- * An existing **directory** is not a collision. This tool writes files into directories a user may
169
- * legitimately have created; refusing an empty directory would make `new` unusable in the ordinary case
170
- * and teach nobody anything.
171
- */
172
93
  export function collisions(paths, root = null) {
173
94
  const found = [];
174
95
  for (const target of paths) {
@@ -177,8 +98,6 @@ export function collisions(paths, root = null) {
177
98
  try {
178
99
  stat = fs.lstatSync(step);
179
100
  } catch (cause) {
180
- // Only ENOENT means absent. Anything else means the question could not be answered, and
181
- // an unanswerable question must not be answered "no".
182
101
  if (cause.code === "ENOENT") continue;
183
102
  found.push({ path: step, why: "unreadable", detail: cause.code ?? cause.message });
184
103
  break;
@@ -187,12 +106,6 @@ export function collisions(paths, root = null) {
187
106
  found.push({ path: step, why: "symlink" });
188
107
  break;
189
108
  }
190
- // A DIRECTORY at the leaf is a collision too, and the paragraph above says the opposite —
191
- // rightly, about the *intermediate* segments, and wrongly here: every path this plans is a
192
- // file, so a directory sitting at one is something already there. Preflight passed and
193
- // `writeFileSync` then threw `EISDIR` mid-loop, which is the ordering this whole function
194
- // exists to prevent. Found by Copilot on #164 against the copy of this shape in
195
- // `cli/vendor.mjs`; fixed in both, because a defect and its source are the same defect.
196
109
  if (step === target) {
197
110
  if (stat.isDirectory()) found.push({ path: step, why: "directory" });
198
111
  else found.push({ path: step, why: "exists" });
@@ -202,25 +115,7 @@ export function collisions(paths, root = null) {
202
115
  return found;
203
116
  }
204
117
 
205
- /**
206
- * The path steps from `root` down to `target`, inclusive of both — or every step to the filesystem root
207
- * when no root is given.
208
- *
209
- * **Where the boundary sits is the whole rule, and getting it wrong was found by running rather than by
210
- * reading.** The first cut walked to the filesystem root and refused any symlink anywhere on the chain.
211
- * On macOS `os.tmpdir()` resolves under `/var`, and `/var` is a symlink to `/private/var` — so every
212
- * scratch directory in this suite was refused, and by extension so was every user whose destination sat
213
- * under a symlinked mount, a home directory on a linked volume, or `/tmp`.
214
- *
215
- * The reason it was wrong is not that the check was too strict; it is that it asked about the wrong
216
- * paths. A symlink among the **ancestors of the directory the user named** is a fact about their
217
- * filesystem, and the user named that path — resolving it is not an escape, it is obeying them. A
218
- * symlink **at or below** the named path is different: those are the segments this tool derives and
219
- * creates, and a link there is precisely how `init` wrote nine files into an unrelated directory and
220
- * reported success (session 1, rounds 4 and 6, the write path and then the read path).
221
- *
222
- * So the boundary is the named destination. Above it, resolve. At it and below it, refuse.
223
- */
118
+ /** `root` down to `target`, both included; links above `root` are the user's own, as macOS `/var` is. */
224
119
  function chain(target, root = null) {
225
120
  const steps = [];
226
121
  let at = target;
@@ -235,30 +130,13 @@ function chain(target, root = null) {
235
130
  return steps.reverse();
236
131
  }
237
132
 
238
- /** A path as a reader would recognise it — relative to cwd when that is shorter, absolute otherwise. */
239
133
  function display(target) {
240
134
  const rel = path.relative(process.cwd(), target);
241
135
  return rel && !rel.startsWith("..") && rel.length < target.length ? rel : target;
242
136
  }
243
137
 
244
- /**
245
- * Is this destination inside a `core/` — this checkout's or anybody's?
246
- *
247
- * The rule is about the LAYER, not about this repository: a vendored or installed copy of core is still
248
- * core, and a scaffold landing there is still an edit to a file the project ships. So two questions are
249
- * asked — is it inside THIS checkout's core, and does the resolved path contain a `core` directory that
250
- * looks like one (it holds `engine.md` or `templates/`).
251
- */
252
138
  export function intoCore(resolved) {
253
- // **Compared case-INSENSITIVELY, because the filesystem may not care and `realpathSync` does not
254
- // canonicalise case.** Measured by the pre-commit checkpoint on macOS: `--into <repo>/CORE/templates/x`
255
- // exited 0 and created a directory inside the shipped `core/`. A case-sensitive compare is not a
256
- // compare at all on a case-insensitive volume, which is the default on macOS and common on Windows.
257
- //
258
- // The cost, stated rather than discovered: on a case-SENSITIVE filesystem a directory genuinely named
259
- // `CORE` — distinct from `core` — is also refused. That is a conservative refusal, and the asymmetry
260
- // is the whole argument: refusing costs the user one corrected path, permitting costs an edit to a
261
- // layer this project ships and the property row 7 states in the imperative.
139
+ // Case-folded: a volume may ignore case and `realpathSync` keeps it, so a genuine `CORE/` is refused too.
262
140
  const fold = (p) => p.toLowerCase();
263
141
  const ours = path.resolve(CORE);
264
142
  if (fold(resolved) === fold(ours) || fold(resolved).startsWith(fold(`${ours}${path.sep}`))) return ours;
@@ -271,16 +149,6 @@ export function intoCore(resolved) {
271
149
  return null;
272
150
  }
273
151
 
274
- /**
275
- * Parse the command line.
276
- *
277
- * A value beginning with **any** leading `-` is a missing value, not a value — session 1's round 9,
278
- * where `--residence -h` was consumed as the residence and the user was then blamed for a token they
279
- * typed as a flag. `-h` is the likeliest thing to land there and it is a help request.
280
- *
281
- * An empty string is refused for every flag, because none of them has a meaningful empty value and an
282
- * empty one reached a manifest once already, failing `minLength: 1` on the run that reported success.
283
- */
284
152
  export function parseArgs(argv) {
285
153
  const out = { kind: null, name: null, into: null, category: null, kindOf: null, governedBy: null, help: false, given: new Set() };
286
154
  const positional = [];
@@ -338,11 +206,7 @@ function usage() {
338
206
  ].join("\n");
339
207
  }
340
208
 
341
- /**
342
- * What a kind writes: a list of `{ path, contents }`. Multi-file kinds are planned in one place so the
343
- * collision check sees **every** path before the first byte is written — a partial write that wedges its
344
- * own retry is what session 1 shipped when the manifest went out first.
345
- */
209
+ /** Every file a kind writes, planned whole so the collision check sees each path before the first byte. */
346
210
  export function plan(parsed) {
347
211
  const { kind, name } = parsed;
348
212
  const into = path.resolve(parsed.into ?? process.cwd());
@@ -386,7 +250,6 @@ export function plan(parsed) {
386
250
  return [{ path: destination(kind, name, into), contents: fill(body, { name }) }];
387
251
  }
388
252
 
389
- /** Substitutes the placeholders a scaffold can honestly know. Everything else stays in `{braces}`. */
390
253
  function fill(body, values) {
391
254
  let out = body;
392
255
  if (values.name !== undefined) out = out.replaceAll("{kebab-case-name}", values.name);
@@ -414,33 +277,9 @@ function packReadme(name, category) {
414
277
  ].join("\n");
415
278
  }
416
279
 
417
- /**
418
- * A workspace scaffold that is **green under `doctor` on the run after it is written**.
419
- *
420
- * That is a deliberate departure from what `init` does, and the reason is that the two tools are in
421
- * different situations rather than that one of them is wrong. `init` composes a checkpoints pack by
422
- * default because row 7 clause (a) says the cycle arrives opt-**out**, and nothing discovers a pack — so
423
- * its draft is red until a root is named, loudly and on purpose. `new workspace` composes no pack: it is
424
- * for a workspace somebody is authoring by hand, where a red first run would be a red the author did not
425
- * cause and cannot act on.
426
- *
427
- * `portfolio` is the default kind for the same reason. A `repository` workspace must declare `tree`, and
428
- * a scaffold that guesses which tree it governs would be a claim about the filesystem that the scaffold
429
- * cannot check — the claims lint would then grade a repository nobody chose.
430
- */
431
280
  function workspaceFiles(root, name, wkind, body, governedBy = null) {
432
- // A POINTER carries `governed_by` and NONE of the governing keys — no slots, no verify, no gates.
433
- // The Workspace Definition requires the first and `doctor` refuses the second, so a pointer built
434
- // from the governing shape is red on the run after it is written. Copilot's suppressed notes on #156
435
- // caught it, twice on one file: the scaffolder always wrote the governing shape while the help screen
436
- // advertised `pointer` as a supported kind. That is this session's own recurring defect for the third
437
- // time — a scaffold failing the validation that lives beside it — so it is fixed rather than triaged.
438
281
  if (wkind === "pointer") {
439
- // 2.7, not the newest definition: a writer declares the version its OUTPUT needs, and a pointer
440
- // needs 2.7 — the version that added the kind. Nothing scaffolded here declares a key from a
441
- // later MINOR, so claiming one would narrow the manifest against adopter tooling pinned at 2.7
442
- // for a contract it does not use. Same rule as `cli/init.mjs`'s and `cli/vendor.mjs`'s `SPEC`,
443
- // and as the `GATE_POLICY_SPEC = "2.2"` beside them.
282
+ // 2.7 added the pointer kind; a writer declares the version its output needs, not the newest.
444
283
  const manifest = { portulan: { spec: "2.7" }, name, kind: "pointer", governed_by: { workspace: governedBy } };
445
284
  return [
446
285
  { path: path.join(root, "workspace.json"), contents: `${JSON.stringify(manifest, null, 2)}\n` },
@@ -462,21 +301,12 @@ function workspaceFiles(root, name, wkind, body, governedBy = null) {
462
301
  }
463
302
 
464
303
  const manifest = {
465
- // The version this output NEEDS, not the newest one — see the pointer branch above.
304
+ // The version this output needs, not the newest.
466
305
  portulan: { spec: "2.7" },
467
306
  name,
468
307
  summary: `{One line: whose workspace this is and what it governs.}`,
469
308
  kind: wkind,
470
- // `repos` is declared and its directory scaffolded so the six kinds compose end-to-end from
471
- // `new` alone: without it, `new repo-card --into <a workspace new just wrote>` exits 2 because
472
- // the manifest declares nowhere for a card to land. A tool whose own output cannot host its own
473
- // next command is a tool with a seam in the middle of it.
474
309
  slots: { identity: "identity.md", principles: "principles.md", gates: "gate-map.md", repos: "repos/" },
475
- // The schema requires a `verify` block on every workspace that is not a pointer, so this is not
476
- // optional and a scaffold omitting it is red on the run after it is written. What it must NOT be
477
- // is a stub exiting 0: the author has not said what green means here yet, and a false green under
478
- // every gate from the day the workspace was created is the worst artifact this tool could emit.
479
- // Exit 2 — could not run — is the honest state, and it is the same answer `init` reached.
480
310
  verify: {
481
311
  default: "unset",
482
312
  recipes: [{ id: "unset", run: "./verify/unset.sh", requires: ["bash"], doc: "verify/README.md" }],
@@ -496,15 +326,6 @@ function workspaceFiles(root, name, wkind, body, governedBy = null) {
496
326
  ];
497
327
  }
498
328
 
499
- /**
500
- * The recipe a scaffolded workspace starts with: one that **exits 2**, meaning *could not run*.
501
- *
502
- * It is deliberately not a stub exiting 0. A verify recipe is what decides what *done* means for a
503
- * workspace, the Stop-gate runs the default, and a recipe returning green before anybody has said what
504
- * green means would put a false pass under every gate the workspace has — on day one, in the artifact
505
- * meant to welcome the author. The three-code discipline exists exactly for this: a recipe that cannot
506
- * run must never be indistinguishable from one that ran and passed.
507
- */
508
329
  function unsetRecipe(name) {
509
330
  return [
510
331
  "#!/usr/bin/env bash",
@@ -611,8 +432,6 @@ export function run(argv, options = {}) {
611
432
 
612
433
  const into = path.resolve(parsed.into ?? process.cwd());
613
434
 
614
- // Refused before the template is even read: the destination is the question, and a `core/` answer
615
- // is refused whether it was reached by name, by `..`, or by a link.
616
435
  const linkAt = firstLink(into);
617
436
  if (linkAt) {
618
437
  say(
@@ -623,9 +442,6 @@ export function run(argv, options = {}) {
623
442
  }
624
443
  const core = intoCore(realOrSelf(into));
625
444
  if (core) {
626
- // Phrased against the RESOLVED destination rather than the argument as typed, because the two
627
- // differ in exactly the cases worth naming — `packs/../core` and a link — and a refusal that
628
- // echoes the argument back tells the reader nothing they did not already type.
629
445
  const resolved = realOrSelf(into);
630
446
  say(
631
447
  `new: that destination resolves to ${resolved === core ? `\`${core}\`` : `${resolved}, inside \`${core}\``}, ` +
@@ -643,9 +459,7 @@ export function run(argv, options = {}) {
643
459
  return 2;
644
460
  }
645
461
 
646
- // The manifest is written LAST for the multi-file kinds, so an I/O failure mid-loop cannot leave a
647
- // torso that a later run reads as a finished artifact — session 1's partial write, which wedged
648
- // its own retry and refused it with a sentence that was false.
462
+ // The manifest last, so a failure mid-loop leaves nothing a later run reads as finished.
649
463
  const ordered = [...files].sort((a, b) => Number(manifestish(a.path)) - Number(manifestish(b.path)));
650
464
  for (const file of ordered) {
651
465
  fs.mkdirSync(path.dirname(file.path), { recursive: true });
@@ -656,10 +470,6 @@ export function run(argv, options = {}) {
656
470
  for (const file of files) say(` ${display(file.path)}`);
657
471
  say("");
658
472
  say(`It is a draft — the placeholders in \`{braces}\` are yours to fill. Then: portulan doctor <workspace-dir>`);
659
- // What a scaffold CANNOT do for you, said at the moment it matters rather than in a README nobody
660
- // opens next. This tool never edits a file it did not write — a manifest is a curated layer, and
661
- // a generator that rewrites one is the generator that eventually rewrites the wrong one — so the
662
- // wiring step is the author's, and leaving it unsaid would produce a file nothing reads.
663
473
  for (const line of wiring(parsed.kind, parsed.name, parsed.category, into)) say(line);
664
474
  return 0;
665
475
  } catch (error) {
@@ -670,24 +480,12 @@ export function run(argv, options = {}) {
670
480
 
671
481
  const manifestish = (p) => /(?:workspace|pack)\.json$/.test(p);
672
482
 
673
- /**
674
- * The step a scaffold cannot take for the author: declaring the thing it just wrote.
675
- *
676
- * Nothing here edits a manifest the tool did not write in the same run. That is deliberate and it is the
677
- * same rule `init` settled — a manifest is part of the curated layer, and a generator that edits one is
678
- * the generator that eventually edits the wrong one. The cost of that rule is a file nothing references,
679
- * so the cost is paid out loud, here, at the moment the file lands.
680
- */
483
+ /** Said rather than done: nothing here edits a manifest it did not write in the same run. */
681
484
  function wiring(kind, name, category, target) {
682
485
  switch (kind) {
683
486
  case "gate-policy":
684
487
  return ["", `Nothing reads it yet: add \`"gates": "${name}.json"\` to the workspace's \`workspace.json\`, then \`portulan compile\`.`];
685
488
  case "skill": {
686
- // **Say only what was actually checked.** The previous version read whether a `pack.json`
687
- // EXISTED and then made a claim about what it DECLARES — which is the same defect one level
688
- // down: a pack declaring `contributes.skills: ["parts/"]` was told `skills/` is "already
689
- // declared", and a skill landing under a declared root was told nothing declares it. Found
690
- // by review, inside the fix for that exact class.
691
489
  let roots = null;
692
490
  try {
693
491
  roots = JSON.parse(fs.readFileSync(path.join(target ?? ".", "pack.json"), "utf8"))?.contributes?.skills ?? [];
@@ -713,20 +511,7 @@ function wiring(kind, name, category, target) {
713
511
  }
714
512
  }
715
513
 
716
- /**
717
- * The destination as the filesystem actually sees it, **including when it does not exist yet**.
718
- *
719
- * The first cut was `try { realpathSync(t) } catch { return path.resolve(t) }` — and the catch is not
720
- * the rare branch, it is the **ordinary** one: a scaffolding tool's destination usually does not exist.
721
- * So whenever the leaf was new, this returned an unresolved path and the `core/` check graded a string
722
- * the filesystem had never confirmed. Measured by the pre-commit checkpoint: a symlink whose target is
723
- * `core/`, plus a leaf one level below it, wrote into the shipped `core/` and exited 0. The file header
724
- * two hundred lines up promised "the check resolves first and compares after"; it did, only when the
725
- * destination already existed, which is the case that matters least.
726
- *
727
- * So: realpath the nearest **existing** ancestor and re-append the unresolved tail. That resolves every
728
- * link on the part of the path that exists — which is the only part a link can be on.
729
- */
514
+ /** The realpath of the nearest existing ancestor, with the part not created yet appended. */
730
515
  function realOrSelf(target) {
731
516
  let head = path.resolve(target);
732
517
  const tail = [];
@@ -734,8 +519,6 @@ function realOrSelf(target) {
734
519
  try {
735
520
  return tail.length ? path.join(fs.realpathSync(head), ...tail) : fs.realpathSync(head);
736
521
  } catch (cause) {
737
- // Only ENOENT means "not there yet, keep walking up". Anything else means the question could
738
- // not be answered, and an unanswerable question must not be answered "fine".
739
522
  if (cause.code !== "ENOENT") {
740
523
  throw new NewError(
741
524
  `${display(head)} could not be resolved — ${cause.code ?? cause.message}. Refusing rather than grading a path nothing confirmed`,
@@ -750,29 +533,15 @@ function realOrSelf(target) {
750
533
  return path.resolve(target);
751
534
  }
752
535
 
753
- /**
754
- * Is the destination the user named itself a symlink?
755
- *
756
- * Only the named path is asked about here, not its ancestors — see `chain` for why that boundary is the
757
- * rule rather than a convenience. A user who points `--into` at a link is refused rather than followed,
758
- * because deciding whether a link's target is "really" where they meant is a containment judgement with
759
- * a bad failure mode, where refusing has none and costs one `realpath` of typing.
760
- */
761
536
  function firstLink(target) {
762
537
  try {
763
538
  return fs.lstatSync(target).isSymbolicLink() ? target : null;
764
539
  } catch (cause) {
765
- // Only ENOENT means absent — a destination that does not exist yet is the ordinary case here.
766
540
  if (cause.code === "ENOENT") return null;
767
541
  throw new NewError(`${display(target)} could not be read — ${cause.code ?? cause.message}. Only a missing path means "nothing there", so nothing was written`);
768
542
  }
769
543
  }
770
544
 
771
- /**
772
- * One line per cause, not one per path. Session 1 learned this the hard way: a single symlinked
773
- * directory made the refusal name the same cause ten times, and a refusal nobody finishes reading is a
774
- * refusal that failed to explain.
775
- */
776
545
  function groupByCause(clash) {
777
546
  const by = new Map();
778
547
  for (const item of clash) {
@@ -1,37 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Every file `npm pack` emits is byte-identical to the tracked blob it came from.
3
3
  //
4
- // The rail [#149](https://github.com/sleepy-panda-srl/portulan/issues/149) asked for, in the form that
5
- // can actually run on every pull request. `.portulan/identity.md` states the property — **the `npx`
6
- // path IS an install, and it installs the SAME BYTES** — and until 2026-08-18 nothing checked it: the
7
- // figure was re-measured by hand three times (72 files, then 114, then 73) and was wrong in the tree
8
- // twice between those measurements.
9
- //
10
- // ## Why this compares against the TREE and not against the registry
11
- //
12
- // The measurement taken at the first publish compared the published tarball to a fresh `npm pack`, and
13
- // they hashed the same. That is the stronger claim and it is recorded on #149 — but it is the wrong
14
- // shape for a recipe: it needs the network, it needs a published version to exist, and it cannot run
15
- // before the first publish or on a machine offline. A recipe that cannot run is a recipe that stops
16
- // being run.
17
- //
18
- // What this checks instead needs neither: `npm pack --dry-run --json` lists what the package would
19
- // contain, and every one of those paths must be byte-identical to its STAGED blob, `git show :<path>`
20
- // — the index rather than HEAD, for the reason the comment on `blobAt` gives. That is the
21
- // property the registry comparison DEPENDS on — if the packed bytes match the tree, and the published
22
- // tarball matches a fresh pack, then the published bytes are the tree's. This rail owns the first half,
23
- // which is the half that can drift silently on any commit; the second half is a hand measurement taken
24
- // at each publish, which is what #149 asked for and what its comment records.
25
- //
26
- // ## Exit codes, per ../.portulan/memory/verify-preconditions-fail-closed.md
27
- //
28
- // 0 every packed file matches its staged blob
29
- // 1 a finding: at least one packed file differs from what is staged, or is not tracked at all
30
- // 2 could not run: `npm pack` unusable, not a git repository, or HEAD unreadable
31
- //
32
- // The 2 matters more here than in most recipes. `npm pack` shells out to npm, which can be absent,
33
- // offline-hostile, or a different major version — and a rail that reports "the bytes differ" when what
34
- // happened is "npm did not run" sends someone hunting a drift that does not exist.
4
+ // Exit 0 every packed file matches its staged blob · 1 one differs or is untracked · 2 could not run.
35
5
 
36
6
  import { execFileSync } from "node:child_process";
37
7
  import fs from "node:fs";
@@ -43,14 +13,7 @@ class CannotRun extends Error {}
43
13
 
44
14
  const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024, ...opts });
45
15
 
46
- /**
47
- * What the package would contain, as repo-relative paths.
48
- *
49
- * **Exported because `./payload.mjs` classifies exactly this roster**, and two enumerations of *what
50
- * npm would pack* is the second-carrier defect this repository keeps paying for. That rail asks which
51
- * files ship; this one asks whether each is byte-identical to its source. One question about
52
- * membership, asked once, answered here.
53
- */
16
+ /** The paths `npm pack` would ship, relative to `root`. */
54
17
  export function packedPaths(root) {
55
18
  let out;
56
19
  try {
@@ -66,10 +29,6 @@ export function packedPaths(root) {
66
29
  }
67
30
  const files = parsed?.[0]?.files;
68
31
  if (!Array.isArray(files) || files.length === 0) throw new CannotRun("`npm pack` reported no files — refusing to report a green over an empty roster");
69
- // The SHAPE is checked, not assumed. `files` is npm's JSON, and a changed schema or an odd entry
70
- // would otherwise yield `undefined` paths that crash further down — surfacing as an uncaught
71
- // exception the runner reads as a red FINDING about the package. npm's output is an input like any
72
- // other; a roster this tool cannot read is could-not-run.
73
32
  const paths = [];
74
33
  for (const [i, entry] of files.entries()) {
75
34
  const rel = entry?.path;
@@ -81,13 +40,7 @@ export function packedPaths(root) {
81
40
  return paths;
82
41
  }
83
42
 
84
- // The blob is read from the INDEX (`git show :<path>`), not from HEAD, and the difference is the whole
85
- // usability of this rail. `npm pack` reads the WORKING TREE, so comparing it to HEAD makes the recipe red
86
- // for any uncommitted edit — and `.portulan/dod.md` requires every recipe green BEFORE a commit, which a
87
- // check that can only pass after one contradicts. The index is what is about to become the commit, so
88
- // staged work compares clean and unstaged work is correctly still a difference. In CI the distinction
89
- // vanishes: `actions/checkout` leaves the index matching HEAD, so this reads exactly as HEAD there.
90
- /** The staged blob for a path, or null when the path is not tracked. */
43
+ /** The staged blob, not HEAD's, so staged work compares clean before its commit; null when the path is not tracked. */
91
44
  function blobAt(root, rel) {
92
45
  try {
93
46
  return run("git", ["show", `:${rel}`], { cwd: root, stdio: ["ignore", "pipe", "ignore"] });
@@ -96,17 +49,6 @@ function blobAt(root, rel) {
96
49
  }
97
50
  }
98
51
 
99
- // Every path npm reports is checked for CONTAINMENT before it is used, by RESOLUTION rather than by
100
- // pattern — the shape `cli/eval-bundle.mjs` was hardened to on #280, for the same reason: a pattern
101
- // test answers a question about a string, and what matters is where the filesystem actually goes.
102
- // `rel` arrives from `npm pack`'s JSON, so it is not this tool's to trust: an absolute path, a `..`
103
- // escape or an embedded NUL would send both `git show` and the read outside the repository, and the
104
- // rail would then compare the wrong bytes and report GREEN. Refused as could-not-run, never as a
105
- // finding — a path this tool will not resolve supports no verdict about the package.
106
- //
107
- // Symlinks are refused for the same reason and separately from containment: `readFileSync` follows
108
- // them, so a link whose target sits outside the tree reads bytes git never carried while `git show`
109
- // reads the link itself. `lstat` is what distinguishes them, and `realpath` alone would not.
110
52
  function containedPath(root, rel) {
111
53
  if (rel.includes("\0")) throw new CannotRun(`npm reported a path containing NUL — refusing to resolve it`);
112
54
  if (path.isAbsolute(rel)) throw new CannotRun(`npm reported an absolute path (${rel}); packed paths are repo-relative`);
@@ -136,16 +78,10 @@ export function compare(root) {
136
78
  const untracked = [];
137
79
  const differing = [];
138
80
  for (const rel of packed) {
139
- // `package.json` is the one path npm may rewrite on pack — it normalises fields. Compared like
140
- // every other file rather than exempted: the 2026-08-13 measurement asserted all 114 identical
141
- // "package.json included, with no exemption", and an exemption added here would quietly retire
142
- // that claim.
81
+ // `package.json` too, though npm may normalise it on pack: the same-bytes claim takes no exemption.
143
82
  const abs = containedPath(root, rel);
144
83
  const blob = blobAt(root, rel);
145
84
  if (blob === null) untracked.push(rel);
146
- // Read with `fs`, not by shelling out. `cat` would be an undeclared dependency this recipe's
147
- // `requires` does not list, and it takes its argument as an OPTION when a filename begins with
148
- // `-` — so a file named `-n` would silently be read as a flag rather than compared.
149
85
  else if (!blob.equals(fs.readFileSync(abs))) differing.push(rel);
150
86
  }
151
87
  return { packed, untracked, differing };
@@ -161,10 +97,7 @@ function main(argv, stdout, stderr) {
161
97
  stderr.write(`pack-identity: could not run — ${error.message}\n`);
162
98
  return 2;
163
99
  }
164
- // An unexpected throw is COULD-NOT-RUN, never a finding. Rethrowing would exit non-contractually
165
- // — node's own code, typically 1 — and the runner would read a crash in this rail as a verdict
166
- // about the package's bytes. `1` is reserved for a finding; this repository has a commit by that
167
- // name. The diagnostic names the rail so the exit is traceable to the thing that broke.
100
+ // Rethrown, a crash would exit 1, which reads as a finding.
168
101
  stderr.write(`pack-identity: could not run — unexpected ${error?.name ?? "error"}: ${String(error?.message ?? error).split("\n")[0]}\n`);
169
102
  return 2;
170
103
  }
@@ -179,8 +112,6 @@ function main(argv, stdout, stderr) {
179
112
  return 1;
180
113
  }
181
114
 
182
- // The same guard `pack-version.mjs` uses. A bare path comparison is FALSE when npm installs a bin as a
183
- // symlink — node realpaths the main module for `import.meta.url` while `process.argv[1]` keeps the link.
184
115
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
185
116
  process.exitCode = main(process.argv, process.stdout, process.stderr);
186
117
  }