@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,425 @@
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
+ */
59
+
60
+ import fs from "node:fs";
61
+ import path from "node:path";
62
+ import { pathToFileURL } from "node:url";
63
+
64
+ import { resolvePack, rootPlan } from "./compile.mjs";
65
+ import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
66
+
67
+ /**
68
+ * Every reader of the recipe set, by path.
69
+ *
70
+ * **Pinned in two halves, because this list alone holds less than it looks like it holds.**
71
+ * `cli/recipe-set.test.mjs` asserts the roster's contents, which catches a reader being removed or
72
+ * renamed — but a *fifth* reader that never adds itself here touches nothing that assertion reads. So
73
+ * `cli/recipe-set.live.test.mjs` sweeps `cli/*.mjs` and the workflows for an undeclared enumeration of
74
+ * `verify.recipes`, which is the half that actually catches drift. `cli/collisions.test.mjs` carries
75
+ * the same pair for the three `collisions()`, and the sweep is the half it had to add for the same
76
+ * reason: a copy nothing asserts is invisible to every other test.
77
+ *
78
+ * Two sites enumerate `verify.recipes` directly and are exempt by argument, not by oversight: this
79
+ * module, and `cli/doctor.mjs`, which validates the workspace's own DECLARATION rather than reading
80
+ * the runnable set.
81
+ */
82
+ export const RECIPE_SET_READERS = Object.freeze([
83
+ ".github/workflows/verify.yml",
84
+ "cli/doctor.mjs",
85
+ "cli/stop-gate.mjs",
86
+ "cli/vendor.mjs",
87
+ ]);
88
+
89
+ /** `$defs/slug`, held identically by both schemas. A workspace id and `verify.default` are this. */
90
+ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
91
+
92
+ /** The token a composed `run` uses to name its own pack's files. */
93
+ const PACK_ROOT_TOKEN = "${PACK_ROOT}";
94
+
95
+ /** The composed spelling. Exported so no caller re-derives it — one carrier, again, one layer down. */
96
+ export function composedId(packRef, id) {
97
+ return `${packRef}:${id}`;
98
+ }
99
+
100
+ function refuse(reason) {
101
+ return { ok: false, exitCode: 2, reason };
102
+ }
103
+
104
+ /**
105
+ * Validate one recipe's runnable parts. Returns an error string or null.
106
+ *
107
+ * These four refusals were carried by `.github/workflows/verify.yml`'s inline emitter alone until this
108
+ * module took them. Losing one in the move would have been a fix not done at the site it was found,
109
+ * committed inside the change that cites the doctrine — so they are asserted here in the suite rather
110
+ * than trusted to have survived.
111
+ */
112
+ function invalidRunnable(id, run, where, { requireSlug = true } = {}) {
113
+ // `requireSlug` is off only on the deliberately-bypassed path. Without it `trustPackSpelling` could
114
+ // not do the one thing its JSDoc says it exists for: a genuinely pre-namespaced id carries `/` and
115
+ // `:`, so the slug check refused it *before* the shadow refusal it was meant to reach — an option
116
+ // that named a purpose it could not serve. Copilot round 6.
117
+ if (requireSlug && !SLUG.test(String(id ?? ""))) return `${where} declares a recipe id that is not a slug: ${JSON.stringify(id)}`;
118
+ const command = String(run ?? "");
119
+ if (!command.trim()) return `${where} declares an empty run command for recipe \`${id}\``;
120
+ // Names all three characters it rejects. It said "newline or tab" while the pattern also refused a
121
+ // carriage return, so a manifest carrying one got a message pointing at two characters it did not
122
+ // contain — a diagnosis that sends the reader looking for the wrong thing. Copilot round 1.
123
+ if (/[\n\r\t]/.test(command)) {
124
+ return `${where} declares a run containing a newline, carriage return or tab for recipe \`${id}\``;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * The composed runnable set for a workspace manifest.
131
+ *
132
+ * @param {object} manifest a parsed Workspace Definition
133
+ * @param {object} [options]
134
+ * @param {string[]} [options.packs] the workspace's `packs` array; defaults to `manifest.packs`
135
+ * @param {(ref: string) => ({ ref: string, root: string, manifest: object } | null)} [options.resolve]
136
+ * resolves a pack reference to its root and manifest, or null when it cannot be found.
137
+ * Absent when packs are declared means nothing resolves, which is could-not-run — fail-closed,
138
+ * because a resolver that silently is not there would compose nothing and report green.
139
+ * @param {boolean} [options.trustPackSpelling] use a pack's own recipe id verbatim instead of
140
+ * namespacing it. Exists so the shadow refusal can be exercised against a real input; no
141
+ * production caller passes it.
142
+ *
143
+ * @returns {{ ok: true, recipes: object[], default: string|undefined }
144
+ * | { ok: false, exitCode: 2, reason: string }}
145
+ */
146
+ export function recipeSet(manifest, options = {}) {
147
+ const { packs = manifest?.packs ?? [], resolve = null, trustPackSpelling = false } = options;
148
+
149
+ // `packs` must be an array, for the same reason `verify.recipes` must be — and this is that fix's
150
+ // sibling, at the site round 1 did not reach. A string here would iterate CHARACTERS and report
151
+ // "the pack `t` could not be resolved", a refusal whose message names something the manifest never
152
+ // said; an object would throw a TypeError, which is a stack trace rather than a diagnosis. Both are
153
+ // could-not-run wearing a worse face. Copilot round 3.
154
+ if (!Array.isArray(packs)) {
155
+ 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`);
156
+ }
157
+
158
+ // A manifest with no `verify.recipes` ARRAY is could-not-run, not a workspace that declares none.
159
+ //
160
+ // This was `?? []`, and that was a fail-open the move introduced: the CI emitter this carrier
161
+ // replaced iterated `m.verify.recipes` directly, so an absent block threw and became exit 2. Under
162
+ // `?? []` an invalid manifest could still yield a runnable set as soon as a pack contributed one —
163
+ // CI would then run a green over a workspace whose own declaration nobody could read. One of the
164
+ // four validations that was supposed to survive the move, and the one that did not; the pre-commit
165
+ // checkpoint asked the question and neither pass caught it. Copilot round 1's suppressed note did.
166
+ //
167
+ // Absent is distinguishable from empty here only in the message: both refuse, because `spec/workspace.schema.json`
168
+ // requires the block and a workspace that declares no recipes cannot report green either way.
169
+ if (!Array.isArray(manifest?.verify?.recipes)) {
170
+ return refuse(
171
+ "the workspace manifest declares no `verify.recipes` array — could-not-run rather than a set " +
172
+ "computed as if it had declared none",
173
+ );
174
+ }
175
+ const declared = manifest.verify.recipes;
176
+ const recipes = [];
177
+ const seen = new Set();
178
+
179
+ for (const recipe of declared) {
180
+ const bad = invalidRunnable(recipe?.id, recipe?.run, "the workspace manifest");
181
+ if (bad) return refuse(bad);
182
+ // Normalised to strings on the way out, the way the pack path below already does.
183
+ //
184
+ // The validation above coerces with `String(...)`, so a non-string `id` that stringifies to a
185
+ // slug PASSED and was then pushed through unchanged — and every downstream comparison is an
186
+ // identity check on that value: `r.id === set.default` in the Stop gate, `seen.has(id)` here,
187
+ // the emitter's tab-separated line. One arm of this function stringified and the other did not,
188
+ // which is the two-sites-one-rule shape this whole change is about, inside the carrier that
189
+ // carries it. Copilot round 2's suppressed note.
190
+ const id = String(recipe.id);
191
+ const run = String(recipe.run);
192
+ if (seen.has(id)) return refuse(`the workspace manifest declares the recipe \`${id}\` more than once`);
193
+ seen.add(id);
194
+ recipes.push({ ...recipe, id, run, source: { kind: "workspace" } });
195
+ }
196
+
197
+ for (const ref of packs) {
198
+ const found = resolve ? resolve(ref) : null;
199
+ if (!found) {
200
+ // Never silently absent. The row's words: exit 2, and the run says which pack — and which
201
+ // recipe, where one is known. A composed recipe quietly missing from the set would be a
202
+ // green computed over something nobody ran, arriving through the composition path.
203
+ return refuse(
204
+ `the pack \`${ref}\` is composed by this workspace and could not be resolved, so its verify recipes ` +
205
+ `could not be read — could-not-run rather than a set that quietly lost them`,
206
+ );
207
+ }
208
+
209
+ // A pack declaring no `verify` key contributes no recipes and is not an error. That is
210
+ // `packs/rituals/checkpoints`'s deliberate shape, by its own argued policy, and conflating
211
+ // "declared none" with "failed to resolve" would make the honest pack red.
212
+ //
213
+ // But a key that is PRESENT and not an array is neither of those: it is a manifest nobody can
214
+ // read, and `for..of` over it throws a TypeError — the carrier crashing where its whole
215
+ // contract is to exit 2 with a sentence naming the pack. Third site of round 1's rule, and the
216
+ // second one it did not reach. Copilot round 3.
217
+ const contributed = found.manifest?.contributes?.verify;
218
+ if (contributed !== undefined && !Array.isArray(contributed)) {
219
+ return refuse(`the pack \`${ref}\` declares \`contributes.verify\` as ${typeof contributed}, not an array — could-not-run`);
220
+ }
221
+ for (const recipe of contributed ?? []) {
222
+ const declaredId = String(recipe?.id ?? "");
223
+ if (!trustPackSpelling && !SLUG.test(declaredId)) {
224
+ return refuse(`the pack \`${ref}\` declares a recipe id that is not a slug: ${JSON.stringify(recipe?.id)}`);
225
+ }
226
+
227
+ const id = trustPackSpelling ? declaredId : composedId(ref, declaredId);
228
+
229
+ // Expansion first, so a pack root carrying a newline cannot pass a check that ran too early.
230
+ const run = String(recipe?.run ?? "").split(PACK_ROOT_TOKEN).join(found.root);
231
+
232
+ const bad = invalidRunnable(declaredId, run, `the pack \`${ref}\``, { requireSlug: !trustPackSpelling });
233
+ if (bad) return refuse(bad);
234
+
235
+ if (seen.has(id)) {
236
+ return refuse(
237
+ `the pack \`${ref}\` contributes a recipe that would shadow \`${id}\`, which is already in this ` +
238
+ `workspace's set — composition is additive and may never redefine or replace a recipe`,
239
+ );
240
+ }
241
+ seen.add(id);
242
+ recipes.push({ ...recipe, id, run, source: { kind: "pack", pack: ref, root: found.root } });
243
+ }
244
+ }
245
+
246
+ // Reporting green having run nothing is the failure the whole gate exists to prevent, and
247
+ // enumeration is a precondition exactly as it is inside each recipe —
248
+ // `../.portulan/memory/verify-preconditions-fail-closed.md`.
249
+ if (recipes.length === 0) return refuse("the manifest yields no verify recipes — refusing to report green");
250
+
251
+ // `default` is normalised the same way the ids are, and this is round 2's own fix's sibling.
252
+ //
253
+ // Round 2 stringified every recipe's `id` on the way out and left `default` as whatever the
254
+ // manifest held. Every downstream lookup is an identity comparison — `r.id === set.default` in
255
+ // `cli/stop-gate.mjs` — so a non-string default that used to match a non-string id stopped matching
256
+ // the moment one side was normalised and the other was not. A repair that reached one of the two
257
+ // values it had to reach, in the change whose subject is exactly that. Copilot round 6.
258
+ const declaredDefault = manifest?.verify?.default;
259
+ return { ok: true, recipes, default: declaredDefault == null ? declaredDefault : String(declaredDefault) };
260
+ }
261
+
262
+ /**
263
+ * A resolver over a workspace's declared pack roots, for callers that actually compose.
264
+ *
265
+ * Reaches `compile.mjs`'s `rootPlan` and `resolvePack` rather than re-deriving where a pack lives —
266
+ * the precedence rule (**a named root wins outright; otherwise discovery is unioned with the derived
267
+ * root, discovered first**) lives once, in `resolutionRoots`, and a second derivation here would be a
268
+ * second carrier of the fact `cli/discover.mjs` was built to hold. _(It read "asked-for discovery" until
269
+ * 2026-08-13; the unasked arm consults a wired thunk too, and what asking still changes is the degrade —
270
+ * asked-and-could-not-look is exit 2, unasked-and-could-not-look keeps the derived root.)_ The pack root handed to
271
+ * `${PACK_ROOT}` is made **relative to the repository root**, because that is the root a recipe's
272
+ * `run` is typed from.
273
+ *
274
+ * **`forced` rides beside `discovery` because without it the pair WAS dead plumbing.** This function
275
+ * accepted `discovery` and passed it on while `resolutionRoots` consulted it only under `forced`, so no
276
+ * caller could ever have reached discovery through here — the parameter looked wired and was not. Both
277
+ * are still threaded and `forced` now carries less: since 2026-08-13 a wired `discovery` is consulted
278
+ * either way, and `forced` selects the strict degrade. A caller passing neither stays hermetic, which is
279
+ * the property `recipe-set.test.mjs` pins — and it is the reason `stop-gate`, which passes `packs: []`,
280
+ * still reaches no host at all.
281
+ */
282
+ export function resolverFor({ workspaceDir, manifest, repoRoot = ".", named = [], discovery = null, forced = false }) {
283
+ const plan = rootPlan(workspaceDir, manifest, { named, discovery, forced });
284
+ // A refusal must not arrive as an empty root set. That is the silent drop this change exists to
285
+ // remove, and the first cut of this function reintroduced it one layer down — an API caller
286
+ // passing `named` beside `forced: true` would have resolved nothing and been told nothing.
287
+ // Raised by Copilot, round 1 on #233; swept to `skills-set.mjs`'s sibling in the same stroke.
288
+ if (plan.refusal) throw new Error(`recipe-set: ${plan.refusal}`);
289
+ if (plan.couldNotRun) throw new Error(`recipe-set: ${plan.couldNotRun}`);
290
+ const roots = plan.roots ?? [];
291
+ return (ref) => {
292
+ const found = resolvePack(ref, roots);
293
+ // `resolvePack` returns `manifest` as the PATH to `pack.json`, not as the parsed object —
294
+ // reading it is this adapter's job. The first cut of this function treated it as parsed, and
295
+ // every pack then contributed nothing while the suite stayed green, because the fixture
296
+ // resolver in `recipe-set.test.mjs` handed back a parsed object and so inherited the same
297
+ // assumption the code made. Caught by running the emitter against this repository rather than
298
+ // against its own fixtures; `cli/recipe-set.live.test.mjs` is what stops it coming back.
299
+ if (!found?.dir || !found.manifest) return null;
300
+ let parsed;
301
+ try {
302
+ parsed = JSON.parse(fs.readFileSync(found.manifest, "utf8"));
303
+ } catch {
304
+ // A pack whose manifest is present and unreadable is NOT a pack that contributes nothing.
305
+ // Returning null here makes it could-not-run at the caller, which is the honest answer —
306
+ // only absence is absence.
307
+ return null;
308
+ }
309
+ const rel = path.relative(path.resolve(repoRoot), path.resolve(found.dir));
310
+ return { ref, root: rel === "" ? "." : rel, manifest: parsed };
311
+ };
312
+ }
313
+
314
+ /**
315
+ * The emitter CI runs: one `id<TAB>run` line per recipe in the composed set, exit 2 if the set cannot
316
+ * be produced.
317
+ *
318
+ * `.github/workflows/verify.yml` carried this as an inline `node -e` until milestone 7's composition
319
+ * amendment. It is here now because the row puts CI inside the contract — *CI runs the recipe set the
320
+ * manifest yields* — so a workflow computing its own set would be the required check reporting green
321
+ * over a recipe nobody ran, which is the false green the row names in words.
322
+ */
323
+ export function run(argv = [], { stdout = process.stdout, stderr = process.stderr } = {}) {
324
+ // **Three refusals, not one.** `init`, `new` and `vendor` each refuse a missing value, a FLAG as a
325
+ // value, and an empty one; session 9 recorded a fourth parser taking only the first, where
326
+ // `--tree --write <dir>` swallowed the write and silently reported instead of migrating. This was
327
+ // the fifth: `argv.indexOf` plus a truthiness test meant `--workspace --pack-root packs` bound the
328
+ // workspace to the string `--pack-root`, and the run then failed with a misleading *manifest could
329
+ // not be read* about a directory nobody asked for. Copilot, round 5 on #236.
330
+ let argError = null;
331
+ const arg = (flag, fallback) => {
332
+ const i = argv.indexOf(flag);
333
+ if (i < 0) return fallback;
334
+ const value = argv[i + 1];
335
+ if (value === undefined) argError ??= `${flag} needs a value`;
336
+ else if (value.startsWith("-")) argError ??= `${flag} was given ${JSON.stringify(value)}, which is a flag rather than a value`;
337
+ else if (value.trim() === "") argError ??= `${flag} was given an empty value, and no option here has a meaningful empty value`;
338
+ return argError ? fallback : value;
339
+ };
340
+ const repoRoot = arg("--repo-root", ".");
341
+ const workspaceDir = arg("--workspace", path.join(repoRoot, ".portulan"));
342
+ if (argError) {
343
+ stderr.write(`${argError}\n`);
344
+ return 2;
345
+ }
346
+
347
+ // `--pack-root`, repeatable, with `auto` and the shared refusal — the same surface the other six
348
+ // tools take (`compile`, `doctor`, `index`, `init`, `vendor`, `skills-set`). It was missing here, and the plumbing behind it (`resolverFor`'s `discovery` and
349
+ // `forced`) existed with no way to reach it: a capability that looked wired and was not, which is
350
+ // the defect this file's neighbour `skills-set` was caught with a day earlier. CI calls this
351
+ // command for the recipe set it runs, so without the flag a workspace composing from the host
352
+ // cache had no invocation that could enumerate its own recipes.
353
+ const named = [];
354
+ let forced = false;
355
+ for (let i = 0; i < argv.length; i += 1) {
356
+ if (argv[i] !== "--pack-root") continue;
357
+ const value = argv[i + 1];
358
+ i += 1;
359
+ if (value === undefined || value.startsWith("-")) {
360
+ stderr.write("--pack-root needs a directory, or `auto` to discover one from the host plugin cache. A directory actually named `auto` is `./auto`\n");
361
+ return 2;
362
+ }
363
+ // The keyword on the RAW argument, before any resolution, so `./auto` still names a directory.
364
+ if (value === AUTO) {
365
+ forced = true;
366
+ continue;
367
+ }
368
+ // A root that is not there is a fact about the FILESYSTEM, not a pack that failed to resolve,
369
+ // and reporting it as the latter sends an author to the one file that is not at fault. A FILE
370
+ // is the sharper case: `resolvePack` only tests for `pack.json` under the root, so a file-valued
371
+ // root yields "unresolvable" for every pack while the argument is what was wrong. Fourth carrier
372
+ // of a rule `compile`, `doctor` and `index` already hold; raised by Copilot, round 1 on #236,
373
+ // as an inconsistency with them.
374
+ // **The RAW value is stat'd, and resolution happens after.** `path.resolve("")` is the current
375
+ // working directory, so resolving first would let `--pack-root ""` pass the directory check and
376
+ // silently adopt the cwd as a pack root — the empty-value refusal this file gained one round
377
+ // earlier for `--workspace`, still missing here. `doctor`'s `directoryRoot` stats the raw value
378
+ // for exactly this reason. Copilot, round 6 on #236.
379
+ let rootStat = null;
380
+ try {
381
+ rootStat = fs.statSync(value);
382
+ } catch (cause) {
383
+ stderr.write(`--pack-root ${JSON.stringify(value)} cannot be read — ${cause.code ?? cause.message}. Refusing to report a pack unresolvable against a root nothing looked in\n`);
384
+ return 2;
385
+ }
386
+ if (!rootStat.isDirectory()) {
387
+ stderr.write(`--pack-root ${JSON.stringify(value)} is not a directory — a resolution root is a directory packs are looked up under\n`);
388
+ return 2;
389
+ }
390
+ named.push(path.resolve(value));
391
+ }
392
+ const bothAsked = namedWithAuto(named, forced);
393
+ if (bothAsked) {
394
+ stderr.write(`${bothAsked}\n`);
395
+ return 2;
396
+ }
397
+
398
+ let manifest;
399
+ try {
400
+ manifest = JSON.parse(fs.readFileSync(path.join(workspaceDir, "workspace.json"), "utf8"));
401
+ } catch (err) {
402
+ stderr.write(`the workspace manifest at ${workspaceDir} could not be read: ${err.message}\n`);
403
+ return 2;
404
+ }
405
+
406
+ let resolve;
407
+ try {
408
+ resolve = resolverFor({ workspaceDir, manifest, repoRoot, named, discovery: () => discoverPackRoots(), forced });
409
+ } catch (err) {
410
+ // `resolverFor` throws on a refusal or a could-not-look; both are exit 2 rather than a set.
411
+ stderr.write(`${err.message}\n`);
412
+ return 2;
413
+ }
414
+ const set = recipeSet(manifest, { resolve });
415
+ if (!set.ok) {
416
+ stderr.write(`${set.reason}\n`);
417
+ return set.exitCode;
418
+ }
419
+ for (const recipe of set.recipes) stdout.write(`${recipe.id}\t${recipe.run}\n`);
420
+ return 0;
421
+ }
422
+
423
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
424
+ process.exit(run(process.argv.slice(2)));
425
+ }