@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.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +87 -0
- package/cli/README.md +337 -0
- package/cli/compile.mjs +2209 -0
- package/cli/control-chars.mjs +570 -0
- package/cli/discover.mjs +985 -0
- package/cli/doctor.mjs +2803 -0
- package/cli/feedback.mjs +970 -0
- package/cli/gate.mjs +271 -0
- package/cli/index.mjs +1390 -0
- package/cli/init.mjs +1729 -0
- package/cli/librarian.mjs +1384 -0
- package/cli/manifest.mjs +59 -0
- package/cli/new.mjs +803 -0
- package/cli/pack-version.mjs +543 -0
- package/cli/plugin-lint.mjs +1566 -0
- package/cli/portulan.mjs +266 -0
- package/cli/recipe-set.mjs +425 -0
- package/cli/rule-carriers.mjs +489 -0
- package/cli/skills-set.mjs +715 -0
- package/cli/stop-gate.mjs +434 -0
- package/cli/upgrade.mjs +897 -0
- package/cli/vendor.mjs +1504 -0
- package/core/README.md +46 -0
- package/core/engine.md +47 -0
- package/core/operating/autonomy.md +100 -0
- package/core/operating/evolution.md +84 -0
- package/core/operating/loop.md +83 -0
- package/core/operating/memory.md +161 -0
- package/core/operating/safety.md +42 -0
- package/core/operating/verification.md +76 -0
- package/core/personas/README.md +40 -0
- package/core/personas/implementer.md +51 -0
- package/core/personas/librarian.md +73 -0
- package/core/personas/reviewer.md +52 -0
- package/core/skills/README.md +52 -0
- package/core/skills/clarify/SKILL.md +42 -0
- package/core/skills/codify/SKILL.md +65 -0
- package/core/skills/consolidate/SKILL.md +121 -0
- package/core/templates/gate-policy.md +76 -0
- package/core/templates/handoff.md +29 -0
- package/core/templates/memory-entry.md +44 -0
- package/core/templates/pack.md +94 -0
- package/core/templates/persona.md +69 -0
- package/core/templates/proposal.md +27 -0
- package/core/templates/repo-card.md +28 -0
- package/core/templates/skill.md +57 -0
- package/core/templates/task.md +26 -0
- package/core/templates/workspace.md +81 -0
- package/package.json +41 -0
- package/packs/.claude-plugin/plugin.json +22 -0
- package/packs/README.md +61 -0
- package/packs/rituals/README.md +13 -0
- package/packs/rituals/checkpoints/README.md +105 -0
- package/packs/rituals/checkpoints/pack.json +36 -0
- package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
- package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
- package/packs/stacks/README.md +8 -0
- package/packs/tools/README.md +17 -0
- package/packs/tools/github/README.md +65 -0
- package/packs/tools/github/pack.json +29 -0
- package/packs/tools/github/verify/README.md +51 -0
- package/packs/tools/github/verify/actions-pinned.sh +132 -0
- package/spec/README.md +399 -0
- package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
- package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
- package/spec/migrations/README.md +95 -0
- package/spec/pack.schema.json +201 -0
- package/spec/slots.md +796 -0
- 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
|
+
}
|