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