@sleepy_panda_srl/portulan 0.1.3 → 0.3.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 (89) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +145 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1719 -1699
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +269 -1080
  10. package/cli/drills.mjs +92 -504
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +882 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +6 -125
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +29 -265
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +82 -505
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +258 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/README.md +33 -0
  52. package/core/personas/librarian.md +2 -2
  53. package/core/skills/clarify/SKILL.md +1 -1
  54. package/core/skills/consolidate/SKILL.md +5 -3
  55. package/core/templates/handoff.md +28 -15
  56. package/core/templates/memory-entry.md +1 -0
  57. package/evals/README.md +271 -845
  58. package/evals/releases/0.2.0.json +146 -0
  59. package/evals/releases/0.2.0.md +67 -0
  60. package/evals/releases/0.3.0.json +150 -0
  61. package/evals/releases/0.3.0.md +68 -0
  62. package/evals/releases/README.md +3 -3
  63. package/package.json +5 -2
  64. package/packs/.claude-plugin/plugin.json +2 -1
  65. package/packs/README.md +7 -4
  66. package/packs/rituals/checkpoints/README.md +1 -1
  67. package/packs/tools/README.md +8 -8
  68. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  69. package/packs/tools/local-llm/README.md +136 -0
  70. package/packs/tools/local-llm/pack.json +32 -0
  71. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  72. package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
  73. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
  74. package/packs/tools/local-llm/verify/README.md +18 -0
  75. package/spec/README.md +113 -6
  76. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  77. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  78. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  79. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  80. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  81. package/spec/migrations/0006-boot-card.mjs +77 -0
  82. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  83. package/spec/migrations/0008-card-reading.mjs +55 -0
  84. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  85. package/spec/migrations/0010-comment-rail.mjs +57 -0
  86. package/spec/migrations/README.md +45 -10
  87. package/spec/pack.schema.json +1 -1
  88. package/spec/slots.md +343 -27
  89. package/spec/workspace.schema.json +145 -4
@@ -1,205 +1,39 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * The registrable set — the one carrier of *what a host must declare so a composed pack's skills
4
- * register*.
3
+ * The registrable set: the `skills` paths a plugin manifest must declare so a composed pack's skills register.
5
4
  *
6
5
  * node cli/skills-set.mjs [--workspace <dir>] [--repo-root <dir>] [--plugin-root <dir>]
7
6
  * [--pack-root <dir>|auto ...] [--check|--write]
8
7
  *
9
8
  * Exit 0 derived (or, with `--check`, agrees) · 1 the manifest has drifted · 2 could not run.
10
- *
11
- * Row 7 clause (b)'s adopter half, [#184](https://github.com/sleepy-panda-srl/portulan/issues/184):
12
- * *the generator that derives a plugin's `skills` from a workspace's `packs`*.
13
- *
14
- * ## The gap this closes, measured rather than argued
15
- *
16
- * **Registration is a property of `.claude-plugin/plugin.json` and of nothing else** — measured
17
- * 2026-08-09 on Claude Code 2.1.226 by deleting the `packs` key from the governing workspace outright
18
- * and reinstalling, which changed the host's inventory not at all. So until this landed, a composed
19
- * pack's skill was invocable **by coincidence of a hand-written path**: two manifests carried one
20
- * fact, and the only thing holding them together was `cli/plugin-lint.mjs`'s `compose` check, which
21
- * buys *this bundle's* parity and not an adopter's.
22
- *
23
- * `spec/pack.schema.json` had already undertaken this in its own words, under `contributes.skills`:
24
- * *"Reaching parity means reading this key, so the row has undertaken to open it."* `doctor` opens it
25
- * to VALIDATE the skills behind it (the 2026-08-03 amendment); nothing folded it into registration.
26
- * That is exactly the declare-only state `contributes.verify` sat in until milestone 7 session 5, and
27
- * the repair is the one that session established: **one carrier, and the readers reach it**
28
- * (`../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md`).
29
- *
30
- * ## Why a module and not a ninth subcommand
31
- *
32
- * `docs/vision.md` names eight and is human-owned. `cli/README.md` already records the precedent in
33
- * `discover`'s own row — *"deliberately not a ninth `portulan` subcommand … joins that list only if
34
- * the maintainer says so."* And not a widening of `compile` either, whose glossary entry is *"Emits
35
- * restriction only — never an `allow` rule"*: a skills path is a capability **grant**, which is the
36
- * other direction, and its input is `workspace.json`'s `packs` rather than the gate policy.
37
- *
38
- * ## The derivation, and the one platform fact it rests on
39
- *
40
- * A pack declares where its skills live (`contributes.skills`, each entry a directory that either IS
41
- * one skill or CONTAINS them). The host expands a declared skills path **one level and no further**.
42
- * Both shapes are therefore reached by declaring the root itself — a root holding `SKILL.md`
43
- * registers at depth 0, a root of skill directories registers them at depth 1 — which is why the
44
- * derived path is the `contributes.skills` root, relativised to the plugin root, and nothing cleverer.
45
- *
46
- * `HOST_SKILL_DEPTH` lives HERE rather than in `cli/plugin-lint.mjs`, where it was measured and was
47
- * module-local. It moved because that file now imports this one for the `compose` check, so exporting
48
- * it from there would have made an import cycle. The direction matches `AGENT_DIR`, which flows
49
- * plugin-lint → doctor: the carrier of a derived set carries the platform constant the derivation
50
- * depends on. **It is a measurement, not a derivation** — Claude Code 2.1.224 and 2.1.226 — and a host
51
- * that changes it makes every path below wrong with nothing here to say so. Re-measure on upgrade.
52
- *
53
- * ## What this deliberately does NOT do
54
- *
55
- * - **It never creates a plugin manifest.** A workspace shipping no plugin is a legitimate state, not
56
- * a hole to fill, and inventing a manifest would be this tool deciding an adopter ships one.
57
- * - **It touches only the pack portion of `skills`.** Entries outside the roots packs actually resolve
58
- * from — `./core/skills/`, `./plugin/skills/` — are somebody else's declaration and are preserved
59
- * verbatim, including their original spelling and order.
60
- *
61
- * **That boundary is derived, never assumed to be `./packs/`.** The first cut had two partitions of
62
- * one fact: the derivation emitted a path for any pack resolving inside the plugin root, while the
63
- * declared side recognised only `<pluginRoot>/packs`. Anything in the gap was derived and never
64
- * recognised as derived, so `--check` reported the same drift forever and `--write` appended a
65
- * duplicate entry on every run — **reachable with no flags at all**, from a `tree` pointing anywhere
66
- * but the conventional place, which is to say from an ordinary adopter's layout. This repository's
67
- * own layout makes the two partitions coincide, which is exactly why every test and the byte-identity
68
- * check passed over it: *a harness written against blind spots inherits this one.* Found at the
69
- * pre-commit checkpoint by constructing the layout rather than by reading the code.
70
- * - **It does not check the tree.** This derives from what a pack DECLARES. Whether a `SKILL.md` is
71
- * actually there, and whether one sits too deep for the host to reach, is `plugin-lint`'s walk. The
72
- * two are deliberately different derivations: a pack whose declaration and tree disagree is a
73
- * finding, and collapsing them would delete the check that finds it.
74
- * - **`--write` re-serialises with two-space indentation and a trailing newline**, which is the form
75
- * every manifest in this repository already has. A manifest formatted otherwise is reformatted, and
76
- * that is a real cost rather than a hidden one.
77
- *
78
- * ## A pack two roots answer for is refused, not picked
79
- *
80
- * On a host carrying an installed copy of a composed pack **and** the tree's own, resolution is
81
- * discovered-first and first-match-wins, so the installed copy answers. That copy lives outside the
82
- * plugin root, containment fails, and this tool concluded the tree's declared skills path belonged to
83
- * no composed pack — printing `--write` as the remedy, where `--write` is the mutation that strips a
84
- * correct declaration out of a tracked manifest
85
- * ([#317](https://github.com/sleepy-panda-srl/portulan/issues/317)).
86
- *
87
- * So this refuses at RESOLUTION — exit 2 — where `compile` refuses for the same family
88
- * ([#316](https://github.com/sleepy-panda-srl/portulan/issues/316)), and for the same reason: the
89
- * printer, `--check` and `--write` inherit ONE rule rather than three. Refusing on `--write` alone
90
- * would leave `--check` exiting **1**, asserting the *repository* had drifted when it had not — and an
91
- * exit code is the machine-read API, so a false 1 is a false verdict about the tree.
92
- *
93
- * **The predicate is imported, not re-spelled.** `shadowedCopy` and `packDifferences` live in
94
- * `./compile.mjs`. That comparison had already been wrong twice in `doctor` before it became one
95
- * carrier, and a third copy written inside a fix for its own family is precisely the defect
96
- * `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md` names.
97
- *
98
- * **Where this tool's reason is SHARPER than `compile`'s, and the message says so.** `compile` refuses
99
- * a shadow whose manifests agree because `recordedOrigin` writes the answering root into the artifact,
100
- * so agreement in the manifests is not agreement in the bytes. Here agreement is beside the point in a
101
- * more direct way: the two copies sit on opposite sides of the plugin root, so which one answers
102
- * decides whether this pack contributes a `skills` path **at all**. Version parity does not save it,
103
- * which is why this fires on hosts where `compile`'s difference-driven half would have nothing to say.
104
- *
105
- * Zero dependencies, no network, no install step — the same constraints as every tool here.
106
9
  */
107
10
 
108
11
  import fs from "node:fs";
109
12
  import path from "node:path";
110
13
  import { pathToFileURL } from "node:url";
111
14
 
112
- // The precedence rule — **a named root wins outright; asked-for discovery is unioned with the
113
- // tree-derived root, discovered first** — lives once, in `discover.mjs`'s `resolutionRoots`, reached
114
- // through `compile.mjs`'s `rootPlan`. A second derivation here would be a second carrier of the fact
115
- // that file was built to hold. _(It read "named > discovered > derived, never union" until
116
- // 2026-08-12. The rule changed because the workspace `init` drafts by default could not go green
117
- // under any invocation that did not name a host cache path; this comment is one of the carriers that
118
- // moved with it, and the file below it held the defect that made the class concrete. **On 2026-08-13
119
- // "asked-for" went too**: the unasked arm consults a wired thunk as well, so the rule is now **a named
120
- // root wins outright; otherwise discovery is unioned with the derived root, discovered first** — asking
121
- // only selects the strict degrade. This tool exited **2** on the ordinary drafted workspace until then,
122
- // which is what the row's "optional where discovery finds a root" was not.)_
123
15
  import { packDifferences, recordedOrigin, resolvePack, rootPlan, shadowedCopy } from "./compile.mjs";
124
16
  import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
125
17
 
126
- /**
127
- * How far below a DECLARED skills root the host itself looks. One — `<root>/<skill>/SKILL.md` — and no
128
- * further.
129
- *
130
- * **Measured 2026-08-07 on Claude Code 2.1.224** against a local marketplace built from this
131
- * repository, in both directions: `./packs/rituals/` registered **0 of the pack's 3 skills**, and
132
- * `./packs/rituals/checkpoints/skills/` registered **all 3**. Re-measured on 2.1.226. It is its own
133
- * constant rather than a literal because it is a PLATFORM fact this repository does not control.
134
- *
135
- * A skill resolved deeper than this is packaged, counted by a validator, and inert on every install —
136
- * the failure `../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md` records against
137
- * the sibling `agents` key. Re-measure it at a host upgrade; a change here is a change in what
138
- * installs, not in what this tool prefers, and **nothing here can detect one**.
139
- *
140
- * It lived in `./plugin-lint.mjs` until milestone 7 session 8, where it was measured; it moved when
141
- * that file became a consumer of this one, since exporting it back would have made an import cycle.
142
- * The counts above moved with it deliberately — the first cut of this move kept the mandate and
143
- * dropped the evidence, which leaves a re-measurer nothing to compare against.
144
- */
18
+ /** Claude Code 2.1.226 finds a skill at most one level below a declared skills root: `<root>/<skill>/SKILL.md`. */
145
19
  export const HOST_SKILL_DEPTH = 1;
146
20
 
147
- /** Could-not-run. Always 2, never 1: *nobody looked* outranks *we looked and it was bad*. */
148
21
  function refuse(reason) {
149
22
  return { ok: false, exitCode: 2, reason };
150
23
  }
151
24
 
152
- /**
153
- * Lexical containment. `candidate` inside `root`, or `root` itself, is not an escape.
154
- *
155
- * Lexical is all this needs and the boundary is worth stating: nothing here OPENS a derived path, so
156
- * a symlink resolving elsewhere is not this tool's hazard the way it is `plugin-lint`'s walk or
157
- * `vendor`'s write. What is derived is a *string a manifest will declare*, and the property that
158
- * matters is that the string names somewhere under the root it claims to be under.
159
- */
25
+ /** Lexical: a symlink is not followed, since nothing here opens a derived path. */
160
26
  function escapes(root, candidate) {
161
27
  const rel = path.relative(root, candidate);
162
28
  return rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
163
29
  }
164
30
 
165
- /**
166
- * The `./…/` spelling both plugin manifests in this repository already use, from a relative path.
167
- *
168
- * Canonical on both sides of every comparison, so `packs/a/skills` and `./packs/a/skills/` are one
169
- * path rather than two — a difference in spelling is not drift, and reporting it as drift would make
170
- * `--check` red on a manifest that is correct.
171
- */
172
31
  export function canonical(relPath) {
173
32
  const posix = String(relPath).split(path.sep).join("/").replace(/^\.\//, "").replace(/\/+$/, "");
174
33
  return posix === "" ? "./" : `./${posix}/`;
175
34
  }
176
35
 
177
- /**
178
- * The registrable set for a workspace manifest.
179
- *
180
- * @param {object} manifest a parsed Workspace Definition
181
- * @param {object} [options]
182
- * @param {string[]} [options.packs] the workspace's `packs`; defaults to `manifest.packs`
183
- * @param {string} options.pluginRoot absolute path of the plugin root whose manifest would declare these
184
- * @param {(ref: string) => ({ ref: string, root: string, manifest: object } | null)} [options.resolve]
185
- * resolves a pack reference to its ABSOLUTE root and parsed manifest, or null when it cannot be
186
- * found. Absent while packs are declared means nothing resolves, which is could-not-run: a
187
- * resolver that silently is not there would derive nothing and report an empty set.
188
- *
189
- * @param {string[]} [options.fallbackRoots] roots to own when NO pack resolved, so a workspace that
190
- * has stopped composing still has its stale entries recognised. `run` passes the ones `rootPlan`
191
- * produced; the conventional `<pluginRoot>/packs` is the last resort.
192
- *
193
- * @returns {{ ok: true, paths: {path: string, pack: string, root: string}[],
194
- * external: {pack: string, root: string}[], composed: number, owned: string[] }
195
- * | { ok: false, exitCode: 2, reason: string }}
196
- *
197
- * `owned` is part of the contract, not an implementation detail: `compare()` and `declaredFor()` take
198
- * it to partition the manifest, and a caller holding a set from elsewhere must pass it through or those
199
- * two will fall back to the conventional root and disagree with the derivation — which is the defect
200
- * this whole partition exists to prevent. It went undocumented in the change that introduced it; raised
201
- * as a promoted low-confidence note on #229.
202
- */
36
+ /** `options.resolve(ref)` returns a pack's `{ root, manifest }` or `{ root, unreadable }`, and `null` when not found. */
203
37
  export function skillsSet(manifest, options = {}) {
204
38
  const { packs = manifest?.packs ?? [], resolve = null, pluginRoot } = options;
205
39
 
@@ -207,10 +41,6 @@ export function skillsSet(manifest, options = {}) {
207
41
  return refuse("no plugin root was given — there is nothing for a derived path to be relative to");
208
42
  }
209
43
 
210
- // `packs` must be an array. A string here iterates CHARACTERS and reports "the pack `r` could not
211
- // be resolved" — a refusal naming something the manifest never said; an object throws a TypeError,
212
- // which is a stack trace rather than a diagnosis. Both are could-not-run wearing a worse face.
213
- // `cli/recipe-set.mjs` carries the same guard, put there by review rather than by design.
214
44
  if (!Array.isArray(packs)) {
215
45
  return refuse(
216
46
  `this workspace's \`packs\` is not an array (${packs === null ? "null" : typeof packs}) — ` +
@@ -221,12 +51,6 @@ export function skillsSet(manifest, options = {}) {
221
51
  const paths = [];
222
52
  const external = [];
223
53
  const reportedExternal = new Set();
224
- // The roots this tool OWNS in the declared manifest, accumulated from the packs that actually
225
- // resolved rather than assumed to be `<pluginRoot>/packs`. A resolver hands back a pack directory,
226
- // and `resolvePack` builds one as `<root>/<category>/<name>` — so the root is two segments up, and
227
- // recovering it that way keeps this partition identical to the one the derivation uses **by
228
- // construction**. Two partitions of one fact is the defect this replaces; a second option to pass
229
- // in would have been a third chance to disagree.
230
54
  const owned = new Set();
231
55
 
232
56
  for (const ref of packs) {
@@ -239,17 +63,12 @@ export function skillsSet(manifest, options = {}) {
239
63
 
240
64
  const found = resolve ? resolve(ref) : null;
241
65
  if (!found) {
242
- // Never silently absent. A pack quietly missing from this set is a manifest that looks
243
- // complete and registers nothing — the same false green the row refuses for recipes.
244
66
  return refuse(
245
67
  `the pack \`${ref}\` is composed by this workspace and could not be resolved — no pack.json ` +
246
68
  "under any resolution root, so what it contributes to registration could not be read. " +
247
69
  "Could-not-run rather than a set that quietly lost it",
248
70
  );
249
71
  }
250
- // Resolved, and its manifest defeated the reader. Its own sentence because the repair is its own
251
- // act: the pack is exactly where it should be and the file inside it is the problem, which is
252
- // not what "could not be resolved" sends anyone to go and check.
253
72
  if (found.unreadable) {
254
73
  return refuse(
255
74
  `the pack \`${ref}\` is composed by this workspace and resolves at ${found.root}, but its ` +
@@ -257,9 +76,6 @@ export function skillsSet(manifest, options = {}) {
257
76
  );
258
77
  }
259
78
 
260
- // Absent is a real state: a pack may contribute no skills at all, which is `packs/tools/github`'s
261
- // deliberate shape. PRESENT and not an array is neither that nor a resolution failure — it is a
262
- // manifest nobody can read, and `for..of` over it throws where the contract is a sentence.
263
79
  const contributed = found.manifest?.contributes?.skills;
264
80
  if (contributed !== undefined && !Array.isArray(contributed)) {
265
81
  return refuse(
@@ -269,24 +85,11 @@ export function skillsSet(manifest, options = {}) {
269
85
  }
270
86
 
271
87
  const packRoot = path.resolve(found.root);
272
- // Recorded for every pack that resolves inside the plugin root, whether or not it contributes
273
- // skills: a pack contributing none still makes its root ours, which is what lets a declared
274
- // path under it be reported as belonging to no composed pack once the pack stops shipping one.
88
+ // `resolvePack` builds a pack directory as `<root>/<category>/<name>`.
275
89
  const resolutionRoot = path.resolve(packRoot, "..", "..");
276
90
 
277
- // Only a pack INSIDE the plugin root is one this tool could own — an outside one is the fourth
278
- // outcome below and its resolution root is somebody else's business. Asking the question of an
279
- // outside pack fires on an ordinary case, since a pack at `/elsewhere/x` has resolution root
280
- // `/`, which contains every plugin root there is. Caught by the fourth-outcome tests the moment
281
- // the guard was written without this condition.
91
+ // Only an inside pack is owned: an outside pack's root may be `/`, which contains every plugin root.
282
92
  if (!escapes(pluginRoot, packRoot)) {
283
- // A resolution root that CONTAINS the plugin root — or is it — makes every declared path a
284
- // pack path, so this tool would own `./core/skills/` and delete it on the next `--write`.
285
- // That is the contract above ("preserved verbatim") turned into data loss, and it is one
286
- // `--pack-root <pluginRoot>` away. Refused rather than narrowed: excluding the plugin root
287
- // from the owned set while still deriving paths under it would put the two partitions back
288
- // out of step, which is the defect this partition exists to prevent. Symmetric by
289
- // construction — nothing derived, nothing recognised. Found at the pre-commit re-check.
290
93
  if (!escapes(resolutionRoot, pluginRoot)) {
291
94
  return refuse(
292
95
  `the pack \`${ref}\` resolves from ${resolutionRoot}, which contains the plugin root ` +
@@ -307,9 +110,6 @@ export function skillsSet(manifest, options = {}) {
307
110
 
308
111
  const absolute = path.resolve(packRoot, entry);
309
112
 
310
- // A skills root escaping its own pack is `doctor`'s verdict on that pack, and this tool does
311
- // not render it — but deriving from it anyway would put a path outside the pack into
312
- // somebody's manifest, which is worse than either. Named and refused.
313
113
  if (escapes(packRoot, absolute)) {
314
114
  return refuse(
315
115
  `the pack \`${ref}\` declares the skills root \`${entry}\`, which resolves outside the ` +
@@ -317,11 +117,6 @@ export function skillsSet(manifest, options = {}) {
317
117
  );
318
118
  }
319
119
 
320
- // The fourth outcome, and the one an adopter meets first. A pack resolved from the host's
321
- // plugin cache — which is the case `--pack-root auto` and `discover.mjs` exist for —
322
- // resolves perfectly well and has **no path expressible relative to this plugin root**. It
323
- // is not drift, not could-not-run, and must not be silence: it registers through its own
324
- // plugin or not at all. Reported, excluded from the set, and it moves no exit code.
325
120
  if (escapes(pluginRoot, absolute)) {
326
121
  if (!reportedExternal.has(ref)) {
327
122
  reportedExternal.add(ref);
@@ -334,29 +129,6 @@ export function skillsSet(manifest, options = {}) {
334
129
  }
335
130
  }
336
131
 
337
- // An empty set is NOT refused here, and that is the one place this carrier's contract differs from
338
- // `recipeSet`'s on purpose. A workspace that yields no verify recipe cannot report green, so that
339
- // one refuses; a workspace may legitimately **compose no packs**, and refusing would make the
340
- // honest case red. Emptiness is two questions — nothing composed, or nothing readable — and every
341
- // unreadable arm above has already returned. What is left is examined and genuinely empty.
342
- //
343
- // With nothing resolved there is no root to derive from a pack, so the caller's own resolution
344
- // roots stand in — `run` passes the ones `rootPlan` produced — and the conventional root is the
345
- // last resort. That ordering is the finding: a workspace that has STOPPED composing still needs its
346
- // stale pack entries recognised as ours and cleaned, and falling straight to `<pluginRoot>/packs`
347
- // did that **only for the conventional layout**, silently preserving a stale entry forever anywhere
348
- // else. Layout-dependence in the fallback of a fix whose whole subject was layout-dependence.
349
- // Raised at the pre-commit re-check.
350
- // The same ambiguity the in-pack guard refuses, on the path that has no pack to guard. A fallback
351
- // root that CONTAINS the plugin root makes every declared entry read as pack-owned, and with no
352
- // packs composed the derived set is empty — so `--write` deleted `./core/skills/` and
353
- // `./plugin/skills/` outright and **exited 0 announcing "wrote 0 skills path(s)"**. Silent data
354
- // loss, reachable with `--pack-root <pluginRoot>`.
355
- //
356
- // The guard was written for the resolved-pack arm and not for this one, which is the third instance
357
- // in this change of a fix landing at one site of an operation and not its sibling —
358
- // `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md`. Raised by Copilot on
359
- // #229, after the two earlier instances were raised the same way.
360
132
  const fallbackCandidates = (options.fallbackRoots ?? []).map((r) => path.resolve(r));
361
133
  for (const candidate of fallbackCandidates) {
362
134
  if (!escapes(candidate, pluginRoot)) {
@@ -377,27 +149,14 @@ export function skillsSet(manifest, options = {}) {
377
149
  };
378
150
  }
379
151
 
380
- /**
381
- * Split a plugin manifest's declared `skills` into the portion this tool owns and the portion it must
382
- * leave exactly as it found it.
383
- *
384
- * The outside entries keep their **original spelling**, because `declaredFor` writes them back and a
385
- * generator that silently re-spelled somebody's hand-written path would be editing what it was asked
386
- * to preserve.
387
- */
388
152
  export function packPortion(skills, pluginRoot, ownedRoots) {
389
153
  if (skills === undefined) return { inside: [], outside: [], malformed: false };
390
154
  if (!Array.isArray(skills)) return { inside: [], outside: [], malformed: true };
391
155
 
392
- // `skillsSet` reports the roots it owns and every caller passes them through. The conventional root
393
- // is the default for a caller asking about a manifest with no derivation beside it — never a second
394
- // opinion about where packs live when one is available.
395
156
  const roots = (ownedRoots ?? [path.join(pluginRoot, "packs")]).map((r) => path.resolve(r));
396
157
  const inside = [];
397
158
  const outside = [];
398
159
  for (const raw of skills) {
399
- // A non-string entry is not this tool's verdict — `plugin-lint` grades the manifest — but it is
400
- // also not a pack path, so it is preserved rather than dropped.
401
160
  if (typeof raw !== "string") {
402
161
  outside.push(raw);
403
162
  continue;
@@ -409,14 +168,6 @@ export function packPortion(skills, pluginRoot, ownedRoots) {
409
168
  return { inside, outside, malformed: false };
410
169
  }
411
170
 
412
- /**
413
- * Compare a derived set against what a plugin manifest declares.
414
- *
415
- * Both directions are named, because each is a distinct defect and both are silent today: a composed
416
- * pack the manifest does not declare ships, counts, and is inert on every install; a pack path the
417
- * manifest declares that no composed pack asks for is a capability the host registers that no
418
- * workspace layer asked for.
419
- */
420
171
  export function compare(set, pluginManifest, pluginRoot) {
421
172
  const portion = packPortion(pluginManifest?.skills, pluginRoot, set?.owned);
422
173
  if (portion.malformed) {
@@ -427,9 +178,6 @@ export function compare(set, pluginManifest, pluginRoot) {
427
178
  why: "the plugin manifest declares `skills` as something other than an array, so what it registers could not be read",
428
179
  };
429
180
  }
430
- // Sets rather than `includes` on both sides. The cost is nothing at this repository's three
431
- // entries and the shape is an adopter's — the same finding #228 records against the persona
432
- // correspondence, applied here rather than shipped again and fixed later.
433
181
  const derived = set.paths.map((p) => p.path);
434
182
  const declared = portion.inside;
435
183
  const derivedSet = new Set(derived);
@@ -439,34 +187,14 @@ export function compare(set, pluginManifest, pluginRoot) {
439
187
  return { agree: missing.length === 0 && extra.length === 0, missing, extra };
440
188
  }
441
189
 
442
- /**
443
- * The `skills` array `--write` would put in the manifest: every non-pack entry exactly as it was, in
444
- * its original order, followed by the derived pack entries.
445
- *
446
- * Returns `null` when the manifest's `skills` cannot be read as an array — a caller must not treat
447
- * that as "there were no entries to preserve", which would silently delete a key it could not parse.
448
- */
190
+ /** Every non-pack entry as it was, in order, then the derived paths; `null` when `skills` is not an array. */
449
191
  export function declaredFor(set, pluginManifest, pluginRoot) {
450
192
  const portion = packPortion(pluginManifest?.skills, pluginRoot, set?.owned);
451
193
  if (portion.malformed) return null;
452
194
  return [...portion.outside, ...set.paths.map((p) => p.path)];
453
195
  }
454
196
 
455
- /**
456
- * The refusal a shadowed pack earns, or `null` when no declared pack is shadowed.
457
- *
458
- * Separated from `run` so the sentence a reader gets is testable without a command line, and kept in
459
- * this file rather than pushed into `compile.mjs` because the CONSEQUENCE clause is this tool's own:
460
- * `compile` talks about emitted policy, and this one talks about whether a `skills` path is derived.
461
- * What is shared — *is there a second copy behind the one that answered, and do the two differ* — is
462
- * imported, and that split is the point.
463
- *
464
- * Only the **unasked** path reaches here. `--pack-root <dir>` names a root, which replaces the derived
465
- * one and leaves nothing behind it to shadow; `--pack-root auto` is discovery ELECTED, and refusing
466
- * somebody for answering the question the refusal asks would be incoherent. Both spellings are offered
467
- * in the message for that reason — naming only the tree would make the refusal decide the question it
468
- * claims to hand back.
469
- */
197
+ /** The refusal for a composed pack a second root also carries, or `null` when none is shadowed. */
470
198
  export function shadowRefusal({ packs, roots, plan, repoRoot, pluginRoot }) {
471
199
  const originOf = (r) => (plan?.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin;
472
200
  for (const ref of Array.isArray(packs) ? packs : []) {
@@ -476,28 +204,9 @@ export function shadowRefusal({ packs, roots, plan, repoRoot, pluginRoot }) {
476
204
  if (!behind) continue;
477
205
 
478
206
  const there = path.relative(repoRoot, behind.root) || behind.root;
479
- // **The tree spelling is the root we just named, not the conventional guess.** Both arms below
480
- // hard-coded `--pack-root packs`, and the derived root is `path.resolve(workspaceDir, tree,
481
- // "packs")` — so a workspace whose `tree` is `../nested/` derives `nested/packs` and was being
482
- // told to pass a directory that does not carry the pack. That is this branch's own defect class
483
- // one more time: a message prescribing a remedy it had not checked, in the sentence whose whole
484
- // job is to hand back an accurate choice. `there` is already the relative spelling of that root,
485
- // and for the conventional layout it is exactly `packs`, so the common output is unchanged.
486
- //
487
- // The `verify/plugin.sh` clause is gated with it, because that rail pins the literal
488
- // `--pack-root packs`: claiming the equivalence for `nested/packs` would replace one false
489
- // sentence with another. Raised through the promoted suppressed-note channel, which carries
490
- // findings the inline round does not.
207
+ // `verify/plugin.sh` passes `--pack-root packs`, so only that root may claim to be what it checks.
491
208
  const rail = there === "packs" ? ", which is what `verify/plugin.sh` checks" : "";
492
209
 
493
- // **Read and parse are two failures with two repairs**, and one sentence for both misdiagnoses
494
- // the commoner one — the defect Copilot raised against `run`'s workspace arm on #229, whose
495
- // comment sits one screen below this and which the first cut of this block reintroduced
496
- // anyway. A `SyntaxError` from `JSON.parse` carries no `.code`, so a collapsed arm reporting
497
- // `error.code ?? error.message` under *could not be read* sends somebody with a malformed
498
- // manifest to look at permissions. `readManifest` and `resolverFor` both keep the two apart;
499
- // so does this. It also names WHICH of the two copies is the broken one, which the collapsed
500
- // form could not — with two manifests in play, "one of the two" is half a diagnosis.
501
210
  const load = (file) => {
502
211
  let text;
503
212
  try {
@@ -515,9 +224,6 @@ export function shadowRefusal({ packs, roots, plan, repoRoot, pluginRoot }) {
515
224
  const beneath = load(behind.manifest);
516
225
  const broken = here.why ? { at: found.root, why: here.why } : beneath.why ? { at: there, why: beneath.why } : null;
517
226
  if (broken) {
518
- // Could-not-run wearing its own sentence rather than the SHADOWED one: with a manifest
519
- // unreadable, *what differs* is unanswerable, and a refusal that guessed would be claiming
520
- // to have compared two things it could not read. The remedy is the same either way.
521
227
  return (
522
228
  `the pack \`${found.name}\` resolved under the root ${found.root} while the root ${there} also ` +
523
229
  `carries it, and the copy under ${broken.at} ${broken.why} — so which one this would derive ` +
@@ -528,21 +234,8 @@ export function shadowRefusal({ packs, roots, plan, repoRoot, pluginRoot }) {
528
234
  const mine = here.value;
529
235
  const other = beneath.value;
530
236
 
531
- // **Containment, computed rather than assumed.** The sharp case — one copy inside the plugin
532
- // root and one outside — is what makes `--write` destructive, but it is not a law: a
533
- // `--plugin-root` pointing elsewhere puts both outside, and the refusal still stands because
534
- // the derived path is relative to whichever root answered. Asserting the sharp case
535
- // unconditionally would be this message's first lie, in the sentence that exists to be exact.
536
- //
537
- // Tested against the ROOTS, which is also what `packPortion` partitions on — `resolvePack`
538
- // builds `dir` as `<root>/<category>/<pack>`, so the two can never disagree here, and using the
539
- // root keeps this predicate spelled the same way as the derivation it is predicting.
540
237
  const differs = packDifferences(mine, other);
541
238
  const split = escapes(pluginRoot, path.resolve(found.root)) !== escapes(pluginRoot, path.resolve(behind.root));
542
- // **Both ROOTS by path — the roots, not the pack directories.** The sibling refusal in
543
- // `compile` shipped `dir` while calling it a root, which mislabels the thing it names in a
544
- // message whose own claim is to name both; the root is also the half a reader types back into
545
- // `--pack-root`. Corrected here in the same stroke rather than left to be found twice.
546
239
  return (
547
240
  `the pack \`${found.name}\` is SHADOWED — it resolved under ${found.root}, a root discovered on this ` +
548
241
  `host, while the root ${there} also carries it. ` +
@@ -560,38 +253,15 @@ export function shadowRefusal({ packs, roots, plan, repoRoot, pluginRoot }) {
560
253
  return null;
561
254
  }
562
255
 
563
- /**
564
- * A resolver over a workspace's declared pack roots.
565
- *
566
- * Reaches `compile.mjs`'s `rootPlan` and `resolvePack` rather than re-deriving where a pack lives.
567
- * The root handed back is **absolute**, which is where this differs from `recipe-set.mjs`'s resolver
568
- * and why the two are not shared: a recipe's `${PACK_ROOT}` is typed from the repository root, while a
569
- * declared skills path is relative to the **plugin** root, and those are not the same directory. The
570
- * honest way to serve both is to hand back the absolute path and relativise once, at the caller that
571
- * knows which root it means.
572
- */
256
+ /** Throws on a refused or could-not-run root plan; the resolver returns `null` for a pack it cannot find. */
573
257
  export function resolverFor({ workspaceDir, manifest, named = [], discovery = null, roots = null }) {
574
- // `roots` lets a caller that already computed the plan share it rather than deriving a second one —
575
- // `run` needs the same list twice, once to resolve and once as the fallback owned set, and two
576
- // derivations of one plan is the shape this file exists to refuse.
577
258
  const plan = roots ? null : rootPlan(workspaceDir, manifest, { named, discovery });
578
259
  if (plan?.refusal) throw new Error(`skills-set: ${plan.refusal}`);
579
260
  if (plan?.couldNotRun) throw new Error(`skills-set: ${plan.couldNotRun}`);
580
261
  const resolved = roots ?? plan.roots ?? [];
581
262
  return (ref) => {
582
263
  const found = resolvePack(ref, resolved);
583
- // `resolvePack` returns `manifest` as the PATH to `pack.json`, not as the parsed object.
584
- // Reading it is this adapter's job, and the first cut of the sibling resolver got this wrong
585
- // while its unit suite stayed green, because the fixture resolver returned a parsed object and
586
- // so encoded the same assumption the code made. `cli/skills-set.live.test.mjs` is what stops
587
- // that here.
588
264
  if (!found?.dir || !found.manifest) return null;
589
- // Present and unreadable is NOT a pack that contributes nothing — and it is not one that could
590
- // not be FOUND either. Returning bare `null` for both made the caller's refusal say "could not
591
- // be resolved" about a pack it had resolved perfectly well, misdiagnosing exactly the
592
- // present-but-unreadable case this tool treats as could-not-run. Raised as a promoted
593
- // low-confidence note on #229. Both stay could-not-run; what changes is the sentence the reader
594
- // gets, and read is kept apart from parse for the reason the arm above it now does.
595
265
  let text;
596
266
  try {
597
267
  text = fs.readFileSync(found.manifest, "utf8");
@@ -606,29 +276,12 @@ export function resolverFor({ workspaceDir, manifest, named = [], discovery = nu
606
276
  };
607
277
  }
608
278
 
609
- /** Where a plugin root's manifest sits. One spelling, so a reader and a writer cannot disagree. */
610
279
  export function manifestPath(pluginRoot) {
611
280
  return path.join(pluginRoot, ".claude-plugin", "plugin.json");
612
281
  }
613
282
 
614
- /**
615
- * Read a plugin manifest for `--check`/`--write`, under the three rules a tool that writes into
616
- * somebody's tree carries — `init`, `new` and `vendor` paid for these and a fourth reader inherits
617
- * them.
618
- *
619
- * Returns `{ ok: true, text, value }` or `{ ok: false, reason }`.
620
- */
621
283
  function readManifest(file, pluginRoot) {
622
- // Rule 2: refuse a symlink **at or below the named destination**, rather than resolving through it.
623
- //
624
- // The whole chain from the named plugin root down, not just the last two steps. The first cut
625
- // lstat'd `dirname(file)` and `file` alone, so a symlinked `--plugin-root` was resolved straight
626
- // through and this tool wrote into whatever it pointed at — which is the exact failure `init` paid
627
- // for at nine files written into an unrelated directory and reported as success. `cli/new.mjs`'s
628
- // `chain` is the shape being matched; it is not exported, so the walk is spelled here rather than
629
- // reaching into a subcommand for it, and the boundary rule is the one that file argues: **above the
630
- // named path, resolve — the user named it, and on macOS `os.tmpdir()` runs through `/var`. At it
631
- // and below it, refuse.**
284
+ // Refuse a symlink at or below the plugin root, not above it: macOS's `os.tmpdir()` runs through `/var`.
632
285
  const chain = [];
633
286
  for (let at = file, i = 0; i < 64; i += 1) {
634
287
  chain.push(at);
@@ -642,8 +295,6 @@ function readManifest(file, pluginRoot) {
642
295
  try {
643
296
  stat = fs.lstatSync(candidate);
644
297
  } catch (error) {
645
- // Rule 3: only ENOENT means absent. An EACCES is a question that could not be answered, and
646
- // answering it *nothing there* is "nothing looked" recorded as "nothing wrong".
647
298
  if (error.code === "ENOENT") {
648
299
  return {
649
300
  ok: false,
@@ -671,21 +322,10 @@ function readManifest(file, pluginRoot) {
671
322
  try {
672
323
  return { ok: true, text, value: JSON.parse(text) };
673
324
  } catch (error) {
674
- // Never overwritten. A manifest that will not parse is one whose other keys nobody can preserve.
675
325
  return { ok: false, reason: `${file} does not parse as JSON — ${error.message}` };
676
326
  }
677
327
  }
678
328
 
679
- /**
680
- * The runnable surface: the printer CI and an adopter's own pipeline call, plus `--check` and
681
- * `--write`.
682
- *
683
- * `options.manifest`, `options.resolve` and `options.discovery` are injection points for the suite, the
684
- * way `init` injects its reader. No production caller passes any of them; every flag below is the real
685
- * surface. `discovery` is injected as a THUNK like the production one, because the guarantee that a
686
- * branch which cannot use the answer never touches the host is a property of the ARGUMENT — a suite
687
- * handed a pre-computed value would be testing a shape production never takes.
688
- */
689
329
  export function run(argv = [], options = {}) {
690
330
  const { stdout = process.stdout, stderr = process.stderr } = options;
691
331
  const arg = (flag, fallback) => {
@@ -705,10 +345,6 @@ export function run(argv = [], options = {}) {
705
345
  const workspaceDir = path.resolve(arg("--workspace", path.join(repoRoot, ".portulan")));
706
346
  const pluginRoot = path.resolve(arg("--plugin-root", repoRoot));
707
347
 
708
- // Refused before the manifest is read, the sibling of the placement Copilot's round 2 found in
709
- // `compile`. It sat below the read here too, so an unreadable or unparsable workspace answered
710
- // first and this tool disagreed with the others about WHEN the command line is judged. Swept in
711
- // the same stroke rather than left for the round that would eventually find it.
712
348
  const askedBoth = namedWithAuto(
713
349
  many("--pack-root").filter((r) => r !== AUTO),
714
350
  many("--pack-root").includes(AUTO),
@@ -720,11 +356,6 @@ export function run(argv = [], options = {}) {
720
356
 
721
357
  let manifest = options.manifest;
722
358
  if (manifest === undefined) {
723
- // Read and parse are two failures with two repairs, and one sentence for both misdiagnoses the
724
- // commoner one. A `SyntaxError` from `JSON.parse` carries no `.code`, so it fell through to
725
- // `.message` under a sentence reading "could not be read" — sending an adopter with a malformed
726
- // manifest to look at permissions and paths. `readManifest` below already keeps the two apart;
727
- // this arm did not. Raised by Copilot on #229.
728
359
  const file = path.join(workspaceDir, "workspace.json");
729
360
  let text;
730
361
  try {
@@ -747,20 +378,9 @@ export function run(argv = [], options = {}) {
747
378
  const named = many("--pack-root");
748
379
  const wantsDiscovery = named.includes(AUTO);
749
380
  const namedRoots = named.filter((r) => r !== AUTO);
750
- // Two defects lived in this call until 2026-08-12, and both were the finding this change is
751
- // about, one caller further on. It passed `discovery` WITHOUT `forced`, and `resolutionRoots`
752
- // consults `discovery` only inside its forced branch — so `--pack-root auto` here read the
753
- // host's record, threw the answer away, resolved from the derived root, and said nothing.
754
- // Silently inert, and eagerly reading `~/.claude` to be so: `discovery` is a THUNK precisely
755
- // so a branch that cannot use the answer never touches the host, and calling
756
- // `discoverPackRoots()` at the argument site defeated that whichever branch ran.
757
- //
758
- // _(The thunk's reason NARROWED on 2026-08-13 and the wording followed: it was *an unasked path
759
- // never touches the host*, and the unasked path now consults discovery deliberately. What the
760
- // thunk still guarantees is that the **named** and **refused** arms do not — and that an API
761
- // caller wiring no thunk stays hermetic on every arm.)_
762
381
  const plan = rootPlan(workspaceDir, manifest, {
763
382
  named: namedRoots,
383
+ // A thunk, so an arm that has no use for discovery never reads the host.
764
384
  discovery: options.discovery ?? (() => discoverPackRoots()),
765
385
  forced: wantsDiscovery,
766
386
  });
@@ -769,23 +389,6 @@ export function run(argv = [], options = {}) {
769
389
  return 2;
770
390
  }
771
391
  const roots = plan.roots ?? [];
772
- // **Refused here, above the mode branches, so the printer, `--check` and `--write` inherit one
773
- // rule** (#317). The damage this prevents is specific to the write path — the tool printed
774
- // `run with --write` while `--write` would have stripped a correct, tracked `skills` entry on
775
- // the strength of what happens to be installed on the machine — but guarding only `--write`
776
- // would leave `--check` exiting 1 about a repository that had not drifted, which is a false
777
- // verdict handed to a machine. `compile` settled the same question the same way for #316.
778
- //
779
- // A caller that injected its own `resolve` never reaches this, and that is deliberate rather
780
- // than a hole: it chose its own roots, which is the same election `--pack-root` makes.
781
- //
782
- // **The `named` half of this condition is defence in depth, and it was measured HERE rather
783
- // than inherited from `compile`'s note.** Forcing the whole condition to `true` reds exactly
784
- // one case — the `--pack-root auto` exemption — while the named-root case goes on passing: a
785
- // named root REPLACES the derived one, so the plan carries no discovered root, `shadowedCopy`
786
- // finds nothing behind it, and there is no shadow to refuse. The protection is structural, and
787
- // no test in this suite binds the clause. It stays because it states the intent at the site
788
- // where a future change to `resolutionRoots` could quietly make it load-bearing.
789
392
  if (!wantsDiscovery && plan.source !== "named") {
790
393
  const shadowed = shadowRefusal({ packs: manifest?.packs, roots, plan, repoRoot, pluginRoot });
791
394
  if (shadowed) {
@@ -794,9 +397,6 @@ export function run(argv = [], options = {}) {
794
397
  }
795
398
  }
796
399
  resolve = resolverFor({ workspaceDir, manifest, roots });
797
- // The same roots stand in as the owned set when nothing resolves — so a workspace that has
798
- // stopped composing gets its stale entries cleaned wherever its packs WOULD have been, not
799
- // only at the conventional path.
800
400
  fallbackRoots ??= roots;
801
401
  }
802
402
 
@@ -806,8 +406,6 @@ export function run(argv = [], options = {}) {
806
406
  return set.exitCode;
807
407
  }
808
408
 
809
- // Reported on every run, in every mode. A pack registering through its own plugin is not a fault
810
- // and is not nothing — a reader who cannot see it will read the derived set as the whole answer.
811
409
  for (const { pack, root } of set.external) {
812
410
  stderr.write(
813
411
  `skills-set: the pack \`${pack}\` resolves outside this plugin root (${root}) — it registers ` +