@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,985 @@
1
+ #!/usr/bin/env node
2
+ // Host plugin-cache discovery — reading a host's installed-plugin record so a POINTER can be
3
+ // resolved to the workspace it names.
4
+ //
5
+ // Row 7 of `../docs/plan.md` owns this, by the amendment of 2026-08-03: *"reading a host's
6
+ // installed-plugin record to resolve an installed workspace or pack, resolving a pointer's
7
+ // `governed_by` to the workspace it names (a cache hit, or the honest not installed here
8
+ // sentence)"*. The same amendment fixes where the answer is produced and where it is merely
9
+ // reported: **the boot is a skill, and real resolution stays the CLI's.** So this file resolves and
10
+ // `../plugin/skills/portulan/SKILL.md` reports what it says.
11
+ //
12
+ // ## What this closes, and what it deliberately leaves open
13
+ //
14
+ // [#134](https://github.com/sleepy-panda-srl/portulan/issues/134)'s boot half: a workspace
15
+ // delivered by a private feed was invisible to `/portulan`, because the boot looked at exactly one
16
+ // path and nothing anywhere resolved `governed_by`. The pointer kind landed at Workspace Definition
17
+ // 2.7 with proposal `0017` and named the governing workspace; nothing dereferenced the name.
18
+ //
19
+ // **`--pack-root` IS wired to this file**, and this paragraph said the opposite until 2026-08-12. It
20
+ // read *"`--pack-root` is NOT wired to this file … nothing here adds a root at all"*, which was true
21
+ // when written and stopped being true at milestone 7 session 4, when
22
+ // [#123](https://github.com/sleepy-panda-srl/portulan/issues/123)'s half landed `resolutionRoots`
23
+ // here — a stale sentence that survived a session because the change that falsified it added a
24
+ // function below rather than editing the header above it. Corrected in the same stroke as the union,
25
+ // whose whole subject is a rule with more carriers than its author remembered.
26
+ //
27
+ // #117's property is the one to keep hold of while reading the rest: **a NAMED root replaces the
28
+ // `tree`-derived one**, so *"this pack resolved from the feed"* cannot be satisfied by a copy lying in
29
+ // the local tree. That half is untouched. The row states the direction discovery takes: it **adds a
30
+ // root only where none was named**, and never replaces one that was — and since 2026-08-12 the
31
+ // implementation takes that verb literally, which is what `resolutionRoots`' own docblock argues.
32
+ // **On 2026-08-13 it began taking the row's OTHER verb literally too** — *`--pack-root` and its
33
+ // siblings are optional where discovery finds a root* — so the unasked arm consults a wired thunk
34
+ // as well. `resolutionRoots`' docblock carries the ruling, the asymmetry between the two arms, and
35
+ // what now holds the boundary the old narrowing was standing in for.
36
+ //
37
+ // ## Four states, because three of them are not "no"
38
+ //
39
+ // A resolver with two answers is the fail-open this repository keeps minting rules about
40
+ // (`../.portulan/memory/verify-preconditions-fail-closed.md`): *could not look* must never read as
41
+ // *nothing wrong*, and *two candidates* must never read as *this one*.
42
+ //
43
+ // `resolved` exactly one installed plugin carries a workspace manifest whose `name` is the
44
+ // one the pointer asks for. The root is the directory that manifest sits in.
45
+ // `not-installed` nothing on this host carries it — including the case where the host has no
46
+ // installed-plugin record at all, which is a host with nothing installed.
47
+ // `ambiguous` two or more distinct roots answer to the name. REFUSED, both named. Picking one
48
+ // would be booting on a workspace the project did not ask for while looking
49
+ // exactly like success, which is the failure the boot skill's step 2 exists for.
50
+ // `could-not-look` the record is there and could not be read or parsed. Never reported as absence.
51
+ //
52
+ // ## What it matches on, and why that is the manifest rather than the plugin
53
+ //
54
+ // A pointer names a **workspace**, by the `name` the governing workspace's own manifest carries —
55
+ // `spec/workspace.schema.json` says so of `governed_by.workspace`, on the same slug definition, "so
56
+ // a pointer and its target cannot drift into different spellings of one identifier". A plugin name
57
+ // is a different identifier in a different namespace, and the two agreeing on this machine today is
58
+ // a coincidence of naming rather than a contract. So every candidate's `workspace.json` is read and
59
+ // its `name` compared. A cache hit is a hit on the manifest.
60
+ //
61
+ // ## Three limits, stated rather than left to be found
62
+ //
63
+ // 1. **Two candidate locations per plugin, named.** `<installPath>/workspace.json` and
64
+ // `<installPath>/.portulan/workspace.json` — the payload that IS a workspace, and the payload
65
+ // that is a repository carrying one. A recursive walk would find more and would be the matcher
66
+ // clever enough to be wrong quietly that `../.portulan/gate-map.md` refuses elsewhere; a limit a
67
+ // reader can measure is worth more here than reach.
68
+ // 2. **Nothing is fetched.** No network call, ever — not by this file and not by anything it is
69
+ // wired into. A workspace that is not installed is not installed, and the row puts feed
70
+ // authentication and discovery of the uninstalled explicitly out of scope.
71
+ // 3. **A resolved workspace is not a graded workspace.** This says where it is. Whether it is green
72
+ // is `doctor`'s answer about THAT directory, and running it is the caller's business.
73
+ //
74
+ // ## It is also runnable, and that is a seam rather than a convenience
75
+ //
76
+ // node cli/discover.mjs [--json] <workspace-dir>
77
+ //
78
+ // The boot skill has to get a DIRECTORY out of this, and the only other route was reading `doctor`'s
79
+ // prose — a sentence this very change rewrites, parsed by an agent, with no field to key on. So there
80
+ // is a surface, and `--json` is the machine-readable half of it.
81
+ //
82
+ // **This is not a ninth `portulan` subcommand.** `docs/vision.md` names eight and is human-owned;
83
+ // `plugin-lint` and `librarian` are the standing precedent for a tool that lives in `cli/`, runs as
84
+ // `node cli/<tool>.mjs`, and is deliberately absent from `./portulan.mjs`'s list. Whether this ever
85
+ // joins that list is the maintainer's call, exactly as theirs is.
86
+ //
87
+ // Exit codes are the three this repository uses everywhere: **0** resolved — **and also
88
+ // `resides-here`**, the answer for a manifest that is not a pointer at all · **1** a verdict that the
89
+ // workspace is not resolvable here (`not-installed`, `ambiguous`) · **2** could not run or could not
90
+ // look. The split matters at the one place it is read: *could not look* must never be spendable as
91
+ // *not installed*.
92
+ //
93
+ // **`state` is the field to key on, not the code.** Two states share exit 0 and no exit code can tell
94
+ // them apart — that is the cost of answering the question for both kinds of manifest rather than
95
+ // pushing the case analysis back onto the caller, and it is a cost rather than a defect. _(This
96
+ // paragraph said "**0** resolved" alone until #182 item 3: a sentence narrower than the code it
97
+ // described, committed in the same change that corrected that exact shape in eleven other carriers.
98
+ // Copilot, round 4 on #181.)_
99
+ //
100
+ // Zero dependencies, Node built-ins only, and every environment input injectable — the host config
101
+ // directory is read from `env`, so a test never depends on the machine it runs on.
102
+
103
+ import fs from "node:fs";
104
+ import os from "node:os";
105
+ import path from "node:path";
106
+ import { pathToFileURL } from "node:url";
107
+
108
+ /** The plugin-record filename and its directory, both the host's rather than ours. */
109
+ export const RECORD = path.join("plugins", "installed_plugins.json");
110
+
111
+ /**
112
+ * The record schema versions this reader claims to understand. Measured: `2` on Claude Code 2.1.226.
113
+ *
114
+ * Exported because it is a limit, and because refusing an unrecognised version is a **decision** rather
115
+ * than an oversight. The file belongs to the host, not to us: a later version may move `installPath`,
116
+ * rename it, or nest it, and a reader that parsed on regardless would report roots that are not roots —
117
+ * a confident wrong answer, which is the class this module's four states exist to prevent. So an
118
+ * unknown version is `unreadable`, which reaches a caller as **could-not-look** and never as
119
+ * *not installed*.
120
+ *
121
+ * The cost is stated rather than hidden: on the day the host bumps to `3`, discovery stops here until
122
+ * this set is widened by someone who has looked. That is the direction
123
+ * `../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md` requires, and the opposite
124
+ * failure — silently resolving against a shape nobody verified — is the one that cannot be noticed.
125
+ *
126
+ * _(Raised by the session building `--pack-root auto` on #183, which had the guard on its own reader and
127
+ * flagged its absence here before the two modules were reconciled. Verified against the live record
128
+ * rather than accepted: it carries `"version": 2`.)_
129
+ */
130
+ export const RECORD_VERSIONS = new Set([2]);
131
+
132
+ /**
133
+ * Where a candidate plugin payload may carry a workspace manifest, in the order tried.
134
+ *
135
+ * Exported because it is a limit, and a limit nobody can enumerate is a limit nobody can check —
136
+ * the suite asserts this list rather than restating it.
137
+ */
138
+ export const MANIFEST_AT = ["workspace.json", path.join(".portulan", "workspace.json")];
139
+
140
+ /**
141
+ * The host's configuration directory.
142
+ *
143
+ * `CLAUDE_CONFIG_DIR` overrides it — that is the host's documented escape hatch and it is the reason
144
+ * this takes an `env` at all rather than reading `process.env` at the point of use. An empty string
145
+ * is treated as unset: an exported-but-blank variable is a shell accident, and honouring it would
146
+ * point discovery at **the process's working directory**, since `path.resolve("")` is `cwd` and not
147
+ * `/` — a plausible-looking answer to a question nobody asked, and worse than an obvious one because
148
+ * it changes with where the tool was invoked from.
149
+ *
150
+ * _(This said "the filesystem root" until #182's round, which is wrong about `path.resolve` and was
151
+ * contradicted by this function's own test one file away — the same two-carriers-one-fact shape this
152
+ * change corrected in eleven other places, here in the docblock of the guard the fix cites as its
153
+ * precedent. Copilot, on the round reviewing the records.)_
154
+ */
155
+ export function configDir({ env = process.env, home = os.homedir() } = {}) {
156
+ const named = env.CLAUDE_CONFIG_DIR;
157
+ if (typeof named === "string" && named.trim() !== "") return path.resolve(named);
158
+ return path.join(home, ".claude");
159
+ }
160
+
161
+ /** The installed-plugin record's path for a host. */
162
+ export function recordPath(options = {}) {
163
+ return path.join(configDir(options), RECORD);
164
+ }
165
+
166
+ /**
167
+ * Read the host's installed-plugin record.
168
+ *
169
+ * Returns `{ state, path, entries, detail }` where `state` is one of `read` · `absent` ·
170
+ * `unreadable`. The three are kept apart at the source because collapsing them is exactly how a
171
+ * resolver starts answering "not installed" to a question it never asked.
172
+ *
173
+ * `entries` is flat — the record maps `<plugin>@<marketplace>` to an ARRAY, one per install scope,
174
+ * and a caller reasoning about roots wants the installs rather than the grouping. Malformed
175
+ * individual entries are dropped rather than taking the whole read down: one plugin's bad record
176
+ * should not blind discovery to every other plugin on the host.
177
+ */
178
+ export function readInstalls(options = {}) {
179
+ const file = recordPath(options);
180
+ let raw;
181
+ try {
182
+ raw = fs.readFileSync(file, "utf8");
183
+ } catch (cause) {
184
+ // ENOENT is a host with nothing installed — or no host at all, which is CI. Every other
185
+ // errno is a file that exists and would not open, which is a different fact and gets a
186
+ // different word.
187
+ if (cause.code === "ENOENT") return { state: "absent", path: file, entries: [], detail: null };
188
+ return { state: "unreadable", path: file, entries: [], detail: cause.code ?? cause.message };
189
+ }
190
+ let record;
191
+ try {
192
+ record = JSON.parse(raw);
193
+ } catch (cause) {
194
+ return { state: "unreadable", path: file, entries: [], detail: `not JSON — ${cause.message}` };
195
+ }
196
+ // `Array.isArray` is checked on BOTH the record and its `plugins`, and the second was missing — the
197
+ // sibling of a guard sitting one clause away. `typeof [] === "object"`, so `{"plugins": []}` read as
198
+ // a healthy record with nothing installed, and a malformed file collapsed into `not-installed`:
199
+ // *could not look* spent as absence, which is the one thing this file's four states exist to prevent.
200
+ // Found by Copilot, round 2, in a function whose own docblock argues that the three read states are
201
+ // kept apart at the source.
202
+ if (record === null || typeof record !== "object" || Array.isArray(record) || typeof record.plugins !== "object" || record.plugins === null || Array.isArray(record.plugins)) {
203
+ return { state: "unreadable", path: file, entries: [], detail: "no `plugins` object — this is not an installed-plugin record" };
204
+ }
205
+ // The version is checked BEFORE the entries are read, because it is the claim that makes reading them
206
+ // meaningful: `installPath` means what it means in version 2, and this reader has only ever seen
207
+ // version 2. Refused rather than parsed hopefully — see RECORD_VERSIONS for the argument and its cost.
208
+ if (!RECORD_VERSIONS.has(record.version)) {
209
+ return {
210
+ state: "unreadable",
211
+ path: file,
212
+ entries: [],
213
+ detail:
214
+ `record version ${JSON.stringify(record.version)} is not one this reader understands ` +
215
+ `(${[...RECORD_VERSIONS].join(", ")}) — refusing rather than reading a shape nobody has verified`,
216
+ };
217
+ }
218
+ const entries = [];
219
+ for (const [key, installs] of Object.entries(record.plugins)) {
220
+ if (!Array.isArray(installs)) continue;
221
+ // `<plugin>@<marketplace>`, split on the LAST `@` so a scoped plugin name keeps its own.
222
+ const at = key.lastIndexOf("@");
223
+ const plugin = at > 0 ? key.slice(0, at) : key;
224
+ const marketplace = at > 0 ? key.slice(at + 1) : null;
225
+ for (const install of installs) {
226
+ if (install === null || typeof install !== "object") continue;
227
+ if (typeof install.installPath !== "string" || install.installPath === "") continue;
228
+ entries.push({
229
+ key,
230
+ plugin,
231
+ marketplace,
232
+ installPath: path.resolve(install.installPath),
233
+ version: typeof install.version === "string" ? install.version : null,
234
+ scope: typeof install.scope === "string" ? install.scope : null,
235
+ gitCommitSha: typeof install.gitCommitSha === "string" ? install.gitCommitSha : null,
236
+ });
237
+ }
238
+ }
239
+ return { state: "read", path: file, entries, detail: null };
240
+ }
241
+
242
+ /**
243
+ * Read a candidate manifest, returning `{ manifest, name, kind }` or `null`.
244
+ *
245
+ * Unreadable and unparseable are both `null` and both deliberate: a plugin payload carrying
246
+ * something that is not a workspace manifest at one of the two named paths is simply not a
247
+ * candidate, and a resolver that failed loudly on every unrelated `workspace.json` on the host would
248
+ * be unusable. What it must never do is COUNT such a file as a match, and returning `null` is that.
249
+ *
250
+ * **`name` alone is not enough to say a file is a workspace manifest, and requiring only `name` was a
251
+ * fail-open.** Found at the pre-commit checkpoint by building it rather than by reading this
252
+ * function: a plugin payload carrying an Nx-style `workspace.json` — `{"version": 2, "name": "…",
253
+ * "projects": {…}}`, no `portulan` key, no `kind` — resolved, exit 0, and the boot would have been
254
+ * pointed at it. `workspace.json` is a common filename in the wider ecosystem, so that is an ordinary
255
+ * file rather than a contrived one, and the docblock above already promised it was not a candidate.
256
+ *
257
+ * The gate is `portulan` **and** `name`, which is the Workspace Definition's required identity minus
258
+ * `kind` — `kind` is read here for the pointer refusal and is not required, because a manifest
259
+ * missing it is schema-invalid and that is `doctor`'s verdict about the directory rather than
260
+ * discovery's. **This is deliberately not a second schema validator**: it is the cheapest test that
261
+ * distinguishes *a Portulan workspace manifest* from *a file that happens to share the name*, and
262
+ * conformance stays the one tool that owns it.
263
+ */
264
+ function readCandidate(root) {
265
+ for (const rel of MANIFEST_AT) {
266
+ const file = path.join(root, rel);
267
+ let parsed;
268
+ try {
269
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
270
+ } catch {
271
+ continue;
272
+ }
273
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
274
+ if (typeof parsed.name !== "string") continue;
275
+ // `portulan.spec`, not merely a `portulan` key. An empty object satisfied the first cut of this
276
+ // gate, so a non-Portulan file could still qualify by carrying one — the same fail-open one
277
+ // tightening later (Copilot, round 2). `spec` is what the Workspace Definition REQUIRES of the
278
+ // block, so this keys on the contract rather than on a key's presence; the pattern is left to
279
+ // `doctor`, which owns conformance.
280
+ if (parsed.portulan === null || typeof parsed.portulan !== "object" || Array.isArray(parsed.portulan)) continue;
281
+ if (typeof parsed.portulan.spec !== "string") continue;
282
+ return { manifest: file, dir: path.dirname(file), name: parsed.name, kind: typeof parsed.kind === "string" ? parsed.kind : null };
283
+ }
284
+ return null;
285
+ }
286
+
287
+ /**
288
+ * Resolve a pointer's `governed_by` against a host's plugin cache.
289
+ *
290
+ * `governedBy` is the manifest's own object — `{ workspace, feed }`. `feed`, where present,
291
+ * CONSTRAINS: it names the marketplace the workspace ships through, and an install from a different
292
+ * marketplace is not the thing the pointer asked for however well its name matches. That near miss
293
+ * is reported rather than swallowed, because "the right name from the wrong feed" is a diagnosis a
294
+ * reader needs and an absence sentence would hide.
295
+ *
296
+ * Returns a verdict carrying its own `sentence` — one carrier, so every surface that reports this
297
+ * prints the same words rather than paraphrasing them into four slightly different claims.
298
+ */
299
+ export function resolveGovernor(governedBy, options = {}) {
300
+ // **Blank is unset on both, which is `configDir()`'s rule applied at a second site of the same
301
+ // operation** — and blank-testing is the whole of it: the value that survives is the RAW one, never
302
+ // a trimmed copy.
303
+ //
304
+ // The two halves are different classes and the difference is worth stating, because a fix that
305
+ // flattened them would be claiming to close more than it does. `governed_by.feed` is
306
+ // `type: string, minLength: 1` in the Workspace Definition and nothing more, so `" "` **validates**
307
+ // — it constrained every candidate to a marketplace of that name, matched nothing, and answered
308
+ // `not-installed` about a workspace that is installed, which is the real defect and the class the
309
+ // four verdicts exist to prevent. `governed_by.workspace` is a `slug`, so `" "` is
310
+ // **schema-invalid** and `doctor` refuses it before anything reaches here: that half is
311
+ // defence-in-depth on this file's own runnable seam rather than a hole in the schema.
312
+ //
313
+ // **Not trimmed, deliberately, and the reason is a measurement.** Trimming only the wanted side makes
314
+ // `" sleepy-panda "` in a pointer match `sleepy-panda` on disk while the same padding ON DISK still
315
+ // misses — a new asymmetry, and discovery quietly repairing a manifest the slug pattern refuses,
316
+ // which `readCandidate` above promises it is not doing. `configDir()` blank-tests and hands
317
+ // `path.resolve` the raw string for the same reason; normalising identifiers is conformance's job and
318
+ // conformance is `doctor`'s. Found at the pre-commit checkpoint, which built the padded case rather
319
+ // than reading the diff — the first draft here trimmed, and turned a `resolved` into a
320
+ // `not-installed`.
321
+ //
322
+ // Copilot, #181 rounds 3 and 4; filed as #182 item 1 at the bound and taken here on the maintainer's
323
+ // grant of an extension, recorded in this pull request's own conversation.
324
+ const named = (value) => (typeof value === "string" && value.trim() !== "" ? value : null);
325
+ const wanted = {
326
+ workspace: named(governedBy?.workspace),
327
+ feed: named(governedBy?.feed),
328
+ };
329
+ const verdict = (state, extra) => ({
330
+ state,
331
+ wanted,
332
+ root: null,
333
+ manifest: null,
334
+ plugin: null,
335
+ marketplace: null,
336
+ version: null,
337
+ matches: [],
338
+ nearMisses: [],
339
+ record: null,
340
+ ...extra,
341
+ });
342
+
343
+ if (wanted.workspace === null) {
344
+ // A pointer with no `governed_by.workspace` is refused by `doctor` and by the schema before
345
+ // anything reaches here. Guarded anyway: a resolver asked to find "undefined" must say what
346
+ // it was asked rather than search for it.
347
+ return verdict("could-not-look", {
348
+ sentence: "this pointer names no governing workspace, so there is nothing to resolve — `governed_by.workspace` is required of a pointer",
349
+ });
350
+ }
351
+
352
+ const installs = options.installs ?? readInstalls(options);
353
+ const where = installs.path;
354
+ if (installs.state === "unreadable") {
355
+ return verdict("could-not-look", {
356
+ record: where,
357
+ sentence:
358
+ `the host's installed-plugin record at \`${where}\` could not be read (${installs.detail}), so whether ` +
359
+ `\`${wanted.workspace}\` is installed here is unknown — this is *could not look*, never *not installed*`,
360
+ });
361
+ }
362
+ if (installs.state === "absent") {
363
+ return verdict("not-installed", {
364
+ record: where,
365
+ sentence:
366
+ `\`${wanted.workspace}\` is not installed here — this host has no installed-plugin record at ` +
367
+ `\`${where}\`, so nothing is installed for it to be among. Nothing was fetched: discovery reads ` +
368
+ "what is on disk and never the network",
369
+ });
370
+ }
371
+
372
+ const matches = [];
373
+ const nearMisses = [];
374
+ for (const entry of installs.entries) {
375
+ const found = readCandidate(entry.installPath);
376
+ if (found === null || found.name !== wanted.workspace) continue;
377
+ const candidate = { ...entry, manifest: found.manifest, root: found.dir, kind: found.kind };
378
+ if (wanted.feed !== null && entry.marketplace !== wanted.feed) {
379
+ nearMisses.push({ ...candidate, why: "feed" });
380
+ continue;
381
+ }
382
+ if (found.kind === "pointer") {
383
+ // A pointer resolving to a pointer is a chain, and the residence ruling permits none: a
384
+ // repository is governed by exactly one workspace. Refused as a near miss rather than
385
+ // silently skipped, because the reader needs to know the name WAS found.
386
+ nearMisses.push({ ...candidate, why: "pointer" });
387
+ continue;
388
+ }
389
+ matches.push(candidate);
390
+ }
391
+
392
+ // Distinct roots, because one workspace installed at two scopes is two record entries and one
393
+ // directory, and refusing that would be refusing the ordinary case.
394
+ const roots = [...new Set(matches.map((m) => m.root))];
395
+ if (roots.length > 1) {
396
+ return verdict("ambiguous", {
397
+ record: where,
398
+ matches,
399
+ nearMisses,
400
+ sentence:
401
+ `\`${wanted.workspace}\` resolves to ${roots.length} installed workspaces here — ` +
402
+ `${matches.map((m) => `\`${m.key}\` at \`${m.root}\``).join(", ")}. Refusing to pick one: booting on a ` +
403
+ "workspace the repository did not ask for looks exactly like a boot and is not one",
404
+ });
405
+ }
406
+ if (roots.length === 1) {
407
+ const hit = matches[0];
408
+ return verdict("resolved", {
409
+ record: where,
410
+ matches,
411
+ nearMisses,
412
+ root: hit.root,
413
+ manifest: hit.manifest,
414
+ plugin: hit.plugin,
415
+ marketplace: hit.marketplace,
416
+ version: hit.version,
417
+ sentence:
418
+ `\`${wanted.workspace}\` is installed here — \`${hit.key}\`` +
419
+ (hit.version ? ` version ${hit.version}` : "") +
420
+ `, its manifest at \`${hit.manifest}\`. That directory is the workspace this repository is governed by`,
421
+ });
422
+ }
423
+ return verdict("not-installed", {
424
+ record: where,
425
+ nearMisses,
426
+ sentence:
427
+ `\`${wanted.workspace}\` is not installed here — nothing among the ${installs.entries.length} install(s) ` +
428
+ `recorded at \`${where}\` carries a workspace manifest of that name` +
429
+ (nearMisses.length
430
+ ? `. ${nearMisses
431
+ .map((m) =>
432
+ m.why === "feed"
433
+ ? `\`${m.key}\` carries that name and ships through ${
434
+ // `marketplace` is null wherever the record key carries no `@` — a shape
435
+ // `readInstalls` tolerates on purpose, so the sentence has to survive it.
436
+ // It printed "ships through `null`", which reads as a marketplace called
437
+ // null rather than as one nobody recorded. Copilot, on the round that
438
+ // reviewed the fix; the same class as the note beside it.
439
+ m.marketplace === null ? "no marketplace the record names" : `\`${m.marketplace}\``
440
+ }, which is not the feed \`${wanted.feed}\` this pointer names`
441
+ : `\`${m.key}\` carries that name and is itself a pointer, and a repository is governed by exactly one workspace`,
442
+ )
443
+ .join("; ")}`
444
+ : "") +
445
+ ". Nothing was fetched: discovery reads what is on disk and never the network",
446
+ });
447
+ }
448
+
449
+ // ===========================================================================================
450
+ // The command — the seam the boot skill reads
451
+ // ===========================================================================================
452
+
453
+ /**
454
+ * Which exit code each verdict carries. Exported so the suite asserts the mapping rather than a literal.
455
+ *
456
+ * **This maps the RESOLVER's four states, and `run()` carries a fifth answer of its own.** A directory
457
+ * whose manifest is not a pointer is answered `resides-here` and exits **0** without consulting this
458
+ * table — so exit 0 does not identify a state, and `state` is the field a caller keys on. Said here
459
+ * rather than left implicit, because a table named `EXIT` with four entries reads as the whole contract
460
+ * (#182 item 3).
461
+ */
462
+ export const EXIT = { resolved: 0, "not-installed": 1, ambiguous: 1, "could-not-look": 2 };
463
+
464
+ /**
465
+ * Resolve the pointer at `<workspace-dir>/workspace.json` and print the answer.
466
+ *
467
+ * The argument is a **workspace directory**, the same thing `doctor` takes, so a caller holding one
468
+ * path can hand it to either tool. A directory carrying a governing manifest is not an error and not
469
+ * a resolution: it is answered as *this repository's workspace resides here*, exit 0, because the
470
+ * question "where is the workspace that governs this" has a true answer for both kinds and a tool
471
+ * that only understood one of them would push the case analysis back onto its caller.
472
+ */
473
+ export function run(argv, options = {}) {
474
+ const say = options.say ?? ((line) => process.stdout.write(`${line}\n`));
475
+ const warn = options.warn ?? ((line) => process.stderr.write(`${line}\n`));
476
+ const json = argv.includes("--json");
477
+ const dirs = argv.filter((a) => !a.startsWith("-"));
478
+ // An unknown flag is REFUSED rather than dropped, and this is the boot's contract rather than
479
+ // tidiness: `--jsonn <ptr>` used to print prose and exit 0, so a typo — or a later rename of this
480
+ // flag — silently degraded the machine-readable answer into the half the skill is told never to
481
+ // read, with a success code on top. `./doctor.mjs` already guards leading-`-` arguments; found at
482
+ // the pre-commit checkpoint by typing the typo rather than by reading the filter.
483
+ const KNOWN = new Set(["--json", "--help", "-h"]);
484
+ const unknown = argv.filter((a) => a.startsWith("-") && !KNOWN.has(a));
485
+ if (unknown.length) {
486
+ warn(`discover: unknown option ${unknown.map((f) => `\`${f}\``).join(", ")} — refusing rather than ignoring it, because a dropped flag here returns prose where a caller asked for JSON.`);
487
+ warn("usage: node cli/discover.mjs [--json] <workspace-dir>");
488
+ return 2;
489
+ }
490
+ if (argv.includes("--help") || argv.includes("-h")) {
491
+ say("usage: node cli/discover.mjs [--json] <workspace-dir>");
492
+ say("");
493
+ say("Resolves a `kind: pointer` manifest's `governed_by` against the host's installed-plugin");
494
+ say("record. Reads what is on disk; never the network.");
495
+ say("Exit: 0 resolved — and also `resides-here`, a manifest that is not a pointer · 1 not");
496
+ say("resolvable here (`not-installed`, `ambiguous`) · 2 could not run or could not look.");
497
+ say("Key on `state`: two states share exit 0 and the code cannot tell them apart.");
498
+ return 0;
499
+ }
500
+ if (dirs.length !== 1) {
501
+ warn("usage: node cli/discover.mjs [--json] <workspace-dir>");
502
+ return 2;
503
+ }
504
+ const manifestPath = path.join(path.resolve(dirs[0]), "workspace.json");
505
+ let manifest;
506
+ try {
507
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
508
+ } catch (cause) {
509
+ warn(`discover: no readable manifest at ${manifestPath} — ${cause.message}`);
510
+ return 2;
511
+ }
512
+ if (manifest?.kind !== "pointer") {
513
+ // Answered rather than refused, and the shape is the same object so a caller keys on `state`
514
+ // either way. `resides-here` is not one of the resolver's four: those are answers about a
515
+ // HOST, and this is an answer about the argument.
516
+ const verdict = {
517
+ state: "resides-here",
518
+ root: path.dirname(manifestPath),
519
+ manifest: manifestPath,
520
+ wanted: null,
521
+ sentence:
522
+ `this manifest is \`kind: ${manifest?.kind ?? "unknown"}\`, not a pointer — the workspace governing this ` +
523
+ `repository resides at \`${path.dirname(manifestPath)}\` and nothing needs resolving`,
524
+ };
525
+ say(json ? JSON.stringify(verdict, null, 2) : verdict.sentence);
526
+ return 0;
527
+ }
528
+ const verdict = resolveGovernor(manifest.governed_by, options);
529
+ say(json ? JSON.stringify(verdict, null, 2) : verdict.sentence);
530
+ return EXIT[verdict.state] ?? 2;
531
+ }
532
+
533
+ // ===========================================================================================
534
+ // Pack-resolution roots
535
+ // ===========================================================================================
536
+ //
537
+ // The second half of row 7's discovery clause (#123). Everything above resolves a WORKSPACE by name,
538
+ // which is what a pointer needs; this resolves the roots a declared PACK is looked up under, which is
539
+ // what `--pack-root` needed a value for.
540
+ //
541
+ // It is built on `readInstalls` above rather than on a second reader — the two halves landed as
542
+ // separate pull requests (#181 and #183) and each shipped its own record reader, which is the
543
+ // three-tools-two-semantics defect `../cli/compile.mjs`'s `namedRootsOption` records. Reconciled on
544
+ // merge: the reader, the config directory and the record-version refusal are this file's, once.
545
+
546
+ /**
547
+ * The value of `--pack-root` that means *discover it*, rather than naming a directory.
548
+ *
549
+ * Matched against the **raw argument, before any path resolution**, so a directory genuinely called
550
+ * `auto` stays reachable as `./auto` or by an absolute path. A keyword in a value space that until now
551
+ * held only paths is a spelling whose meaning this change flips, so the escape is stated in the help
552
+ * text everywhere, and in the missing-value error of the three tools that resolve roots themselves
553
+ * (`compile`, `doctor`, `index`). `init` and `vendor` forward roots rather than resolving them and
554
+ * carry it in their usage text only — narrower than "every parse error", which is what this sentence
555
+ * said until a checkpoint checked it.
556
+ */
557
+ export const AUTO = "auto";
558
+
559
+ /**
560
+ * Is this directory a pack-resolution root — does it hold `<category>/<name>/pack.json`?
561
+ *
562
+ * Asking the RESOLVER's own question rather than looking for a directory named `packs` is what makes
563
+ * this work on both shapes a plugin lands in, and the first version of this function did not:
564
+ *
565
+ * - **Repository-shaped** — the plugin IS a repository checkout (`source: "./"`), so its packs sit
566
+ * under `<installPath>/packs`. The engine plugin is this shape.
567
+ * - **Flat** — the plugin ships a pack family directly, so the CATEGORIES are at the install root:
568
+ * `<installPath>/rituals/checkpoints/pack.json`. `portulan-checkpoints@portulan-internal` is this
569
+ * shape, and it is the pack this project's own workspace composes.
570
+ *
571
+ * Probing only the first meant discovery could not see either plugin the private feed actually ships,
572
+ * with a green suite throughout, because the fixtures encoded the same assumption as the code. Found
573
+ * by a pre-commit checkpoint that looked at a real install — `.portulan/memory/`'s standing lesson:
574
+ * a harness you write to check your own change inherits your blind spot.
575
+ */
576
+ export function isPackRoot(dir) {
577
+ let categories;
578
+ try {
579
+ categories = fs.readdirSync(dir, { withFileTypes: true });
580
+ } catch {
581
+ return false;
582
+ }
583
+ for (const category of categories) {
584
+ if (!category.isDirectory() || category.name.startsWith(".")) continue;
585
+ let packs;
586
+ try {
587
+ packs = fs.readdirSync(path.join(dir, category.name), { withFileTypes: true });
588
+ } catch {
589
+ continue;
590
+ }
591
+ for (const pack of packs) {
592
+ if (!pack.isDirectory()) continue;
593
+ if (fs.existsSync(path.join(dir, category.name, pack.name, "pack.json"))) return true;
594
+ }
595
+ }
596
+ return false;
597
+ }
598
+
599
+ /**
600
+ * Pack-resolution roots discovered from the host's plugin cache.
601
+ *
602
+ * Returns `{ ok, roots, installs, why }`. `ok: false` is **could not look** — the record is there and
603
+ * could not be read — and is never the same answer as `ok: true` with no roots, which is *looked,
604
+ * found nothing installed that carries packs*.
605
+ *
606
+ * ## An ABSENT record is `ok: true`, and this line was the opposite until 2026-08-13
607
+ *
608
+ * `readInstalls` keeps three states apart at the source — `read`, `absent`, `unreadable` — and its
609
+ * docblock says collapsing them is how a resolver starts lying. **This function then collapsed two of
610
+ * them**, mapping `absent` to could-not-look from the day this function was written (2026-08-09) to
611
+ * 2026-08-13 — four days, not the "year of sessions" an earlier draft of this paragraph claimed, which
612
+ * was a superlative nobody counted in a file about answers nobody measured. The docblock above
613
+ * described the collapse as though it were the design.
614
+ *
615
+ * It is not a style question, and the measurement is what settles it. A host with **no record at all**
616
+ * is a host with nothing installed — which is every CI runner, and precisely what
617
+ * `readInstalls`'s own comment above says `absent` means. Reported as could-not-look it made
618
+ * `--pack-root auto` return the **empty set**, and an empty set makes `doctor` report every declared
619
+ * pack *unverifiable* and exit **0**. Measured on the workspace `init` drafts by default: with no
620
+ * flag, exit 1 and the cache pack correctly FAILS; with `--pack-root auto`, exit **0** and neither
621
+ * pack is looked at — including one that resolves perfectly well from the adopter's own tree. The flag
622
+ * did not merely fail to help, it **discarded a root it already had** and converted a correct red into
623
+ * a green.
624
+ *
625
+ * The target behaviour was already in the table: a host with a **valid record and nothing installed**
626
+ * has always done the right thing here. `absent` now joins it, and **this module already said so in
627
+ * two other places** — `readInstalls`'s ENOENT comment, and the pointer path below, which has always
628
+ * turned `absent` into a `not-installed` VERDICT ("nothing is installed for it to be among") rather
629
+ * than a could-not-look. `./plugin-lint.mjs` states the general rule: *"Absent counts as
630
+ * examined … that IS the finding."* `discoverPackRoots` was the outlier inside its own file.
631
+ *
632
+ * **The distinction that keeps this from contradicting the fail-closed rule:** a *dependency the check
633
+ * needs* being missing is could-not-run — that is `../.portulan/memory/verify-preconditions-fail-closed.md`'s
634
+ * subject, and why a missing tool still exits 2. A *world-state the check observes* being empty is an
635
+ * answer. The plugin record is the second kind: discovery's whole question is "what is installed
636
+ * here", and "nothing" answers it.
637
+ *
638
+ * Both shapes are offered per install, repository-shaped first; `resolvePack` is first-match-wins, and
639
+ * no plugin carries both.
640
+ */
641
+ export function discoverPackRoots(options = {}) {
642
+ const read = readInstalls(options);
643
+ // Absent is an ANSWER: nothing is installed. Only a record that is there and will not be read is
644
+ // could-not-look, and that one keeps `ok: false` — asking for discovery and being unable to look
645
+ // must never be spendable as a green.
646
+ if (read.state === "absent") {
647
+ return {
648
+ ok: true,
649
+ roots: [],
650
+ installs: [],
651
+ why: `${read.path} does not exist — this host has nothing installed, which is an answer rather than a failure to look`,
652
+ };
653
+ }
654
+ if (read.state !== "read") {
655
+ return {
656
+ ok: false,
657
+ roots: [],
658
+ installs: [],
659
+ why: `${read.path} could not be read (${read.state}${read.detail ? ` — ${read.detail}` : ""}). Discovery could not look; this is not the same as finding nothing installed`,
660
+ };
661
+ }
662
+ const roots = [];
663
+ const contributing = [];
664
+ for (const install of read.entries) {
665
+ for (const candidate of [path.join(install.installPath, "packs"), install.installPath]) {
666
+ if (!isPackRoot(candidate)) continue;
667
+ roots.push(candidate);
668
+ contributing.push({ ...install, root: candidate });
669
+ }
670
+ }
671
+ return { ok: true, roots, installs: contributing, why: null };
672
+ }
673
+
674
+ /**
675
+ * The one sentence every tool prints when a caller asks for both a named root and `auto`.
676
+ *
677
+ * A constant because **seven** commands reach this refusal — `compile`, `doctor`, `index`, `init`,
678
+ * `vendor`, `skills-set` and, since 2026-08-13, `recipe-set` — and seven spellings of it is how one of
679
+ * them ends up wording it as a warning, which is the silent drop it replaces wearing a different coat.
680
+ * _(It said "five" until `recipe-set` gained the flag; six was already wrong.)_
681
+ */
682
+ export const NAMED_WITH_AUTO =
683
+ "name roots or ask for `auto`, never both: `--pack-root auto` cannot be combined with a named root";
684
+
685
+ /**
686
+ * The refusal's CONDITION, exported so an argument parser and the resolver share one, not two.
687
+ *
688
+ * Each command must refuse at parse time — that is where an exit 2 belongs, before a workspace is
689
+ * read — while `resolutionRoots` must refuse for API callers that never parsed anything. Two places
690
+ * have to ask, so the thing they ask is a function rather than a repeated `&&`.
691
+ *
692
+ * Returns the sentence to print, or `null` when the combination was not asked for.
693
+ */
694
+ export function namedWithAuto(named = [], forced = false) {
695
+ // Takes the array a parser has or the boolean a resolver has, because the two callers genuinely
696
+ // hold different shapes of the same fact and the alternative was worse: the first cut passed a
697
+ // one-element array containing a SENTINEL STRING so an explicitly-empty named set would trip this.
698
+ // That put a fake root in a predicate about roots, and made the refusal say "a named root" about a
699
+ // set that had none. Raised by Copilot, round 1 on #233.
700
+ const given = Array.isArray(named) ? named.length > 0 : Boolean(named);
701
+ return given && forced ? NAMED_WITH_AUTO : null;
702
+ }
703
+
704
+ /**
705
+ * Apply the precedence rule to the sources of a pack-resolution root.
706
+ *
707
+ * ## A named root wins outright; asked-for discovery ADDS
708
+ *
709
+ * Two ratified texts describe this and use the verb "add" for different objects, which reads as a
710
+ * contradiction until the objects are named:
711
+ *
712
+ * - **The row** (`docs/plan.md` row 7): *"an explicitly named root is never silently overridden —
713
+ * discovery adds a root only where none was named"*. The object is a **named** root; discovery
714
+ * loses to `--pack-root`, and that half has never moved.
715
+ * - **#123's closing constraint:** *"Discovery that silently adds roots would reintroduce exactly the
716
+ * substitution the pre-commit checkpoint caught."* The object is the **`tree`-derived** root, and
717
+ * the operative word in it is **silently**.
718
+ *
719
+ * **The rule is: named > (discovered ∪ derived), discovered first.** It was *named > discovered >
720
+ * derived, never union* until 2026-08-12, and what changed it was a measurement rather than a
721
+ * preference — see below. The order inside the union is load-bearing and not cosmetic:
722
+ * `resolvePack` is first-match-wins, so where both carry a pack the **discovered** copy wins, which
723
+ * keeps every result the old `auto` produced as a subset of this one and keeps the pin meaningful.
724
+ *
725
+ * ## What forced the change: the ordinary workspace could not go green
726
+ *
727
+ * A workspace composing a cache-installed pack **and** one of its own — which is what `init` produces
728
+ * by default the moment an adopter adds a pack, since it composes `rituals/checkpoints` — had **no
729
+ * green invocation** that did not require typing the host plugin-cache path by hand. Measured
730
+ * 2026-08-12 across all four arrangements: no flag reds on the cache pack, `auto` reds on the
731
+ * adopter's own, `auto` plus a named root reds on the cache pack because named replaces both, and
732
+ * only two named roots — one of them a cache path nobody should have to know — went green. That
733
+ * falsified this docblock's own claim that the narrowing survived *"where it matters — nobody has to
734
+ * know the cache path"*. For that shape, somebody had to.
735
+ *
736
+ * ## What the union costs, said plainly because the first framing of it did not
737
+ *
738
+ * The replaced rule had **two** grounds and only the first was surfaced when the change was proposed.
739
+ *
740
+ * 1. **The unasked path must not read the host.** An earlier draft gave a discovered root, unasked, to
741
+ * a workspace deriving none. A pre-commit checkpoint priced it: `examples/workspace.json` declares
742
+ * packs and no `tree`, and `.portulan/verify/doctor.sh` grades `examples`, so that branch made a
743
+ * **required recipe** read `~/.claude` on every run — red locally, green in CI. **Untouched here by
744
+ * construction:** the union lives inside the `forced` branch, so nothing changes when nobody asks.
745
+ * 2. **A structural provenance guarantee, now traded.** Under the old rule the resolution set held no
746
+ * tree-derived root under `auto`, so *"this pack resolved from the feed"* was a property of the
747
+ * set's composition — knowable from the invocation alone. Under the union it becomes a **statement
748
+ * per pack**, which is detection where there was prevention, and this repository has measured that
749
+ * nobody reads a passing validator's output. The trade was put to the maintainer and ruled: union,
750
+ * **but never silently** — which is #123's own word, and the reason `origins` below is a field
751
+ * rather than a sentence. A provenance a caller can only grep out of prose is checkable against
752
+ * sentences this same change wrote.
753
+ *
754
+ * **What may be claimed after the trade:** no path needs typing; every pack's resolution names the root
755
+ * it used and whether that root was discovered or derived; a local-tree resolution is visible rather
756
+ * than silent. **What may NOT:** that `auto` resolves only from a feed — it no longer does — or that a
757
+ * green under it certifies provenance. The green certifies resolution. What bounds a pack's *content* is
758
+ * unchanged and is the pin, never this function. The narrower property #117 demonstrated also stands
759
+ * verbatim: a **named** root still replaces the derived one, so a resolved-from-a-feed demonstration
760
+ * given a named root cannot be satisfied by a local copy.
761
+ *
762
+ * _(Each clause above read "under `--pack-root auto`" until 2026-08-13. The trade is the same trade and
763
+ * it now applies to the bare invocation as well, which is the disposal: a flag whose absence bought a
764
+ * provenance guarantee could not also be optional. What asking still buys is the **strict degrade** —
765
+ * see the asymmetry below — and nothing else.)_
766
+ *
767
+ * ## Asking for both is refused, not reconciled
768
+ *
769
+ * `--pack-root auto --pack-root ./packs` used to drop the `auto` without a word. A rule whose whole
770
+ * justification is *never silently* cannot ship beside a branch that silently discards an explicit
771
+ * request, so the combination is a **refusal** — `refusal` is set, and the roots are empty so a caller
772
+ * that forgets to check fails closed rather than resolving against half of what was asked for.
773
+ *
774
+ * ## The unasked arm consults discovery too, as of 2026-08-13 — the row's word, met
775
+ *
776
+ * The row's clause is *"optional where discovery finds a root"*, and until this date `--pack-root` was
777
+ * not optional: on the workspace `init` drafts by default plus one pack of the adopter's own, `doctor`
778
+ * exited **1** with no flag and **0** under `auto`. Measured, and it was worse than one red — the same
779
+ * arrangement made `recipe-set` and `skills-set --check` exit **2**, and left `compile` composing the
780
+ * bound checkpoint pack's two gate fragments into nothing. Three of five tools were unusable unasked.
781
+ *
782
+ * The maintainer ruled a **behaviour change rather than a row amendment**: the row's word stands and
783
+ * the implementation meets it. A fresh supervisor graded that ruling sound from `../docs/vision.md`,
784
+ * which defines `doctor` as a **per-host capability report** — answering about the host is what it is
785
+ * for — and sharpened the boundary to: **a verdict about the *repository* must not depend on the
786
+ * machine.**
787
+ *
788
+ * **What holds that boundary is the PIN, not this branch.** Six required invocations name their root
789
+ * (2026-08-13), a named root **replaces** every other source, and `../cli/pinned-roots.live.test.mjs`
790
+ * fails if any of them drops it or if a seventh joins them unpinned. So a required check's verdict
791
+ * cannot move with what is installed on the machine running it, whatever the unasked default is. This
792
+ * docblock said the **thunk** was what kept `compile --check` host-independent, and that sentence had
793
+ * already gone false one pull request earlier, when `.portulan/verify/compile.sh` gained
794
+ * `--pack-root packs`: it names a root. The guarantee moved carriers and the prose had not.
795
+ *
796
+ * ## The asymmetry between the two arms, which is the whole reason they are two
797
+ *
798
+ * **`forced` and unasked differ on exactly one question: what an unreadable record means.**
799
+ *
800
+ * - **Asked and could not look is could-not-run** — exit 2, ruled 2026-08-13. You asked; the question
801
+ * is unanswerable; that is what the third code is for.
802
+ * - **Unasked and could not look degrades to derived-only, with the diagnostic REPORTED** — never an
803
+ * empty set, never exit 2. Nobody asked, so the readability of the host's record cannot be a
804
+ * precondition for grading a repository. Silent, though, it must not be: the diagnostic rides in
805
+ * `why`, so a host with a corrupt record is visible rather than merely survived.
806
+ *
807
+ * **Reusing the `forced` branch as the unasked default is therefore the one implementation that must
808
+ * not happen.** It would import that branch's `couldNotRun` and every CI runner — where the record is
809
+ * absent, then unreadable the day a host bumps its schema — would exit 2 or, worse, be given an empty
810
+ * root set and go green by not looking. That is the fail-open this module spent a session closing.
811
+ *
812
+ * `discovery` may be a THUNK, and **its presence is the switch**: an API caller that wires none keeps
813
+ * the hermetic behaviour this function had before, on every arm. There is no second flag for it,
814
+ * because a per-caller opt-out would be a second carrier of the precedence rule — the
815
+ * three-tools-two-semantics defect `./compile.mjs`'s `namedRootsOption` records.
816
+ */
817
+ export function resolutionRoots({ named = [], namedGiven = null, derived = [], discovery = null, forced = false } = {}) {
818
+ let cached;
819
+ const resolveDiscovery = () => {
820
+ if (cached === undefined) cached = typeof discovery === "function" ? discovery() : discovery;
821
+ return cached;
822
+ };
823
+ // `namedGiven` separates *a caller named roots* from *the list is non-empty*, because an
824
+ // explicitly EMPTY named set means **search nowhere** for API callers and must not fall through to
825
+ // the derived root. Three tools disagreed about that once; keeping the distinction here rather
826
+ // than in each of them is what stops a fourth from inventing a fifth answer.
827
+ const givenNamed = namedGiven ?? named.length > 0;
828
+ // Every branch returns the same shape, `origins` included: a caller joining a resolved pack to its
829
+ // root must not have to know which branch produced the plan.
830
+ const tag = (roots, origin) => roots.map((root) => ({ root, origin }));
831
+ // `refusal` and `couldNotRun` are separate fields because they are separate facts, and both map to
832
+ // exit 2. `refusal` says *your command line asked for two different resolution sets*; `couldNotRun`
833
+ // says *you asked me to look and I could not*. Collapsing them would send a reader with an
834
+ // unreadable plugin record to re-read their flags.
835
+ const plan = (roots, source, why, origins = null, refusal = null, couldNotRun = null) => ({
836
+ roots,
837
+ source,
838
+ why,
839
+ origins: origins ?? tag(roots, source),
840
+ refusal,
841
+ couldNotRun,
842
+ });
843
+ // **ONE union, reached from both arms.** Discovered-first is load-bearing rather than cosmetic:
844
+ // `resolvePack` is first-match-wins, so where both roots carry a pack the discovered copy answers,
845
+ // which keeps every result the old `auto` produced a subset of this one. Written once because two
846
+ // orders keyed on whether a flag was typed would make the flag change the *meaning* of resolution
847
+ // rather than its inputs — and two matching literals are a convention, while one function is a
848
+ // guarantee. `lead` is the only difference between the arms: who asked.
849
+ //
850
+ // **What `source: "union"` means, because the arms reach this differently and a reader will assume
851
+ // they do not.** The `forced` arm calls this even when discovery found **nothing** — you asked, and
852
+ // *"I looked, and there is nothing installed"* is the answer to report. The unasked arm calls it only
853
+ // where discovery contributed a root; otherwise it returns `derived`, with discovery's own sentence in
854
+ // the `why`. So `union` reads as *discovery contributed, or was asked for* — never as *the set has two
855
+ // origins*. Recorded because a test fixture with an empty cache passed the asked half and bound
856
+ // nothing on the unasked one, which is how the asymmetry was noticed at all.
857
+ const union = (found, lead) => {
858
+ const nothingFound = found.roots.length === 0 && found.why;
859
+ return plan(
860
+ [...found.roots, ...derived],
861
+ "union",
862
+ `${lead} (${found.roots.length} root(s))${nothingFound ? ` — ${found.why}` : ""} and derived from the manifest's \`tree\` (${derived.length} root(s)) — each pack's resolution states which of the two it came from`,
863
+ [...tag(found.roots, "discovered"), ...tag(derived, "derived")],
864
+ );
865
+ };
866
+
867
+ const refusal = namedWithAuto(givenNamed, forced);
868
+ if (refusal) {
869
+ return plan(
870
+ [],
871
+ "none",
872
+ "both an explicit root and `auto` were given — discovery adds a root only where none was named, so this asks for two different resolution sets",
873
+ [],
874
+ refusal,
875
+ );
876
+ }
877
+ if (givenNamed) {
878
+ // Deliberately does not consult discovery: the row's guarantee is that a named root is never
879
+ // silently overridden, and the cheapest way to keep that true is to have nothing to override
880
+ // it with on this branch.
881
+ return plan(
882
+ [...named],
883
+ "named",
884
+ named.length ? "named on the command line" : "named on the command line as an empty set — search nowhere",
885
+ );
886
+ }
887
+ if (forced) {
888
+ const found = resolveDiscovery();
889
+ // **Also could-not-run**, and it took Copilot's round 4 to see it: this branch's own `why`
890
+ // says *discovery was requested and did not run*, which is the definition of the third exit
891
+ // code, and it was returning a bare empty plan — the exact fail-open the branch below was just
892
+ // repaired for, one line above it. `resolutionRoots` is exported and `rootPlan` takes `forced`
893
+ // and `discovery` independently, so a caller can reach this with no thunk wired and, treating
894
+ // an empty root set as *nothing to check*, exit 0 over a discovery that never happened.
895
+ if (!found) {
896
+ const why = "discovery was requested and did not run — no discovery was wired into this call";
897
+ return plan([], "none", why, [], null, why);
898
+ }
899
+ // **Asked for, and could not look — exit 2.** Ruled 2026-08-13 on a measurement: this branch
900
+ // returned an empty plan, and an empty root set makes `doctor` report every declared pack
901
+ // *unverifiable* and exit **0**. So `--pack-root auto` against an unreadable record was a
902
+ // green over a host nobody could read, and it discarded the tree-derived root on the way —
903
+ // turning a correct red into a green while losing information it already had. A discovery that
904
+ // could not look is a could-not-run, which is what the third exit code exists for.
905
+ //
906
+ // An **absent** record no longer reaches here: `discoverPackRoots` answers `ok: true` with no
907
+ // roots for that, because a host with no record is a host with nothing installed. Only a
908
+ // record that exists and will not be read is this branch.
909
+ //
910
+ // **The strongest ground for exit 2 rather than a quiet degrade is `RECORD_VERSIONS`.** That
911
+ // constant promises discovery stops on a record schema this reader has not seen, "until this
912
+ // set is widened by someone who has looked". A host bumping its record version lands exactly
913
+ // here — so a degrade-and-carry-on would silently convert every `auto` user to derived-only on
914
+ // that day, fleet-wide, repealing a decision the constant records as deliberate. Exit 2 makes
915
+ // it loud, which is what the constant asked for.
916
+ //
917
+ // **That argument is about the ASKED arm alone, and saying so is the point.** Since the unasked
918
+ // arm began consulting discovery (2026-08-13) a record-version bump degrades the *bare* fleet to
919
+ // derived-only rather than stopping it — which is correct, because nobody asked — so "loud" has
920
+ // to be delivered there by the diagnostic rather than by an exit code. Where each tool surfaces
921
+ // it: `doctor` prints `plan.why` on its `resolution root` note and on every unresolved pack;
922
+ // `compile` prints the plan line whenever the source is a union and an `UNRESOLVED` line per
923
+ // pack; `recipe-set` and `skills-set` still exit **2** on a composed artefact they could not
924
+ // read, which is the row's own could-not-run rule and is untouched by the degrade.
925
+ if (!found.ok) return plan([], "none", found.why, [], null, found.why);
926
+ // Where discovery found NOTHING, its own sentence rides through — see `union`. "0 root(s)" alone
927
+ // cannot tell a host with no record from a host with a valid record listing nothing installed,
928
+ // and those are different facts about the machine — the second is a host that has Portulan packs
929
+ // available and none of them relevant; the first is a host that has never installed one.
930
+ return union(found, "discovered in the host plugin cache");
931
+ }
932
+ // ── The unasked arm. Nobody named a root and nobody typed `auto`.
933
+ //
934
+ // A wired thunk is consulted here, and the DEGRADES are the whole of the difference from the arm
935
+ // above: an unreadable record loses the discovered half and keeps the derived one, rather than
936
+ // becoming a could-not-run. Guarded on `discovery` rather than on the memoised value, so a caller
937
+ // that wires nothing never reaches the thunk at all — that identity, not a flag, is what keeps an
938
+ // API caller hermetic.
939
+ const found = discovery === null || discovery === undefined ? null : resolveDiscovery();
940
+ if (found && found.ok && found.roots.length) return union(found, "discovered in the host plugin cache unasked");
941
+ // Everything below is derived-only, and the three ways of getting here need three sentences,
942
+ // because collapsing them is how this module started lying about `absent` in the first place.
943
+ // `couldNotRun` is null on every one of them: nobody asked.
944
+ // `found.why` is **optional**, and appending it unguarded put the literal `null` in a user-facing
945
+ // sentence. `discoverPackRoots` returns `why: null` on its ordinary success path — a record that
946
+ // READ fine and simply lists no plugin carrying packs — which is a real host rather than an edge:
947
+ // anyone with plugins installed and none of them a pack feed. The `union` helper one screen up
948
+ // guards exactly this (`found.roots.length === 0 && found.why`) and this arm did not: one operation,
949
+ // two sites, correct at one. Raised by Copilot, round 1 on #237.
950
+ const said = (lead) => (found.why ? `${lead} — ${found.why}` : lead);
951
+ const because = !found
952
+ ? null
953
+ : found.ok
954
+ ? // Looked, nothing installed that carries packs. Discovery's own sentence, where it has one,
955
+ // distinguishes a host with no record from one whose record lists nothing relevant.
956
+ said("discovery was consulted and found no root")
957
+ : // Could not look, and it is REPORTED rather than swallowed. Degrading in silence would
958
+ // make a corrupt plugin record indistinguishable from a clean host, which is the pair
959
+ // `discoverPackRoots` keeps apart one function above.
960
+ said("discovery was consulted and could not look, so only the derived root is searched");
961
+ if (derived.length) {
962
+ // Same discipline as the arm below: the stem is ONE string and only the tail branches, so a
963
+ // discriminator matching on it cannot be broken by editing the other case.
964
+ return plan(
965
+ [...derived],
966
+ "derived",
967
+ `derived from the workspace manifest's \`tree\`${because ? `; ${because}` : " — pass `--pack-root auto` to search the host plugin cache as well"}`,
968
+ );
969
+ }
970
+ // The STEM is deliberately the same clause in both arms — *no root was named and none is derivable
971
+ // from the manifest* — and only the tail differs. Rewording it cost a test that was asserting the
972
+ // right property for the right reason: `doctor.test.mjs`'s *"a declared pack on a workspace with no
973
+ // tree is REPORTED, never failed"* matched on `none is derivable from the manifest`, and a
974
+ // paraphrase broke a discriminator this change had no business touching. Prose that carries a
975
+ // discriminator is an interface.
976
+ return plan(
977
+ [],
978
+ "none",
979
+ `no root was named and none is derivable from the manifest; ${because ?? "discovery was not asked for (`--pack-root auto`)"}`,
980
+ );
981
+ }
982
+
983
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
984
+ process.exitCode = run(process.argv.slice(2));
985
+ }