@sleepy_panda_srl/portulan 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +142 -291
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1443 -1210
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +225 -1071
  10. package/cli/drills.mjs +1107 -0
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +595 -0
  15. package/cli/gate.mjs +7 -198
  16. package/cli/goldens.mjs +311 -0
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +244 -564
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +764 -0
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +6 -68
  27. package/cli/pack-version.mjs +13 -248
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +16 -240
  31. package/cli/release-eval.mjs +748 -0
  32. package/cli/review-meter.mjs +500 -0
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +424 -0
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +126 -454
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +582 -0
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +9 -74
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/gate-policy.md +27 -8
  54. package/core/templates/handoff.md +28 -15
  55. package/core/templates/memory-entry.md +1 -0
  56. package/evals/README.md +320 -0
  57. package/evals/releases/0.1.3.json +134 -0
  58. package/evals/releases/0.1.3.md +64 -0
  59. package/evals/releases/0.2.0.json +146 -0
  60. package/evals/releases/0.2.0.md +67 -0
  61. package/evals/releases/README.md +32 -0
  62. package/package.json +9 -2
  63. package/packs/rituals/checkpoints/README.md +9 -2
  64. package/packs/rituals/checkpoints/pack.json +1 -1
  65. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
  66. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  67. package/spec/README.md +112 -6
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  70. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  71. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  72. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  73. package/spec/migrations/0006-boot-card.mjs +77 -0
  74. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  75. package/spec/migrations/0008-card-reading.mjs +55 -0
  76. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  77. package/spec/migrations/0010-comment-rail.mjs +57 -0
  78. package/spec/migrations/README.md +45 -10
  79. package/spec/pack.schema.json +1 -1
  80. package/spec/slots.md +359 -23
  81. package/spec/workspace.schema.json +145 -4
package/cli/portulan.mjs CHANGED
@@ -1,63 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Portulan — the one entry point the `npx` package exposes.
3
3
  //
4
- // `docs/vision.md` names **eight** subcommands and is human-owned: `init` · `doctor` · `compile` ·
5
- // `vendor` · `index` · `upgrade` · `new` · `feedback`. This is milestone 7's packaging clause.
6
- //
7
- // It named six until 2026-08-03. `new` and `feedback` arrived in the CLI first, licensed by row 7 of
8
- // `docs/plan.md` naming them in its own ratified text, and the maintainer then folded both into the
9
- // constitution — so the two-carrier state that licence created lasted one pull request and is gone. The
10
- // history is worth one sentence rather than none: a subcommand may be licensed by the row before the
11
- // constitution catches up, and when that happens this file says which document names it. Everything
12
- // beyond these eight is still the maintainer's call.
13
- //
14
- // ## What it is NOT
15
- //
16
- // **Not a reimplementation.** `doctor`, `compile`, `index` and `init` each live in their own file and
17
- // are exercised by their own suites; this dispatches to them and adds nothing to what they do. A
18
- // wrapper that re-derived any part of their behaviour would be a second carrier of one fact, which is
19
- // the class `0020` names — a rule enforced at two sites and repaired at one — so the dispatch imports each tool's
20
- // exported `run` and returns its exit code untouched.
21
- //
22
- // **Not a home for the tools that are off the list.** Several tools live in `cli/` and are deliberately
23
- // absent here. `docs/vision.md` names eight, it is human-owned, and whether any of the others ever joins
24
- // that list is the maintainer's call and not an implementer's. Wiring one in would mint a ninth
25
- // subcommand into a list this file does not own. They stay runnable exactly as they are today —
26
- // `node cli/plugin-lint.mjs …` — and every verify recipe still invokes them that way.
27
- //
28
- // **The roster itself is `cli/README.md`'s and is cited rather than repeated here.** This paragraph
29
- // said **two**, then **three**, and named a different three from the root `README.md`'s three while
30
- // **four** were on disk — one rule with three carriers, obeyed at the narrowest, in the file whose own
31
- // comments name that class. _(It also named the wrong ordinal once — *a seventh subcommand*, written
32
- // when the list held six. A count in prose beside a list nothing derives it from is the drift this
33
- // file's own suite catches for `SUBCOMMANDS.length` and cannot catch here.)_
34
- //
35
- // ## All eight are built, and the shape of the answer while one was not is kept
36
- //
37
- // `upgrade` carried `module: null` from milestone 7 session 0 until session 9 and exited **2 — could
38
- // not run**, naming where it arrived. It dispatches now like the rest. The paragraph is kept rather
39
- // than deleted, because the state it describes recurs: the constitution may name a verb before the
40
- // tree carries it, and both alternatives to that answer were worse — hiding the entry makes the
41
- // package look like a smaller tool and the row like it is further along than it is, and stubbing it
42
- // to exit 0 is a fail-open in the one place a user is most likely to trust silence. "Nothing looked"
43
- // is never "nothing wrong" — the same three-code discipline the verify recipes hold
44
- // (`.portulan/memory/verify-preconditions-fail-closed.md`). The machinery for the next such entry is
45
- // still here: `module: null` plus `arrives`, and the refusal below reads them.
46
- //
47
- // _(`vendor` was one of three until milestone 7 session 3, and `feedback` one of two until session 6.
48
- // The count is written out here and derived in the suite, which is the split that has held: the
49
- // sentence explains, the assertion counts.)_
50
- //
51
- // ## Exit codes, which are the tools' own
52
- //
53
- // `0` the subcommand succeeded · `1` it rendered a red verdict · `2` it could not run — including
54
- // an unknown subcommand, an unbuilt one, and being invoked with **no arguments at all**, because
55
- // none of those is a verdict about anybody's workspace. An **explicit** `--help` exits 0: asking for
56
- // help is a request, and it succeeded.
57
- //
58
- // Zero dependencies and no build step, which is a ruling rather than an accident: the maintainer
59
- // settled on 2026-07-31 that this ships as plain ESM on Node, against `.portulan/identity.md`'s
60
- // older "TypeScript on Node" line, so that the repository stays checkable by cloning it.
4
+ // Exit codes are the subcommand's own; else 0 for `--help` or `--version`, and 2 for no arguments or a subcommand that cannot run.
61
5
 
62
6
  import fs from "node:fs";
63
7
  import path from "node:path";
@@ -65,23 +9,11 @@ import { fileURLToPath, pathToFileURL } from "node:url";
65
9
 
66
10
  const HERE = path.dirname(fileURLToPath(import.meta.url));
67
11
 
68
- // ONE carrier for the version, read from the manifest that publishes it — and read in `manifest.mjs`
69
- // rather than here, since milestone 7 session 6.
70
- //
71
- // It was written down twice — here and in `package.json` — which is this repository's dominant defect
72
- // class committed in the file whose own comments name it. The read moved down a level when `feedback`
73
- // needed the same value: importing it *from this file* made the subcommand circular with the entry
74
- // point and hung the command line on an unsettled top-level await, which `manifest.mjs`'s header
75
- // records in full. Re-exported here so this file's published surface is unchanged.
12
+ // A subcommand imports VERSION from ./manifest.mjs: importing it from here would cycle through this file's top-level await and exit 13.
76
13
  export { VERSION } from "./manifest.mjs";
77
14
  import { VERSION } from "./manifest.mjs";
78
15
 
79
- // The eight, in the order `docs/vision.md` lists them. `module` is the file that carries the
80
- // subcommand's `run`; `null` means the subcommand exists in the vision and not yet in the tree.
81
- //
82
- // `arrives` is not decoration. Condition 4 of `.portulan/dod.md` is that a document describing
83
- // capability either has the capability or names where it arrives, and a help screen listing a verb
84
- // that does nothing is exactly that document.
16
+ // In `docs/vision.md`'s order. An unbuilt entry sets `module: null` and `arrives`, and `namedIn` where another document names it.
85
17
  export const SUBCOMMANDS = [
86
18
  {
87
19
  name: "init",
@@ -101,11 +33,6 @@ export const SUBCOMMANDS = [
101
33
  {
102
34
  name: "vendor",
103
35
  module: "vendor.mjs",
104
- // BOTH directions, because the constitution now glosses both. It said "a self-contained
105
- // AGENTS.md + .portulan/ for any host" until 2026-08-03, when the maintainer widened the gloss
106
- // to cover materialising a workspace out of a repository as well as into one — which is what
107
- // settled the verb row 7 had deliberately left unassigned. A summary carrying only the older
108
- // half would be the narrower of two carriers of one fact, which is the shape a reader obeys.
109
36
  summary: "materialise a workspace where it is needed: into a host, or between residences",
110
37
  },
111
38
  {
@@ -116,11 +43,6 @@ export const SUBCOMMANDS = [
116
43
  {
117
44
  name: "upgrade",
118
45
  module: "upgrade.mjs",
119
- // BOTH kinds, because the chain carries both. A summary naming only the version half would
120
- // be the narrower of two carriers of one fact — and the narrower is the one a reader obeys.
121
- // What a step is lives in `spec/migrations/README.md`; this line only has to not contradict
122
- // it, which the older wording ("migrate a workspace to a newer Workspace Definition") would
123
- // have started doing the moment the maintainer admitted repairs on 2026-08-12.
124
46
  summary: "migrate a workspace: the Workspace Definition steps it owes, and the repairs a rewriter owes it",
125
47
  },
126
48
  {
@@ -148,8 +70,6 @@ export function usage() {
148
70
  "",
149
71
  ];
150
72
  for (const entry of SUBCOMMANDS) {
151
- // The state is printed per subcommand rather than as a footnote, because a reader scanning
152
- // this list is deciding what to type, and a footnote is read after that decision.
153
73
  const state = entry.module ? "" : ` (not built — arrives at ${entry.arrives})`;
154
74
  lines.push(` ${entry.name.padEnd(width)} ${entry.summary}${state}`);
155
75
  }
@@ -172,12 +92,6 @@ export async function run(argv, options = {}) {
172
92
 
173
93
  if (name === undefined || name === "--help" || name === "-h" || name === "help") {
174
94
  say(usage());
175
- // The split is the point: a bare `portulan` was asked to do something and did nothing, which
176
- // is could-not-run; an explicit `--help` asked for the help screen and got it, which is
177
- // success. Written out because the two look alike in a shell and a reader scripting against
178
- // this will meet both. _(These lines previously argued that explicit `--help` should exit 2
179
- // as well — an argument the code never implemented and the tests contradict. Corrected in
180
- // the direction of the behaviour, which is the conventional one.)_
181
95
  return name === undefined ? 2 : 0;
182
96
  }
183
97
 
@@ -189,28 +103,17 @@ export async function run(argv, options = {}) {
189
103
  const entry = find(name);
190
104
  if (!entry) {
191
105
  warn(`portulan: unknown subcommand \`${name}\`.`);
192
- // Derived, never a literal. A hard-coded count is wrong the hour a subcommand lands — which is
193
- // exactly what happened to a literal `3` in this file's own suite when `init` shipped.
194
106
  warn(`portulan: the ${SUBCOMMANDS.length} subcommands are ${SUBCOMMANDS.map((s) => s.name).join(", ")}. Run \`portulan --help\`.`);
195
107
  return 2;
196
108
  }
197
109
 
198
110
  if (!entry.module) {
199
- // WHERE the name comes from, not a blanket "docs/vision.md". All eight are in the constitution
200
- // as of 2026-08-03, so `namedIn` is unset on every entry today and the fallback is the whole
201
- // behaviour — but the field stays, because the state it exists for is real and recurred once
202
- // already: `feedback` was named by row 7 and by nothing else for one pull request, and the
203
- // shipped tool told users the constitution named something it did not. A test asserts the
204
- // sentence now, which is what that round was missing.
205
111
  warn(`portulan: \`${name}\` is named in ${entry.namedIn ?? "docs/vision.md"} and is not built yet — it arrives at ${entry.arrives}.`);
206
112
  warn("portulan: refusing to exit 0 on a subcommand that did nothing.");
207
113
  return 2;
208
114
  }
209
115
 
210
- // Loaded on demand, never at startup. Two reasons and both are measured elsewhere in this
211
- // repository: a tool that fails to parse takes down only its own subcommand rather than the
212
- // whole command line, and the built modules together are large enough that eagerly importing
213
- // them would make `portulan --help` pay for `doctor`.
116
+ // Imported on demand: a module that fails to load takes down only its own subcommand, and `--help` loads none.
214
117
  let module;
215
118
  try {
216
119
  module = await load(entry.module);
@@ -220,36 +123,14 @@ export async function run(argv, options = {}) {
220
123
  }
221
124
 
222
125
  if (typeof module.run !== "function") {
223
- // A guard rather than an assumption. Every built tool exports `run` today; if one stops, the
224
- // failure must name itself here instead of surfacing as `module.run is not a function`.
225
126
  warn(`portulan: ${entry.module} does not export a \`run\` function — refusing to guess at its entry point.`);
226
127
  return 2;
227
128
  }
228
129
 
229
- // The tool's own exit code, returned unchanged. Re-mapping it here would put a second opinion
230
- // about a workspace between the tool and its user.
231
130
  return await module.run(rest);
232
131
  }
233
132
 
234
- // THE MAIN-MODULE GUARD RESOLVES SYMLINKS, and this file is the one in the repository that must.
235
- //
236
- // Every other tool here guards with a bare `pathToFileURL(process.argv[1])` comparison and is right
237
- // to: nothing ever invokes them through a link. This one is a `bin`, and **npm installs a bin as a
238
- // symlink** — `node_modules/.bin/portulan` → `cli/portulan.mjs`. Node realpaths the main module for
239
- // `import.meta.url` while `process.argv[1]` keeps the link path, so the bare comparison is FALSE
240
- // through the link and `run` never executes.
241
- //
242
- // Measured on the staged tree before this line existed, by packing the package and installing the
243
- // tarball: through `node_modules/.bin/portulan`, `--version` printed nothing and exited 0, and
244
- // `doctor <nonexistent>` exited **0** where the same command exits 1 from a checkout. **Every
245
- // verdict became a silent success** — "nothing looked" reported as "nothing wrong", at the one
246
- // surface a user reaches by the documented route. Found by the pre-commit checkpoint, which
247
- // installed the tarball rather than reading the guard.
248
- //
249
- // `realpathSync` rather than `import.meta.main`, which needs a newer Node than `engines` asks for.
250
- // Wrapped, because `argv[1]` may be absent or unreadable and a guard that throws would be a worse
251
- // failure than the one it prevents — falling back to the unresolved path leaves the old behaviour
252
- // rather than crashing.
133
+ // npm installs a bin as a symlink, and Node realpaths `import.meta.url` but not `argv[1]`; `import.meta.main` needs a newer Node than `engines`.
253
134
  function isMain() {
254
135
  const invoked = process.argv[1];
255
136
  if (!invoked) return false;
@@ -1,99 +1,30 @@
1
- /**
2
- * The recipe set — the one carrier of *what this workspace runs*.
3
- *
4
- * Row 7's amendment of 2026-07-31 (argument in `docs/milestones/m07.md`) ruled that a composed
5
- * pack's verify recipes reach the adopting workspace's runnable set, under a contract stated in the
6
- * row rather than left to this file: **additive only** — a pack may add a recipe and may never
7
- * redefine, remove or replace one the workspace declares, nor become the workspace's
8
- * `verify.default` — **namespaced by its pack, so a collision is impossible rather than resolved**,
9
- * and **could-not-run — exit 2, never silently absent** when a composed recipe cannot resolve.
10
- *
11
- * ## Why this is a module and not four implementations
12
- *
13
- * Until this landed the recipe set was read independently in four places — `.github/workflows/verify.yml`,
14
- * `cli/doctor.mjs`, `cli/vendor.mjs` and `cli/stop-gate.mjs` — with none authoritative. Composing into
15
- * fewer than all four would have left the rest disagreeing about what the set IS, which is
16
- * `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md`'s defect in its code
17
- * material: one rule, several enforcement sites, repaired at fewer than all of them. The repair that
18
- * proposal prescribes is `core/operating/evolution.md`'s — **one carrier, and the others reach it** —
19
- * and here the rule can be a function, which is where that repair applies.
20
- *
21
- * The row makes CI part of the contract in its own words: *CI runs the recipe set the manifest yields*,
22
- * and a composed recipe missing from it *"would be a green computed over something nobody ran"*. So
23
- * `verify.yml` calls this module rather than carrying its own enumeration, and `cli/recipe-set.test.mjs`
24
- * pins the roster so a fifth reader reds instead of drifting.
25
- *
26
- * ## What the namespace buys, and why the refusal stays anyway
27
- *
28
- * A workspace-declared recipe id is a bare slug (`$defs/slug`, `^[a-z0-9]+(-[a-z0-9]+)*$`) in
29
- * `spec/workspace.schema.json`, and so is `verify.default`. A composed id is `<category>/<name>:<id>`,
30
- * whose `/` and `:` are outside that grammar. So a composed recipe cannot carry a workspace id and
31
- * cannot be named by `verify.default`: **both are impossible by construction, not refused by a check
32
- * somebody has to remember.**
33
- *
34
- * The refusal below is kept as belt and braces behind that construction — the pattern `verify.yml`
35
- * already names in its own comment — and it is not a claim to an enforcement that cannot fire, which
36
- * `../.portulan/memory/a-stated-enforcer-must-be-the-real-one.md` would forbid. Two classes reach it:
37
- *
38
- * 1. **A pack declaring one id twice with different runs.** Schema-legal: `contributes.verify`'s
39
- * `uniqueItems` compares whole recipe objects, so two entries differing only in `run` validate.
40
- * The namespacer gives both the same composed id and only this refusal catches it. It is forced
41
- * red in the suite.
42
- * 2. **A hand-edited manifest reaching a reader that does not validate against the schema** — CI and
43
- * the Stop gate both read `workspace.json` raw.
44
- *
45
- * ## The composed `run` contract
46
- *
47
- * `spec/pack.schema.json` glosses a recipe's `run` as *"the command, as it would be typed from the
48
- * repository root"*. A pack has no repository until it is adopted, and its script ships inside the
49
- * pack — so a composed `run` needs a way to name its own files from a root it cannot know. It does
50
- * that with **`${PACK_ROOT}`**, expanded here to the resolved pack root before the command is handed
51
- * to anyone. After expansion the gloss is true again, which is the point: what runs is a command
52
- * typeable from the repository root.
53
- *
54
- * Expansion happens **before** the newline and tab check, so a pack root carrying one cannot smuggle
55
- * it past a validation that ran too early. A workspace recipe's `run` is never expanded — the token
56
- * has no meaning outside a pack, and rewriting it would be this module inventing a feature the
57
- * Workspace Definition does not declare.
58
- */
1
+ // The recipe set: the verify recipes a workspace yields, its own plus each composed pack's, namespaced by pack.
59
2
 
60
3
  import fs from "node:fs";
61
4
  import path from "node:path";
62
5
  import { pathToFileURL } from "node:url";
63
6
 
64
- // `shadowedCopy` rides along: one carrier for *is this pack shadowed*, per #316.
65
7
  import { resolvePack, rootPlan, shadowedCopy } from "./compile.mjs";
66
8
  import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
67
9
 
68
- /**
69
- * Every reader of the recipe set, by path.
70
- *
71
- * **Pinned in two halves, because this list alone holds less than it looks like it holds.**
72
- * `cli/recipe-set.test.mjs` asserts the roster's contents, which catches a reader being removed or
73
- * renamed — but a *fifth* reader that never adds itself here touches nothing that assertion reads. So
74
- * `cli/recipe-set.live.test.mjs` sweeps `cli/*.mjs` and the workflows for an undeclared enumeration of
75
- * `verify.recipes`, which is the half that actually catches drift. `cli/collisions.test.mjs` carries
76
- * the same pair for the three `collisions()`, and the sweep is the half it had to add for the same
77
- * reason: a copy nothing asserts is invisible to every other test.
78
- *
79
- * Two sites enumerate `verify.recipes` directly and are exempt by argument, not by oversight: this
80
- * module, and `cli/doctor.mjs`, which validates the workspace's own DECLARATION rather than reading
81
- * the runnable set.
82
- */
10
+ /** Add any new reader here: `recipe-set.live.test.mjs` catches only a direct enumeration of `verify.recipes`. */
83
11
  export const RECIPE_SET_READERS = Object.freeze([
84
12
  ".github/workflows/verify.yml",
85
13
  "cli/doctor.mjs",
14
+ "cli/drills.mjs",
15
+ "cli/finish.mjs",
16
+ "cli/form.mjs",
86
17
  "cli/stop-gate.mjs",
87
18
  "cli/vendor.mjs",
88
19
  ]);
89
20
 
90
- /** `$defs/slug`, held identically by both schemas. A workspace id and `verify.default` are this. */
21
+ /** `$defs/slug`, as both schemas hold it. */
91
22
  const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
92
23
 
93
24
  /** The token a composed `run` uses to name its own pack's files. */
94
25
  const PACK_ROOT_TOKEN = "${PACK_ROOT}";
95
26
 
96
- /** The composed spelling. Exported so no caller re-derives it — one carrier, again, one layer down. */
27
+ /** Its `:` is outside `$defs/slug`, so a composed id never equals a workspace id or `verify.default`. */
97
28
  export function composedId(packRef, id) {
98
29
  return `${packRef}:${id}`;
99
30
  }
@@ -102,71 +33,25 @@ function refuse(reason) {
102
33
  return { ok: false, exitCode: 2, reason };
103
34
  }
104
35
 
105
- /**
106
- * Validate one recipe's runnable parts. Returns an error string or null.
107
- *
108
- * These four refusals were carried by `.github/workflows/verify.yml`'s inline emitter alone until this
109
- * module took them. Losing one in the move would have been a fix not done at the site it was found,
110
- * committed inside the change that cites the doctrine — so they are asserted here in the suite rather
111
- * than trusted to have survived.
112
- */
36
+ /** An error sentence, or null when the recipe is runnable. */
113
37
  function invalidRunnable(id, run, where, { requireSlug = true } = {}) {
114
- // `requireSlug` is off only on the deliberately-bypassed path. Without it `trustPackSpelling` could
115
- // not do the one thing its JSDoc says it exists for: a genuinely pre-namespaced id carries `/` and
116
- // `:`, so the slug check refused it *before* the shadow refusal it was meant to reach — an option
117
- // that named a purpose it could not serve. Copilot round 6.
118
38
  if (requireSlug && !SLUG.test(String(id ?? ""))) return `${where} declares a recipe id that is not a slug: ${JSON.stringify(id)}`;
119
39
  const command = String(run ?? "");
120
40
  if (!command.trim()) return `${where} declares an empty run command for recipe \`${id}\``;
121
- // Names all three characters it rejects. It said "newline or tab" while the pattern also refused a
122
- // carriage return, so a manifest carrying one got a message pointing at two characters it did not
123
- // contain — a diagnosis that sends the reader looking for the wrong thing. Copilot round 1.
124
41
  if (/[\n\r\t]/.test(command)) {
125
42
  return `${where} declares a run containing a newline, carriage return or tab for recipe \`${id}\``;
126
43
  }
127
44
  return null;
128
45
  }
129
46
 
130
- /**
131
- * The composed runnable set for a workspace manifest.
132
- *
133
- * @param {object} manifest a parsed Workspace Definition
134
- * @param {object} [options]
135
- * @param {string[]} [options.packs] the workspace's `packs` array; defaults to `manifest.packs`
136
- * @param {(ref: string) => ({ ref: string, root: string, manifest: object } | null)} [options.resolve]
137
- * resolves a pack reference to its root and manifest, or null when it cannot be found.
138
- * Absent when packs are declared means nothing resolves, which is could-not-run — fail-closed,
139
- * because a resolver that silently is not there would compose nothing and report green.
140
- * @param {boolean} [options.trustPackSpelling] use a pack's own recipe id verbatim instead of
141
- * namespacing it. Exists so the shadow refusal can be exercised against a real input; no
142
- * production caller passes it.
143
- *
144
- * @returns {{ ok: true, recipes: object[], default: string|undefined }
145
- * | { ok: false, exitCode: 2, reason: string }}
146
- */
47
+ /** `{ ok: true, recipes, default }`, or `{ ok: false, exitCode: 2, reason }` when the set cannot be composed. */
147
48
  export function recipeSet(manifest, options = {}) {
148
49
  const { packs = manifest?.packs ?? [], resolve = null, trustPackSpelling = false } = options;
149
50
 
150
- // `packs` must be an array, for the same reason `verify.recipes` must be — and this is that fix's
151
- // sibling, at the site round 1 did not reach. A string here would iterate CHARACTERS and report
152
- // "the pack `t` could not be resolved", a refusal whose message names something the manifest never
153
- // said; an object would throw a TypeError, which is a stack trace rather than a diagnosis. Both are
154
- // could-not-run wearing a worse face. Copilot round 3.
155
51
  if (!Array.isArray(packs)) {
156
52
  return refuse(`this workspace's \`packs\` is not an array (${typeof packs}) — could-not-run rather than a set composed from a value nothing can enumerate`);
157
53
  }
158
54
 
159
- // A manifest with no `verify.recipes` ARRAY is could-not-run, not a workspace that declares none.
160
- //
161
- // This was `?? []`, and that was a fail-open the move introduced: the CI emitter this carrier
162
- // replaced iterated `m.verify.recipes` directly, so an absent block threw and became exit 2. Under
163
- // `?? []` an invalid manifest could still yield a runnable set as soon as a pack contributed one —
164
- // CI would then run a green over a workspace whose own declaration nobody could read. One of the
165
- // four validations that was supposed to survive the move, and the one that did not; the pre-commit
166
- // checkpoint asked the question and neither pass caught it. Copilot round 1's suppressed note did.
167
- //
168
- // Absent is distinguishable from empty here only in the message: both refuse, because `spec/workspace.schema.json`
169
- // requires the block and a workspace that declares no recipes cannot report green either way.
170
55
  if (!Array.isArray(manifest?.verify?.recipes)) {
171
56
  return refuse(
172
57
  "the workspace manifest declares no `verify.recipes` array — could-not-run rather than a set " +
@@ -180,14 +65,7 @@ export function recipeSet(manifest, options = {}) {
180
65
  for (const recipe of declared) {
181
66
  const bad = invalidRunnable(recipe?.id, recipe?.run, "the workspace manifest");
182
67
  if (bad) return refuse(bad);
183
- // Normalised to strings on the way out, the way the pack path below already does.
184
- //
185
- // The validation above coerces with `String(...)`, so a non-string `id` that stringifies to a
186
- // slug PASSED and was then pushed through unchanged — and every downstream comparison is an
187
- // identity check on that value: `r.id === set.default` in the Stop gate, `seen.has(id)` here,
188
- // the emitter's tab-separated line. One arm of this function stringified and the other did not,
189
- // which is the two-sites-one-rule shape this whole change is about, inside the carrier that
190
- // carries it. Copilot round 2's suppressed note.
68
+ // As strings: validation coerced them with `String`, and every reader compares ids with `===`.
191
69
  const id = String(recipe.id);
192
70
  const run = String(recipe.run);
193
71
  if (seen.has(id)) return refuse(`the workspace manifest declares the recipe \`${id}\` more than once`);
@@ -198,23 +76,13 @@ export function recipeSet(manifest, options = {}) {
198
76
  for (const ref of packs) {
199
77
  const found = resolve ? resolve(ref) : null;
200
78
  if (!found) {
201
- // Never silently absent. The row's words: exit 2, and the run says which pack — and which
202
- // recipe, where one is known. A composed recipe quietly missing from the set would be a
203
- // green computed over something nobody ran, arriving through the composition path.
204
79
  return refuse(
205
80
  `the pack \`${ref}\` is composed by this workspace and could not be resolved, so its verify recipes ` +
206
81
  `could not be read — could-not-run rather than a set that quietly lost them`,
207
82
  );
208
83
  }
209
84
 
210
- // A pack declaring no `verify` key contributes no recipes and is not an error. That is
211
- // `packs/rituals/checkpoints`'s deliberate shape, by its own argued policy, and conflating
212
- // "declared none" with "failed to resolve" would make the honest pack red.
213
- //
214
- // But a key that is PRESENT and not an array is neither of those: it is a manifest nobody can
215
- // read, and `for..of` over it throws a TypeError — the carrier crashing where its whole
216
- // contract is to exit 2 with a sentence naming the pack. Third site of round 1's rule, and the
217
- // second one it did not reach. Copilot round 3.
85
+ // An absent `contributes.verify` is a pack that contributes no recipes, not an error.
218
86
  const contributed = found.manifest?.contributes?.verify;
219
87
  if (contributed !== undefined && !Array.isArray(contributed)) {
220
88
  return refuse(`the pack \`${ref}\` declares \`contributes.verify\` as ${typeof contributed}, not an array — could-not-run`);
@@ -244,79 +112,26 @@ export function recipeSet(manifest, options = {}) {
244
112
  }
245
113
  }
246
114
 
247
- // Reporting green having run nothing is the failure the whole gate exists to prevent, and
248
- // enumeration is a precondition exactly as it is inside each recipe —
249
- // `../.portulan/memory/verify-preconditions-fail-closed.md`.
250
115
  if (recipes.length === 0) return refuse("the manifest yields no verify recipes — refusing to report green");
251
116
 
252
- // `default` is normalised the same way the ids are, and this is round 2's own fix's sibling.
253
- //
254
- // Round 2 stringified every recipe's `id` on the way out and left `default` as whatever the
255
- // manifest held. Every downstream lookup is an identity comparison — `r.id === set.default` in
256
- // `cli/stop-gate.mjs` — so a non-string default that used to match a non-string id stopped matching
257
- // the moment one side was normalised and the other was not. A repair that reached one of the two
258
- // values it had to reach, in the change whose subject is exactly that. Copilot round 6.
259
117
  const declaredDefault = manifest?.verify?.default;
260
118
  return { ok: true, recipes, default: declaredDefault == null ? declaredDefault : String(declaredDefault) };
261
119
  }
262
120
 
263
- /**
264
- * A resolver over a workspace's declared pack roots, for callers that actually compose.
265
- *
266
- * Reaches `compile.mjs`'s `rootPlan` and `resolvePack` rather than re-deriving where a pack lives —
267
- * the precedence rule (**a named root wins outright; otherwise discovery is unioned with the derived
268
- * root, discovered first**) lives once, in `resolutionRoots`, and a second derivation here would be a
269
- * second carrier of the fact `cli/discover.mjs` was built to hold. _(It read "asked-for discovery" until
270
- * 2026-08-13; the unasked arm consults a wired thunk too, and what asking still changes is the degrade —
271
- * asked-and-could-not-look is exit 2, unasked-and-could-not-look keeps the derived root.)_ The pack root handed to
272
- * `${PACK_ROOT}` is made **relative to the repository root**, because that is the root a recipe's
273
- * `run` is typed from.
274
- *
275
- * **`forced` rides beside `discovery` because without it the pair WAS dead plumbing.** This function
276
- * accepted `discovery` and passed it on while `resolutionRoots` consulted it only under `forced`, so no
277
- * caller could ever have reached discovery through here — the parameter looked wired and was not. Both
278
- * are still threaded and `forced` now carries less: since 2026-08-13 a wired `discovery` is consulted
279
- * either way, and `forced` selects the strict degrade. A caller passing neither stays hermetic, which is
280
- * the property `recipe-set.test.mjs` pins — and it is the reason `stop-gate`, which passes `packs: []`,
281
- * still reaches no host at all.
282
- */
121
+ /** The `resolve` that `recipeSet` takes; throws on a refusal, a could-not-look or a shadowed pack. */
283
122
  export function resolverFor({ workspaceDir, manifest, repoRoot = ".", named = [], discovery = null, forced = false }) {
284
123
  const plan = rootPlan(workspaceDir, manifest, { named, discovery, forced });
285
- // A refusal must not arrive as an empty root set. That is the silent drop this change exists to
286
- // remove, and the first cut of this function reintroduced it one layer down — an API caller
287
- // passing `named` beside `forced: true` would have resolved nothing and been told nothing.
288
- // Raised by Copilot, round 1 on #233; swept to `skills-set.mjs`'s sibling in the same stroke.
289
124
  if (plan.refusal) throw new Error(`recipe-set: ${plan.refusal}`);
290
125
  if (plan.couldNotRun) throw new Error(`recipe-set: ${plan.couldNotRun}`);
291
126
  const roots = plan.roots ?? [];
292
127
  const originAt = (r) => (plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin;
293
128
 
294
- // **A shadowed pack is refused here too** (#318), and this tool's ground is the strongest of the
295
- // three. `compile` refuses even an agreeing shadow because its artifact records which root
296
- // answered; `index` refuses one although its artifact deliberately does not. Here the answering
297
- // root is not merely recorded — **it is spliced into the output**: `${PACK_ROOT}` expands to the
298
- // root that answered, so two copies whose manifests are byte-identical still yield different `run`
299
- // lines pointing at different files. What CI runs and what a bare local run runs would diverge,
300
- // with nothing in either output to say so.
301
- //
302
- // So the agreeing case is not a carve-out anyone could argue for here, and the honest predicate was
303
- // never manifest comparison: it is output equality, computable only by resolving both worlds.
304
- // Refusing the ambiguity is what remains.
305
- //
306
- // **Checked HERE rather than inside the returned closure**, which is where a first cut put it. The
307
- // closure runs during `recipeSet`, outside the `try` that wraps this constructor — so the refusal
308
- // escaped as an uncaught throw, exit 1 with a stack trace, in a tool whose whole contract is exit 2
309
- // for could-not-run. Measured, not reasoned about. Throwing from the constructor puts it on the
310
- // path `plan.refusal` already uses and the caller already catches.
311
- //
312
- // **Only where the caller did not choose** — a named root replaces the derived one, `auto` is
313
- // discovery elected — which is `compile`'s guard shape, bound to it deliberately.
129
+ // Checked here, not in the returned closure: that runs inside `recipeSet`, beyond the caller's `try`.
314
130
  if (!forced && plan.source !== "named") {
315
131
  const declared = Array.isArray(manifest?.packs) ? manifest.packs : [];
316
132
  for (const ref of declared) {
317
133
  const found = resolvePack(String(ref), roots);
318
134
  if (!found?.dir) continue;
319
- // Path-resolved, matching `compile` — see the sibling note in `index.mjs`.
320
135
  const behind = shadowedCopy(String(ref), originAt(found.root), roots, originAt);
321
136
  if (!behind) continue;
322
137
  throw new Error(
@@ -331,20 +146,12 @@ export function resolverFor({ workspaceDir, manifest, repoRoot = ".", named = []
331
146
 
332
147
  return (ref) => {
333
148
  const found = resolvePack(ref, roots);
334
- // `resolvePack` returns `manifest` as the PATH to `pack.json`, not as the parsed object —
335
- // reading it is this adapter's job. The first cut of this function treated it as parsed, and
336
- // every pack then contributed nothing while the suite stayed green, because the fixture
337
- // resolver in `recipe-set.test.mjs` handed back a parsed object and so inherited the same
338
- // assumption the code made. Caught by running the emitter against this repository rather than
339
- // against its own fixtures; `cli/recipe-set.live.test.mjs` is what stops it coming back.
340
149
  if (!found?.dir || !found.manifest) return null;
341
150
  let parsed;
342
151
  try {
343
152
  parsed = JSON.parse(fs.readFileSync(found.manifest, "utf8"));
344
153
  } catch {
345
- // A pack whose manifest is present and unreadable is NOT a pack that contributes nothing.
346
- // Returning null here makes it could-not-run at the caller, which is the honest answer —
347
- // only absence is absence.
154
+ // Unreadable is not absent: null makes it could-not-run, not a pack with no recipes.
348
155
  return null;
349
156
  }
350
157
  const rel = path.relative(path.resolve(repoRoot), path.resolve(found.dir));
@@ -352,22 +159,8 @@ export function resolverFor({ workspaceDir, manifest, repoRoot = ".", named = []
352
159
  };
353
160
  }
354
161
 
355
- /**
356
- * The emitter CI runs: one `id<TAB>run` line per recipe in the composed set, exit 2 if the set cannot
357
- * be produced.
358
- *
359
- * `.github/workflows/verify.yml` carried this as an inline `node -e` until milestone 7's composition
360
- * amendment. It is here now because the row puts CI inside the contract — *CI runs the recipe set the
361
- * manifest yields* — so a workflow computing its own set would be the required check reporting green
362
- * over a recipe nobody ran, which is the false green the row names in words.
363
- */
162
+ /** CI's emitter: one `id<TAB>run` line per recipe in the set; exit 2 when the set cannot be produced. */
364
163
  export function run(argv = [], { stdout = process.stdout, stderr = process.stderr } = {}) {
365
- // **Three refusals, not one.** `init`, `new` and `vendor` each refuse a missing value, a FLAG as a
366
- // value, and an empty one; session 9 recorded a fourth parser taking only the first, where
367
- // `--tree --write <dir>` swallowed the write and silently reported instead of migrating. This was
368
- // the fifth: `argv.indexOf` plus a truthiness test meant `--workspace --pack-root packs` bound the
369
- // workspace to the string `--pack-root`, and the run then failed with a misleading *manifest could
370
- // not be read* about a directory nobody asked for. Copilot, round 5 on #236.
371
164
  let argError = null;
372
165
  const arg = (flag, fallback) => {
373
166
  const i = argv.indexOf(flag);
@@ -385,12 +178,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
385
178
  return 2;
386
179
  }
387
180
 
388
- // `--pack-root`, repeatable, with `auto` and the shared refusal — the same surface the other six
389
- // tools take (`compile`, `doctor`, `index`, `init`, `vendor`, `skills-set`). It was missing here, and the plumbing behind it (`resolverFor`'s `discovery` and
390
- // `forced`) existed with no way to reach it: a capability that looked wired and was not, which is
391
- // the defect this file's neighbour `skills-set` was caught with a day earlier. CI calls this
392
- // command for the recipe set it runs, so without the flag a workspace composing from the host
393
- // cache had no invocation that could enumerate its own recipes.
394
181
  const named = [];
395
182
  let forced = false;
396
183
  for (let i = 0; i < argv.length; i += 1) {
@@ -406,17 +193,7 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
406
193
  forced = true;
407
194
  continue;
408
195
  }
409
- // A root that is not there is a fact about the FILESYSTEM, not a pack that failed to resolve,
410
- // and reporting it as the latter sends an author to the one file that is not at fault. A FILE
411
- // is the sharper case: `resolvePack` only tests for `pack.json` under the root, so a file-valued
412
- // root yields "unresolvable" for every pack while the argument is what was wrong. Fourth carrier
413
- // of a rule `compile`, `doctor` and `index` already hold; raised by Copilot, round 1 on #236,
414
- // as an inconsistency with them.
415
- // **The RAW value is stat'd, and resolution happens after.** `path.resolve("")` is the current
416
- // working directory, so resolving first would let `--pack-root ""` pass the directory check and
417
- // silently adopt the cwd as a pack root — the empty-value refusal this file gained one round
418
- // earlier for `--workspace`, still missing here. `doctor`'s `directoryRoot` stats the raw value
419
- // for exactly this reason. Copilot, round 6 on #236.
196
+ // The raw value is stat'd: `path.resolve("")` is the cwd, so resolving first would pass an empty root.
420
197
  let rootStat = null;
421
198
  try {
422
199
  rootStat = fs.statSync(value);
@@ -448,7 +225,6 @@ export function run(argv = [], { stdout = process.stdout, stderr = process.stder
448
225
  try {
449
226
  resolve = resolverFor({ workspaceDir, manifest, repoRoot, named, discovery: () => discoverPackRoots(), forced });
450
227
  } catch (err) {
451
- // `resolverFor` throws on a refusal or a could-not-look; both are exit 2 rather than a set.
452
228
  stderr.write(`${err.message}\n`);
453
229
  return 2;
454
230
  }