@sleepy_panda_srl/portulan 0.1.3 → 0.2.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/README.md +6 -5
- package/cli/README.md +142 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1322 -1607
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +224 -1074
- package/cli/drills.mjs +68 -502
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +20 -305
- package/cli/gate.mjs +7 -199
- package/cli/goldens.mjs +26 -184
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +243 -566
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +778 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +9 -170
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +5 -74
- package/cli/pack-version.mjs +13 -255
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +5 -124
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +17 -258
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +10 -176
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +83 -514
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +27 -492
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +8 -86
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +183 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/consolidate/SKILL.md +4 -2
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +255 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/README.md +3 -3
- package/package.json +4 -2
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/spec/README.md +112 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +333 -21
- package/spec/workspace.schema.json +145 -4
package/cli/skills-set.mjs
CHANGED
|
@@ -1,205 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* The registrable set
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 ` +
|