@dzhechkov/harness-core 0.3.130 → 0.3.132
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/.dz-manifest.json +15 -15
- package/README.md +1 -1
- package/dist/guard.d.ts +5 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +18 -0
- package/dist/guard.js.map +1 -1
- package/dist/skills-verify.d.ts +31 -0
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +256 -31
- package/dist/skills-verify.js.map +1 -1
- package/package.json +6 -6
- package/sbom.json +14 -14
- package/src/guard.ts +19 -0
- package/src/skills-verify.ts +273 -34
package/src/guard.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface GuardFacts {
|
|
|
52
52
|
readonly counts?: readonly { readonly label: string; readonly a: number; readonly b: number }[];
|
|
53
53
|
/** for store-bloat-cap: current learned-store size vs its cap. */
|
|
54
54
|
readonly store?: { readonly count: number; readonly cap: number };
|
|
55
|
+
/** for skills-registrable: per skill pack, dirs that would ship un-registrable (no depth-1 SKILL.md). */
|
|
56
|
+
readonly skillPacks?: readonly { readonly name: string; readonly nonRegistrable: readonly string[] }[];
|
|
55
57
|
/** for readme-first: per publishable package, is a version bump staged without a README change? */
|
|
56
58
|
readonly readmeFirst?: readonly { readonly name: string; readonly versionBumped: boolean; readonly readmeChanged: boolean }[];
|
|
57
59
|
}
|
|
@@ -62,6 +64,7 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
|
|
|
62
64
|
{ id: 'no-skill-drift', severity: 'hard', ops: ['publish', 'consolidate'], description: 'no unexpected byte-drift between shared skill copies' },
|
|
63
65
|
{ id: 'no-secrets', severity: 'hard', ops: ['teach', 'publish'], description: 'no private key or API token in lesson text or a published file' },
|
|
64
66
|
{ id: 'readme-consistency', severity: 'soft', ops: ['publish'], description: 'README counts agree (CJM header vs All Commands, etc.)' },
|
|
67
|
+
{ id: 'skills-registrable', severity: 'soft', ops: ['publish'], description: 'every skill directory in a skill pack has a depth-1 SKILL.md (a buried or missing one ships un-registrable — the health-advisor 1.2.0 class)' },
|
|
65
68
|
{ id: 'readme-first', severity: 'soft', ops: ['publish'], description: 'a package with a staged version bump must update its own README.md in the same change (README-first)' },
|
|
66
69
|
{ id: 'store-bloat-cap', severity: 'soft', ops: ['teach', 'consolidate'], description: 'the learned store is within its size cap' },
|
|
67
70
|
];
|
|
@@ -123,6 +126,22 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
123
126
|
}
|
|
124
127
|
return out;
|
|
125
128
|
},
|
|
129
|
+
'skills-registrable': (f, sev) => {
|
|
130
|
+
// The health-advisor 1.2.0 class, mechanized at publish time: a skill directory that ships with
|
|
131
|
+
// no depth-1 SKILL.md registers NOWHERE, however green the tests are. SOFT: the discriminator is
|
|
132
|
+
// a heuristic (a pack counts only if it already has one registrable skill, and only
|
|
133
|
+
// markdown-bearing dirs are considered intended), so it informs rather than blocks.
|
|
134
|
+
const out: Violation[] = [];
|
|
135
|
+
for (const p of f.skillPacks ?? []) {
|
|
136
|
+
if (!p || !Array.isArray(p.nonRegistrable) || p.nonRegistrable.length === 0) continue;
|
|
137
|
+
out.push({
|
|
138
|
+
rule: 'skills-registrable',
|
|
139
|
+
severity: sev,
|
|
140
|
+
detail: `${p.name}: ${p.nonRegistrable.length} skill dir(s) would ship un-registrable (no depth-1 SKILL.md): ${p.nonRegistrable.join(', ')} — run \`dz skills-verify --static\``,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
},
|
|
126
145
|
'readme-first': (f, sev) => {
|
|
127
146
|
// The 2026-07-18 violation shape, mechanized: a package about to publish (version bumped in the diff)
|
|
128
147
|
// whose own README.md is untouched in the same diff. SOFT: some republishes legitimately need no doc
|
package/src/skills-verify.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* FAIL-CLOSED: anything that prevents an honest observation yields `inconclusive`, never `pass`.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
17
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
18
18
|
import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
19
19
|
|
|
20
20
|
// ── Layer 1: static layout scan ─────────────────────────────────────
|
|
@@ -55,7 +55,17 @@ export interface StaticScan {
|
|
|
55
55
|
* it did not register and that is a failure (QE4 #1 — without this, making the container advisory
|
|
56
56
|
* re-opened the vacuous pass and the gate stopped catching health-advisor 1.2.0).
|
|
57
57
|
*/
|
|
58
|
-
readonly containers: readonly {
|
|
58
|
+
readonly containers: readonly {
|
|
59
|
+
readonly dir: string;
|
|
60
|
+
/** Absolute path of the container — matched against `init.plugins[].path` (QE5 #1). */
|
|
61
|
+
readonly path: string;
|
|
62
|
+
/** The name the container's OWN manifest declares (may differ from the dir name — QE5 #1b). */
|
|
63
|
+
readonly manifestName: string | null;
|
|
64
|
+
/** Skill names it would provide: discovered on disk AND declared by the manifest (QE5 #4). */
|
|
65
|
+
readonly candidates: readonly string[];
|
|
66
|
+
/** True when the dir ALSO has a depth-1 SKILL.md — a single-skill plugin (QE5 #2). */
|
|
67
|
+
readonly alsoBare: boolean;
|
|
68
|
+
}[];
|
|
59
69
|
/**
|
|
60
70
|
* Set when the scan could not complete (unreadable dir, `.claude/skills` is a file, …). A failed
|
|
61
71
|
* scan must NOT read as "a clean empty project" — the classifier turns this into `inconclusive`
|
|
@@ -64,7 +74,10 @@ export interface StaticScan {
|
|
|
64
74
|
readonly scanError?: string;
|
|
65
75
|
}
|
|
66
76
|
|
|
67
|
-
|
|
77
|
+
// Bounded on purpose: deep enough for every real layout seen (extended/<name>/, skills/<name>/,
|
|
78
|
+
// base/<dep>/), shallow enough not to walk a whole tree. A SKILL.md below this bound is not seen —
|
|
79
|
+
// a stated limit, not a silent one (QE6 #9).
|
|
80
|
+
const MAX_BURIED_DEPTH = 6;
|
|
68
81
|
|
|
69
82
|
function findBuriedSkillMd(dir: string, depth: number, acc: string[], errors: string[]): void {
|
|
70
83
|
if (depth > MAX_BURIED_DEPTH) return;
|
|
@@ -98,6 +111,65 @@ function hasPluginManifest(dir: string): boolean {
|
|
|
98
111
|
return existsSync(join(dir, '.claude-plugin', 'plugin.json'));
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Read a container's own `.claude-plugin/plugin.json`: the name it DECLARES and the skill paths it
|
|
116
|
+
* declares. A manifest may point at a custom path deeper than the on-disk walk, which otherwise left
|
|
117
|
+
* the container with zero candidates and restored a vacuous PASS (QE5 #4).
|
|
118
|
+
*/
|
|
119
|
+
function readContainerManifest(dir: string): { name: string | null; declared: string[] } {
|
|
120
|
+
try {
|
|
121
|
+
const raw = readFileSync(join(dir, '.claude-plugin', 'plugin.json'), 'utf8');
|
|
122
|
+
const obj = JSON.parse(raw) as Record<string, unknown>;
|
|
123
|
+
const name = typeof obj.name === 'string' && obj.name ? obj.name : null;
|
|
124
|
+
const declared = Array.isArray(obj.skills)
|
|
125
|
+
? obj.skills
|
|
126
|
+
.filter((x): x is string => typeof x === 'string')
|
|
127
|
+
.map((rel) => basename(rel.replace(/\/+$/, '')))
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
: [];
|
|
130
|
+
return { name, declared };
|
|
131
|
+
} catch {
|
|
132
|
+
return { name: null, declared: [] }; // unreadable/!JSON: the manifest's INTENT still counts
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Does this directory look like it was INTENDED as a skill? A dir with no markdown at all is
|
|
138
|
+
* ordinary content (`scripts/`, `bin/`, `templates/`) — calling it a failed skill is a false FAIL
|
|
139
|
+
* (Codex QE5 #7). MEASURED: naively flagging every dir marked ~40 healthy directories across 9
|
|
140
|
+
* npx-toolkit packages (reproducer: the pack survey in features/skills-verify/08_qe_report.md).
|
|
141
|
+
*/
|
|
142
|
+
export function looksLikeSkillDir(dir: string): boolean {
|
|
143
|
+
const walk = (d: string, depth: number): boolean => {
|
|
144
|
+
if (depth > 2) return false;
|
|
145
|
+
let entries: string[];
|
|
146
|
+
try {
|
|
147
|
+
entries = readdirSync(d);
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
for (const name of entries) {
|
|
152
|
+
if (name.startsWith('.') || name === 'node_modules') continue;
|
|
153
|
+
const full = join(d, name);
|
|
154
|
+
let isDir = false;
|
|
155
|
+
try {
|
|
156
|
+
isDir = statSync(full).isDirectory();
|
|
157
|
+
} catch {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (isDir) {
|
|
161
|
+
// A DIRECTORY named SKILL.md is a botched skill file, not ordinary content — clear intent.
|
|
162
|
+
if (name === 'SKILL.md') return true;
|
|
163
|
+
if (walk(full, depth + 1)) return true;
|
|
164
|
+
} else if (name.toLowerCase().endsWith('.md')) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
};
|
|
170
|
+
return walk(dir, 1);
|
|
171
|
+
}
|
|
172
|
+
|
|
101
173
|
/**
|
|
102
174
|
* A directory registers only if `SKILL.md` is a regular FILE. `existsSync` also answers true for a
|
|
103
175
|
* DIRECTORY named SKILL.md, which registers nothing yet suppressed every other check (QE2 #4).
|
|
@@ -193,7 +265,13 @@ export function scanSkillsLayout(projectDir: string): StaticScan {
|
|
|
193
265
|
const registrable: string[] = [];
|
|
194
266
|
const findings: SkillLayoutFinding[] = [];
|
|
195
267
|
const advisories: SkillLayoutFinding[] = [];
|
|
196
|
-
const containers: {
|
|
268
|
+
const containers: {
|
|
269
|
+
dir: string;
|
|
270
|
+
path: string;
|
|
271
|
+
manifestName: string | null;
|
|
272
|
+
candidates: string[];
|
|
273
|
+
alsoBare: boolean;
|
|
274
|
+
}[] = [];
|
|
197
275
|
const scanErrors: string[] = [];
|
|
198
276
|
|
|
199
277
|
let entries: string[] = [];
|
|
@@ -224,9 +302,17 @@ export function scanSkillsLayout(projectDir: string): StaticScan {
|
|
|
224
302
|
scanErrors.push(`cannot stat ${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
225
303
|
continue;
|
|
226
304
|
}
|
|
305
|
+
// A `SKILL.md` sitting directly in `.claude/skills/` registers nothing, whether it is a FILE or
|
|
306
|
+
// a DIRECTORY — the directory form previously slipped into the ordinary branch (QE6 #6).
|
|
307
|
+
if (name === 'SKILL.md') {
|
|
308
|
+
findings.push({
|
|
309
|
+
dir: '.',
|
|
310
|
+
kind: 'no-skill-md',
|
|
311
|
+
detail: 'SKILL.md sits directly in .claude/skills/ — a skill must live in its own directory to register',
|
|
312
|
+
});
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
227
315
|
if (!isDir) {
|
|
228
|
-
// A SKILL.md dropped directly into .claude/skills/ registers nothing — it is a real defect,
|
|
229
|
-
// not something to skip silently (QE #4).
|
|
230
316
|
if (name === 'SKILL.md') {
|
|
231
317
|
findings.push({
|
|
232
318
|
dir: '.',
|
|
@@ -238,22 +324,31 @@ export function scanSkillsLayout(projectDir: string): StaticScan {
|
|
|
238
324
|
}
|
|
239
325
|
|
|
240
326
|
const registers = hasSkillFile(dir);
|
|
241
|
-
// A manifest
|
|
327
|
+
// A manifest makes this a plugin CONTAINER even when it ALSO has a depth-1 SKILL.md — that is a
|
|
328
|
+
// single-skill plugin, whose skills are namespaced, so expecting the bare name false-FAILs (QE5 #2).
|
|
242
329
|
// It did not register in measured practice, but the class may be supported under workspace
|
|
243
330
|
// trust — so the WHOLE container is advisory. Emitting the generic `no-skill-md` /
|
|
244
331
|
// `buried-skill-md` findings for it killed the layout anyway, which was the over-claim (QE4 #1).
|
|
245
|
-
const isPluginContainer =
|
|
332
|
+
const isPluginContainer = hasPluginManifest(dir);
|
|
246
333
|
const bucket = isPluginContainer ? advisories : findings;
|
|
247
334
|
|
|
248
|
-
if (registers) {
|
|
335
|
+
if (registers && !isPluginContainer) {
|
|
249
336
|
registrable.push(name);
|
|
337
|
+
} else if (registers && isPluginContainer) {
|
|
338
|
+
// A single-skill plugin: it has a depth-1 SKILL.md AND a manifest, so it registers NAMESPACED.
|
|
339
|
+
// Expecting the bare directory name here was a false FAIL (QE5 #2); the container check below
|
|
340
|
+
// accepts either form.
|
|
250
341
|
} else {
|
|
251
|
-
|
|
342
|
+
// A dir with no markdown was never meant to be a skill — advisory, not a failure (QE5 #7).
|
|
343
|
+
const intended = looksLikeSkillDir(dir);
|
|
344
|
+
(isPluginContainer || !intended ? advisories : findings).push({
|
|
252
345
|
dir: name,
|
|
253
346
|
kind: 'no-skill-md',
|
|
254
347
|
detail: isPluginContainer
|
|
255
348
|
? `${name}/ is a plugin-shaped container (advisory): it has no depth-1 SKILL.md, so it registers only if the client loads it as a plugin`
|
|
256
|
-
:
|
|
349
|
+
: intended
|
|
350
|
+
? `no SKILL.md at ${name}/SKILL.md — this directory cannot register`
|
|
351
|
+
: `${name}/ holds no markdown (advisory): it looks like ordinary content, not a skill that failed to register`,
|
|
257
352
|
});
|
|
258
353
|
}
|
|
259
354
|
|
|
@@ -262,11 +357,26 @@ export function scanSkillsLayout(projectDir: string): StaticScan {
|
|
|
262
357
|
// Only a report when the dir registers NOTHING: a dir with its own depth-1 SKILL.md registers
|
|
263
358
|
// fine and a stray manifest beside it is inert, not a blocker (Codex QE #8).
|
|
264
359
|
if (isPluginContainer) {
|
|
265
|
-
// What would this container provide?
|
|
360
|
+
// What would this container provide? Names found on disk UNION names its manifest declares —
|
|
361
|
+
// a manifest may point at a custom path deeper than the walk, which previously left the
|
|
362
|
+
// container with zero candidates and let it slip through (QE5 #4).
|
|
266
363
|
const inner: string[] = [];
|
|
267
364
|
findBuriedSkillMd(dir, 1, inner, scanErrors);
|
|
268
|
-
const
|
|
269
|
-
|
|
365
|
+
const manifest = readContainerManifest(dir);
|
|
366
|
+
const candidates = [
|
|
367
|
+
...new Set([
|
|
368
|
+
...inner.map((f) => basename(join(f, '..'))),
|
|
369
|
+
...manifest.declared,
|
|
370
|
+
...(registers ? [manifest.name ?? name] : []), // the single-skill plugin's own skill (QE5 #2)
|
|
371
|
+
]),
|
|
372
|
+
];
|
|
373
|
+
containers.push({
|
|
374
|
+
dir: name,
|
|
375
|
+
path: dir,
|
|
376
|
+
manifestName: manifest.name,
|
|
377
|
+
candidates,
|
|
378
|
+
alsoBare: registers,
|
|
379
|
+
});
|
|
270
380
|
advisories.push({
|
|
271
381
|
dir: name,
|
|
272
382
|
kind: 'plugin-manifest-trap',
|
|
@@ -280,6 +390,20 @@ export function scanSkillsLayout(projectDir: string): StaticScan {
|
|
|
280
390
|
// register may legitimately bundle nested SKILL.md files as its own resources — health-advisor
|
|
281
391
|
// 1.2.1 co-locates its base deps under the master exactly so they do NOT register. Only a dir
|
|
282
392
|
// that registers NOTHING while hiding SKILL.md files inside is the 1.2.0 defect shape.
|
|
393
|
+
if (registers && !isPluginContainer) {
|
|
394
|
+
const nested: string[] = [];
|
|
395
|
+
findBuriedSkillMd(dir, 1, nested, scanErrors);
|
|
396
|
+
if (nested.length > 0) {
|
|
397
|
+
advisories.push({
|
|
398
|
+
dir: name,
|
|
399
|
+
kind: 'buried-skill-md',
|
|
400
|
+
detail:
|
|
401
|
+
`${name}/ bundles ${nested.length} nested SKILL.md file(s) as resources — they do NOT register ` +
|
|
402
|
+
`(correct for co-located dependencies; a real skill placed there would be invisible)`,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
283
407
|
if (!registers) {
|
|
284
408
|
const buried: string[] = [];
|
|
285
409
|
findBuriedSkillMd(dir, 1, buried, scanErrors);
|
|
@@ -310,12 +434,18 @@ export function scanSkillsLayout(projectDir: string): StaticScan {
|
|
|
310
434
|
export interface InitPlugin {
|
|
311
435
|
readonly name: string;
|
|
312
436
|
readonly version?: string;
|
|
437
|
+
/** Where the plugin was loaded FROM — the only identity that cannot be spoofed by a name (QE5 #1). */
|
|
438
|
+
readonly path?: string;
|
|
439
|
+
/** e.g. `telegram@claude-plugins-official`, `health-advisor@skills-dir`. */
|
|
440
|
+
readonly source?: string;
|
|
313
441
|
}
|
|
314
442
|
|
|
315
443
|
export interface InitFacts {
|
|
316
444
|
/** Registered skill names. `null` means the key was ABSENT (schema drift) — never "none". */
|
|
317
445
|
readonly skills: readonly string[] | null;
|
|
318
446
|
readonly plugins: readonly InitPlugin[];
|
|
447
|
+
/** False when the `plugins` key was absent or not an array — unreadable, not empty (QE6 #7). */
|
|
448
|
+
readonly pluginsReadable: boolean;
|
|
319
449
|
/** The project the session actually read — the built-in control. */
|
|
320
450
|
readonly cwd: string | null;
|
|
321
451
|
readonly clientVersion: string | null;
|
|
@@ -379,19 +509,28 @@ function parseStream(streamText: string): StreamParse {
|
|
|
379
509
|
: null;
|
|
380
510
|
|
|
381
511
|
const rawPlugins = obj.plugins;
|
|
512
|
+
// An ABSENT or non-array `plugins` key is unreadable schema, not proof that nothing loaded —
|
|
513
|
+
// with containers present that difference decides FAIL vs INCONCLUSIVE (QE6 #7).
|
|
514
|
+
const pluginsReadable = Array.isArray(rawPlugins);
|
|
382
515
|
const plugins: InitPlugin[] = Array.isArray(rawPlugins)
|
|
383
516
|
? rawPlugins
|
|
384
517
|
.filter((p): p is Record<string, unknown> => !!p && typeof p === 'object')
|
|
385
518
|
.map((p) => {
|
|
386
519
|
const name = typeof p.name === 'string' ? p.name : '(unnamed)';
|
|
387
|
-
// exactOptionalPropertyTypes: omit
|
|
388
|
-
return
|
|
520
|
+
// exactOptionalPropertyTypes: omit a field rather than set it to undefined.
|
|
521
|
+
return {
|
|
522
|
+
name,
|
|
523
|
+
...(typeof p.version === 'string' ? { version: p.version } : {}),
|
|
524
|
+
...(typeof p.path === 'string' ? { path: p.path } : {}),
|
|
525
|
+
...(typeof p.source === 'string' ? { source: p.source } : {}),
|
|
526
|
+
};
|
|
389
527
|
})
|
|
390
528
|
: [];
|
|
391
529
|
|
|
392
530
|
found.push({
|
|
393
531
|
skills,
|
|
394
532
|
plugins,
|
|
533
|
+
pluginsReadable,
|
|
395
534
|
// An empty or relative cwd testifies to nothing — `resolve("")` silently becomes the caller's
|
|
396
535
|
// own cwd and can forge a match (Codex QE #6). Only an absolute path counts as evidence.
|
|
397
536
|
cwd: typeof obj.cwd === 'string' && obj.cwd !== '' && isAbsolute(obj.cwd) ? obj.cwd : null,
|
|
@@ -475,6 +614,11 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
475
614
|
if (!evidence.scan || !evidence.probe || !evidence.provenance || typeof evidence.projectDir !== 'string') {
|
|
476
615
|
return bad('incomplete evidence (scan, probe, provenance and projectDir are all required)');
|
|
477
616
|
}
|
|
617
|
+
// …and the scan must be SHAPED like a scan: a partial object crashed on `layout.length` (QE6 #8).
|
|
618
|
+
const sc = evidence.scan as Partial<StaticScan>;
|
|
619
|
+
if (!Array.isArray(sc.findings) || !Array.isArray(sc.registrable) || !Array.isArray(sc.advisories) || !Array.isArray(sc.containers)) {
|
|
620
|
+
return bad('malformed scan (findings, registrable, advisories and containers must all be arrays)');
|
|
621
|
+
}
|
|
478
622
|
const { projectDir, scan, probe, provenance } = evidence;
|
|
479
623
|
// The contract is CANONICAL identity: a lexical `resolve` alone reports a symlinked project as a
|
|
480
624
|
// different one and turns a correct registration into `inconclusive` (QE4 #3).
|
|
@@ -520,6 +664,15 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
520
664
|
}
|
|
521
665
|
if (scan.scanError !== undefined) return fail('inconclusive', `layout scan failed: ${scan.scanError}`);
|
|
522
666
|
|
|
667
|
+
// 1b. A load-blocking layout finding is DETERMINISTIC evidence — it needs no session at all.
|
|
668
|
+
// Evaluating it only after the probe meant a definite failure degraded to `inconclusive`
|
|
669
|
+
// whenever `claude` was missing (Codex QE5 #5).
|
|
670
|
+
if (layout.length > 0) {
|
|
671
|
+
return fail('fail', `${layout.length} layout problem(s) can never register — proven from the layout alone`, {
|
|
672
|
+
missing: [],
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
523
676
|
// 2. The probe must have produced a stream.
|
|
524
677
|
if (!probe.ok) return fail('inconclusive', `probe failed: ${probe.error}`);
|
|
525
678
|
|
|
@@ -593,17 +746,58 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
593
746
|
};
|
|
594
747
|
}
|
|
595
748
|
|
|
596
|
-
// 6b. A plugin-shaped container
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
|
|
602
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
749
|
+
// 6b. A plugin-shaped container must be ATTRIBUTED, then its skills must actually be listed.
|
|
750
|
+
//
|
|
751
|
+
// Identity is `path`/`source`, never the display name: matching by name both passed a broken
|
|
752
|
+
// container that shared a name with an unrelated marketplace plugin AND failed a correct
|
|
753
|
+
// container whose directory name differs from the name its manifest declares (Codex QE5 #1).
|
|
754
|
+
// And a loaded plugin is not proof its skills registered — a misconfigured skill can be absent
|
|
755
|
+
// from the authoritative listing while the plugin itself loads fine (QE5 #3).
|
|
756
|
+
if (scan.containers.length > 0 && !facts.pluginsReadable) {
|
|
757
|
+
return fail('inconclusive', 'the init event carried no readable `plugins` list — a container cannot be attributed', {
|
|
758
|
+
...common,
|
|
759
|
+
missing,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const containerProblems: string[] = [];
|
|
764
|
+
const verifiedContainers: string[] = [];
|
|
765
|
+
for (const c of scan.containers) {
|
|
766
|
+
const loaded = facts.plugins.find((p) => {
|
|
767
|
+
if (typeof p.path === 'string' && p.path) {
|
|
768
|
+
try {
|
|
769
|
+
if (resolvePath(p.path) === resolvePath(c.path)) return true; // strongest: same directory
|
|
770
|
+
} catch {
|
|
771
|
+
/* an unresolvable path is simply not a match */
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
// A skills-directory plugin reports `<declared-name>@skills-dir`; fall back to that, then to
|
|
775
|
+
// the declared name itself. The container's DIRECTORY name is never used as identity.
|
|
776
|
+
if (c.manifestName) {
|
|
777
|
+
if (p.source === `${c.manifestName}@skills-dir`) return true;
|
|
778
|
+
if (p.name === c.manifestName && (p.source ?? '').endsWith('@skills-dir')) return true;
|
|
779
|
+
}
|
|
780
|
+
return false;
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
if (!loaded) {
|
|
784
|
+
containerProblems.push(
|
|
785
|
+
`${c.dir}/ declares a plugin${c.manifestName ? ` (${c.manifestName})` : ''} that did not load — ` +
|
|
786
|
+
`${c.candidates.length} skill(s) inside would register nowhere`,
|
|
787
|
+
);
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
// It loaded. We deliberately DO NOT verify its individual skills here.
|
|
791
|
+
//
|
|
792
|
+
// Doing so requires reproducing Claude Code's command-name resolution: a plugin skill's
|
|
793
|
+
// frontmatter `name` replaces the final command segment, and a manifest's `skills` field may be a
|
|
794
|
+
// string or point at a directory of children. That contract is undocumented-to-us and moving, and
|
|
795
|
+
// six review rounds showed every attempt to model it produced a NEW false verdict. A narrow
|
|
796
|
+
// promise kept exactly beats a broad promise kept unreliably — so the gate reports the gap
|
|
797
|
+
// instead of guessing (see the advisory below and the README).
|
|
798
|
+
verifiedContainers.push(`${c.dir}/ → plugin "${loaded.name}" loaded`);
|
|
799
|
+
}
|
|
800
|
+
if (containerProblems.length > 0) {
|
|
607
801
|
return {
|
|
608
802
|
expected,
|
|
609
803
|
layout,
|
|
@@ -611,11 +805,7 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
611
805
|
...common,
|
|
612
806
|
missing,
|
|
613
807
|
verdict: 'fail',
|
|
614
|
-
reason:
|
|
615
|
-
`${deadContainers.length} plugin-shaped container(s) did not load — ` +
|
|
616
|
-
deadContainers
|
|
617
|
-
.map((c) => `${c.dir}/ (${c.candidates.length} skill(s) inside, but no such plugin in the session)`)
|
|
618
|
-
.join('; '),
|
|
808
|
+
reason: `${containerProblems.length} plugin container problem(s) — ${containerProblems.join('; ')}`,
|
|
619
809
|
};
|
|
620
810
|
}
|
|
621
811
|
|
|
@@ -635,11 +825,18 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
635
825
|
);
|
|
636
826
|
}
|
|
637
827
|
|
|
828
|
+
const containerAdvisories: SkillLayoutFinding[] = verifiedContainers.map((v) => ({
|
|
829
|
+
dir: v.split('/')[0] ?? v,
|
|
830
|
+
kind: 'plugin-manifest-trap' as const,
|
|
831
|
+
detail: `${v} — its individual skills are NOT verified: a plugin skill's command name depends on frontmatter this gate does not model`,
|
|
832
|
+
}));
|
|
833
|
+
const allAdvisories = [...scan.advisories, ...containerAdvisories];
|
|
834
|
+
|
|
638
835
|
if (missing.length > 0) {
|
|
639
836
|
return {
|
|
640
837
|
expected,
|
|
641
838
|
layout,
|
|
642
|
-
advisories:
|
|
839
|
+
advisories: allAdvisories,
|
|
643
840
|
...common,
|
|
644
841
|
missing,
|
|
645
842
|
verdict: 'fail',
|
|
@@ -650,7 +847,7 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
650
847
|
return {
|
|
651
848
|
expected,
|
|
652
849
|
layout,
|
|
653
|
-
advisories:
|
|
850
|
+
advisories: allAdvisories,
|
|
654
851
|
...common,
|
|
655
852
|
missing: [],
|
|
656
853
|
verdict: 'pass',
|
|
@@ -708,3 +905,45 @@ export function renderRegistrationReport(result: RegistrationResult, scan?: Stat
|
|
|
708
905
|
}
|
|
709
906
|
return out.join('\n');
|
|
710
907
|
}
|
|
908
|
+
|
|
909
|
+
// ── The publish-time guard fact ─────────────────────────────────────
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Scan a PACKAGE directory for skill dirs that could never register.
|
|
913
|
+
*
|
|
914
|
+
* Discriminator (MEASURED before it was chosen — a naive "every dir needs SKILL.md" rule flagged ~40
|
|
915
|
+
* healthy directories across 9 npx-toolkit packages, and a markdown-based one still flagged `docs/`):
|
|
916
|
+
* 1. a package counts as a SKILL PACK only if it already has at least one `<dir>/SKILL.md`;
|
|
917
|
+
* 2. inside it, a dir is broken only when a `SKILL.md` EXISTS somewhere inside but not at depth 1.
|
|
918
|
+
* That second rule is unambiguous — the skill file is there, just where nothing will load it (exactly
|
|
919
|
+
* health-advisor 1.2.0). A dir with no SKILL.md anywhere is ordinary content, not a failed skill.
|
|
920
|
+
*/
|
|
921
|
+
export function findNonRegistrableSkillDirs(packDir: string): string[] {
|
|
922
|
+
let entries: string[];
|
|
923
|
+
try {
|
|
924
|
+
entries = readdirSync(packDir);
|
|
925
|
+
} catch {
|
|
926
|
+
return [];
|
|
927
|
+
}
|
|
928
|
+
const dirs: string[] = [];
|
|
929
|
+
for (const name of entries) {
|
|
930
|
+
if (name.startsWith('.') || name === 'node_modules') continue;
|
|
931
|
+
const full = join(packDir, name);
|
|
932
|
+
try {
|
|
933
|
+
if (statSync(full).isDirectory()) dirs.push(name);
|
|
934
|
+
} catch {
|
|
935
|
+
/* unreadable entries are not evidence of a defect */
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
const isSkillPack = dirs.some((d) => hasSkillFile(join(packDir, d)));
|
|
939
|
+
if (!isSkillPack) return [];
|
|
940
|
+
return dirs
|
|
941
|
+
.filter((d) => {
|
|
942
|
+
const full = join(packDir, d);
|
|
943
|
+
if (hasSkillFile(full)) return false; // registers fine
|
|
944
|
+
const buried: string[] = [];
|
|
945
|
+
findBuriedSkillMd(full, 1, buried, []);
|
|
946
|
+
return buried.length > 0; // a SKILL.md exists, but nothing will load it from there
|
|
947
|
+
})
|
|
948
|
+
.sort();
|
|
949
|
+
}
|