@sleepy_panda_srl/portulan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +87 -0
  4. package/cli/README.md +337 -0
  5. package/cli/compile.mjs +2209 -0
  6. package/cli/control-chars.mjs +570 -0
  7. package/cli/discover.mjs +985 -0
  8. package/cli/doctor.mjs +2803 -0
  9. package/cli/feedback.mjs +970 -0
  10. package/cli/gate.mjs +271 -0
  11. package/cli/index.mjs +1390 -0
  12. package/cli/init.mjs +1729 -0
  13. package/cli/librarian.mjs +1384 -0
  14. package/cli/manifest.mjs +59 -0
  15. package/cli/new.mjs +803 -0
  16. package/cli/pack-version.mjs +543 -0
  17. package/cli/plugin-lint.mjs +1566 -0
  18. package/cli/portulan.mjs +266 -0
  19. package/cli/recipe-set.mjs +425 -0
  20. package/cli/rule-carriers.mjs +489 -0
  21. package/cli/skills-set.mjs +715 -0
  22. package/cli/stop-gate.mjs +434 -0
  23. package/cli/upgrade.mjs +897 -0
  24. package/cli/vendor.mjs +1504 -0
  25. package/core/README.md +46 -0
  26. package/core/engine.md +47 -0
  27. package/core/operating/autonomy.md +100 -0
  28. package/core/operating/evolution.md +84 -0
  29. package/core/operating/loop.md +83 -0
  30. package/core/operating/memory.md +161 -0
  31. package/core/operating/safety.md +42 -0
  32. package/core/operating/verification.md +76 -0
  33. package/core/personas/README.md +40 -0
  34. package/core/personas/implementer.md +51 -0
  35. package/core/personas/librarian.md +73 -0
  36. package/core/personas/reviewer.md +52 -0
  37. package/core/skills/README.md +52 -0
  38. package/core/skills/clarify/SKILL.md +42 -0
  39. package/core/skills/codify/SKILL.md +65 -0
  40. package/core/skills/consolidate/SKILL.md +121 -0
  41. package/core/templates/gate-policy.md +76 -0
  42. package/core/templates/handoff.md +29 -0
  43. package/core/templates/memory-entry.md +44 -0
  44. package/core/templates/pack.md +94 -0
  45. package/core/templates/persona.md +69 -0
  46. package/core/templates/proposal.md +27 -0
  47. package/core/templates/repo-card.md +28 -0
  48. package/core/templates/skill.md +57 -0
  49. package/core/templates/task.md +26 -0
  50. package/core/templates/workspace.md +81 -0
  51. package/package.json +41 -0
  52. package/packs/.claude-plugin/plugin.json +22 -0
  53. package/packs/README.md +61 -0
  54. package/packs/rituals/README.md +13 -0
  55. package/packs/rituals/checkpoints/README.md +105 -0
  56. package/packs/rituals/checkpoints/pack.json +36 -0
  57. package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
  58. package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
  59. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
  60. package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
  61. package/packs/stacks/README.md +8 -0
  62. package/packs/tools/README.md +17 -0
  63. package/packs/tools/github/README.md +65 -0
  64. package/packs/tools/github/pack.json +29 -0
  65. package/packs/tools/github/verify/README.md +51 -0
  66. package/packs/tools/github/verify/actions-pinned.sh +132 -0
  67. package/spec/README.md +399 -0
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
  70. package/spec/migrations/README.md +95 -0
  71. package/spec/pack.schema.json +201 -0
  72. package/spec/slots.md +796 -0
  73. package/spec/workspace.schema.json +438 -0
@@ -0,0 +1,1566 @@
1
+ #!/usr/bin/env node
2
+ // `plugin-lint` — the packaging validator.
3
+ //
4
+ // node cli/plugin-lint.mjs [--payload] <plugin-root> [<plugin-root> ...]
5
+ //
6
+ // Exit 0 the packaging holds together · 1 it does not · 2 could not run. Same three codes as
7
+ // ./doctor.mjs and for the same reason: a verdict ABOUT the tree is 1, and 2 means nothing was
8
+ // judged at all. Borrowing 1 for the second would claim a judgement nobody reached.
9
+ //
10
+ // ## What this checks, and what it deliberately does not
11
+ //
12
+ // This validator checks **this repository's own invariants** about its packaging: that both
13
+ // manifests parse and are objects, that the fields this repository depends on are present and
14
+ // correctly shaped, that the two manifests agree with each other, that every path they declare
15
+ // starts with `./`, stays inside the tree, and resolves — and that every skill and agent behind
16
+ // those paths is a real artifact with frontmatter and a non-empty description.
17
+ //
18
+ // It is **not** an implementation of the Claude Code plugin contract, and must never be described
19
+ // as one. `claude plugin validate --strict` is the authority for that contract; it is run at the
20
+ // supervised checkpoints and before any release, and its result is recorded in the session log.
21
+ // The split exists because CI here installs nothing by stated doctrine (.github/workflows/verify.yml),
22
+ // so a recipe declaring the `claude` binary as a dependency would exit 2 — "could not run" — on
23
+ // every pull request, which under this repository's own precondition rule is permanently red.
24
+ //
25
+ // So: the platform's contract is checked by the platform's own tool at a checkpoint, and the rail
26
+ // that runs on every pull request checks what this repository can honestly own. The gap between
27
+ // those two is real and is stated in ./README.md and in ../.portulan/products/portulan/affordances.md
28
+ // rather than left for someone to discover — a mandate nothing checks is already broken
29
+ // (../.portulan/memory/a-mandate-nothing-checks-is-already-broken.md), and so is a claim of coverage
30
+ // nothing measures.
31
+ //
32
+ // Explicitly NOT checked, each for a reason:
33
+ // * reserved marketplace names — the platform re-checks that list on every marketplace load and
34
+ // has already changed it once. A copy frozen here would drift into either a false red or a
35
+ // false green, and the platform enforces it where it actually matters.
36
+ // * field semantics beyond shape — whether a description is *good*, whether a skill is worth its
37
+ // tokens. That is review, and ../core/skills/README.md holds the bar.
38
+ // * anything needing the network. Nothing here fetches (../.portulan/verify/README.md).
39
+ // * **whether a declared skill becomes a capability the host registers — beyond its DEPTH.** Since
40
+ // 2026-08-07 one half of that question is checked: a skill resolved more than HOST_SKILL_DEPTH
41
+ // below its declared root fails, because the host expands a declared root exactly one level and a
42
+ // skill deeper than that is packaged, counted and inert. That was measured in both directions on
43
+ // Claude Code 2.1.224 against a local marketplace built from this repository — `./packs/rituals/`
44
+ // registered 0 of the pack's 3, `./packs/rituals/checkpoints/skills/` registered all 3 — and it is
45
+ // why `N skill(s)` and the host's inventory had disagreed by three for a milestone (#134).
46
+ // Since 2026-08-09 a second half is checked, and it is a different question: not *can the host
47
+ // reach this path* but *did the workspace ask for it*. The `compose` check below pins the
48
+ // governing workspace's `packs` array to the `skills` paths that land inside `./packs/`, both
49
+ // ways, because registration is otherwise a property of plugin.json alone — measured by deleting
50
+ // the `packs` key outright and reinstalling, which changed the host's inventory not at all.
51
+ // What is still NOT checked is everything else about registration: whether the host accepts the
52
+ // frontmatter, whether a name collides, whether the plugin loads at all. `N skill(s)` answers *is
53
+ // the packaging coherent and reachable*, never *does this install work* — that stays the
54
+ // fresh-machine demonstration below and `claude plugin validate --strict`.
55
+ // * whether the plugin, once installed, behaves. That is the fresh-machine install demonstration
56
+ // the milestone-3 criterion asks for, and no lint can stand in for it.
57
+
58
+ import fs from "node:fs";
59
+ import path from "node:path";
60
+ import { pathToFileURL } from "node:url";
61
+
62
+ // The registrable set — what a composed pack's own `contributes.skills` says this manifest must
63
+ // declare. `HOST_SKILL_DEPTH` was measured in THIS file and lives there now; see its note below for
64
+ // why the direction is this way round rather than the other.
65
+ import { HOST_SKILL_DEPTH, skillsSet, canonical } from "./skills-set.mjs";
66
+
67
+ /** Raised when `plugin-lint` cannot run at all. Always exit 2, never 1. */
68
+ export class PluginLintError extends Error {
69
+ constructor(message) {
70
+ super(message);
71
+ this.name = "PluginLintError";
72
+ }
73
+ }
74
+
75
+ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
76
+
77
+ // The private feeds this project ships, named so a public manifest cannot be sourced from one. Named
78
+ // rather than pattern-matched on "private": visibility is a live GitHub setting no file can read, so the
79
+ // only honest form is a list this repository maintains. `docs/plan.md`'s topology is the declaring
80
+ // authority for what is on it, and the name is already public there — what is refused is a *pointer*,
81
+ // not a mention. Entries are `owner/name`, not bare names: matching a name alone refused an unrelated
82
+ // public repository that happened to share it, which is a false red in a rail, and a false red is how a
83
+ // whole rail gets switched off. A feed added to the topology and not added here is the drift this comment
84
+ // exists to make findable; the list is short enough to keep by hand.
85
+ const PRIVATE_FEEDS = ["sleepy-panda-srl/portulan-internal"];
86
+ // Deliberately permissive about pre-release and build metadata, strict about the three numbers:
87
+ // the platform compares version strings, and this repository's own convention is SemVer from
88
+ // v0.1.0 (../docs/plan.md, Protocol → Versioning).
89
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
90
+
91
+ // Manifest keys whose value may name a path. `hooks`, `mcpServers` and `lspServers` also accept an
92
+ // inline object, so only string values are treated as paths — an object there is configuration, not
93
+ // a claim about the tree.
94
+ //
95
+ // `agents` is deliberately absent: see AGENT_DIR below. Declaring it does not resolve to a component
96
+ // the host will load, so treating it as a path claim would be validating a path nobody reads.
97
+ const PATH_FIELDS = [
98
+ "skills",
99
+ "commands",
100
+ "workflows",
101
+ "outputStyles",
102
+ "hooks",
103
+ "mcpServers",
104
+ "lspServers",
105
+ ];
106
+
107
+ // Directories never walked when looking for skills nobody declared: `.git` and `node_modules` for
108
+ // size, and `.claude-plugin/` because it holds manifests rather than skills. Deliberately NOT a
109
+ // blanket dot-directory rule — a SKILL.md under any other dot-directory is walked and reported,
110
+ // because "declared is what ships" and a skill hidden from this pass is one its author believes is
111
+ // shipping. Reporting beats skipping here for the same reason it does everywhere else in this tree.
112
+ const SKIP_DIRS = new Set([".git", "node_modules", ".claude-plugin"]);
113
+
114
+ // The one location the host loads agents from, and it loads them from nowhere else. Measured
115
+ // 2026-07-26 against Claude Code v2.1.215 with a positive control, because the failure is silent in
116
+ // both directions:
117
+ //
118
+ // files at ./agents/, no `agents` key → Agents (1) loaded
119
+ // the same files named explicitly in `agents` → Agents (0) the key SUPPRESSES the scan
120
+ // files at ./plugin/agents/, no key → Agents (0) nowhere else is scanned
121
+ // `agents` naming a directory, any path → the plugin fails to load at all
122
+ //
123
+ // So agents are found here by convention rather than by declaration. That is not a preference: the
124
+ // manifest cannot express a working agent path, and `claude plugin validate --strict` accepts the
125
+ // explicit-file form that loads nothing — this repository shipped exactly that and its personas were
126
+ // inert on every install (../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md).
127
+ // **Exported since milestone 7 session 7**, because `cli/doctor.mjs` now resolves a composed pack's
128
+ // persona to the host binding that would carry it and needs this exact directory. A second spelling
129
+ // there would be one measurement with two carriers — the class
130
+ // ../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md names — and the one that
131
+ // drifts is always the copy, never the file that did the measuring.
132
+ export const AGENT_DIR = "agents";
133
+ const MAX_WALK_DEPTH = 6;
134
+
135
+ // ===========================================================================================
136
+ // Frontmatter
137
+ // ===========================================================================================
138
+ //
139
+ // A deliberately small YAML reader: enough for `name` and `description`, and honest about the rest.
140
+ // The policy is the same one ./doctor.mjs applies to a repo card — parse conservatively, because a
141
+ // false red is what gets a whole recipe switched off — with one inversion: the ABSENCE of a
142
+ // frontmatter block is reported, because for a skill the block is the contract, not decoration.
143
+
144
+ /**
145
+ * @returns {{fields: Record<string,string>|null, error?: string}}
146
+ * `fields` is null when there is no usable frontmatter; `error` says why when that is a defect
147
+ * rather than simply an absent block.
148
+ */
149
+ export function parseFrontmatter(text) {
150
+ const lines = text.split(/\r?\n/);
151
+ if (lines[0]?.trim() !== "---") return { fields: null };
152
+
153
+ const close = lines.indexOf("---", 1);
154
+ if (close === -1) {
155
+ return { fields: null, error: "the frontmatter block is never closed" };
156
+ }
157
+
158
+ const fields = {};
159
+ const body = lines.slice(1, close);
160
+ for (let i = 0; i < body.length; i += 1) {
161
+ // Only the first colon splits: `description: Use when: x` is one value, not a truncated
162
+ // one. Getting this wrong would silently shorten exactly the field this validator judges.
163
+ const match = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(body[i]);
164
+ if (!match) continue;
165
+ const key = match[1];
166
+ let value = match[2].trim();
167
+
168
+ // A block scalar (`|`, `>`, with any chomping indicator) is legal YAML and common in long
169
+ // skill descriptions. Failing one would be a false red on correct input.
170
+ if (/^[|>][+-]?$/.test(value)) {
171
+ const collected = [];
172
+ while (i + 1 < body.length && (body[i + 1].trim() === "" || /^\s+/.test(body[i + 1]))) {
173
+ i += 1;
174
+ collected.push(body[i].trim());
175
+ }
176
+ value = collected.join(" ").trim();
177
+ } else if (
178
+ (value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
179
+ (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
180
+ ) {
181
+ value = value.slice(1, -1);
182
+ }
183
+ fields[key] = value;
184
+ }
185
+ return { fields };
186
+ }
187
+
188
+ // ===========================================================================================
189
+ // Inspection
190
+ // ===========================================================================================
191
+
192
+ /** True when `target` lies outside `root`. Lexical — the caller decides what it is fed. */
193
+ function escapes(root, target) {
194
+ const inside = path.relative(root, target);
195
+ return inside.startsWith("..") || path.isAbsolute(inside);
196
+ }
197
+
198
+ /**
199
+ * Lint the packaging rooted at `root`.
200
+ *
201
+ * @param {string} rawRoot - the plugin root, absolute or relative.
202
+ * @param {object} [options]
203
+ * @param {boolean} [options.payload=false] - treat this root as a **payload**: a plugin a feed
204
+ * publishes without being a marketplace itself. It changes exactly one thing — a
205
+ * `marketplace.json` that is **absent** becomes a counted `unverifiable` note instead of a
206
+ * `manifest` failure, and every marketplace-specific check is skipped because there is nothing to
207
+ * check. Everything else is unchanged, and three boundaries are deliberate: a marketplace manifest
208
+ * that is **present** is validated in full, an **unusable** one (a dangling symlink, an unreadable
209
+ * file) still fails, and the mode is never inferred from an absent file — the caller opts in. What
210
+ * the exemption costs is what the note says: nothing here can check that the feed's entry agrees
211
+ * with this manifest, so the name, version and source path it publishes under are unverified.
212
+ * @throws {PluginLintError} when the root itself cannot be read — that is not a verdict.
213
+ * @returns {{findings: Array<{severity: "fail"|"note", check: string, message: string}>,
214
+ * stats: {skills: number, agents: number, paths: number, unverifiable: number}}}
215
+ */
216
+ export function inspect(rawRoot, { payload = false } = {}) {
217
+ // Absolute from here on. Two sets of paths are compared later — the skills the manifest declares
218
+ // and the skills found by walking the tree — and they are only comparable if both are built the
219
+ // same way. With a relative root (`node cli/plugin-lint.mjs .`, which is how the verify recipe
220
+ // calls it) the declared side resolved to absolute while the walked side stayed relative, so
221
+ // every shipped skill was reported as undeclared: a note that was false about every skill, which
222
+ // is worse than no note, because it hides the true one in noise. The suite missed it because
223
+ // every fixture passed an absolute temp directory. Now normalised once, at the boundary.
224
+ let stat;
225
+ try {
226
+ stat = fs.statSync(path.resolve(rawRoot));
227
+ } catch (error) {
228
+ throw new PluginLintError(`cannot read ${rawRoot} — ${error.code ?? error.message}`);
229
+ }
230
+ if (!stat.isDirectory()) throw new PluginLintError(`${rawRoot} is not a directory`);
231
+ // Canonical, not merely absolute: every containment answer below compares against this, and
232
+ // comparing a canonical target to a symlinked root reports an escape that is not one. Both
233
+ // sides must be canonicalised or neither. (On macOS this is not hypothetical — the temp
234
+ // directories the suite builds fixtures in live under a symlinked `/tmp`.)
235
+ let root;
236
+ try {
237
+ root = fs.realpathSync(path.resolve(rawRoot));
238
+ } catch (error) {
239
+ throw new PluginLintError(`cannot resolve ${rawRoot} — ${error.code ?? error.message}`);
240
+ }
241
+
242
+ const findings = [];
243
+ const stats = { skills: 0, agents: 0, paths: 0, unverifiable: 0 };
244
+ // Every agent file this pass opened, keyed by the basename the persona correspondence below looks
245
+ // it up by. Collected during the walk rather than re-read afterwards: the files are already open,
246
+ // and a second read is a second chance to disagree about what they said.
247
+ const bound = new Map();
248
+ // Whether the agents directory was actually EXAMINED, which is not the same as whether `bound` has
249
+ // anything in it. An empty map has two causes — nothing is bound, or nothing could be looked at —
250
+ // and the correspondence below may only speak on the first. Absent counts as examined: a plugin
251
+ // with no `agents/` has genuinely bound nothing, and that IS the finding.
252
+ let agentsExamined = false;
253
+ const fail = (check, message) => findings.push({ severity: "fail", check, message });
254
+ const note = (check, message) => findings.push({ severity: "note", check, message });
255
+
256
+ /** Read a file, turning any failure into a finding rather than an exception. */
257
+ const read = (file, check, label) => {
258
+ try {
259
+ return fs.readFileSync(file, "utf8");
260
+ } catch (error) {
261
+ // doctor shipped this defect twice: an unguarded read turned a tree already judged red
262
+ // into "could not run", discarding every finding the run had made. A file that cannot
263
+ // be read IS a verdict about the tree.
264
+ fail(check, `${label} could not be read — ${error.code ?? error.message}`);
265
+ return null;
266
+ }
267
+ };
268
+
269
+ /** Parse a manifest. Returns null on any problem, having recorded it. */
270
+ const manifest = (rel) => {
271
+ const file = path.join(root, rel);
272
+ if (!fs.existsSync(file)) {
273
+ fail("manifest", `${rel} is missing`);
274
+ return null;
275
+ }
276
+ const text = read(file, "manifest", rel);
277
+ if (text === null) return null;
278
+ let value;
279
+ try {
280
+ value = JSON.parse(text);
281
+ } catch (error) {
282
+ fail("manifest", `${rel} does not parse as JSON — ${error.message}`);
283
+ return null;
284
+ }
285
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
286
+ fail("manifest", `${rel} is not a JSON object`);
287
+ return null;
288
+ }
289
+ return value;
290
+ };
291
+
292
+ /**
293
+ * Resolve a declared path against the root, enforcing the platform's two stated rules — it
294
+ * must start with `./` and it must not leave the plugin root — and this repository's one:
295
+ * it must actually resolve.
296
+ *
297
+ * Containment is checked **twice**, and the second one is the check that means something.
298
+ * `path.relative` is lexical: it reads `./plugin/skills/` as inside the root whether or not
299
+ * `plugin/skills` is a symlink to somewhere else entirely. Since a plugin's whole contract is
300
+ * that its components live inside it — and since a symlink escaping the root is a shape this
301
+ * repository actively considered and rejected for its own packaging — the lexical answer is
302
+ * the one an attacker or an accident would satisfy. So the target is canonicalised and asked
303
+ * again. The lexical check is kept first because it is the only one that can judge a path that
304
+ * does not exist: `./../elsewhere/` must fail as *outside*, not as *missing*.
305
+ *
306
+ * The kind travels back with the path deliberately: it is read here, once, inside this
307
+ * function's guard, so no caller has a second unguarded `statSync` to throw from.
308
+ *
309
+ * @returns {{file: string, isDirectory: boolean}|null} the canonical absolute path and what it
310
+ * is, or null having recorded the failure.
311
+ */
312
+ const resolve = (raw, check, where) => {
313
+ stats.paths += 1;
314
+ if (typeof raw !== "string" || raw.trim() === "") {
315
+ fail(check, `${where} declares a path that is not a string`);
316
+ return null;
317
+ }
318
+ if (!raw.startsWith("./")) {
319
+ fail(check, `${where} declares "${raw}" — a component path must start with "./"`);
320
+ return null;
321
+ }
322
+ const target = path.resolve(root, raw);
323
+ if (escapes(root, target)) {
324
+ fail(check, `${where} declares "${raw}", which resolves outside the plugin root`);
325
+ return null;
326
+ }
327
+ // `existsSync` follows symlinks, so a broken link is reported as not resolving rather than
328
+ // crashing the canonicalisation below.
329
+ if (!fs.existsSync(target)) {
330
+ fail(check, `${where} declares "${raw}", which does not resolve to anything`);
331
+ return null;
332
+ }
333
+ let real;
334
+ try {
335
+ real = fs.realpathSync(target);
336
+ } catch (error) {
337
+ fail(check, `${where} declares "${raw}", which could not be resolved — ${error.code ?? error.message}`);
338
+ return null;
339
+ }
340
+ if (escapes(root, real)) {
341
+ fail(
342
+ check,
343
+ `${where} declares "${raw}", which is a link out of the plugin root (to ${real})`,
344
+ );
345
+ return null;
346
+ }
347
+ // The kind is read here, once, inside the guard — rather than by the callers, which used to
348
+ // `statSync` the path a second time unguarded. Existence having been established two lines
349
+ // up makes that second call *look* safe, and it is not: the window is small (a removal
350
+ // between the two calls, a transient filesystem error) but what it costs is large, because
351
+ // a throw from there reaches the top-level catch and turns a run that had already found
352
+ // real failures into exit 2 — "could not run" — discarding every one of them. That is the
353
+ // defect this repository has now fixed four times, and the reliable cure is not another
354
+ // try/catch but having only one read to guard.
355
+ let stat;
356
+ try {
357
+ stat = fs.statSync(real);
358
+ } catch (error) {
359
+ fail(check, `${where} declares "${raw}", which could not be read — ${error.code ?? error.message}`);
360
+ return null;
361
+ }
362
+ return { file: real, isDirectory: stat.isDirectory() };
363
+ };
364
+
365
+ const asList = (value) => (Array.isArray(value) ? value : value === undefined ? [] : [value]);
366
+
367
+ // --- the two manifests --------------------------------------------------------------------
368
+
369
+ const plugin = manifest(path.join(".claude-plugin", "plugin.json"));
370
+ // A **payload** root is a plugin a feed ships without being a marketplace itself. `packs/` is one:
371
+ // the private feed's `portulan-checkpoints` entry is a `git-subdir` source rooted there, so the
372
+ // marketplace entry describing it lives in that feed and cannot exist here. Requiring one would
373
+ // demand a fake marketplace beside a real payload.
374
+ //
375
+ // It is an OPT-IN and not an inference — a root that merely *lacks* a marketplace still fails, and
376
+ // the caller has to say which roots are payloads. Inferring it would turn a deleted
377
+ // `marketplace.json` into a silently narrower lint, which is the relaxation
378
+ // `../.portulan/gate-map.md` says to scrutinise hardest.
379
+ //
380
+ // What it costs is stated rather than hidden: **nothing here checks that the feed's entry agrees
381
+ // with this manifest** — the name, the version and the source path are verified in the feed's own
382
+ // repository or not at all. That is one unverifiable, counted as such.
383
+ // `lstatSync` rather than `existsSync`, and the difference is a verdict: `existsSync` FOLLOWS a
384
+ // symlink, so a payload root whose `marketplace.json` is a dangling link read as "none is owed" and
385
+ // went green, where the strict path calls the same tree a `manifest` failure. That is this file's
386
+ // recurring defect in its own words one screen down — *"absent and unusable are different verdicts
387
+ // and only one is benign"* — and it is why only ENOENT takes the payload arm. Found at the
388
+ // pre-commit checkpoint by planting the dangling link.
389
+ const marketPath = path.join(root, ".claude-plugin", "marketplace.json");
390
+ let marketAbsent = false;
391
+ let marketUnexaminable = null;
392
+ try {
393
+ fs.lstatSync(marketPath);
394
+ } catch (error) {
395
+ // ENOENT is the only absence. Every other errno — EACCES, EIO, ELOOP — is a file that is
396
+ // THERE and would not answer, and falling through with `marketAbsent` false sent it to
397
+ // `manifest()`, whose `existsSync` cannot stat it either and reports it **missing**. That is
398
+ // the same conflation this block exists to prevent, one branch further out, and it reached
399
+ // both the payload and the strict path. Reported as itself instead. (Copilot, final round.)
400
+ if (error.code === "ENOENT") marketAbsent = true;
401
+ else marketUnexaminable = error.code ?? error.message;
402
+ }
403
+ let market = null;
404
+ if (marketUnexaminable !== null) {
405
+ fail(
406
+ "manifest",
407
+ `.claude-plugin/marketplace.json could not be examined — ${marketUnexaminable}. That is not ` +
408
+ "absence: absent and unusable are different verdicts and this is the second, so it fails " +
409
+ "here whether or not this root is a payload",
410
+ );
411
+ } else if (payload && marketAbsent) {
412
+ stats.unverifiable += 1;
413
+ note(
414
+ "market",
415
+ "payload root — no marketplace.json, and none is owed: the feed that ships this directory " +
416
+ "carries the entry, so the name, version and source path it is published under are not " +
417
+ "checkable from here",
418
+ );
419
+ } else {
420
+ market = manifest(path.join(".claude-plugin", "marketplace.json"));
421
+ }
422
+
423
+ if (plugin) {
424
+ if (typeof plugin.name !== "string") {
425
+ fail("plugin", "plugin.json has no string `name` — it is the one required field");
426
+ } else if (!SLUG.test(plugin.name)) {
427
+ fail("plugin", `plugin.json name "${plugin.name}" is not kebab-case`);
428
+ }
429
+ if (plugin.version !== undefined) {
430
+ if (typeof plugin.version !== "string" || !SEMVER.test(plugin.version)) {
431
+ fail("plugin", `plugin.json version ${JSON.stringify(plugin.version)} is not SemVer`);
432
+ }
433
+ }
434
+ }
435
+
436
+ if (market) {
437
+ if (typeof market.name !== "string" || !SLUG.test(market.name)) {
438
+ fail("market", "marketplace.json needs a kebab-case string `name`");
439
+ }
440
+ const owner = market.owner;
441
+ if (!owner || typeof owner !== "object" || typeof owner.name !== "string" || !owner.name) {
442
+ fail("market", "marketplace.json needs an `owner` object with a `name`");
443
+ }
444
+ if (!Array.isArray(market.plugins)) {
445
+ fail("market", "marketplace.json needs a `plugins` array");
446
+ } else if (market.plugins.length === 0) {
447
+ // The failure this whole file exists to stop being a warning. `claude plugin validate`
448
+ // reports an empty marketplace as a warning, and a warning is the severity a milestone
449
+ // walks past: the repository would call itself a plugin marketplace and ship nothing.
450
+ fail("market", "marketplace.json declares no plugins — a marketplace that ships nothing");
451
+ }
452
+ }
453
+
454
+ // --- the marketplace entries, and whether they agree with plugin.json ----------------------
455
+
456
+ for (const [index, entry] of (Array.isArray(market?.plugins) ? market.plugins : []).entries()) {
457
+ const label = `marketplace plugins[${index}]`;
458
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
459
+ fail("market", `${label} is not an object`);
460
+ continue;
461
+ }
462
+ const name = typeof entry.name === "string" ? entry.name : null;
463
+ if (!name || !SLUG.test(name)) {
464
+ fail("market", `${label} needs a kebab-case string \`name\``);
465
+ }
466
+ if (entry.source === undefined) {
467
+ fail("market", `${label} ("${name ?? "?"}") declares no \`source\``);
468
+ continue;
469
+ }
470
+ if (typeof entry.source !== "string") {
471
+ // **One direction only.** The private feed may point at this repository — that is the
472
+ // maintainer's #113 ruling, "the feed points, the public repository carries" — and this
473
+ // repository may never point back. A public entry sourced from the private feed is a **dead
474
+ // pointer for every stranger**, since the fetch 404s on a repository they cannot see, and it
475
+ // publishes the private feed's internal structure in a manifest anyone can read. Refused
476
+ // rather than noted, because the two failure modes are invisible from inside this tree: no
477
+ // resolution attempt here can tell a private repository from a nonexistent one.
478
+ //
479
+ // Deliberately narrow. Pointing at another PUBLIC repository is a legal shape nobody has
480
+ // ruled against, and it stays counted-and-reported below — widening this to every off-tree
481
+ // source would be this lint inventing a policy rather than enforcing one.
482
+ // Matched as a NAME, never as a substring. `includes` false-positives on an unrelated public
483
+ // repository whose name merely contains the feed's — `someone-else/portulan-internal-tools` —
484
+ // and a false red in a rail is how the whole rail gets switched off. Found by Copilot on #117,
485
+ // round 5. Split on everything that can delimit a repository or package name, so
486
+ // `owner/portulan-internal`, an `https://` URL and an `scp`-style `git@host:owner/name.git`
487
+ // all reduce to the same segment set.
488
+ const target = [entry.source?.repo, entry.source?.url, entry.source?.package]
489
+ .filter((v) => typeof v === "string")
490
+ .join(" ");
491
+ // **Lowercased on both sides.** GitHub repository names are case-insensitive, so
492
+ // `Sleepy-Panda-Srl/Portulan-Internal` resolves to the same repository and walked straight
493
+ // through a case-sensitive membership test. Found by Copilot one round after the rail landed —
494
+ // a rail a different capitalisation gets past is not a rail, which is why this went past the
495
+ // review loop's two-fix-round bound rather than to triage (the precedent is #105).
496
+ // Matched as **owner/name**, not name alone. Matching the repo segment by itself refused
497
+ // `someone-else/portulan-internal` — an unrelated public repository — which contradicts this
498
+ // rail's own stated narrowness, and a false red is how a whole rail gets switched off. Copilot,
499
+ // #117 round 8. Lowercased on both sides because GitHub names are case-insensitive (round 6),
500
+ // and adjacent segment PAIRS are compared so `owner/name`, an https URL and an scp-style
501
+ // `git@host:owner/name.git` all reduce to the same set.
502
+ const segments = target.toLowerCase().split(/[\s/:@]+/).map((seg) => seg.replace(/\.git$/, "")).filter(Boolean);
503
+ const pairs = new Set(segments.slice(0, -1).map((seg, i) => `${seg}/${segments[i + 1]}`));
504
+ if (PRIVATE_FEEDS.some((feed) => pairs.has(feed.toLowerCase()))) {
505
+ fail(
506
+ "market",
507
+ `${label} ("${name ?? "?"}") is sourced from the private feed (\`${target}\`). ` +
508
+ "A public marketplace may not point into a private one: the fetch 404s for every " +
509
+ "stranger, and the entry publishes the private feed's structure. The ruling is " +
510
+ "one-way — the feed points at this repository, never the reverse",
511
+ );
512
+ continue;
513
+ }
514
+ // A github / url / git-subdir / npm source is legal and points outside this tree, so
515
+ // nothing here can resolve it. Counted and reported, never silently skipped — the same
516
+ // rule doctor applies to a workspace that declares no tree.
517
+ stats.unverifiable += 1;
518
+ note("market", `${label} ("${name ?? "?"}") has an off-tree source — not verifiable here`);
519
+ continue;
520
+ }
521
+
522
+ const resolved = resolve(entry.source, "market", `${label} ("${name ?? "?"}") source`);
523
+ if (!resolved) continue;
524
+
525
+ // The entry that points at the marketplace root IS this plugin, so the two manifests are
526
+ // describing one artifact and must not contradict each other. Drift between them is
527
+ // invisible at runtime — plugin.json wins — which is what makes it worth a check.
528
+ // Both sides are canonical by construction, so this compares like with like.
529
+ if (resolved.file === root && plugin) {
530
+ if (name && typeof plugin.name === "string" && name !== plugin.name) {
531
+ fail(
532
+ "agree",
533
+ `${label} names "${name}" but plugin.json names "${plugin.name}" — same plugin, two names`,
534
+ );
535
+ }
536
+ if (
537
+ typeof entry.version === "string" &&
538
+ typeof plugin.version === "string" &&
539
+ entry.version !== plugin.version
540
+ ) {
541
+ fail(
542
+ "agree",
543
+ `${label} version ${entry.version} disagrees with plugin.json ${plugin.version}`,
544
+ );
545
+ }
546
+ }
547
+ }
548
+
549
+ // --- component paths ----------------------------------------------------------------------
550
+
551
+ const declaredSkillRoots = [];
552
+
553
+ for (const field of PATH_FIELDS) {
554
+ for (const raw of asList(plugin?.[field])) {
555
+ // An inline object for hooks / MCP / LSP is configuration, not a path claim.
556
+ if (typeof raw === "object" && raw !== null) continue;
557
+ const resolved = resolve(raw, "paths", `plugin.json ${field}`);
558
+ if (!resolved) continue;
559
+ if (field === "skills") declaredSkillRoots.push(resolved);
560
+ }
561
+ }
562
+
563
+ // An `agents` key is refused whatever it names — see AGENT_DIR. A path that resolves is exactly
564
+ // as dead as one that does not, so this fails on the key's presence rather than on its value,
565
+ // and it fails rather than notes: a note is read past, and re-adding the key silently unloads
566
+ // every persona the plugin ships.
567
+ if (plugin !== null && typeof plugin === "object" && plugin.agents !== undefined) {
568
+ fail(
569
+ "agents",
570
+ "plugin.json declares `agents` — measured 2026-07-26 on Claude Code v2.1.215, the key " +
571
+ `suppresses the scan of ./${AGENT_DIR}/ and loads nothing. Remove it; agents load by ` +
572
+ "convention",
573
+ );
574
+ }
575
+
576
+ // --- the skills behind those paths ----------------------------------------------------------
577
+
578
+ const skillDirs = new Set();
579
+ for (const { file: skillRoot, isDirectory } of declaredSkillRoots) {
580
+ if (!isDirectory) {
581
+ fail("skills", `plugin.json skills path ${path.relative(root, skillRoot)} is not a directory`);
582
+ continue;
583
+ }
584
+ // A skills path may point straight at one skill (the `"./"` form), at a directory of them, or
585
+ // at a pack-shaped tree with them nested deeper — see expandDeclaredSkillRoot below.
586
+ const expanded = expandDeclaredSkillRoot(skillRoot);
587
+ if (expanded.unreadable !== undefined) {
588
+ fail("skills", `${path.relative(root, skillRoot)} could not be read — ${expanded.unreadable}`);
589
+ continue;
590
+ }
591
+ if (expanded.empty) {
592
+ fail(
593
+ "skills",
594
+ `plugin.json declares ${path.relative(root, skillRoot)} but it contains no skill`,
595
+ );
596
+ continue;
597
+ }
598
+ for (const dir of expanded.found) skillDirs.add(dir);
599
+ for (const dir of expanded.barren) {
600
+ fail("skills", `${path.relative(root, dir)}/ has no SKILL.md`);
601
+ }
602
+ for (const dir of expanded.truncated) {
603
+ fail(
604
+ "skills",
605
+ `${path.relative(root, dir)}/ has subdirectories this validator did not search — the ` +
606
+ `walk below a declared skills path stops ${MAX_DECLARED_SKILL_DEPTH} levels down. ` +
607
+ `Declare the deeper directory as its own skills path, or flatten the tree; what ` +
608
+ `this check must never do is go green over what it could not reach. It is NOT a ` +
609
+ `report that the directory is empty — this walk stopped, it did not finish and ` +
610
+ `find nothing`,
611
+ );
612
+ }
613
+ // Its own heading, and its own sentence. Sharing `truncated`'s would have named the depth
614
+ // bound as the cause of a permission error — a confident wrong reason, which is worse than
615
+ // no reason, and the class this whole batch is about. (#108)
616
+ for (const { dir, why } of expanded.unreadableBranches ?? []) {
617
+ fail(
618
+ "skills",
619
+ `${path.relative(root, dir)}/ could not be read — ${why}. This validator cannot say ` +
620
+ `whether a skill is packaged below it, so it says that rather than reporting the ` +
621
+ `branch empty: could-not-look is not nothing-there`,
622
+ );
623
+ }
624
+ // The host expands a declared root ONE level. A skill this validator resolves deeper than that
625
+ // is packaged, counted, and inert on every install — which is a green over a skill nobody can
626
+ // invoke, the failure ../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md
627
+ // records against the sibling `agents` key. The repair is in the message because it is one
628
+ // edit: name the directory that actually holds the skills.
629
+ for (const dir of expanded.beyondHostReach ?? []) {
630
+ fail(
631
+ "skills",
632
+ `${path.relative(root, dir)}/ sits more than ${HOST_SKILL_DEPTH} level below the ` +
633
+ `declared root ${path.relative(root, skillRoot)} — this validator resolves it and ` +
634
+ `the HOST does not, so it would be counted here and inert on every install. ` +
635
+ `Declare ${path.relative(root, path.dirname(dir))}/ instead`,
636
+ );
637
+ }
638
+ }
639
+
640
+ for (const dir of [...skillDirs].sort()) {
641
+ const rel = path.relative(root, dir);
642
+ const file = path.join(dir, "SKILL.md");
643
+ if (!fs.existsSync(file)) {
644
+ fail("skills", `${rel}/ has no SKILL.md`);
645
+ continue;
646
+ }
647
+ stats.skills += 1;
648
+ const text = read(file, "skills", `${rel}/SKILL.md`);
649
+ if (text === null) continue;
650
+ // `name` is required here although the platform makes it optional, and the stricter rule is
651
+ // this repository's own invariant rather than a claim about the contract. Without it the
652
+ // invocation name is inherited from the layout, and the fallback differs by layout: a skill
653
+ // in a `skills/<dir>/` subdirectory takes the directory name, which is stable, while a skill
654
+ // reached by a path pointing straight at it — the `"./"` form, which packs will use — takes
655
+ // the *install* directory name, which for a marketplace install is a version string that
656
+ // changes on every update. Requiring `name` makes the invocation name a property of the
657
+ // skill rather than of where it happens to sit, and every skill this repository ships has
658
+ // one already, so the rule costs nothing and closes the case before packs arrive.
659
+ checkFrontmatter(text, `${rel}/SKILL.md`, "skills", { requireName: true });
660
+ }
661
+
662
+ // --- composition drives registration -------------------------------------------------------
663
+ //
664
+ // Two manifests carry one fact and nothing pinned them together. A workspace's `packs` array
665
+ // says which packs this repository **composes**; `plugin.json`'s `skills` says which directories
666
+ // the **host registers**. Registration is a property of `plugin.json` and of nothing else —
667
+ // measured 2026-08-09 on Claude Code 2.1.226 by deleting the `packs` key from
668
+ // `.portulan/workspace.json` outright and reinstalling, which changed the host's inventory not at
669
+ // all: the same `Skills (7)`, the checkpoints pack's three among them. So a composed pack's skill
670
+ // is invocable here by coincidence of a hand-written path, which is exactly what row 7 clause (b)
671
+ // refuses ([#184](https://github.com/sleepy-panda-srl/portulan/issues/184)): the demonstration
672
+ // it asks for is **parity** — invoked because the workspace composed it — not files present at a
673
+ // path a human knows.
674
+ //
675
+ // The correspondence runs both ways, and each direction is a defect that is silent today:
676
+ //
677
+ // composed, undeclared → packaged, counted by this validator, inert on every install. The
678
+ // failure ../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md
679
+ // records against the sibling `agents` key, arriving through the other
680
+ // manifest.
681
+ // declared, uncomposed → a capability the host registers that no workspace asked for. The
682
+ // cascade is core < pack < workspace, and a pack reaching the host
683
+ // without the workspace naming it has skipped the layer that owns the
684
+ // decision.
685
+ //
686
+ // **Scoped to the workspace that GOVERNS this bundle, never to every manifest it ships.** The demo
687
+ // under `examples/` composes packs of its own and the fixture under `cli/fixtures/` exists to be
688
+ // invalid; neither governs this bundle, and that is the durable reason — it is the whole reason
689
+ // this reads one named path rather than discovering manifests.
690
+ //
691
+ // _This paragraph justified the scoping by saying the demo's packs "deliberately do not exist",
692
+ // which was half-false from milestone 7 session 5 (`tools/github` landed) and wholly false from
693
+ // 2026-08-13 (`stacks/python`, which never existed, was swapped for `rituals/checkpoints`). It
694
+ // also said holding the demo to this rule would be "a red by construction" — and after that swap
695
+ // the demo composes what the governing workspace composes, so it would now coincidentally PASS.
696
+ // A justification that has become an accident is worse than none, which is why the scoping now
697
+ // rests on governance alone. Found by the pre-commit checkpoint on the change that falsified it._
698
+ //
699
+ // What this does NOT establish, each found by the pre-commit checkpoint on #195 rather than
700
+ // assumed:
701
+ // * **that the host then INVOKES the skill.** This establishes that the path the host registers
702
+ // from is the one composition asked for. `claude plugin details` counts registration and is
703
+ // run at the checkpoints; nobody has yet shown a composed pack's skill *invoked* through
704
+ // composition, which is the parity row 7 clause (b) ultimately asks for.
705
+ // * **that a `packs` entry names a pack at all.** `"."` or a family name like `"rituals"`
706
+ // satisfies the correspondence without pointing at anything carrying a `pack.json`. Requiring
707
+ // one here would put a second answer to *what is a pack* beside `doctor`'s, so the gap is
708
+ // named instead of closed.
709
+ // * **that the host resolves symlinks the way either walk does.** The walks are aligned with
710
+ // each other and with `doctor`; what the host does with a symlinked `SKILL.md` at install is
711
+ // unmeasured, and nothing here claims it.
712
+
713
+ const GOVERNING = path.join(".portulan", "workspace.json");
714
+ let composition;
715
+ let composable = true;
716
+ try {
717
+ composition = JSON.parse(fs.readFileSync(path.join(root, GOVERNING), "utf8"));
718
+ } catch (error) {
719
+ // A bundle shipping no governing workspace composes nothing, which is coherent rather than a
720
+ // fault. *Could not read* is a different answer and is never *not there*
721
+ // (../.portulan/memory/verify-preconditions-fail-closed.md), so the two are reported apart and
722
+ // only the second is a finding.
723
+ if (error.code === "ENOENT") composable = false;
724
+ else {
725
+ fail(
726
+ "compose",
727
+ `${GOVERNING} could not be read — ${error.code ?? error.message}; composition was ` +
728
+ "not checked against the declared skills, so this run establishes nothing about it",
729
+ );
730
+ composable = false;
731
+ }
732
+ }
733
+
734
+ // A manifest that PARSES but is not a plain object — an array, a string, a number, **or the JSON
735
+ // literal `null`** — silently skipped this whole check in the first cut, which is a green over
736
+ // something nobody evaluated: the exact shape the `could not read` branch above exists to refuse,
737
+ // one type away.
738
+ //
739
+ // `null` is the one that hid, and it hid because it was doing two jobs. The first cut used `null`
740
+ // as the *sentinel* for "there is no governing workspace here", and `null` is also what
741
+ // `JSON.parse("null")` returns — so a present, invalid manifest was indistinguishable from an
742
+ // absent one and skipped the check without a word. A sentinel that collides with a legal value of
743
+ // the thing it describes cannot report on that thing; the state is a separate boolean now, and
744
+ // absence is the only thing that reads as absence. Raised by Copilot on #195, both halves.
745
+ if (composable && (composition === null || typeof composition !== "object" || Array.isArray(composition))) {
746
+ fail(
747
+ "compose",
748
+ `${GOVERNING} parses but is not a JSON object (${jsonKind(composition)}) — ` +
749
+ "composition could not be read from it, so parity with the declared skills is unchecked",
750
+ );
751
+ composable = false;
752
+ }
753
+
754
+ // `packs` PRESENT but not an array is the check's core invariant going quiet: a manifest saying
755
+ // `"packs": "rituals/checkpoints"` iterates zero times and the run then reports nothing at all
756
+ // about parity. Absent is a real state — a workspace may compose nothing — and *the wrong type* is
757
+ // not that state. It joins the unreadable-manifest case above rather than being caught inside the
758
+ // block: the first cut failed here and then RAN ON with an empty composition, so the converse
759
+ // direction reported every declared pack skill as belonging to no composed pack — an unreadable
760
+ // composition read as *composes nothing*, the same fail-open pointing the other way, contradicting
761
+ // the sentence it had just emitted. Both halves raised by Copilot on #195, one round apart: the
762
+ // guard was right and its control flow was not.
763
+ if (composable && composition.packs !== undefined && !Array.isArray(composition.packs)) {
764
+ fail(
765
+ "compose",
766
+ `${GOVERNING} declares \`packs\` as ${jsonKind(composition.packs)} rather than an array — ` +
767
+ "no composition could be read from it, so parity with the declared skills is unchecked",
768
+ );
769
+ composable = false;
770
+ }
771
+
772
+ if (composable) {
773
+ const packsRoot = path.join(root, "packs");
774
+ const composed = [];
775
+ for (const entry of Array.isArray(composition.packs) ? composition.packs : []) {
776
+ // A `packs` entry that is not a usable name is `doctor`'s verdict on the workspace, but
777
+ // going quiet about it here would be this check reporting on a composition it did not
778
+ // read in full. Named, then passed on.
779
+ if (typeof entry !== "string" || entry === "") {
780
+ fail(
781
+ "compose",
782
+ `${GOVERNING} has a \`packs\` entry that is not a non-empty string ` +
783
+ `(${JSON.stringify(entry)}) — it was not checked against the declared skills`,
784
+ );
785
+ continue;
786
+ }
787
+ const name = entry;
788
+ const dir = path.join(packsRoot, name);
789
+ // A `packs` entry escaping the tree is the workspace validator's verdict, not this one's —
790
+ // but *skipping* it is not this one's either. It is named as unchecked below rather than
791
+ // `continue`d in silence, because a check that goes quiet on what it could not evaluate is
792
+ // the fail-open this file refuses everywhere else.
793
+ //
794
+ // `escapes` is LEXICAL, and lexical containment is not containment: `packs/` or a composed
795
+ // pack directory can be a symlink whose target is anywhere, and `statSync`/`readdirSync`
796
+ // follow it. So the path is canonicalised and re-checked before anything is walked.
797
+ //
798
+ // **What that buys, stated as narrowly as it is true: a link whose target ESCAPES
799
+ // `./packs/` is refused.** A symlink resolving to somewhere still inside `./packs/` passes,
800
+ // and is meant to — it reaches nothing the walk could not reach by its own path, so
801
+ // refusing it would be policy about tree layout rather than containment. This is
802
+ // deliberately weaker than `../cli/vendor.mjs`'s rule 2, which refuses a symlink at or
803
+ // below the named destination outright: that tool WRITES into somebody's tree, where a
804
+ // link is a way to make it write outside; this one only reads, and the harm is reading
805
+ // outside. The two are different rules for different verbs, and the earlier draft of this
806
+ // comment claimed vendor's. Both halves raised by Copilot on #195 — the lexical hole
807
+ // first, then the comment that overclaimed the fix.
808
+ // Both containment checks are against `packsRoot`, not `root`. The first cut compared
809
+ // against the plugin root while the message and the rule said `./packs/`, so an entry like
810
+ // `../plugin` stayed inside the bundle, passed, and was WALKED — its skills then measured
811
+ // against the declared roots as though it were a composed pack. A guard whose test is wider
812
+ // than the sentence beside it is the two-carriers defect in one statement. Raised by Copilot
813
+ // on #195, in the round after the one that closed the lexical hole.
814
+ if (escapes(packsRoot, dir)) {
815
+ fail(
816
+ "compose",
817
+ `${GOVERNING} composes \`${name}\`, which names a path outside ./packs/ — nothing ` +
818
+ "was walked for it, so composition is unchecked for that entry",
819
+ );
820
+ continue;
821
+ }
822
+ let real;
823
+ try {
824
+ real = fs.realpathSync(dir);
825
+ } catch {
826
+ real = dir; // absent, or unreadable — the stat below reports it as itself.
827
+ }
828
+ if (escapes(packsRoot, real)) {
829
+ fail(
830
+ "compose",
831
+ `${GOVERNING} composes \`${name}\`, which resolves outside ./packs/ — ` +
832
+ "a link out of the packs tree. Nothing was walked; composition is unchecked for it",
833
+ );
834
+ continue;
835
+ }
836
+ // Everything past this point uses `real`, the CANONICALISED path, and that is not tidiness.
837
+ // `skillDirs` is built from declared roots that `resolve()` already canonicalised, so a
838
+ // composed pack directory that is itself a symlink to somewhere else inside `./packs/`
839
+ // would make the two sides describe one directory by two path forms — and the set
840
+ // comparison below is by string. The result is a FALSE "composed but undeclared" failure
841
+ // on a bundle that is correct. Raised by Copilot on #195, twice in one round, which is what
842
+ // a defect with two symptoms looks like.
843
+ let stat;
844
+ try {
845
+ stat = fs.statSync(real);
846
+ } catch (error) {
847
+ // A composed pack absent from the bundle cannot be registered from it, but *why* it is
848
+ // absent is `doctor`'s verdict on the workspace and would be a second carrier here.
849
+ // Noted so the reader knows this check saw it and passed it on.
850
+ note(
851
+ "compose",
852
+ `${GOVERNING} composes \`${name}\`, which does not resolve under ./packs/ — ` +
853
+ `${error.code ?? error.message}. \`doctor\` owns that verdict; nothing here ` +
854
+ "could check its skills",
855
+ );
856
+ continue;
857
+ }
858
+ // A path that exists and is NOT a directory was dropped from evaluation silently in the
859
+ // first cut — composition claimed and never checked. It is a NOTE rather than a failure,
860
+ // and the distinction was earned rather than chosen: the pre-commit checkpoint on #195
861
+ // tried to construct a false green through a regular file and could not, because a file
862
+ // holds no `SKILL.md` for either walk to disagree about. So this matches the ENOENT branch
863
+ // above — `doctor` owns the verdict on a malformed `packs` entry, and what this check owes
864
+ // is to say it saw it. A failure here would claim a hazard nobody could demonstrate.
865
+ if (!stat.isDirectory()) {
866
+ note(
867
+ "compose",
868
+ `${GOVERNING} composes \`${name}\`, and ./packs/${name} is not a directory — ` +
869
+ "nothing was walked for it. `doctor` owns that verdict",
870
+ );
871
+ continue;
872
+ }
873
+ composed.push({ name, dir: real });
874
+ }
875
+
876
+ for (const { name, dir } of composed) {
877
+ // `problems` is what makes the walk's silences visible to a consumer whose output is a
878
+ // failure. Each one is a place this check could not look, and *could not look* reported as
879
+ // *nothing wrong* is the whole defect class the `compose` check was written inside.
880
+ const problems = [];
881
+ for (const skillDir of walkForSkills(root, dir, 0, [], problems)) {
882
+ if (skillDirs.has(skillDir)) continue;
883
+ fail(
884
+ "compose",
885
+ `${GOVERNING} composes \`${name}\` and ${path.relative(root, skillDir)}/ is one of ` +
886
+ "its skills, but no plugin.json `skills` path reaches it — the host registers " +
887
+ "nothing here, so the skill ships, counts, and cannot be invoked. Declare " +
888
+ `./${path.relative(root, path.dirname(skillDir))}/`,
889
+ );
890
+ }
891
+ for (const { dir: where, why } of problems) {
892
+ fail(
893
+ "compose",
894
+ `${GOVERNING} composes \`${name}\` and ${path.relative(root, where)}/ ${why} — so a ` +
895
+ "skill there would be invisible to this check. Composition is unchecked below that point",
896
+ );
897
+ }
898
+ }
899
+
900
+ for (const skillDir of skillDirs) {
901
+ if (escapes(packsRoot, skillDir)) continue;
902
+ if (composed.some(({ dir }) => skillDir === dir || !escapes(dir, skillDir))) continue;
903
+ fail(
904
+ "compose",
905
+ `plugin.json registers ${path.relative(root, skillDir)}/, which is inside ./packs/ and ` +
906
+ `belongs to no pack ${GOVERNING} composes — the host would load it without the ` +
907
+ "workspace layer having asked for it. Compose the pack, or stop declaring it",
908
+ );
909
+ }
910
+
911
+ // --- the DECLARATION side, through the one carrier -------------------------------------
912
+ //
913
+ // Everything above asks about the TREE: is every skill actually sitting in a composed pack
914
+ // reachable from a declared path? This asks a different question from different evidence —
915
+ // does this manifest declare the root each composed pack's own `contributes.skills` NOMINATES?
916
+ //
917
+ // **These are not two implementations of one rule**, and collapsing them would delete a check.
918
+ // A pack whose declaration and tree disagree is a finding, and only asking both finds it. The
919
+ // distinctive case this half catches, which the walk cannot: a pack nominates a skills root
920
+ // that **is not there, or cannot be examined**, while the manifest declares nothing — the walk
921
+ // finds no skill, has nothing to compare, and goes green over a pack that registers nothing.
922
+ // That is *nothing looked* reported as *nothing wrong*, arriving through the composition path.
923
+ //
924
+ // A nominated root that exists and is genuinely EMPTY is a different answer and gets a note:
925
+ // there is nothing to register, so there is no hazard to report — an empty set is two
926
+ // questions, and this one has been examined.
927
+ //
928
+ // The policy — what a manifest must declare — lives once, in ./skills-set.mjs. This calls it.
929
+ //
930
+ // **Resolved against `./packs/`, never against the workspace's `tree`.** The carrier's own
931
+ // resolver derives roots from `tree`, which is right for a workspace on disk and wrong here: a
932
+ // bundle's packs sit where the PLUGIN layout puts them, and this check has already walked and
933
+ // contained exactly those directories. Resolving any other way would answer about a different
934
+ // set of directories than the ones above were graded on. Found by running the suite: every
935
+ // fixture bundle composes packs with no `tree` at all.
936
+ //
937
+ // **A composed entry with no `pack.json` is skipped, deliberately.** This file already refuses
938
+ // to require one — *"Requiring one here would put a second answer to what is a pack beside
939
+ // `doctor`'s"* — and a declaration check that failed on its absence would be that second
940
+ // answer arriving through the back door. No `pack.json` means no declaration to check, which
941
+ // is not the same as a declaration that could not be read: that one fails, below.
942
+ const declaring = [];
943
+ const resolved = new Map();
944
+ for (const { name, dir } of composed) {
945
+ // Read and parse are two failures with two repairs. This said "could not be read" for
946
+ // both, so a malformed `pack.json` sent a reader to look at permissions and paths — the
947
+ // same defect `cli/skills-set.mjs`'s workspace-manifest arm carried, fixed there one round
948
+ // earlier and left standing HERE. That is
949
+ // `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md` in one
950
+ // sentence, and it was a suppressed low-confidence note that caught it rather than the
951
+ // session that wrote the sibling fix. Absent stays a `continue`: a composed entry with no
952
+ // `pack.json` is deliberately not this check's verdict.
953
+ let text;
954
+ try {
955
+ text = fs.readFileSync(path.join(dir, "pack.json"), "utf8");
956
+ } catch (error) {
957
+ if (error.code === "ENOENT") continue;
958
+ fail(
959
+ "compose",
960
+ `${GOVERNING} composes \`${name}\` and its pack.json could not be read — ` +
961
+ `${error.code ?? error.message}. What it nominates for registration is unchecked`,
962
+ );
963
+ continue;
964
+ }
965
+ let parsed;
966
+ try {
967
+ parsed = JSON.parse(text);
968
+ } catch (error) {
969
+ fail(
970
+ "compose",
971
+ `${GOVERNING} composes \`${name}\` and its pack.json does not parse as JSON — ` +
972
+ `${error.message}. What it nominates for registration is unchecked`,
973
+ );
974
+ continue;
975
+ }
976
+ declaring.push(name);
977
+ resolved.set(name, { ref: name, root: dir, manifest: parsed });
978
+ }
979
+ const derived = skillsSet(composition, {
980
+ packs: declaring,
981
+ pluginRoot: root,
982
+ resolve: (ref) => resolved.get(ref) ?? null,
983
+ });
984
+ if (!derived.ok) {
985
+ fail(
986
+ "compose",
987
+ `the registrable set could not be derived from ${GOVERNING} — ${derived.reason}. ` +
988
+ "Composition is unchecked on the declaration side",
989
+ );
990
+ } else {
991
+ const declaredRoots = new Set(declaredSkillRoots.map(({ file }) => canonical(path.relative(root, file))));
992
+ for (const { path: want, pack: packName } of derived.paths) {
993
+ if (declaredRoots.has(want)) continue;
994
+ // A manifest may name each skill individually — the `"./"` form — instead of the root.
995
+ // That registers them just as well, so it is not a failure, and the walk above has
996
+ // already held those to the tree. Checked rather than assumed, because failing it would
997
+ // be a false red on a bundle that is correct.
998
+ const problems = [];
999
+ const beneath = walkForSkills(root, path.join(root, want), 0, [], problems);
1000
+ if (problems.length === 0 && beneath.length > 0 && beneath.every((d) => skillDirs.has(d))) continue;
1001
+ if (problems.length === 0 && beneath.length === 0) {
1002
+ note(
1003
+ "compose",
1004
+ `${GOVERNING} composes \`${packName}\`, whose pack.json nominates ${want} as a skills ` +
1005
+ "root, and nothing there holds a SKILL.md — there is nothing to register. " +
1006
+ "`doctor` owns the verdict on that pack",
1007
+ );
1008
+ continue;
1009
+ }
1010
+ fail(
1011
+ "compose",
1012
+ `${GOVERNING} composes \`${packName}\`, whose pack.json nominates ${want} as a skills root, ` +
1013
+ `and no plugin.json \`skills\` path declares it${problems.length ? " (and it could not be examined in full)" : ""} — ` +
1014
+ `the host registers nothing from it. Declare ${want}`,
1015
+ );
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ // A skill authored and never declared is a skill nobody ships, and its author will believe
1021
+ // otherwise. Reported rather than failed: an undeclared SKILL.md may legitimately be an example
1022
+ // or a fixture, and this validator has no way to tell which.
1023
+ // The same report, for the side of the asymmetry that has already bitten: a skill under a
1024
+ // declared custom path loads, and an agent under any custom path does not. So an agent file
1025
+ // anywhere but `./agents/` is one its author believes is loading, and nothing else in this
1026
+ // validator would ever mention it. Reported rather than failed for the reason below — a `.md`
1027
+ // under some other `agents/` may be a fixture, an example, or another host's binding, and this
1028
+ // validator cannot tell which.
1029
+ for (const stranded of walkForStrandedAgents(root)) {
1030
+ note(
1031
+ "agents",
1032
+ `${path.relative(root, stranded)} is not in ./${AGENT_DIR}/, the only directory the host ` +
1033
+ "loads agents from — it will not load",
1034
+ );
1035
+ }
1036
+
1037
+ for (const found of walkForSkills(root)) {
1038
+ if (!skillDirs.has(found)) {
1039
+ note(
1040
+ "skills",
1041
+ `${path.relative(root, found)}/SKILL.md is not covered by any declared skills path`,
1042
+ );
1043
+ }
1044
+ }
1045
+
1046
+ // --- the agents at the convention location --------------------------------------------------
1047
+ //
1048
+ // Nothing declares these, so nothing but this pass covers them. The milestone-3 criterion asks
1049
+ // that CI check "every declared skill and agent"; for agents the manifest cannot carry the
1050
+ // claim, and the coverage has to come from the convention instead. The moment the `agents` key
1051
+ // came out of this repository's manifest, the recipe printed `0 agent(s)` and GREEN.
1052
+
1053
+ // `lstat`, not `exists`: the question here is whether the tree *has* an `agents` entry, and
1054
+ // `existsSync` answers a different one because it follows the link — so a broken `agents`
1055
+ // symlink reported "absent", took the note branch, and went GREEN with `0 agent(s)` over a tree
1056
+ // that plainly has an `agents` entry and cannot use it. Absent and unusable are different
1057
+ // verdicts and only one is benign. Deciding it non-dereferencingly leaves every other case to
1058
+ // `resolve()` below, which already knows how to fail a link that does not resolve. Found by
1059
+ // review, and it is the short-input-set defect this pass was written to close, in the pass.
1060
+ // Three states, not two, because "I could not tell" is a real answer and collapsing it into
1061
+ // either of the others is a lie. Only ENOENT means absent; EACCES, EIO or anything else means
1062
+ // the question was not answered, and answering an unanswerable question with the reassuring
1063
+ // option is this file's recurring defect — caught here in review, one round after the same
1064
+ // shape was caught in the line above.
1065
+ let state;
1066
+ try {
1067
+ fs.lstatSync(path.join(root, AGENT_DIR));
1068
+ state = "present";
1069
+ } catch (error) {
1070
+ if (error.code === "ENOENT") {
1071
+ state = "absent";
1072
+ } else {
1073
+ state = "unknown";
1074
+ fail("agents", `./${AGENT_DIR}/ could not be examined — ${error.code ?? error.message}`);
1075
+ }
1076
+ }
1077
+ if (state === "unknown") {
1078
+ // Already failed; there is nothing further to say about a directory nobody could look at.
1079
+ } else if (state === "absent") {
1080
+ // A plugin that ships no agents is legitimate, so this is a note. The residual hole this note
1081
+ // named — deleting `agents/` outright degrades the whole check class to a note and exit 0 — is
1082
+ // **closed since milestone 7 session 7 for a plugin that ships personas**: the correspondence
1083
+ // below fails every unbound persona, so deleting the directory in THIS repository is now a red
1084
+ // rather than a shrug. It stays a note for a plugin with no `core/personas/`, which is the
1085
+ // legitimate shape and the reason this cannot simply become a failure.
1086
+ note("agents", `no ./${AGENT_DIR}/ directory — this plugin ships no agents`);
1087
+ agentsExamined = true;
1088
+ } else {
1089
+ // Routed through the same resolver as every declared path, so an `agents/` that is a symlink
1090
+ // out of the tree is an escape here too — undeclared does not mean unchecked, and this is the
1091
+ // one component directory nothing declares.
1092
+ const dir = resolve(`./${AGENT_DIR}/`, "agents", "the plugin's agents directory");
1093
+ if (dir) {
1094
+ if (!dir.isDirectory) {
1095
+ fail("agents", `./${AGENT_DIR}/ is not a directory`);
1096
+ } else {
1097
+ let entries;
1098
+ try {
1099
+ entries = fs.readdirSync(dir.file, { withFileTypes: true });
1100
+ } catch (error) {
1101
+ fail("agents", `./${AGENT_DIR}/ could not be read — ${error.code ?? error.message}`);
1102
+ entries = null;
1103
+ }
1104
+ if (entries) {
1105
+ agentsExamined = true;
1106
+ // `isFile()` is false for a symlink, so the obvious filter *drops* a symlinked
1107
+ // agent — present in the tree, absent from the count, nothing saying so, which
1108
+ // is the exact failure this pass exists to prevent. Symlinks are taken in and
1109
+ // judged instead: a broken one fails through `read` below, and one resolving
1110
+ // out of the plugin root fails here, the same answer `resolve` gives a declared
1111
+ // path. Reporting beats skipping, as everywhere else in this file.
1112
+ const files = entries
1113
+ .filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
1114
+ .map((e) => ({ file: path.join(dir.file, e.name), link: e.isSymbolicLink() }))
1115
+ .sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
1116
+ if (files.length === 0) {
1117
+ fail("agents", `./${AGENT_DIR}/ exists but contains no agent`);
1118
+ }
1119
+ for (const { file, link } of files) {
1120
+ const rel = path.relative(root, file);
1121
+ stats.agents += 1;
1122
+ if (link) {
1123
+ let real = null;
1124
+ try {
1125
+ real = fs.realpathSync(file);
1126
+ } catch (error) {
1127
+ fail("agents", `${rel} is a link that does not resolve — ${error.code ?? error.message}`);
1128
+ continue;
1129
+ }
1130
+ if (escapes(root, real)) {
1131
+ fail("agents", `${rel} is a link out of the plugin root (to ${real})`);
1132
+ continue;
1133
+ }
1134
+ }
1135
+ const text = read(file, "agents", rel);
1136
+ if (text === null) continue;
1137
+ // The name comes back from the check that already parsed this block, rather
1138
+ // than from a second parse of the same text. They could not disagree — one
1139
+ // function, one string — but the doctor side returns it for exactly this
1140
+ // reason and a reader meeting two parses has to work out that they cannot.
1141
+ const fields = checkFrontmatter(text, rel, "agents", { requireName: true });
1142
+ bound.set(path.basename(file, ".md"), { rel, name: fields?.name });
1143
+ }
1144
+ }
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ // ---- the persona ↔ binding correspondence
1150
+ //
1151
+ // `../.portulan/tasks/0005-lint-the-persona-agent-binding.md`, opened 2026-07-26 on the maintainer's
1152
+ // ruling that settled the separation, and unbuilt until milestone 7 session 7. A persona in
1153
+ // `core/personas/` is doctrine; a file in `agents/` is that persona registered on one host. The
1154
+ // relationship is source → binding, the same shape as gate map → compiled hooks, and the only thing
1155
+ // keeping the binding from drifting was whoever last edited it.
1156
+ //
1157
+ // **Both directions FAIL here, and the same absence is only a REPORT in `cli/doctor.mjs`.** That is
1158
+ // not drift between two checkers, and it is worth saying which is which. This tool grades **this
1159
+ // bundle's packaging**, where a shipped persona nothing binds is inert on the host the bundle
1160
+ // targets — the class `../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md`
1161
+ // records, and the residual hole the note above names. `doctor` grades **anybody's workspace**,
1162
+ // where a composed pack's persona may legitimately have no binding: the adopter may not be on this
1163
+ // host at all, and this repository's own `checkpoints` supervisor is deliberately unbound because
1164
+ // its ritual's mechanism is a fresh context rather than a subagent.
1165
+ //
1166
+ // **Task 0005's third criterion is deliberately NOT built** — that a binding restating its persona's
1167
+ // charter should be reported. Its own text says the measurable form of *thin* is an open question
1168
+ // and that "a check that cannot state what it measures should not ship". Nothing has settled it, so
1169
+ // shipping a line count or a similarity threshold here would be exactly the magic number that task
1170
+ // refused to accept at review. The two mechanical halves are what this closes; the judgement half is
1171
+ // still open and still that task's.
1172
+ const personaDir = path.join(root, "core", "personas");
1173
+ let personaFiles = null;
1174
+ try {
1175
+ personaFiles = fs
1176
+ .readdirSync(personaDir)
1177
+ .filter((entry) => entry.endsWith(".md") && entry !== "README.md")
1178
+ .map((entry) => path.basename(entry, ".md"))
1179
+ .sort();
1180
+ } catch (error) {
1181
+ // ENOENT is a plugin that ships no core personas, which is a legitimate shape and not this
1182
+ // check's business. Anything else is a question that could not be answered, and saying nothing
1183
+ // would report an absence nobody established.
1184
+ if (error.code !== "ENOENT") fail("agents", `core/personas/ could not be read — ${error.code ?? error.message}, so the binding correspondence went unchecked`);
1185
+ }
1186
+ if (personaFiles && !agentsExamined) {
1187
+ // **An empty `bound` is not evidence of an unbound persona when nothing could be listed.**
1188
+ // Where `./agents/` could not be stat'd, resolved, or read, every persona would be failed for
1189
+ // "no binding" — a red naming the wrong defect, blaming an author for a permission the pass
1190
+ // could not exercise. The cause is already a failure of its own above, so this is a note: the
1191
+ // run is red either way, and what this adds is which question went unanswered.
1192
+ //
1193
+ // Raised by Copilot as a suppressed note on round 3 of #227, promoted to a thread by the
1194
+ // channel `0021` built. It is this file's own recurring defect read backwards — "nothing
1195
+ // looked" recorded as "definitely missing" rather than as "nothing wrong".
1196
+ note(
1197
+ "agents",
1198
+ `the persona↔binding correspondence went unchecked — ./${AGENT_DIR}/ could not be examined, so an empty binding set is a ` +
1199
+ `question nobody answered rather than ${personaFiles.length} persona(s) with nothing bound to them`,
1200
+ );
1201
+ } else if (personaFiles) {
1202
+ for (const persona of personaFiles) {
1203
+ const binding = bound.get(persona);
1204
+ if (!binding) {
1205
+ fail(
1206
+ "agents",
1207
+ `core/personas/${persona}.md has no binding at ./${AGENT_DIR}/${persona}.md — a persona this plugin ships and does not bind ` +
1208
+ `is doctrine the host never registers, which looks from the outside exactly like a role that is available`,
1209
+ );
1210
+ continue;
1211
+ }
1212
+ if (binding.name !== undefined && binding.name !== persona) {
1213
+ fail(
1214
+ "agents",
1215
+ `${binding.rel} binds \`core/personas/${persona}.md\` by its filename while declaring \`name: ${binding.name}\` — the host keys ` +
1216
+ `on the field, so the two disagree about which role this file registers`,
1217
+ );
1218
+ }
1219
+ }
1220
+ // A Set rather than `personaFiles.includes(name)` inside the loop, which re-scanned the persona
1221
+ // list once per bound agent — O(P·B). This bundle ships three of each, so the cost today is
1222
+ // nothing and the SHAPE is an adopter's larger bundle: triaged out of #227 as
1223
+ // [#228](https://github.com/sleepy-panda-srl/portulan/issues/228) and taken here because this
1224
+ // session already had the file open. Behaviour-preserving, and the suite is what says so.
1225
+ const personaNames = new Set(personaFiles);
1226
+ for (const [name, binding] of bound) {
1227
+ if (!personaNames.has(name)) {
1228
+ fail(
1229
+ "agents",
1230
+ `${binding.rel} binds no persona — there is no \`core/personas/${name}.md\`. A host file that outlived the doctrine it was ` +
1231
+ `bound to registers a role with no charter behind it`,
1232
+ );
1233
+ }
1234
+ }
1235
+ }
1236
+
1237
+ /** Validates a component's frontmatter and RETURNS the parsed fields, or `null` if there were none. */
1238
+ function checkFrontmatter(text, label, check, { requireName }) {
1239
+ const { fields, error } = parseFrontmatter(text);
1240
+ if (!fields) {
1241
+ fail(check, `${label} has no usable frontmatter${error ? ` — ${error}` : ""}`);
1242
+ return null;
1243
+ }
1244
+ if (!fields.description) {
1245
+ fail(check, `${label} has no non-empty \`description\` — it is what decides when it loads`);
1246
+ }
1247
+ if (fields.name !== undefined && !SLUG.test(fields.name)) {
1248
+ fail(check, `${label} declares name "${fields.name}", which is not kebab-case`);
1249
+ } else if (requireName && fields.name === undefined) {
1250
+ fail(check, `${label} has no \`name\``);
1251
+ }
1252
+ return fields;
1253
+ }
1254
+
1255
+ return { findings, stats };
1256
+ }
1257
+
1258
+ /** Every directory beneath `root` that holds a SKILL.md. Bounded, and skips the obvious noise. */
1259
+ /**
1260
+ * Every directory named `agents` in the tree apart from the loadable one at the plugin root.
1261
+ *
1262
+ * The mistake this catches is not exotic — it is the one this repository made and shipped: agent
1263
+ * files under `plugin/agents/`, where the host will never look. It is the natural place to put them,
1264
+ * because **skills** do load from custom declared paths and agents do not, and nothing about the
1265
+ * asymmetry announces itself. See ../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md
1266
+ * for the measurement.
1267
+ *
1268
+ * **This rule and that memory entry retire together.** Both exist only because the platform loads
1269
+ * agents from one fixed location; if a release makes the `agents` key register the files it names,
1270
+ * the stranding stops being a defect, this walk becomes a source of false notes, and both go.
1271
+ */
1272
+ function walkForStrandedAgents(root, dir = root, depth = 0, found = []) {
1273
+ if (depth > MAX_WALK_DEPTH) return found;
1274
+ let entries;
1275
+ try {
1276
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1277
+ } catch {
1278
+ // Same reasoning as the skills walk: an unreadable subtree is not a verdict about packaging.
1279
+ return found;
1280
+ }
1281
+ // Every `agents/` **except the loadable one**, compared by path rather than by depth: the first
1282
+ // version of this used `depth > 0`, which excludes the plugin root itself and not the `agents/`
1283
+ // directly under it — so it reported the three files sitting exactly where it exists to tell
1284
+ // people to put them. Caught by strengthening the test to assert what must *not* be named.
1285
+ if (dir !== path.join(root, AGENT_DIR) && path.basename(dir) === AGENT_DIR) {
1286
+ for (const entry of entries) {
1287
+ if (entry.isFile() && entry.name.endsWith(".md")) found.push(path.join(dir, entry.name));
1288
+ }
1289
+ }
1290
+ for (const entry of entries) {
1291
+ if (!entry.isDirectory()) continue;
1292
+ if (SKIP_DIRS.has(entry.name)) continue;
1293
+ walkForStrandedAgents(root, path.join(dir, entry.name), depth + 1, found);
1294
+ }
1295
+ return found;
1296
+ }
1297
+
1298
+ // `problems` is opt-in and changes nothing for the caller that does not pass it. It exists because
1299
+ // this walk acquired a SECOND consumer — the `compose` check — whose output is a **failure** rather
1300
+ // than a note, and the silences below were sized for the first consumer only. A sweep that misses an
1301
+ // undeclared skill under-reports a note; the same miss under `compose` is a **silent green over a
1302
+ // composed pack whose skills nothing registers**, which is the thing that check exists to refuse.
1303
+ // Raised by the pre-commit checkpoint on #195, which built both cases.
1304
+ //
1305
+ // Two silences, and both are the fail-open this repository keeps naming:
1306
+ //
1307
+ // an unreadable subtree `readdirSync` throws and the walk returns what it has. *Could not
1308
+ // look* becoming *nothing there* — `../cli/vendor.mjs` rule 3.
1309
+ // a SYMLINKED SKILL.md `Dirent.isFile()` is FALSE for a symlink, so the walk cannot see it,
1310
+ // while the DECLARED-side walk reaches the same file through
1311
+ // `existsSync`, which follows links. `../cli/doctor.mjs`'s `walkSkills`
1312
+ // already refuses this shape by `lstat` and says why. One shape, three
1313
+ // behaviours in one repository; this closes the third.
1314
+ /**
1315
+ * What a JSON value IS, for a diagnostic — `typeof null` is `"object"`, which turned a message about a
1316
+ * null `packs` into one about an object and sent a reader looking for a key that was not there.
1317
+ * Raised by Copilot on #195.
1318
+ */
1319
+ function jsonKind(value) {
1320
+ if (value === null) return "null";
1321
+ if (Array.isArray(value)) return "an array";
1322
+ return typeof value === "object" ? "an object" : typeof value;
1323
+ }
1324
+
1325
+ function walkForSkills(root, dir = root, depth = 0, found = [], problems = null) {
1326
+ if (depth > MAX_WALK_DEPTH) {
1327
+ if (problems) problems.push({ dir, why: `not searched — deeper than ${MAX_WALK_DEPTH} levels` });
1328
+ return found;
1329
+ }
1330
+ let entries;
1331
+ try {
1332
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1333
+ } catch (error) {
1334
+ // For the note-only consumer this stays a silence, deliberately: the declared paths are what
1335
+ // that consumer judges, and a failure to read one of those is already reported there.
1336
+ if (problems) problems.push({ dir, why: `could not be read — ${error.code ?? error.message}` });
1337
+ return found;
1338
+ }
1339
+ if (entries.some((e) => e.isFile() && e.name === "SKILL.md")) found.push(dir);
1340
+ if (problems) {
1341
+ for (const entry of entries) {
1342
+ if (entry.name === "SKILL.md" && entry.isSymbolicLink()) {
1343
+ problems.push({
1344
+ dir,
1345
+ why: "holds a SKILL.md that is a SYMLINK — this walk does not follow it and the " +
1346
+ "declared-path walk does, so the two disagree about whether the skill is here",
1347
+ });
1348
+ }
1349
+ }
1350
+ }
1351
+ for (const entry of entries) {
1352
+ if (!entry.isDirectory()) continue;
1353
+ if (SKIP_DIRS.has(entry.name)) continue;
1354
+ walkForSkills(root, path.join(dir, entry.name), depth + 1, found, problems);
1355
+ }
1356
+ return found;
1357
+ }
1358
+
1359
+ // The depth a DECLARED skills root is searched to, counted from the root itself. Three is what the
1360
+ // pack shape needs — `<pack>/skills/<skill>/SKILL.md` — and it is deliberately its own bound rather
1361
+ // than MAX_WALK_DEPTH, which governs the undeclared-skill sweep over a whole tree. One is a search of
1362
+ // something the manifest pointed at; the other is a sweep of everything it did not.
1363
+ const MAX_DECLARED_SKILL_DEPTH = 3;
1364
+
1365
+ // How far below a DECLARED skills root the host itself looks — measured here, and since milestone 7
1366
+ // session 8 it LIVES in ./skills-set.mjs and is imported at the top of this file.
1367
+ //
1368
+ // It moved because the `compose` check below now asks that carrier what a manifest must declare, so
1369
+ // this file imports it; exporting the constant back the other way would have made an import cycle.
1370
+ // The direction matches `AGENT_DIR`, which flows plugin-lint → doctor: the carrier of a derived set
1371
+ // carries the platform constant its derivation depends on. The measurement and its re-measure mandate
1372
+ // travel with it rather than being restated here, because a constant with two homes is the two-carrier
1373
+ // defect this repository keeps paying for.
1374
+
1375
+ /**
1376
+ * Expand one DECLARED skills root into the skill directories beneath it.
1377
+ *
1378
+ * For one milestone this resolved a declared root exactly two ways — the root IS one skill (it holds
1379
+ * `SKILL.md`), or its IMMEDIATE children are — and nothing deeper. A pack shipping
1380
+ * `<root>/<pack>/skills/<skill>/SKILL.md` therefore resolved `<root>/<pack>` as a skill directory and
1381
+ * failed it with `has no SKILL.md`, which blocked packs from carrying skills — most of what a pack is
1382
+ * for (../.portulan/tasks/0008-a-declared-skills-path-sees-one-level-down.md).
1383
+ *
1384
+ * Four results, because the fix must not buy depth with silence — and because for one milestone three
1385
+ * of these were reported as one, which is #108:
1386
+ * `found` — directories holding a SKILL.md, at any depth within the bound.
1387
+ * `barren` — a directory under the root that is not a skill and holds none beneath it. This is
1388
+ * the real failure the one-level version already caught, kept with its own wording.
1389
+ * **It means SEARCHED, and found nothing** — never *the search stopped here*.
1390
+ * `truncated` — where the bound stopped the search with subdirectories still unlooked-at. Reported
1391
+ * rather than passed over: a check that goes green on what it could not reach is the
1392
+ * `docs.sh` `map` hole and the `git ls-files` precondition, a third time.
1393
+ * `unreadableBranches` — a subtree below the root that could not be read at all. Kept apart from
1394
+ * `truncated` because they are different facts with different repairs: one is this
1395
+ * validator's own bound, which the packager fixes by declaring a deeper path, and the
1396
+ * other is a permission on their disk. Reporting them through one sentence made that
1397
+ * sentence wrong about whichever case it was not written for.
1398
+ *
1399
+ * **Until #108 the last three were one.** `walk()` returned `false` for all of them and the caller
1400
+ * turned any `false` into `barren`, so `skills/a/` was reported as holding no SKILL.md when the truth
1401
+ * was that the walk had stopped looking — a false classification riding alongside the true one, which
1402
+ * is the shape this repository keeps naming: a verdict about something never examined.
1403
+ *
1404
+ * A skill directory is a leaf — skills do not nest inside skills — so the walk stops descending the
1405
+ * moment it finds one. Symlinked directories are not descended, which is `Dirent.isDirectory()`'s
1406
+ * own behaviour rather than a new rule here; the escape refusal on the declared path itself is
1407
+ * `escapes()` and does not loosen.
1408
+ */
1409
+ function expandDeclaredSkillRoot(skillRoot) {
1410
+ const found = [];
1411
+ const truncated = [];
1412
+ const barren = [];
1413
+ // **`unreadableBranches`, and the clumsy name is load-bearing.** This function returns a UNION the
1414
+ // caller discriminates with `expanded.unreadable !== undefined` — the arm for a declared root that
1415
+ // could not be read at all. Naming this list `unreadable` would make that test true on every
1416
+ // ordinary return, since `[] !== undefined`, and every successful expansion would be reported as an
1417
+ // unreadable root. A silent catastrophe reachable by picking the obvious word, so the word is not
1418
+ // picked. _(The two facts are genuinely different: one is the declared path itself, which is the
1419
+ // manifest's claim; these are subtrees below it.)_
1420
+ const unreadableBranches = [];
1421
+ // Skills this validator resolves and the HOST will never see. Measured 2026-08-07 on Claude Code
1422
+ // 2.1.224 by installing this repository as a local marketplace and reading the inventory back: the
1423
+ // host expands a declared skills root exactly one level — `<root>/<skill>/SKILL.md` — and descends
1424
+ // no further. `./core/skills/` gave 3, `./plugin/skills/` gave 1, `./packs/rituals/` gave **0**
1425
+ // because the pack's skills sit at `<root>/checkpoints/skills/<skill>/`, and the inventory read
1426
+ // `Skills (4)` while this tool counted 7. Declaring `./packs/rituals/checkpoints/skills/` instead
1427
+ // read `Skills (7)`, naming `pre-commit`, `session-open` and `milestone-close`.
1428
+ //
1429
+ // So the depth bound above buys a resolution the platform does not honour, and a count that is
1430
+ // higher than the host's is the exact shape of a false green: the packaging looks coherent and
1431
+ // three skills are inert on every install. That is
1432
+ // ../.portulan/memory/a-manifest-field-can-validate-and-load-nothing.md a second time, in the
1433
+ // sibling field, and it is why this is a FAILURE rather than a note.
1434
+ const beyondHostReach = [];
1435
+
1436
+ // **The boolean means ACCOUNTED FOR, not *holds a skill*, and the distinction is this function's
1437
+ // whole repair** (#108). Three outcomes used to return `false` — genuinely barren, stopped by the
1438
+ // depth bound, and could-not-be-read — and the caller turned every `false` into `barren`. So a
1439
+ // branch the walk never finished searching was reported as one it had searched and found empty:
1440
+ // one defect wearing two failures, and one of the two classifications simply false. *Searched and
1441
+ // found nothing* and *did not finish searching* are different facts, and only the first is barren.
1442
+ //
1443
+ // Truncated and unreadable now return `true` — they are reported under their own headings by the
1444
+ // caller, so the barren list must not claim them as well. `false` is left meaning exactly one
1445
+ // thing: this branch was searched to the bottom and holds no skill.
1446
+ const walk = (dir, depth) => {
1447
+ if (fs.existsSync(path.join(dir, "SKILL.md"))) {
1448
+ found.push(dir);
1449
+ if (depth > HOST_SKILL_DEPTH) beyondHostReach.push(dir);
1450
+ return true;
1451
+ }
1452
+ let entries;
1453
+ try {
1454
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1455
+ } catch (error) {
1456
+ // *Could not look* is not *nothing there* — `cli/vendor.mjs` rule 3, and the fail-open this
1457
+ // repository names more than any other. It was silent here, and silence let the caller call
1458
+ // it barren. Now it is a fact with its own list and its own sentence.
1459
+ unreadableBranches.push({ dir, why: error.code ?? error.message });
1460
+ return true;
1461
+ }
1462
+ const children = entries.filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name));
1463
+ if (children.length === 0) return false;
1464
+ if (depth >= MAX_DECLARED_SKILL_DEPTH) {
1465
+ truncated.push(dir);
1466
+ return true;
1467
+ }
1468
+ let any = false;
1469
+ for (const child of children) {
1470
+ if (walk(path.join(dir, child.name), depth + 1)) any = true;
1471
+ }
1472
+ return any;
1473
+ };
1474
+
1475
+ // The `"./"` form: the root is itself one skill. Checked before the expansion so a root holding
1476
+ // both a SKILL.md and subdirectories stays one skill rather than becoming several.
1477
+ if (fs.existsSync(path.join(skillRoot, "SKILL.md"))) {
1478
+ return { found: [skillRoot], barren, truncated, unreadableBranches, beyondHostReach, empty: false };
1479
+ }
1480
+
1481
+ let entries;
1482
+ try {
1483
+ entries = fs.readdirSync(skillRoot, { withFileTypes: true });
1484
+ } catch (error) {
1485
+ return { unreadable: error.code ?? error.message };
1486
+ }
1487
+ const children = entries.filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name));
1488
+ if (children.length === 0) return { found, barren, truncated, unreadableBranches, beyondHostReach, empty: true };
1489
+
1490
+ // Attribution is per immediate child rather than per branch: a barren branch reported at every
1491
+ // level of itself is one defect wearing three failures.
1492
+ for (const child of children) {
1493
+ const dir = path.join(skillRoot, child.name);
1494
+ if (!walk(dir, 1)) barren.push(dir);
1495
+ }
1496
+ return { found, barren, truncated, unreadableBranches, beyondHostReach, empty: false };
1497
+ }
1498
+
1499
+ // ===========================================================================================
1500
+ // The command
1501
+ // ===========================================================================================
1502
+
1503
+ const ICON = { fail: "FAIL", note: "note" };
1504
+
1505
+ export async function run(argv, options = {}) {
1506
+ const say = options.quiet ? () => {} : (line = "") => process.stdout.write(`${line}\n`);
1507
+ try {
1508
+ // `--payload` marks EVERY root on the line as a payload root. Kept deliberately coarse: the
1509
+ // recipe invokes this once per kind, which is clearer at the call site than a per-root syntax
1510
+ // nobody would read twice.
1511
+ const payload = argv.includes("--payload");
1512
+ const unknown = argv.filter((a) => a.startsWith("-") && a !== "--payload");
1513
+ if (unknown.length) {
1514
+ if (!options.quiet) {
1515
+ process.stderr.write(`plugin-lint: unknown option ${unknown.join(", ")}\n`);
1516
+ }
1517
+ return 2;
1518
+ }
1519
+ const roots = argv.filter((a) => !a.startsWith("-"));
1520
+ if (roots.length === 0) {
1521
+ if (!options.quiet) {
1522
+ // The usage line takes BOTH the plural and the flag, because it was narrower than the
1523
+ // tool on both counts: several roots have been accepted since this file shipped, and
1524
+ // `--payload` arrived with this change. A usage message printed at the moment a caller
1525
+ // already got it wrong is the worst place to be out of date. (Copilot, on the round
1526
+ // reviewing the payload mode — the same sentence-narrower-than-its-code shape this
1527
+ // pull request exists to correct one layer down.)
1528
+ process.stderr.write("usage: node cli/plugin-lint.mjs [--payload] <plugin-root> [<plugin-root> ...]\n");
1529
+ process.stderr.write(" --payload the roots are payloads a feed publishes: no marketplace.json here, and none owed\n");
1530
+ }
1531
+ return 2;
1532
+ }
1533
+
1534
+ let failed = 0;
1535
+ for (const root of roots) {
1536
+ const { findings, stats } = inspect(root, { payload });
1537
+ const bad = findings.filter((f) => f.severity === "fail");
1538
+ say(root);
1539
+ for (const f of findings) say(` ${ICON[f.severity]} ${f.check.padEnd(8)} ${f.message}`);
1540
+ say(
1541
+ ` ${bad.length ? "RED" : "GREEN"} — ${bad.length} failure(s), ` +
1542
+ `${findings.length - bad.length} note(s), ${stats.skills} skill(s), ` +
1543
+ `${stats.agents} agent(s), ${stats.paths} path(s) checked` +
1544
+ (stats.unverifiable ? `, ${stats.unverifiable} unverifiable` : ""),
1545
+ );
1546
+ say();
1547
+ if (bad.length) failed += 1;
1548
+ }
1549
+ return failed ? 1 : 0;
1550
+ } catch (error) {
1551
+ if (!options.quiet) {
1552
+ process.stderr.write(
1553
+ `plugin-lint: ${
1554
+ error instanceof PluginLintError
1555
+ ? error.message
1556
+ : `unanticipated failure — ${error.stack ?? error}`
1557
+ }\n`,
1558
+ );
1559
+ }
1560
+ return 2;
1561
+ }
1562
+ }
1563
+
1564
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
1565
+ process.exitCode = await run(process.argv.slice(2));
1566
+ }