@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,715 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The registrable set — the one carrier of *what a host must declare so a composed pack's skills
4
+ * register*.
5
+ *
6
+ * node cli/skills-set.mjs [--workspace <dir>] [--repo-root <dir>] [--plugin-root <dir>]
7
+ * [--pack-root <dir>|auto ...] [--check|--write]
8
+ *
9
+ * 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
+ * Zero dependencies, no network, no install step — the same constraints as every tool here.
79
+ */
80
+
81
+ import fs from "node:fs";
82
+ import path from "node:path";
83
+ import { pathToFileURL } from "node:url";
84
+
85
+ // The precedence rule — **a named root wins outright; asked-for discovery is unioned with the
86
+ // tree-derived root, discovered first** — lives once, in `discover.mjs`'s `resolutionRoots`, reached
87
+ // through `compile.mjs`'s `rootPlan`. A second derivation here would be a second carrier of the fact
88
+ // that file was built to hold. _(It read "named > discovered > derived, never union" until
89
+ // 2026-08-12. The rule changed because the workspace `init` drafts by default could not go green
90
+ // under any invocation that did not name a host cache path; this comment is one of the carriers that
91
+ // moved with it, and the file below it held the defect that made the class concrete. **On 2026-08-13
92
+ // "asked-for" went too**: the unasked arm consults a wired thunk as well, so the rule is now **a named
93
+ // root wins outright; otherwise discovery is unioned with the derived root, discovered first** — asking
94
+ // only selects the strict degrade. This tool exited **2** on the ordinary drafted workspace until then,
95
+ // which is what the row's "optional where discovery finds a root" was not.)_
96
+ import { resolvePack, rootPlan } from "./compile.mjs";
97
+ import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
98
+
99
+ /**
100
+ * How far below a DECLARED skills root the host itself looks. One — `<root>/<skill>/SKILL.md` — and no
101
+ * further.
102
+ *
103
+ * **Measured 2026-08-07 on Claude Code 2.1.224** against a local marketplace built from this
104
+ * repository, in both directions: `./packs/rituals/` registered **0 of the pack's 3 skills**, and
105
+ * `./packs/rituals/checkpoints/skills/` registered **all 3**. Re-measured on 2.1.226. It is its own
106
+ * constant rather than a literal because it is a PLATFORM fact this repository does not control.
107
+ *
108
+ * A skill resolved deeper than this is packaged, counted by a validator, and inert on every install —
109
+ * the failure `../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md` records against
110
+ * the sibling `agents` key. Re-measure it at a host upgrade; a change here is a change in what
111
+ * installs, not in what this tool prefers, and **nothing here can detect one**.
112
+ *
113
+ * It lived in `./plugin-lint.mjs` until milestone 7 session 8, where it was measured; it moved when
114
+ * that file became a consumer of this one, since exporting it back would have made an import cycle.
115
+ * The counts above moved with it deliberately — the first cut of this move kept the mandate and
116
+ * dropped the evidence, which leaves a re-measurer nothing to compare against.
117
+ */
118
+ export const HOST_SKILL_DEPTH = 1;
119
+
120
+ /** Could-not-run. Always 2, never 1: *nobody looked* outranks *we looked and it was bad*. */
121
+ function refuse(reason) {
122
+ return { ok: false, exitCode: 2, reason };
123
+ }
124
+
125
+ /**
126
+ * Lexical containment. `candidate` inside `root`, or `root` itself, is not an escape.
127
+ *
128
+ * Lexical is all this needs and the boundary is worth stating: nothing here OPENS a derived path, so
129
+ * a symlink resolving elsewhere is not this tool's hazard the way it is `plugin-lint`'s walk or
130
+ * `vendor`'s write. What is derived is a *string a manifest will declare*, and the property that
131
+ * matters is that the string names somewhere under the root it claims to be under.
132
+ */
133
+ function escapes(root, candidate) {
134
+ const rel = path.relative(root, candidate);
135
+ return rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
136
+ }
137
+
138
+ /**
139
+ * The `./…/` spelling both plugin manifests in this repository already use, from a relative path.
140
+ *
141
+ * Canonical on both sides of every comparison, so `packs/a/skills` and `./packs/a/skills/` are one
142
+ * path rather than two — a difference in spelling is not drift, and reporting it as drift would make
143
+ * `--check` red on a manifest that is correct.
144
+ */
145
+ export function canonical(relPath) {
146
+ const posix = String(relPath).split(path.sep).join("/").replace(/^\.\//, "").replace(/\/+$/, "");
147
+ return posix === "" ? "./" : `./${posix}/`;
148
+ }
149
+
150
+ /**
151
+ * The registrable set for a workspace manifest.
152
+ *
153
+ * @param {object} manifest a parsed Workspace Definition
154
+ * @param {object} [options]
155
+ * @param {string[]} [options.packs] the workspace's `packs`; defaults to `manifest.packs`
156
+ * @param {string} options.pluginRoot absolute path of the plugin root whose manifest would declare these
157
+ * @param {(ref: string) => ({ ref: string, root: string, manifest: object } | null)} [options.resolve]
158
+ * resolves a pack reference to its ABSOLUTE root and parsed manifest, or null when it cannot be
159
+ * found. Absent while packs are declared means nothing resolves, which is could-not-run: a
160
+ * resolver that silently is not there would derive nothing and report an empty set.
161
+ *
162
+ * @param {string[]} [options.fallbackRoots] roots to own when NO pack resolved, so a workspace that
163
+ * has stopped composing still has its stale entries recognised. `run` passes the ones `rootPlan`
164
+ * produced; the conventional `<pluginRoot>/packs` is the last resort.
165
+ *
166
+ * @returns {{ ok: true, paths: {path: string, pack: string, root: string}[],
167
+ * external: {pack: string, root: string}[], composed: number, owned: string[] }
168
+ * | { ok: false, exitCode: 2, reason: string }}
169
+ *
170
+ * `owned` is part of the contract, not an implementation detail: `compare()` and `declaredFor()` take
171
+ * it to partition the manifest, and a caller holding a set from elsewhere must pass it through or those
172
+ * two will fall back to the conventional root and disagree with the derivation — which is the defect
173
+ * this whole partition exists to prevent. It went undocumented in the change that introduced it; raised
174
+ * as a promoted low-confidence note on #229.
175
+ */
176
+ export function skillsSet(manifest, options = {}) {
177
+ const { packs = manifest?.packs ?? [], resolve = null, pluginRoot } = options;
178
+
179
+ if (typeof pluginRoot !== "string" || !pluginRoot) {
180
+ return refuse("no plugin root was given — there is nothing for a derived path to be relative to");
181
+ }
182
+
183
+ // `packs` must be an array. A string here iterates CHARACTERS and reports "the pack `r` could not
184
+ // be resolved" — a refusal naming something the manifest never said; an object throws a TypeError,
185
+ // which is a stack trace rather than a diagnosis. Both are could-not-run wearing a worse face.
186
+ // `cli/recipe-set.mjs` carries the same guard, put there by review rather than by design.
187
+ if (!Array.isArray(packs)) {
188
+ return refuse(
189
+ `this workspace's \`packs\` is not an array (${packs === null ? "null" : typeof packs}) — ` +
190
+ "could-not-run rather than a set derived from a value nothing can enumerate",
191
+ );
192
+ }
193
+
194
+ const paths = [];
195
+ const external = [];
196
+ const reportedExternal = new Set();
197
+ // The roots this tool OWNS in the declared manifest, accumulated from the packs that actually
198
+ // resolved rather than assumed to be `<pluginRoot>/packs`. A resolver hands back a pack directory,
199
+ // and `resolvePack` builds one as `<root>/<category>/<name>` — so the root is two segments up, and
200
+ // recovering it that way keeps this partition identical to the one the derivation uses **by
201
+ // construction**. Two partitions of one fact is the defect this replaces; a second option to pass
202
+ // in would have been a third chance to disagree.
203
+ const owned = new Set();
204
+
205
+ for (const ref of packs) {
206
+ if (typeof ref !== "string" || ref === "") {
207
+ return refuse(
208
+ `this workspace declares a \`packs\` entry that is not a non-empty string ` +
209
+ `(${JSON.stringify(ref)}) — could-not-run rather than a set missing whatever it meant`,
210
+ );
211
+ }
212
+
213
+ const found = resolve ? resolve(ref) : null;
214
+ if (!found) {
215
+ // Never silently absent. A pack quietly missing from this set is a manifest that looks
216
+ // complete and registers nothing — the same false green the row refuses for recipes.
217
+ return refuse(
218
+ `the pack \`${ref}\` is composed by this workspace and could not be resolved — no pack.json ` +
219
+ "under any resolution root, so what it contributes to registration could not be read. " +
220
+ "Could-not-run rather than a set that quietly lost it",
221
+ );
222
+ }
223
+ // Resolved, and its manifest defeated the reader. Its own sentence because the repair is its own
224
+ // act: the pack is exactly where it should be and the file inside it is the problem, which is
225
+ // not what "could not be resolved" sends anyone to go and check.
226
+ if (found.unreadable) {
227
+ return refuse(
228
+ `the pack \`${ref}\` is composed by this workspace and resolves at ${found.root}, but its ` +
229
+ `pack.json ${found.unreadable} — could-not-run rather than a set that quietly lost it`,
230
+ );
231
+ }
232
+
233
+ // Absent is a real state: a pack may contribute no skills at all, which is `packs/tools/github`'s
234
+ // deliberate shape. PRESENT and not an array is neither that nor a resolution failure — it is a
235
+ // manifest nobody can read, and `for..of` over it throws where the contract is a sentence.
236
+ const contributed = found.manifest?.contributes?.skills;
237
+ if (contributed !== undefined && !Array.isArray(contributed)) {
238
+ return refuse(
239
+ `the pack \`${ref}\` declares \`contributes.skills\` as ` +
240
+ `${contributed === null ? "null" : typeof contributed}, not an array — could-not-run`,
241
+ );
242
+ }
243
+
244
+ const packRoot = path.resolve(found.root);
245
+ // Recorded for every pack that resolves inside the plugin root, whether or not it contributes
246
+ // skills: a pack contributing none still makes its root ours, which is what lets a declared
247
+ // path under it be reported as belonging to no composed pack once the pack stops shipping one.
248
+ const resolutionRoot = path.resolve(packRoot, "..", "..");
249
+
250
+ // Only a pack INSIDE the plugin root is one this tool could own — an outside one is the fourth
251
+ // outcome below and its resolution root is somebody else's business. Asking the question of an
252
+ // outside pack fires on an ordinary case, since a pack at `/elsewhere/x` has resolution root
253
+ // `/`, which contains every plugin root there is. Caught by the fourth-outcome tests the moment
254
+ // the guard was written without this condition.
255
+ if (!escapes(pluginRoot, packRoot)) {
256
+ // A resolution root that CONTAINS the plugin root — or is it — makes every declared path a
257
+ // pack path, so this tool would own `./core/skills/` and delete it on the next `--write`.
258
+ // That is the contract above ("preserved verbatim") turned into data loss, and it is one
259
+ // `--pack-root <pluginRoot>` away. Refused rather than narrowed: excluding the plugin root
260
+ // from the owned set while still deriving paths under it would put the two partitions back
261
+ // out of step, which is the defect this partition exists to prevent. Symmetric by
262
+ // construction — nothing derived, nothing recognised. Found at the pre-commit re-check.
263
+ if (!escapes(resolutionRoot, pluginRoot)) {
264
+ return refuse(
265
+ `the pack \`${ref}\` resolves from ${resolutionRoot}, which contains the plugin root ` +
266
+ "itself — every declared skills path would be indistinguishable from a pack's, including " +
267
+ "hand-written ones this tool must preserve. Name a narrower `--pack-root`",
268
+ );
269
+ }
270
+ owned.add(resolutionRoot);
271
+ }
272
+
273
+ for (const entry of contributed ?? []) {
274
+ if (typeof entry !== "string" || entry.trim() === "") {
275
+ return refuse(
276
+ `the pack \`${ref}\` declares a \`contributes.skills\` entry that is not a non-empty ` +
277
+ `string (${JSON.stringify(entry)}) — could-not-run`,
278
+ );
279
+ }
280
+
281
+ const absolute = path.resolve(packRoot, entry);
282
+
283
+ // A skills root escaping its own pack is `doctor`'s verdict on that pack, and this tool does
284
+ // not render it — but deriving from it anyway would put a path outside the pack into
285
+ // somebody's manifest, which is worse than either. Named and refused.
286
+ if (escapes(packRoot, absolute)) {
287
+ return refuse(
288
+ `the pack \`${ref}\` declares the skills root \`${entry}\`, which resolves outside the ` +
289
+ "pack itself — refusing to derive a declaration from it. `doctor` owns the verdict on that pack",
290
+ );
291
+ }
292
+
293
+ // The fourth outcome, and the one an adopter meets first. A pack resolved from the host's
294
+ // plugin cache — which is the case `--pack-root auto` and `discover.mjs` exist for —
295
+ // resolves perfectly well and has **no path expressible relative to this plugin root**. It
296
+ // is not drift, not could-not-run, and must not be silence: it registers through its own
297
+ // plugin or not at all. Reported, excluded from the set, and it moves no exit code.
298
+ if (escapes(pluginRoot, absolute)) {
299
+ if (!reportedExternal.has(ref)) {
300
+ reportedExternal.add(ref);
301
+ external.push({ pack: ref, root: packRoot });
302
+ }
303
+ continue;
304
+ }
305
+
306
+ paths.push({ path: canonical(path.relative(pluginRoot, absolute)), pack: ref, root: packRoot });
307
+ }
308
+ }
309
+
310
+ // An empty set is NOT refused here, and that is the one place this carrier's contract differs from
311
+ // `recipeSet`'s on purpose. A workspace that yields no verify recipe cannot report green, so that
312
+ // one refuses; a workspace may legitimately **compose no packs**, and refusing would make the
313
+ // honest case red. Emptiness is two questions — nothing composed, or nothing readable — and every
314
+ // unreadable arm above has already returned. What is left is examined and genuinely empty.
315
+ //
316
+ // With nothing resolved there is no root to derive from a pack, so the caller's own resolution
317
+ // roots stand in — `run` passes the ones `rootPlan` produced — and the conventional root is the
318
+ // last resort. That ordering is the finding: a workspace that has STOPPED composing still needs its
319
+ // stale pack entries recognised as ours and cleaned, and falling straight to `<pluginRoot>/packs`
320
+ // did that **only for the conventional layout**, silently preserving a stale entry forever anywhere
321
+ // else. Layout-dependence in the fallback of a fix whose whole subject was layout-dependence.
322
+ // Raised at the pre-commit re-check.
323
+ // The same ambiguity the in-pack guard refuses, on the path that has no pack to guard. A fallback
324
+ // root that CONTAINS the plugin root makes every declared entry read as pack-owned, and with no
325
+ // packs composed the derived set is empty — so `--write` deleted `./core/skills/` and
326
+ // `./plugin/skills/` outright and **exited 0 announcing "wrote 0 skills path(s)"**. Silent data
327
+ // loss, reachable with `--pack-root <pluginRoot>`.
328
+ //
329
+ // The guard was written for the resolved-pack arm and not for this one, which is the third instance
330
+ // in this change of a fix landing at one site of an operation and not its sibling —
331
+ // `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md`. Raised by Copilot on
332
+ // #229, after the two earlier instances were raised the same way.
333
+ const fallbackCandidates = (options.fallbackRoots ?? []).map((r) => path.resolve(r));
334
+ for (const candidate of fallbackCandidates) {
335
+ if (!escapes(candidate, pluginRoot)) {
336
+ return refuse(
337
+ `the pack root ${candidate} contains the plugin root itself — every declared skills path ` +
338
+ "would be indistinguishable from a pack's, including hand-written ones this tool must " +
339
+ "preserve. Name a narrower `--pack-root`",
340
+ );
341
+ }
342
+ }
343
+ const fallback = fallbackCandidates.filter((r) => !escapes(pluginRoot, r));
344
+ return {
345
+ ok: true,
346
+ paths,
347
+ external,
348
+ composed: packs.length,
349
+ owned: owned.size ? [...owned] : fallback.length ? fallback : [path.join(pluginRoot, "packs")],
350
+ };
351
+ }
352
+
353
+ /**
354
+ * Split a plugin manifest's declared `skills` into the portion this tool owns and the portion it must
355
+ * leave exactly as it found it.
356
+ *
357
+ * The outside entries keep their **original spelling**, because `declaredFor` writes them back and a
358
+ * generator that silently re-spelled somebody's hand-written path would be editing what it was asked
359
+ * to preserve.
360
+ */
361
+ export function packPortion(skills, pluginRoot, ownedRoots) {
362
+ if (skills === undefined) return { inside: [], outside: [], malformed: false };
363
+ if (!Array.isArray(skills)) return { inside: [], outside: [], malformed: true };
364
+
365
+ // `skillsSet` reports the roots it owns and every caller passes them through. The conventional root
366
+ // is the default for a caller asking about a manifest with no derivation beside it — never a second
367
+ // opinion about where packs live when one is available.
368
+ const roots = (ownedRoots ?? [path.join(pluginRoot, "packs")]).map((r) => path.resolve(r));
369
+ const inside = [];
370
+ const outside = [];
371
+ for (const raw of skills) {
372
+ // A non-string entry is not this tool's verdict — `plugin-lint` grades the manifest — but it is
373
+ // also not a pack path, so it is preserved rather than dropped.
374
+ if (typeof raw !== "string") {
375
+ outside.push(raw);
376
+ continue;
377
+ }
378
+ const absolute = path.resolve(pluginRoot, raw);
379
+ if (roots.some((root) => !escapes(root, absolute))) inside.push(canonical(path.relative(pluginRoot, absolute)));
380
+ else outside.push(raw);
381
+ }
382
+ return { inside, outside, malformed: false };
383
+ }
384
+
385
+ /**
386
+ * Compare a derived set against what a plugin manifest declares.
387
+ *
388
+ * Both directions are named, because each is a distinct defect and both are silent today: a composed
389
+ * pack the manifest does not declare ships, counts, and is inert on every install; a pack path the
390
+ * manifest declares that no composed pack asks for is a capability the host registers that no
391
+ * workspace layer asked for.
392
+ */
393
+ export function compare(set, pluginManifest, pluginRoot) {
394
+ const portion = packPortion(pluginManifest?.skills, pluginRoot, set?.owned);
395
+ if (portion.malformed) {
396
+ return {
397
+ agree: false,
398
+ missing: [],
399
+ extra: [],
400
+ why: "the plugin manifest declares `skills` as something other than an array, so what it registers could not be read",
401
+ };
402
+ }
403
+ // Sets rather than `includes` on both sides. The cost is nothing at this repository's three
404
+ // entries and the shape is an adopter's — the same finding #228 records against the persona
405
+ // correspondence, applied here rather than shipped again and fixed later.
406
+ const derived = set.paths.map((p) => p.path);
407
+ const declared = portion.inside;
408
+ const derivedSet = new Set(derived);
409
+ const declaredSet = new Set(declared);
410
+ const missing = derived.filter((p) => !declaredSet.has(p));
411
+ const extra = declared.filter((p) => !derivedSet.has(p));
412
+ return { agree: missing.length === 0 && extra.length === 0, missing, extra };
413
+ }
414
+
415
+ /**
416
+ * The `skills` array `--write` would put in the manifest: every non-pack entry exactly as it was, in
417
+ * its original order, followed by the derived pack entries.
418
+ *
419
+ * Returns `null` when the manifest's `skills` cannot be read as an array — a caller must not treat
420
+ * that as "there were no entries to preserve", which would silently delete a key it could not parse.
421
+ */
422
+ export function declaredFor(set, pluginManifest, pluginRoot) {
423
+ const portion = packPortion(pluginManifest?.skills, pluginRoot, set?.owned);
424
+ if (portion.malformed) return null;
425
+ return [...portion.outside, ...set.paths.map((p) => p.path)];
426
+ }
427
+
428
+ /**
429
+ * A resolver over a workspace's declared pack roots.
430
+ *
431
+ * Reaches `compile.mjs`'s `rootPlan` and `resolvePack` rather than re-deriving where a pack lives.
432
+ * The root handed back is **absolute**, which is where this differs from `recipe-set.mjs`'s resolver
433
+ * and why the two are not shared: a recipe's `${PACK_ROOT}` is typed from the repository root, while a
434
+ * declared skills path is relative to the **plugin** root, and those are not the same directory. The
435
+ * honest way to serve both is to hand back the absolute path and relativise once, at the caller that
436
+ * knows which root it means.
437
+ */
438
+ export function resolverFor({ workspaceDir, manifest, named = [], discovery = null, roots = null }) {
439
+ // `roots` lets a caller that already computed the plan share it rather than deriving a second one —
440
+ // `run` needs the same list twice, once to resolve and once as the fallback owned set, and two
441
+ // derivations of one plan is the shape this file exists to refuse.
442
+ const plan = roots ? null : rootPlan(workspaceDir, manifest, { named, discovery });
443
+ if (plan?.refusal) throw new Error(`skills-set: ${plan.refusal}`);
444
+ if (plan?.couldNotRun) throw new Error(`skills-set: ${plan.couldNotRun}`);
445
+ const resolved = roots ?? plan.roots ?? [];
446
+ return (ref) => {
447
+ const found = resolvePack(ref, resolved);
448
+ // `resolvePack` returns `manifest` as the PATH to `pack.json`, not as the parsed object.
449
+ // Reading it is this adapter's job, and the first cut of the sibling resolver got this wrong
450
+ // while its unit suite stayed green, because the fixture resolver returned a parsed object and
451
+ // so encoded the same assumption the code made. `cli/skills-set.live.test.mjs` is what stops
452
+ // that here.
453
+ if (!found?.dir || !found.manifest) return null;
454
+ // Present and unreadable is NOT a pack that contributes nothing — and it is not one that could
455
+ // not be FOUND either. Returning bare `null` for both made the caller's refusal say "could not
456
+ // be resolved" about a pack it had resolved perfectly well, misdiagnosing exactly the
457
+ // present-but-unreadable case this tool treats as could-not-run. Raised as a promoted
458
+ // low-confidence note on #229. Both stay could-not-run; what changes is the sentence the reader
459
+ // gets, and read is kept apart from parse for the reason the arm above it now does.
460
+ let text;
461
+ try {
462
+ text = fs.readFileSync(found.manifest, "utf8");
463
+ } catch (error) {
464
+ return { ref, root: path.resolve(found.dir), unreadable: `could not be read — ${error.code ?? error.message}` };
465
+ }
466
+ try {
467
+ return { ref, root: path.resolve(found.dir), manifest: JSON.parse(text) };
468
+ } catch (error) {
469
+ return { ref, root: path.resolve(found.dir), unreadable: `does not parse as JSON — ${error.message}` };
470
+ }
471
+ };
472
+ }
473
+
474
+ /** Where a plugin root's manifest sits. One spelling, so a reader and a writer cannot disagree. */
475
+ export function manifestPath(pluginRoot) {
476
+ return path.join(pluginRoot, ".claude-plugin", "plugin.json");
477
+ }
478
+
479
+ /**
480
+ * Read a plugin manifest for `--check`/`--write`, under the three rules a tool that writes into
481
+ * somebody's tree carries — `init`, `new` and `vendor` paid for these and a fourth reader inherits
482
+ * them.
483
+ *
484
+ * Returns `{ ok: true, text, value }` or `{ ok: false, reason }`.
485
+ */
486
+ function readManifest(file, pluginRoot) {
487
+ // Rule 2: refuse a symlink **at or below the named destination**, rather than resolving through it.
488
+ //
489
+ // The whole chain from the named plugin root down, not just the last two steps. The first cut
490
+ // lstat'd `dirname(file)` and `file` alone, so a symlinked `--plugin-root` was resolved straight
491
+ // through and this tool wrote into whatever it pointed at — which is the exact failure `init` paid
492
+ // for at nine files written into an unrelated directory and reported as success. `cli/new.mjs`'s
493
+ // `chain` is the shape being matched; it is not exported, so the walk is spelled here rather than
494
+ // reaching into a subcommand for it, and the boundary rule is the one that file argues: **above the
495
+ // named path, resolve — the user named it, and on macOS `os.tmpdir()` runs through `/var`. At it
496
+ // and below it, refuse.**
497
+ const chain = [];
498
+ for (let at = file, i = 0; i < 64; i += 1) {
499
+ chain.push(at);
500
+ if (at === pluginRoot) break;
501
+ const up = path.dirname(at);
502
+ if (up === at) break;
503
+ at = up;
504
+ }
505
+ for (const candidate of chain.reverse()) {
506
+ let stat;
507
+ try {
508
+ stat = fs.lstatSync(candidate);
509
+ } catch (error) {
510
+ // Rule 3: only ENOENT means absent. An EACCES is a question that could not be answered, and
511
+ // answering it *nothing there* is "nothing looked" recorded as "nothing wrong".
512
+ if (error.code === "ENOENT") {
513
+ return {
514
+ ok: false,
515
+ reason:
516
+ `${candidate} does not exist — this tool never creates a plugin manifest. A workspace ` +
517
+ "that ships no plugin is a state, not a hole to fill",
518
+ };
519
+ }
520
+ return { ok: false, reason: `${candidate} could not be examined — ${error.code ?? error.message}` };
521
+ }
522
+ if (stat.isSymbolicLink()) {
523
+ return {
524
+ ok: false,
525
+ reason: `${candidate} is a symlink — refusing to write through it rather than resolving it`,
526
+ };
527
+ }
528
+ }
529
+
530
+ let text;
531
+ try {
532
+ text = fs.readFileSync(file, "utf8");
533
+ } catch (error) {
534
+ return { ok: false, reason: `${file} could not be read — ${error.code ?? error.message}` };
535
+ }
536
+ try {
537
+ return { ok: true, text, value: JSON.parse(text) };
538
+ } catch (error) {
539
+ // Never overwritten. A manifest that will not parse is one whose other keys nobody can preserve.
540
+ return { ok: false, reason: `${file} does not parse as JSON — ${error.message}` };
541
+ }
542
+ }
543
+
544
+ /**
545
+ * The runnable surface: the printer CI and an adopter's own pipeline call, plus `--check` and
546
+ * `--write`.
547
+ *
548
+ * `options.manifest` and `options.resolve` are injection points for the suite, the way `init` injects
549
+ * its reader. No production caller passes either; every flag below is the real surface.
550
+ */
551
+ export function run(argv = [], options = {}) {
552
+ const { stdout = process.stdout, stderr = process.stderr } = options;
553
+ const arg = (flag, fallback) => {
554
+ const i = argv.indexOf(flag);
555
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
556
+ };
557
+ const many = (flag) => argv.reduce((out, a, i) => (a === flag && argv[i + 1] ? [...out, argv[i + 1]] : out), []);
558
+
559
+ const check = argv.includes("--check");
560
+ const write = argv.includes("--write");
561
+ if (check && write) {
562
+ stderr.write("skills-set: --check and --write are two questions; pass one\n");
563
+ return 2;
564
+ }
565
+
566
+ const repoRoot = path.resolve(arg("--repo-root", "."));
567
+ const workspaceDir = path.resolve(arg("--workspace", path.join(repoRoot, ".portulan")));
568
+ const pluginRoot = path.resolve(arg("--plugin-root", repoRoot));
569
+
570
+ // Refused before the manifest is read, the sibling of the placement Copilot's round 2 found in
571
+ // `compile`. It sat below the read here too, so an unreadable or unparsable workspace answered
572
+ // first and this tool disagreed with the others about WHEN the command line is judged. Swept in
573
+ // the same stroke rather than left for the round that would eventually find it.
574
+ const askedBoth = namedWithAuto(
575
+ many("--pack-root").filter((r) => r !== AUTO),
576
+ many("--pack-root").includes(AUTO),
577
+ );
578
+ if (askedBoth) {
579
+ stderr.write(`skills-set: ${askedBoth}\n`);
580
+ return 2;
581
+ }
582
+
583
+ let manifest = options.manifest;
584
+ if (manifest === undefined) {
585
+ // Read and parse are two failures with two repairs, and one sentence for both misdiagnoses the
586
+ // commoner one. A `SyntaxError` from `JSON.parse` carries no `.code`, so it fell through to
587
+ // `.message` under a sentence reading "could not be read" — sending an adopter with a malformed
588
+ // manifest to look at permissions and paths. `readManifest` below already keeps the two apart;
589
+ // this arm did not. Raised by Copilot on #229.
590
+ const file = path.join(workspaceDir, "workspace.json");
591
+ let text;
592
+ try {
593
+ text = fs.readFileSync(file, "utf8");
594
+ } catch (error) {
595
+ stderr.write(`skills-set: ${file} could not be read — ${error.code ?? error.message}\n`);
596
+ return 2;
597
+ }
598
+ try {
599
+ manifest = JSON.parse(text);
600
+ } catch (error) {
601
+ stderr.write(`skills-set: ${file} does not parse as JSON — ${error.message}\n`);
602
+ return 2;
603
+ }
604
+ }
605
+
606
+ let resolve = options.resolve;
607
+ let fallbackRoots = options.fallbackRoots;
608
+ if (resolve === undefined) {
609
+ const named = many("--pack-root");
610
+ const wantsDiscovery = named.includes(AUTO);
611
+ const namedRoots = named.filter((r) => r !== AUTO);
612
+ // Two defects lived in this call until 2026-08-12, and both were the finding this change is
613
+ // about, one caller further on. It passed `discovery` WITHOUT `forced`, and `resolutionRoots`
614
+ // consults `discovery` only inside its forced branch — so `--pack-root auto` here read the
615
+ // host's record, threw the answer away, resolved from the derived root, and said nothing.
616
+ // Silently inert, and eagerly reading `~/.claude` to be so: `discovery` is a THUNK precisely
617
+ // so a branch that cannot use the answer never touches the host, and calling
618
+ // `discoverPackRoots()` at the argument site defeated that whichever branch ran.
619
+ //
620
+ // _(The thunk's reason NARROWED on 2026-08-13 and the wording followed: it was *an unasked path
621
+ // never touches the host*, and the unasked path now consults discovery deliberately. What the
622
+ // thunk still guarantees is that the **named** and **refused** arms do not — and that an API
623
+ // caller wiring no thunk stays hermetic on every arm.)_
624
+ const plan = rootPlan(workspaceDir, manifest, {
625
+ named: namedRoots,
626
+ discovery: () => discoverPackRoots(),
627
+ forced: wantsDiscovery,
628
+ });
629
+ if (plan.refusal || plan.couldNotRun) {
630
+ stderr.write(`skills-set: ${plan.refusal ?? plan.couldNotRun}\n`);
631
+ return 2;
632
+ }
633
+ const roots = plan.roots ?? [];
634
+ resolve = resolverFor({ workspaceDir, manifest, roots });
635
+ // The same roots stand in as the owned set when nothing resolves — so a workspace that has
636
+ // stopped composing gets its stale entries cleaned wherever its packs WOULD have been, not
637
+ // only at the conventional path.
638
+ fallbackRoots ??= roots;
639
+ }
640
+
641
+ const set = skillsSet(manifest, { pluginRoot, resolve, fallbackRoots });
642
+ if (!set.ok) {
643
+ stderr.write(`skills-set: ${set.reason}\n`);
644
+ return set.exitCode;
645
+ }
646
+
647
+ // Reported on every run, in every mode. A pack registering through its own plugin is not a fault
648
+ // and is not nothing — a reader who cannot see it will read the derived set as the whole answer.
649
+ for (const { pack, root } of set.external) {
650
+ stderr.write(
651
+ `skills-set: the pack \`${pack}\` resolves outside this plugin root (${root}) — it registers ` +
652
+ "through its own plugin or not at all, and nothing here can declare it\n",
653
+ );
654
+ }
655
+
656
+ if (!check && !write) {
657
+ for (const { path: p, pack } of set.paths) stdout.write(`${p}\t${pack}\n`);
658
+ return 0;
659
+ }
660
+
661
+ const file = manifestPath(pluginRoot);
662
+ const read = readManifest(file, pluginRoot);
663
+ if (!read.ok) {
664
+ stderr.write(`skills-set: ${read.reason}\n`);
665
+ return 2;
666
+ }
667
+
668
+ if (check) {
669
+ const verdict = compare(set, read.value, pluginRoot);
670
+ if (verdict.agree) {
671
+ stdout.write(`skills-set: ${path.relative(repoRoot, file) || file} declares every composed pack's skills (${set.paths.length})\n`);
672
+ return 0;
673
+ }
674
+ if (verdict.why) {
675
+ stderr.write(`skills-set: ${verdict.why}\n`);
676
+ return 2;
677
+ }
678
+ for (const missing of verdict.missing) {
679
+ stderr.write(
680
+ `skills-set: a composed pack's skills live at ${missing} and no \`skills\` path declares it — ` +
681
+ "the host registers nothing there, so the skill ships, counts, and cannot be invoked\n",
682
+ );
683
+ }
684
+ for (const extra of verdict.extra) {
685
+ stderr.write(
686
+ `skills-set: ${extra} is declared and belongs to no pack this workspace composes — the host ` +
687
+ "would load it without the workspace layer having asked for it\n",
688
+ );
689
+ }
690
+ stderr.write("skills-set: run with --write to derive the `skills` key from `packs`\n");
691
+ return 1;
692
+ }
693
+
694
+ const next = declaredFor(set, read.value, pluginRoot);
695
+ if (next === null) {
696
+ stderr.write(
697
+ "skills-set: the plugin manifest declares `skills` as something other than an array — refusing to " +
698
+ "replace a key nobody could read\n",
699
+ );
700
+ return 2;
701
+ }
702
+ const value = { ...read.value, skills: next };
703
+ try {
704
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
705
+ } catch (error) {
706
+ stderr.write(`skills-set: ${file} could not be written — ${error.code ?? error.message}\n`);
707
+ return 2;
708
+ }
709
+ stdout.write(`skills-set: wrote ${next.length} \`skills\` path(s) to ${path.relative(repoRoot, file) || file}\n`);
710
+ return 0;
711
+ }
712
+
713
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
714
+ process.exit(run(process.argv.slice(2)));
715
+ }