@sleepy_panda_srl/portulan 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +142 -291
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1443 -1210
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +225 -1071
  10. package/cli/drills.mjs +1107 -0
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +595 -0
  15. package/cli/gate.mjs +7 -198
  16. package/cli/goldens.mjs +311 -0
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +244 -564
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +764 -0
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +6 -68
  27. package/cli/pack-version.mjs +13 -248
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +16 -240
  31. package/cli/release-eval.mjs +748 -0
  32. package/cli/review-meter.mjs +500 -0
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +424 -0
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +126 -454
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +582 -0
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +9 -74
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/gate-policy.md +27 -8
  54. package/core/templates/handoff.md +28 -15
  55. package/core/templates/memory-entry.md +1 -0
  56. package/evals/README.md +320 -0
  57. package/evals/releases/0.1.3.json +134 -0
  58. package/evals/releases/0.1.3.md +64 -0
  59. package/evals/releases/0.2.0.json +146 -0
  60. package/evals/releases/0.2.0.md +67 -0
  61. package/evals/releases/README.md +32 -0
  62. package/package.json +9 -2
  63. package/packs/rituals/checkpoints/README.md +9 -2
  64. package/packs/rituals/checkpoints/pack.json +1 -1
  65. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
  66. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  67. package/spec/README.md +112 -6
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  70. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  71. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  72. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  73. package/spec/migrations/0006-boot-card.mjs +77 -0
  74. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  75. package/spec/migrations/0008-card-reading.mjs +55 -0
  76. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  77. package/spec/migrations/0010-comment-rail.mjs +57 -0
  78. package/spec/migrations/README.md +45 -10
  79. package/spec/pack.schema.json +1 -1
  80. package/spec/slots.md +359 -23
  81. package/spec/workspace.schema.json +145 -4
@@ -3,65 +3,13 @@
3
3
  //
4
4
  // node cli/plugin-lint.mjs [--payload] <plugin-root> [<plugin-root> ...]
5
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.
6
+ // Exit 0 the packaging holds together · 1 it does not · 2 could not run.
7
+ // It checks this repository's own invariants; `claude plugin validate --strict` owns the plugin contract.
57
8
 
58
9
  import fs from "node:fs";
59
10
  import path from "node:path";
60
11
  import { pathToFileURL } from "node:url";
61
12
 
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
13
  import { HOST_SKILL_DEPTH, skillsSet, canonical } from "./skills-set.mjs";
66
14
 
67
15
  /** Raised when `plugin-lint` cannot run at all. Always exit 2, never 1. */
@@ -74,26 +22,11 @@ export class PluginLintError extends Error {
74
22
 
75
23
  const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
76
24
 
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.
25
+ // Kept by hand with docs/plan.md's repo topology: visibility is a GitHub setting no file can read.
85
26
  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
27
  const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
28
+ const BLOCK_SCALAR = /^[|>][+-]?$/;
90
29
 
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
30
  const PATH_FIELDS = [
98
31
  "skills",
99
32
  "commands",
@@ -104,48 +37,18 @@ const PATH_FIELDS = [
104
37
  "lspServers",
105
38
  ];
106
39
 
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.
40
+ // Only these: a SKILL.md under any other dot-directory is still walked and reported.
112
41
  const SKIP_DIRS = new Set([".git", "node_modules", ".claude-plugin"]);
113
42
 
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.
43
+ // Claude Code 2.1.215 loads agents from ./agents/ alone, and an `agents` key in plugin.json stops even that.
132
44
  export const AGENT_DIR = "agents";
133
45
  const MAX_WALK_DEPTH = 6;
134
46
 
135
47
  // ===========================================================================================
136
48
  // Frontmatter
137
49
  // ===========================================================================================
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
- */
50
+
51
+ /** A flat-YAML reader: `fields` is null without a usable block, and `error` is set when that is a defect. */
149
52
  export function parseFrontmatter(text) {
150
53
  const lines = text.split(/\r?\n/);
151
54
  if (lines[0]?.trim() !== "---") return { fields: null };
@@ -158,16 +61,12 @@ export function parseFrontmatter(text) {
158
61
  const fields = {};
159
62
  const body = lines.slice(1, close);
160
63
  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
64
  const match = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(body[i]);
164
65
  if (!match) continue;
165
66
  const key = match[1];
166
67
  let value = match[2].trim();
167
68
 
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)) {
69
+ if (BLOCK_SCALAR.test(value)) {
171
70
  const collected = [];
172
71
  while (i + 1 < body.length && (body[i + 1].trim() === "" || /^\s+/.test(body[i + 1]))) {
173
72
  i += 1;
@@ -189,38 +88,14 @@ export function parseFrontmatter(text) {
189
88
  // Inspection
190
89
  // ===========================================================================================
191
90
 
192
- /** True when `target` lies outside `root`. Lexical — the caller decides what it is fed. */
91
+ /** Lexical: a symlink inside `root` can still lead out of it. */
193
92
  function escapes(root, target) {
194
93
  const inside = path.relative(root, target);
195
94
  return inside.startsWith("..") || path.isAbsolute(inside);
196
95
  }
197
96
 
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
- */
97
+ /** Throws PluginLintError only when the root is not a readable directory; `payload` excuses an absent marketplace.json. */
216
98
  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
99
  let stat;
225
100
  try {
226
101
  stat = fs.statSync(path.resolve(rawRoot));
@@ -228,10 +103,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
228
103
  throw new PluginLintError(`cannot read ${rawRoot} — ${error.code ?? error.message}`);
229
104
  }
230
105
  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`.)
106
+ // Canonical, since every containment check and set comparison below is between canonical paths.
235
107
  let root;
236
108
  try {
237
109
  root = fs.realpathSync(path.resolve(rawRoot));
@@ -241,32 +113,23 @@ export function inspect(rawRoot, { payload = false } = {}) {
241
113
 
242
114
  const findings = [];
243
115
  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.
116
+ const bindings = new Map();
117
+ // Absent counts as examined: a plugin with no ./agents/ has genuinely bound nothing.
252
118
  let agentsExamined = false;
253
119
  const fail = (check, message) => findings.push({ severity: "fail", check, message });
254
120
  const note = (check, message) => findings.push({ severity: "note", check, message });
255
121
 
256
- /** Read a file, turning any failure into a finding rather than an exception. */
122
+ /** Never throws: a failed read becomes a finding and returns null. */
257
123
  const read = (file, check, label) => {
258
124
  try {
259
125
  return fs.readFileSync(file, "utf8");
260
126
  } 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
127
  fail(check, `${label} could not be read — ${error.code ?? error.message}`);
265
128
  return null;
266
129
  }
267
130
  };
268
131
 
269
- /** Parse a manifest. Returns null on any problem, having recorded it. */
132
+ /** Null on any problem, having recorded it as a finding. */
270
133
  const manifest = (rel) => {
271
134
  const file = path.join(root, rel);
272
135
  if (!fs.existsSync(file)) {
@@ -289,26 +152,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
289
152
  return value;
290
153
  };
291
154
 
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
- */
155
+ /** The canonical path and its kind, or null having recorded why. */
312
156
  const resolve = (raw, check, where) => {
313
157
  stats.paths += 1;
314
158
  if (typeof raw !== "string" || raw.trim() === "") {
@@ -324,8 +168,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
324
168
  fail(check, `${where} declares "${raw}", which resolves outside the plugin root`);
325
169
  return null;
326
170
  }
327
- // `existsSync` follows symlinks, so a broken link is reported as not resolving rather than
328
- // crashing the canonicalisation below.
329
171
  if (!fs.existsSync(target)) {
330
172
  fail(check, `${where} declares "${raw}", which does not resolve to anything`);
331
173
  return null;
@@ -344,14 +186,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
344
186
  );
345
187
  return null;
346
188
  }
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
189
  let stat;
356
190
  try {
357
191
  stat = fs.statSync(real);
@@ -367,36 +201,13 @@ export function inspect(rawRoot, { payload = false } = {}) {
367
201
  // --- the two manifests --------------------------------------------------------------------
368
202
 
369
203
  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.
204
+ // lstat, not existsSync: existsSync follows links, so a dangling marketplace.json would read as absent.
389
205
  const marketPath = path.join(root, ".claude-plugin", "marketplace.json");
390
206
  let marketAbsent = false;
391
207
  let marketUnexaminable = null;
392
208
  try {
393
209
  fs.lstatSync(marketPath);
394
210
  } 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
211
  if (error.code === "ENOENT") marketAbsent = true;
401
212
  else marketUnexaminable = error.code ?? error.message;
402
213
  }
@@ -444,9 +255,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
444
255
  if (!Array.isArray(market.plugins)) {
445
256
  fail("market", "marketplace.json needs a `plugins` array");
446
257
  } 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.
258
+ // Stricter than `claude plugin validate`, which only warns about an empty marketplace.
450
259
  fail("market", "marketplace.json declares no plugins — a marketplace that ships nothing");
451
260
  }
452
261
  }
@@ -468,37 +277,10 @@ export function inspect(rawRoot, { payload = false } = {}) {
468
277
  continue;
469
278
  }
470
279
  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
280
  const target = [entry.source?.repo, entry.source?.url, entry.source?.package]
489
281
  .filter((v) => typeof v === "string")
490
282
  .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.
283
+ // Compared as owner/name pairs, case-insensitively, as GitHub compares repository names.
502
284
  const segments = target.toLowerCase().split(/[\s/:@]+/).map((seg) => seg.replace(/\.git$/, "")).filter(Boolean);
503
285
  const pairs = new Set(segments.slice(0, -1).map((seg, i) => `${seg}/${segments[i + 1]}`));
504
286
  if (PRIVATE_FEEDS.some((feed) => pairs.has(feed.toLowerCase()))) {
@@ -511,9 +293,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
511
293
  );
512
294
  continue;
513
295
  }
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
296
  stats.unverifiable += 1;
518
297
  note("market", `${label} ("${name ?? "?"}") has an off-tree source — not verifiable here`);
519
298
  continue;
@@ -522,10 +301,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
522
301
  const resolved = resolve(entry.source, "market", `${label} ("${name ?? "?"}") source`);
523
302
  if (!resolved) continue;
524
303
 
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.
304
+ // This entry is this plugin; at runtime plugin.json wins, so a disagreement would go unseen.
529
305
  if (resolved.file === root && plugin) {
530
306
  if (name && typeof plugin.name === "string" && name !== plugin.name) {
531
307
  fail(
@@ -560,10 +336,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
560
336
  }
561
337
  }
562
338
 
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
339
  if (plugin !== null && typeof plugin === "object" && plugin.agents !== undefined) {
568
340
  fail(
569
341
  "agents",
@@ -581,8 +353,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
581
353
  fail("skills", `plugin.json skills path ${path.relative(root, skillRoot)} is not a directory`);
582
354
  continue;
583
355
  }
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
356
  const expanded = expandDeclaredSkillRoot(skillRoot);
587
357
  if (expanded.unreadable !== undefined) {
588
358
  fail("skills", `${path.relative(root, skillRoot)} could not be read — ${expanded.unreadable}`);
@@ -610,9 +380,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
610
380
  `find nothing`,
611
381
  );
612
382
  }
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
383
  for (const { dir, why } of expanded.unreadableBranches ?? []) {
617
384
  fail(
618
385
  "skills",
@@ -621,11 +388,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
621
388
  `branch empty: could-not-look is not nothing-there`,
622
389
  );
623
390
  }
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
391
  for (const dir of expanded.beyondHostReach ?? []) {
630
392
  fail(
631
393
  "skills",
@@ -647,68 +409,12 @@ export function inspect(rawRoot, { payload = false } = {}) {
647
409
  stats.skills += 1;
648
410
  const text = read(file, "skills", `${rel}/SKILL.md`);
649
411
  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.
412
+ // The host would name a `"./"`-form skill without `name` after its install directory, a version string.
659
413
  checkFrontmatter(text, `${rel}/SKILL.md`, "skills", { requireName: true });
660
414
  }
661
415
 
662
416
  // --- 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.
417
+ // Claude Code 2.1.226 registers skills from plugin.json alone, whatever the workspace composes.
712
418
 
713
419
  const GOVERNING = path.join(".portulan", "workspace.json");
714
420
  let composition;
@@ -716,10 +422,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
716
422
  try {
717
423
  composition = JSON.parse(fs.readFileSync(path.join(root, GOVERNING), "utf8"));
718
424
  } 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
425
  if (error.code === "ENOENT") composable = false;
724
426
  else {
725
427
  fail(
@@ -731,17 +433,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
731
433
  }
732
434
  }
733
435
 
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
436
  if (composable && (composition === null || typeof composition !== "object" || Array.isArray(composition))) {
746
437
  fail(
747
438
  "compose",
@@ -751,15 +442,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
751
442
  composable = false;
752
443
  }
753
444
 
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
445
  if (composable && composition.packs !== undefined && !Array.isArray(composition.packs)) {
764
446
  fail(
765
447
  "compose",
@@ -773,9 +455,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
773
455
  const packsRoot = path.join(root, "packs");
774
456
  const composed = [];
775
457
  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
458
  if (typeof entry !== "string" || entry === "") {
780
459
  fail(
781
460
  "compose",
@@ -786,31 +465,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
786
465
  }
787
466
  const name = entry;
788
467
  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
468
  if (escapes(packsRoot, dir)) {
815
469
  fail(
816
470
  "compose",
@@ -833,20 +487,11 @@ export function inspect(rawRoot, { payload = false } = {}) {
833
487
  );
834
488
  continue;
835
489
  }
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.
490
+ // Only `real` from here: the set comparisons below are by string, against canonical paths.
843
491
  let stat;
844
492
  try {
845
493
  stat = fs.statSync(real);
846
494
  } 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
495
  note(
851
496
  "compose",
852
497
  `${GOVERNING} composes \`${name}\`, which does not resolve under ./packs/ — ` +
@@ -855,13 +500,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
855
500
  );
856
501
  continue;
857
502
  }
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
503
  if (!stat.isDirectory()) {
866
504
  note(
867
505
  "compose",
@@ -874,9 +512,6 @@ export function inspect(rawRoot, { payload = false } = {}) {
874
512
  }
875
513
 
876
514
  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
515
  const problems = [];
881
516
  for (const skillDir of walkForSkills(root, dir, 0, [], problems)) {
882
517
  if (skillDirs.has(skillDir)) continue;
@@ -908,48 +543,11 @@ export function inspect(rawRoot, { payload = false } = {}) {
908
543
  );
909
544
  }
910
545
 
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.
546
+ // --- the declaration side: what each composed pack's pack.json nominates ---------------
547
+ // The walk above goes green over a nominated root that is missing or unreadable; this half does not.
942
548
  const declaring = [];
943
549
  const resolved = new Map();
944
550
  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
551
  let text;
954
552
  try {
955
553
  text = fs.readFileSync(path.join(dir, "pack.json"), "utf8");
@@ -991,10 +589,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
991
589
  const declaredRoots = new Set(declaredSkillRoots.map(({ file }) => canonical(path.relative(root, file))));
992
590
  for (const { path: want, pack: packName } of derived.paths) {
993
591
  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.
592
+ // Declaring each skill by its own path registers them as well as declaring the root.
998
593
  const problems = [];
999
594
  const beneath = walkForSkills(root, path.join(root, want), 0, [], problems);
1000
595
  if (problems.length === 0 && beneath.length > 0 && beneath.every((d) => skillDirs.has(d))) continue;
@@ -1017,15 +612,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
1017
612
  }
1018
613
  }
1019
614
 
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.
615
+ // Notes, not failures: an undeclared SKILL.md or a stray agent file may be an example or a fixture.
1029
616
  for (const stranded of walkForStrandedAgents(root)) {
1030
617
  note(
1031
618
  "agents",
@@ -1044,24 +631,8 @@ export function inspect(rawRoot, { payload = false } = {}) {
1044
631
  }
1045
632
 
1046
633
  // --- 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.
634
+
635
+ // lstat, not existsSync: a broken ./agents/ link is unusable, not absent.
1065
636
  let state;
1066
637
  try {
1067
638
  fs.lstatSync(path.join(root, AGENT_DIR));
@@ -1075,20 +646,11 @@ export function inspect(rawRoot, { payload = false } = {}) {
1075
646
  }
1076
647
  }
1077
648
  if (state === "unknown") {
1078
- // Already failed; there is nothing further to say about a directory nobody could look at.
649
+ // Already failed above.
1079
650
  } 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
651
  note("agents", `no ./${AGENT_DIR}/ directory — this plugin ships no agents`);
1087
652
  agentsExamined = true;
1088
653
  } 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
654
  const dir = resolve(`./${AGENT_DIR}/`, "agents", "the plugin's agents directory");
1093
655
  if (dir) {
1094
656
  if (!dir.isDirectory) {
@@ -1103,12 +665,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
1103
665
  }
1104
666
  if (entries) {
1105
667
  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.
668
+ // `isFile()` is false for a symlink: links are judged here, not dropped unseen.
1112
669
  const files = entries
1113
670
  .filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
1114
671
  .map((e) => ({ file: path.join(dir.file, e.name), link: e.isSymbolicLink() }))
@@ -1134,12 +691,8 @@ export function inspect(rawRoot, { payload = false } = {}) {
1134
691
  }
1135
692
  const text = read(file, "agents", rel);
1136
693
  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
694
  const fields = checkFrontmatter(text, rel, "agents", { requireName: true });
1142
- bound.set(path.basename(file, ".md"), { rel, name: fields?.name });
695
+ bindings.set(path.basename(file, ".md"), { rel, name: fields?.name });
1143
696
  }
1144
697
  }
1145
698
  }
@@ -1147,28 +700,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
1147
700
  }
1148
701
 
1149
702
  // ---- 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.
703
+ // Fails where doctor only reports: this bundle targets one host, and an unbound persona is inert there.
1172
704
  const personaDir = path.join(root, "core", "personas");
1173
705
  let personaFiles = null;
1174
706
  try {
@@ -1178,21 +710,9 @@ export function inspect(rawRoot, { payload = false } = {}) {
1178
710
  .map((entry) => path.basename(entry, ".md"))
1179
711
  .sort();
1180
712
  } 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
713
  if (error.code !== "ENOENT") fail("agents", `core/personas/ could not be read — ${error.code ?? error.message}, so the binding correspondence went unchecked`);
1185
714
  }
1186
715
  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
716
  note(
1197
717
  "agents",
1198
718
  `the persona↔binding correspondence went unchecked — ./${AGENT_DIR}/ could not be examined, so an empty binding set is a ` +
@@ -1200,7 +720,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
1200
720
  );
1201
721
  } else if (personaFiles) {
1202
722
  for (const persona of personaFiles) {
1203
- const binding = bound.get(persona);
723
+ const binding = bindings.get(persona);
1204
724
  if (!binding) {
1205
725
  fail(
1206
726
  "agents",
@@ -1217,13 +737,8 @@ export function inspect(rawRoot, { payload = false } = {}) {
1217
737
  );
1218
738
  }
1219
739
  }
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
740
  const personaNames = new Set(personaFiles);
1226
- for (const [name, binding] of bound) {
741
+ for (const [name, binding] of bindings) {
1227
742
  if (!personaNames.has(name)) {
1228
743
  fail(
1229
744
  "agents",
@@ -1234,7 +749,7 @@ export function inspect(rawRoot, { payload = false } = {}) {
1234
749
  }
1235
750
  }
1236
751
 
1237
- /** Validates a component's frontmatter and RETURNS the parsed fields, or `null` if there were none. */
752
+ /** The parsed fields, or null when there were none. */
1238
753
  function checkFrontmatter(text, label, check, { requireName }) {
1239
754
  const { fields, error } = parseFrontmatter(text);
1240
755
  if (!fields) {
@@ -1255,33 +770,14 @@ export function inspect(rawRoot, { payload = false } = {}) {
1255
770
  return { findings, stats };
1256
771
  }
1257
772
 
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
773
  function walkForStrandedAgents(root, dir = root, depth = 0, found = []) {
1273
774
  if (depth > MAX_WALK_DEPTH) return found;
1274
775
  let entries;
1275
776
  try {
1276
777
  entries = fs.readdirSync(dir, { withFileTypes: true });
1277
778
  } catch {
1278
- // Same reasoning as the skills walk: an unreadable subtree is not a verdict about packaging.
1279
779
  return found;
1280
780
  }
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
781
  if (dir !== path.join(root, AGENT_DIR) && path.basename(dir) === AGENT_DIR) {
1286
782
  for (const entry of entries) {
1287
783
  if (entry.isFile() && entry.name.endsWith(".md")) found.push(path.join(dir, entry.name));
@@ -1295,27 +791,6 @@ function walkForStrandedAgents(root, dir = root, depth = 0, found = []) {
1295
791
  return found;
1296
792
  }
1297
793
 
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
794
  function jsonKind(value) {
1320
795
  if (value === null) return "null";
1321
796
  if (Array.isArray(value)) return "an array";
@@ -1331,8 +806,6 @@ function walkForSkills(root, dir = root, depth = 0, found = [], problems = null)
1331
806
  try {
1332
807
  entries = fs.readdirSync(dir, { withFileTypes: true });
1333
808
  } 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
809
  if (problems) problems.push({ dir, why: `could not be read — ${error.code ?? error.message}` });
1337
810
  return found;
1338
811
  }
@@ -1356,93 +829,19 @@ function walkForSkills(root, dir = root, depth = 0, found = [], problems = null)
1356
829
  return found;
1357
830
  }
1358
831
 
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.
832
+ // Three reaches `<pack>/skills/<skill>/SKILL.md` from a declared root.
1363
833
  const MAX_DECLARED_SKILL_DEPTH = 3;
1364
834
 
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
- */
835
+ /** `{ unreadable }` alone when the root cannot be read; otherwise each outcome in its own list. */
1409
836
  function expandDeclaredSkillRoot(skillRoot) {
1410
837
  const found = [];
1411
838
  const truncated = [];
1412
839
  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.)_
840
+ // Not `unreadable`: the caller reads that key's presence as the whole root being unreadable.
1420
841
  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
842
  const beyondHostReach = [];
1435
843
 
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.
844
+ // True once a branch is accounted for; false only when searched to the bottom without a skill.
1446
845
  const walk = (dir, depth) => {
1447
846
  if (fs.existsSync(path.join(dir, "SKILL.md"))) {
1448
847
  found.push(dir);
@@ -1453,9 +852,6 @@ function expandDeclaredSkillRoot(skillRoot) {
1453
852
  try {
1454
853
  entries = fs.readdirSync(dir, { withFileTypes: true });
1455
854
  } 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
855
  unreadableBranches.push({ dir, why: error.code ?? error.message });
1460
856
  return true;
1461
857
  }
@@ -1472,8 +868,7 @@ function expandDeclaredSkillRoot(skillRoot) {
1472
868
  return any;
1473
869
  };
1474
870
 
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.
871
+ // The `"./"` form: a root holding a SKILL.md is one skill, whatever lies beneath it.
1477
872
  if (fs.existsSync(path.join(skillRoot, "SKILL.md"))) {
1478
873
  return { found: [skillRoot], barren, truncated, unreadableBranches, beyondHostReach, empty: false };
1479
874
  }
@@ -1487,8 +882,6 @@ function expandDeclaredSkillRoot(skillRoot) {
1487
882
  const children = entries.filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name));
1488
883
  if (children.length === 0) return { found, barren, truncated, unreadableBranches, beyondHostReach, empty: true };
1489
884
 
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
885
  for (const child of children) {
1493
886
  const dir = path.join(skillRoot, child.name);
1494
887
  if (!walk(dir, 1)) barren.push(dir);
@@ -1505,9 +898,6 @@ const ICON = { fail: "FAIL", note: "note" };
1505
898
  export async function run(argv, options = {}) {
1506
899
  const say = options.quiet ? () => {} : (line = "") => process.stdout.write(`${line}\n`);
1507
900
  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
901
  const payload = argv.includes("--payload");
1512
902
  const unknown = argv.filter((a) => a.startsWith("-") && a !== "--payload");
1513
903
  if (unknown.length) {
@@ -1519,12 +909,6 @@ export async function run(argv, options = {}) {
1519
909
  const roots = argv.filter((a) => !a.startsWith("-"));
1520
910
  if (roots.length === 0) {
1521
911
  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
912
  process.stderr.write("usage: node cli/plugin-lint.mjs [--payload] <plugin-root> [<plugin-root> ...]\n");
1529
913
  process.stderr.write(" --payload the roots are payloads a feed publishes: no marketplace.json here, and none owed\n");
1530
914
  }