@sleepy_panda_srl/portulan 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +87 -0
- package/cli/README.md +337 -0
- package/cli/compile.mjs +2209 -0
- package/cli/control-chars.mjs +570 -0
- package/cli/discover.mjs +985 -0
- package/cli/doctor.mjs +2803 -0
- package/cli/feedback.mjs +970 -0
- package/cli/gate.mjs +271 -0
- package/cli/index.mjs +1390 -0
- package/cli/init.mjs +1729 -0
- package/cli/librarian.mjs +1384 -0
- package/cli/manifest.mjs +59 -0
- package/cli/new.mjs +803 -0
- package/cli/pack-version.mjs +543 -0
- package/cli/plugin-lint.mjs +1566 -0
- package/cli/portulan.mjs +266 -0
- package/cli/recipe-set.mjs +425 -0
- package/cli/rule-carriers.mjs +489 -0
- package/cli/skills-set.mjs +715 -0
- package/cli/stop-gate.mjs +434 -0
- package/cli/upgrade.mjs +897 -0
- package/cli/vendor.mjs +1504 -0
- package/core/README.md +46 -0
- package/core/engine.md +47 -0
- package/core/operating/autonomy.md +100 -0
- package/core/operating/evolution.md +84 -0
- package/core/operating/loop.md +83 -0
- package/core/operating/memory.md +161 -0
- package/core/operating/safety.md +42 -0
- package/core/operating/verification.md +76 -0
- package/core/personas/README.md +40 -0
- package/core/personas/implementer.md +51 -0
- package/core/personas/librarian.md +73 -0
- package/core/personas/reviewer.md +52 -0
- package/core/skills/README.md +52 -0
- package/core/skills/clarify/SKILL.md +42 -0
- package/core/skills/codify/SKILL.md +65 -0
- package/core/skills/consolidate/SKILL.md +121 -0
- package/core/templates/gate-policy.md +76 -0
- package/core/templates/handoff.md +29 -0
- package/core/templates/memory-entry.md +44 -0
- package/core/templates/pack.md +94 -0
- package/core/templates/persona.md +69 -0
- package/core/templates/proposal.md +27 -0
- package/core/templates/repo-card.md +28 -0
- package/core/templates/skill.md +57 -0
- package/core/templates/task.md +26 -0
- package/core/templates/workspace.md +81 -0
- package/package.json +41 -0
- package/packs/.claude-plugin/plugin.json +22 -0
- package/packs/README.md +61 -0
- package/packs/rituals/README.md +13 -0
- package/packs/rituals/checkpoints/README.md +105 -0
- package/packs/rituals/checkpoints/pack.json +36 -0
- package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
- package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
- package/packs/stacks/README.md +8 -0
- package/packs/tools/README.md +17 -0
- package/packs/tools/github/README.md +65 -0
- package/packs/tools/github/pack.json +29 -0
- package/packs/tools/github/verify/README.md +51 -0
- package/packs/tools/github/verify/actions-pinned.sh +132 -0
- package/spec/README.md +399 -0
- package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
- package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
- package/spec/migrations/README.md +95 -0
- package/spec/pack.schema.json +201 -0
- package/spec/slots.md +796 -0
- package/spec/workspace.schema.json +438 -0
|
@@ -0,0 +1,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
|
+
}
|