@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/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
@@ -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 { readonly dir: string; readonly candidates: readonly string[] }[];
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
- const MAX_BURIED_DEPTH = 4; // enough to catch extended/<name>/SKILL.md without walking the world
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: { dir: string; candidates: string[] }[] = [];
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-backed CONTAINER (`<dir>/.claude-plugin/plugin.json`) is a plugin-shaped layout.
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 = !registers && hasPluginManifest(dir);
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
- bucket.push({
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
- : `no SKILL.md at ${name}/SKILL.md — this directory cannot register`,
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? The dir name of every nested SKILL.md.
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 candidates = [...new Set(inner.map((f) => basename(join(f, '..'))))];
269
- containers.push({ dir: name, candidates });
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 `version` rather than set it to undefined.
388
- return typeof p.version === 'string' ? { name, version: p.version } : { name };
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 is forgiven ONLY if the SESSION says that plugin loaded.
597
- // Matching on skill NAMES was wrong: one incidental collision with an unrelated plugin's skill
598
- // (e.g. `deep-research`) forgave the whole container, and the gate PASSED the broken
599
- // health-advisor 1.2.0 (MEASURED reproducer: `dz skills-verify` against that published
600
- // install). `init.plugins` names what actually loaded, so ask that instead.
601
- const loadedPlugins = new Set(facts.plugins.map((p) => p.name));
602
- // Only the LOADED-PLUGIN list counts. A namespaced-name fallback (`<dir>:<skill>` present in the
603
- // listing) was still name-based evidence — the very class that made the gate pass a broken install
604
- // and `init.plugins` already covers every legitimate case.
605
- const deadContainers = scan.containers.filter((c) => c.candidates.length > 0 && !loadedPlugins.has(c.dir));
606
- if (deadContainers.length > 0) {
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: scan.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: scan.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
+ }