@dzhechkov/harness-core 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.dz-manifest.json +104 -28
  2. package/README.md +15 -4
  3. package/dist/backlog.d.ts +35 -0
  4. package/dist/backlog.d.ts.map +1 -1
  5. package/dist/backlog.js +167 -3
  6. package/dist/backlog.js.map +1 -1
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/loop-blobs.generated.d.ts.map +1 -1
  12. package/dist/loop-blobs.generated.js +1 -0
  13. package/dist/loop-blobs.generated.js.map +1 -1
  14. package/dist/loop-lint.d.ts +6 -2
  15. package/dist/loop-lint.d.ts.map +1 -1
  16. package/dist/loop-lint.js +54 -0
  17. package/dist/loop-lint.js.map +1 -1
  18. package/dist/loop-plan-graph.d.ts +49 -0
  19. package/dist/loop-plan-graph.d.ts.map +1 -0
  20. package/dist/loop-plan-graph.js +128 -0
  21. package/dist/loop-plan-graph.js.map +1 -0
  22. package/dist/loop-plan.d.ts +17 -0
  23. package/dist/loop-plan.d.ts.map +1 -1
  24. package/dist/loop-plan.js +18 -15
  25. package/dist/loop-plan.js.map +1 -1
  26. package/dist/loop-render.d.ts.map +1 -1
  27. package/dist/loop-render.js +8 -0
  28. package/dist/loop-render.js.map +1 -1
  29. package/dist/model-recommender.d.ts +91 -0
  30. package/dist/model-recommender.d.ts.map +1 -0
  31. package/dist/model-recommender.js +186 -0
  32. package/dist/model-recommender.js.map +1 -0
  33. package/dist/registry.d.ts.map +1 -1
  34. package/dist/registry.js +4 -1
  35. package/dist/registry.js.map +1 -1
  36. package/dist/skills-verify.d.ts +40 -0
  37. package/dist/skills-verify.d.ts.map +1 -1
  38. package/dist/skills-verify.js +80 -10
  39. package/dist/skills-verify.js.map +1 -1
  40. package/dist/trace-bundle.d.ts +209 -0
  41. package/dist/trace-bundle.d.ts.map +1 -0
  42. package/dist/trace-bundle.js +601 -0
  43. package/dist/trace-bundle.js.map +1 -0
  44. package/dist/usage.d.ts +7 -0
  45. package/dist/usage.d.ts.map +1 -1
  46. package/dist/usage.js +30 -2
  47. package/dist/usage.js.map +1 -1
  48. package/package.json +18 -18
  49. package/sbom.json +217 -27
  50. package/src/backlog.ts +176 -3
  51. package/src/index.ts +3 -0
  52. package/src/loop-blobs.generated.ts +1 -0
  53. package/src/loop-lint.ts +55 -1
  54. package/src/loop-plan-graph.ts +132 -0
  55. package/src/loop-plan.ts +35 -15
  56. package/src/loop-render.ts +8 -0
  57. package/src/model-recommender.ts +228 -0
  58. package/src/registry.ts +4 -1
  59. package/src/skills-verify.ts +108 -10
  60. package/src/trace-bundle.ts +743 -0
  61. package/src/usage.ts +42 -2
  62. package/LICENSE +0 -21
@@ -0,0 +1,228 @@
1
+ /**
2
+ * model-recommender (backlog a9c3dd5c, function 3) — the PURE half of `dz routing recommend`.
3
+ *
4
+ * NOT a fifth analyzer (ADR-001 D1): this module HARVESTS per-stage (model → success) samples out of
5
+ * the harness's own workflow records (and imported run-meta sidecars) and hands them to the EXISTING
6
+ * `selectAutoCost` brain in routing-outcomes.ts — the same brain the `auto-cost` plan spec reads. The
7
+ * store that brain trusts (`.dz/routing-outcomes.json`) had never been fed before this feature.
8
+ *
9
+ * Honesty rules, load-bearing:
10
+ * - The ONLY grade a record carries is RUN-level. Attributing it to every stage's model is an
11
+ * INFERENCE, and the printed basis states the rule rather than implying it (ADR-001 D2).
12
+ * - Cross-family QE is UNREPRESENTABLE, not filtered: the qe pick is computed with the family
13
+ * parameter forced to the cross of the code pick's family (ADR-001 D3).
14
+ * - `--apply` idempotency lives here as a pure plan (`planFeed`): double-feeding the same runs
15
+ * would manufacture confidence the data does not contain (ADR-001 D4).
16
+ *
17
+ * No fs, no clock, no randomness — the CLI reads records and does the I/O.
18
+ */
19
+
20
+ import { COST_LADDER, selectAutoCost, type AutoCostPick, type Family, type ModelRung } from './routing-outcomes.js';
21
+
22
+ /** success ⇔ grade ≥ this floor. DATA, exported, and printed in every basis (FR-4). */
23
+ export const GRADE_SUCCESS_FLOOR = 'B';
24
+ const SUCCESS_GRADES = new Set(['A+', 'A', 'A-', 'B+', 'B']);
25
+
26
+ export function gradeIsSuccess(grade: string): boolean {
27
+ return SUCCESS_GRADES.has(grade.trim().toUpperCase());
28
+ }
29
+
30
+ export interface HarvestSample {
31
+ readonly runId: string;
32
+ readonly ts: string | null;
33
+ readonly tier: string;
34
+ readonly stage: string;
35
+ /** Normalized to a COST_LADDER rung id (e.g. `codex:gpt-5.5:xhigh (usage-switched)` → `gpt-5.5`). */
36
+ readonly model: string;
37
+ readonly success: boolean;
38
+ readonly grade: string;
39
+ }
40
+
41
+ export interface Harvest {
42
+ readonly samples: HarvestSample[];
43
+ readonly runsUsed: number;
44
+ readonly window: { min: string; max: string } | null;
45
+ /** Records that contributed nothing, by WHY — printed in the basis, never silent (FR-7). */
46
+ readonly skipped: { noResult: number; noModels: number; noGrade: number; unknownModel: number };
47
+ /** The attribution rule, stated for the reader of every recommendation. */
48
+ readonly rule: string;
49
+ }
50
+
51
+ const RULE_TEXT =
52
+ `success ⇔ QE grade ≥ ${GRADE_SUCCESS_FLOOR} (run-level); the run's ONE grade is attributed to every ` +
53
+ `stage's model of that run — an inference, stated here because a hidden basis is an opinion in uniform`;
54
+
55
+ /** `codex:gpt-5.5:xhigh (usage-switched)` → `gpt-5.5`; claude ids pass through; unknown → null. */
56
+ export function normalizeModelId(raw: unknown): string | null {
57
+ if (typeof raw !== 'string' || raw === '') return null;
58
+ let id = raw.replace(/ \(usage-switched\)$/, '').trim();
59
+ const codex = /^codex:([^:]+)(?::[a-z]+)?$/.exec(id);
60
+ if (codex !== null) id = codex[1]!;
61
+ return COST_LADDER.some((r) => r.id === id) ? id : null;
62
+ }
63
+
64
+ function isRecord(v: unknown): v is Record<string, unknown> {
65
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
66
+ }
67
+
68
+ /** The run-level grade, wherever this record's era put it (`result.qeGrade`, `result.grade`,
69
+ * `result.qe.grade`). A string that does not look like a grade is not one. */
70
+ function extractGrade(result: Record<string, unknown>): string | null {
71
+ const looksLikeGrade = (v: unknown): v is string => typeof v === 'string' && /^[A-F][+-]?$/.test(v.trim().toUpperCase());
72
+ if (looksLikeGrade(result['qeGrade'])) return (result['qeGrade'] as string).trim().toUpperCase();
73
+ if (looksLikeGrade(result['grade'])) return (result['grade'] as string).trim().toUpperCase();
74
+ const qe = result['qe'];
75
+ if (isRecord(qe) && looksLikeGrade(qe['grade'])) return (qe['grade'] as string).trim().toUpperCase();
76
+ return null;
77
+ }
78
+
79
+ /** Harvest per-stage samples from already-read records (live harness records AND the `runMeta.records`
80
+ * of imported run-meta sidecars — they are the same shape by construction). */
81
+ export function harvestStageOutcomes(records: readonly unknown[]): Harvest {
82
+ const samples: HarvestSample[] = [];
83
+ const skipped = { noResult: 0, noModels: 0, noGrade: 0, unknownModel: 0 };
84
+ const runs = new Set<string>();
85
+ let min: string | null = null;
86
+ let max: string | null = null;
87
+ for (const source of records) {
88
+ if (!isRecord(source)) { skipped.noResult += 1; continue; }
89
+ const result = source['result'];
90
+ if (!isRecord(result)) { skipped.noResult += 1; continue; }
91
+ const modelsUsed = result['modelsUsed'];
92
+ if (!isRecord(modelsUsed)) { skipped.noModels += 1; continue; }
93
+ const grade = extractGrade(result);
94
+ if (grade === null) { skipped.noGrade += 1; continue; }
95
+ const runId = typeof source['runId'] === 'string' ? source['runId'] : JSON.stringify(modelsUsed).slice(0, 40);
96
+ const ts = typeof source['timestamp'] === 'string' ? source['timestamp'] : null;
97
+ const tier = typeof result['tier'] === 'string' && result['tier'] !== '' ? result['tier'] : 'unknown';
98
+ let contributed = false;
99
+ for (const [stage, rawModel] of Object.entries(modelsUsed)) {
100
+ const model = normalizeModelId(rawModel);
101
+ if (model === null) { skipped.unknownModel += 1; continue; }
102
+ contributed = true;
103
+ samples.push({ runId, ts, tier, stage, model, success: gradeIsSuccess(grade), grade });
104
+ }
105
+ if (contributed) {
106
+ runs.add(runId);
107
+ if (ts !== null) {
108
+ if (min === null || ts < min) min = ts;
109
+ if (max === null || ts > max) max = ts;
110
+ }
111
+ }
112
+ }
113
+ return {
114
+ samples,
115
+ runsUsed: runs.size,
116
+ window: min !== null && max !== null ? { min, max } : null,
117
+ skipped,
118
+ rule: RULE_TEXT,
119
+ };
120
+ }
121
+
122
+ export interface StageRecommendation {
123
+ readonly stage: string;
124
+ /** The spec string for `args.models` — claude rung ids pass through; openai rungs render as `codex:<id>:high`. */
125
+ readonly spec: string;
126
+ readonly pick: AutoCostPick;
127
+ readonly family: Family;
128
+ readonly samples: number;
129
+ /** `selectAutoCost` met its quality bar on ≥minSamples — otherwise this is cold-start, SAID. */
130
+ readonly insufficientData: boolean;
131
+ }
132
+
133
+ export interface Recommendation {
134
+ readonly perStage: StageRecommendation[];
135
+ readonly basis: {
136
+ readonly runsUsed: number;
137
+ readonly window: Harvest['window'];
138
+ readonly rule: string;
139
+ readonly skipped: Harvest['skipped'];
140
+ readonly crossFamilyNote: string;
141
+ };
142
+ }
143
+
144
+ function rungFamily(id: string): Family {
145
+ const rung: ModelRung | undefined = COST_LADDER.find((r) => r.id === id);
146
+ return rung !== undefined ? rung.family : 'claude';
147
+ }
148
+
149
+ function toSpec(id: string): string {
150
+ return rungFamily(id) === 'openai' ? `codex:${id}:high` : id;
151
+ }
152
+
153
+ const CROSS_NOTE =
154
+ 'the qe pick is computed with the family FORCED to the cross of the code pick — a same-family qe recommendation is unrepresentable (ADR-001 D3)';
155
+
156
+ /** Recommend per stage over the harvested samples (optionally one tier's slice). */
157
+ export function recommendModels(harvest: Harvest, opts: { tier?: string; qualityBar?: number; minSamples?: number } = {}): Recommendation {
158
+ const slice = opts.tier === undefined ? harvest.samples : harvest.samples.filter((s) => s.tier === opts.tier);
159
+ const tierLabel = opts.tier ?? 'all';
160
+ const byStage = new Map<string, HarvestSample[]>();
161
+ for (const s of slice) {
162
+ const bucket = byStage.get(s.stage) ?? [];
163
+ bucket.push(s);
164
+ byStage.set(s.stage, bucket);
165
+ }
166
+ const statsFor = (stage: string) => (model: string) => {
167
+ const rows = (byStage.get(stage) ?? []).filter((s) => s.model === model);
168
+ const successes = rows.filter((s) => s.success).length;
169
+ return { attempts: rows.length, successes, successRate: rows.length === 0 ? 0 : successes / rows.length };
170
+ };
171
+ const pickFor = (stage: string, family?: Family): StageRecommendation => {
172
+ const pick = selectAutoCost(stage, tierLabel, statsFor(stage), {
173
+ ...(opts.qualityBar !== undefined ? { qualityBar: opts.qualityBar } : {}),
174
+ ...(opts.minSamples !== undefined ? { minSamples: opts.minSamples } : {}),
175
+ ...(family !== undefined ? { family } : {}),
176
+ });
177
+ return {
178
+ stage,
179
+ spec: toSpec(pick.model),
180
+ pick,
181
+ family: rungFamily(pick.model),
182
+ samples: (byStage.get(stage) ?? []).length,
183
+ insufficientData: !pick.metBar,
184
+ };
185
+ };
186
+
187
+ const stages = [...byStage.keys()].sort();
188
+ const perStage: StageRecommendation[] = [];
189
+ // code first — the qe family depends on it (D3).
190
+ const code = pickFor('code');
191
+ for (const stage of stages) {
192
+ if (stage === 'qe') {
193
+ const cross: Family = code.family === 'claude' ? 'openai' : 'claude';
194
+ perStage.push(pickFor('qe', cross));
195
+ } else {
196
+ perStage.push(pickFor(stage));
197
+ }
198
+ }
199
+ if (!stages.includes('code')) perStage.unshift(code);
200
+ return {
201
+ perStage,
202
+ basis: { runsUsed: harvest.runsUsed, window: harvest.window, rule: harvest.rule, skipped: harvest.skipped, crossFamilyNote: CROSS_NOTE },
203
+ };
204
+ }
205
+
206
+ /* ── the idempotent feed plan (ADR-001 D4) ─────────────────────────────────────────────── */
207
+
208
+ export interface FeedPlan {
209
+ /** Samples whose runId has not been fed before — the CLI calls finalizeOutcome for each. */
210
+ readonly toFeed: HarvestSample[];
211
+ readonly skippedRuns: string[];
212
+ /** The new fed-set the CLI persists after feeding. */
213
+ readonly fedAfter: string[];
214
+ }
215
+
216
+ /** A run feeds ONCE. Double-feeding the same telemetry manufactures confidence the data does not
217
+ * contain — the second `--apply` must feed 0 and say which runs it skipped. */
218
+ export function planFeed(samples: readonly HarvestSample[], alreadyFed: readonly string[]): FeedPlan {
219
+ const fed = new Set(alreadyFed);
220
+ const toFeed: HarvestSample[] = [];
221
+ const skippedRuns = new Set<string>();
222
+ for (const s of samples) {
223
+ if (fed.has(s.runId)) skippedRuns.add(s.runId);
224
+ else toFeed.push(s);
225
+ }
226
+ const fedAfter = [...new Set([...alreadyFed, ...toFeed.map((s) => s.runId)])].sort();
227
+ return { toFeed, skippedRuns: [...skippedRuns].sort(), fedAfter };
228
+ }
package/src/registry.ts CHANGED
@@ -184,7 +184,10 @@ function categoryFromPack(pack: string): string {
184
184
  pack.includes('pm') ||
185
185
  pack.includes('idea2prd') ||
186
186
  pack.includes('reverse-engineering') ||
187
- pack.includes('presentation')
187
+ pack.includes('presentation') ||
188
+ // decision-mockups: an owner-facing decision page is stakeholder communication, the same
189
+ // cluster as PRDs and presentations — not design, and not a QE artifact.
190
+ pack.includes('decision-mockups')
188
191
  )
189
192
  return 'product';
190
193
  // Digitized-book knowledge packs (ADR-001 book-knowledge-digitizer) — `skills-book-*` and named
@@ -443,6 +443,16 @@ export interface InitPlugin {
443
443
  export interface InitFacts {
444
444
  /** Registered skill names. `null` means the key was ABSENT (schema drift) — never "none". */
445
445
  readonly skills: readonly string[] | null;
446
+ /**
447
+ * Registered slash-command names, e.g. `loop-designer:init` (MEASURED on Claude Code 2.1.233: the
448
+ * `system/init` event carries a `slash_commands` array, and a plugin command registers under
449
+ * `<plugin>:<file basename>` — its frontmatter `name:` does NOT rename it).
450
+ *
451
+ * `null` means the key was ABSENT or not all strings — schema drift, never "no commands". A
452
+ * plugin whose commands silently failed to load and a Claude Code build that stopped emitting the
453
+ * key are indistinguishable from `[]`, so `[]` is never synthesised here.
454
+ */
455
+ readonly slashCommands: readonly string[] | null;
446
456
  readonly plugins: readonly InitPlugin[];
447
457
  /** False when the `plugins` key was absent or not an array — unreadable, not empty (QE6 #7). */
448
458
  readonly pluginsReadable: boolean;
@@ -508,6 +518,15 @@ function parseStream(streamText: string): StreamParse {
508
518
  : null
509
519
  : null;
510
520
 
521
+ // Same treatment as `skills`, for the same reason: an ABSENT key is unreadable schema, and a
522
+ // partially-unparseable array must not be narrowed to the strings it happens to contain.
523
+ const rawCommands = obj.slash_commands;
524
+ const slashCommands = Array.isArray(rawCommands)
525
+ ? rawCommands.every((s) => typeof s === 'string')
526
+ ? (rawCommands as string[])
527
+ : null
528
+ : null;
529
+
511
530
  const rawPlugins = obj.plugins;
512
531
  // An ABSENT or non-array `plugins` key is unreadable schema, not proof that nothing loaded —
513
532
  // with containers present that difference decides FAIL vs INCONCLUSIVE (QE6 #7).
@@ -529,6 +548,7 @@ function parseStream(streamText: string): StreamParse {
529
548
 
530
549
  found.push({
531
550
  skills,
551
+ slashCommands,
532
552
  plugins,
533
553
  pluginsReadable,
534
554
  // An empty or relative cwd testifies to nothing — `resolve("")` silently becomes the caller's
@@ -549,6 +569,8 @@ export interface RegistrationControls {
549
569
  readonly cwdMatched: boolean | null;
550
570
  /** Was the `skills` key present at all? A missing key is schema drift, not "nothing registered". */
551
571
  readonly skillsListPresent: boolean;
572
+ /** Same question for `slash_commands`. `false` when absent/unreadable — never "no commands". */
573
+ readonly commandsListPresent: boolean;
552
574
  }
553
575
 
554
576
  export interface RegistrationResult {
@@ -556,6 +578,10 @@ export interface RegistrationResult {
556
578
  readonly reason: string;
557
579
  readonly expected: readonly string[];
558
580
  readonly missing: readonly string[];
581
+ /** The `--expect-commands` set this verdict was measured against (empty when not asked). */
582
+ readonly expectedCommands: readonly string[];
583
+ /** Expected commands absent from the live `slash_commands` listing. */
584
+ readonly missingCommands: readonly string[];
559
585
  readonly registeredCount: number | null;
560
586
  readonly clientVersion: string | null;
561
587
  readonly plugins: readonly InitPlugin[];
@@ -583,6 +609,12 @@ export interface RegistrationEvidence {
583
609
  readonly provenance: { readonly checked: boolean; readonly ambiguous: readonly string[] };
584
610
  /** Explicit `--expect` list; when absent the expectation is the scan's registrable set. */
585
611
  readonly expected?: readonly string[];
612
+ /**
613
+ * Explicit `--expect-commands` list, e.g. `loop-designer:init`. Absent/empty ⇒ commands are not
614
+ * part of this vehicle's expectation (a bare skill has no commands BY DESIGN — ADR-003 D-2 — so
615
+ * demanding them there would be a false FAIL, not a stronger gate).
616
+ */
617
+ readonly expectedCommands?: readonly string[];
586
618
  }
587
619
 
588
620
  export interface SkillsVerifyOptions {
@@ -603,11 +635,13 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
603
635
  reason,
604
636
  expected: [],
605
637
  missing: [],
638
+ expectedCommands: [],
639
+ missingCommands: [],
606
640
  registeredCount: null,
607
641
  clientVersion: null,
608
642
  plugins: [],
609
643
  advisories: [],
610
- controls: { cwdMatched: null, skillsListPresent: false },
644
+ controls: { cwdMatched: null, skillsListPresent: false, commandsListPresent: false },
611
645
  layout: [],
612
646
  });
613
647
  if (!evidence || typeof evidence !== 'object') return bad('no evidence supplied');
@@ -632,6 +666,10 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
632
666
  }
633
667
  });
634
668
  const expected = evidence.expected ?? scan.registrable;
669
+ // Unlike `expected`, this has NO fallback to a discovered set: there is nothing on disk that
670
+ // proves which command names a session should surface, and a guessed expectation is how a gate
671
+ // starts passing for the wrong reason. Not asked ⇒ not checked, and the report says so.
672
+ const expectedCommands = evidence.expectedCommands ?? [];
635
673
  const layout = scan.findings;
636
674
 
637
675
  const fail = (
@@ -640,13 +678,15 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
640
678
  extra: Partial<RegistrationResult> = {},
641
679
  ): RegistrationResult => ({
642
680
  expected,
681
+ expectedCommands,
682
+ missingCommands: [],
643
683
  layout,
644
684
  advisories: scan.advisories,
645
685
  plugins: [],
646
686
  clientVersion: null,
647
687
  missing: [],
648
688
  registeredCount: null,
649
- controls: { cwdMatched: null, skillsListPresent: false },
689
+ controls: { cwdMatched: null, skillsListPresent: false, commandsListPresent: false },
650
690
  ...extra,
651
691
  verdict,
652
692
  reason,
@@ -713,7 +753,7 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
713
753
  return fail('inconclusive', `session read ${facts.cwd}, not ${projectDir} — its listing does not describe this project`, {
714
754
  ...withFacts,
715
755
  registeredCount: facts.skills?.length ?? null,
716
- controls: { cwdMatched: false, skillsListPresent: facts.skills !== null },
756
+ controls: { cwdMatched: false, skillsListPresent: facts.skills !== null, commandsListPresent: facts.slashCommands !== null },
717
757
  });
718
758
  }
719
759
 
@@ -721,14 +761,29 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
721
761
  if (facts.skills === null) {
722
762
  return fail('inconclusive', 'the init listing has no readable `skills` array (absent or not all strings) — cannot read it', {
723
763
  ...withFacts,
724
- controls: { cwdMatched: true, skillsListPresent: false },
764
+ controls: { cwdMatched: true, skillsListPresent: false, commandsListPresent: facts.slashCommands !== null },
725
765
  });
726
766
  }
727
767
 
728
- const controls: RegistrationControls = { cwdMatched: true, skillsListPresent: true };
768
+ // 5b. …and so must the COMMAND listing, whenever commands were expected. An absent
769
+ // `slash_commands` key is schema drift, exactly like an absent `skills` key: treating it as
770
+ // `[]` would report "your five commands did not register" on a Claude Code build that simply
771
+ // stopped emitting the key, and treating it as "fine" would pass a plugin whose commands
772
+ // really are missing. Neither is observable ⇒ inconclusive.
773
+ if (expectedCommands.length > 0 && facts.slashCommands === null) {
774
+ return fail(
775
+ 'inconclusive',
776
+ 'the init listing has no readable `slash_commands` array (absent or not all strings) — command registration cannot be observed',
777
+ { ...withFacts, controls: { cwdMatched: true, skillsListPresent: true, commandsListPresent: false } },
778
+ );
779
+ }
780
+
781
+ const controls: RegistrationControls = { cwdMatched: true, skillsListPresent: true, commandsListPresent: facts.slashCommands !== null };
729
782
  const registered = new Set(facts.skills);
730
783
  const missing = expected.filter((name) => !registered.has(name));
731
- const common = { ...withFacts, registeredCount: facts.skills.length, controls };
784
+ const registeredCommands = new Set(facts.slashCommands ?? []);
785
+ const missingCommands = expectedCommands.filter((name) => !registeredCommands.has(name));
786
+ const common = { ...withFacts, expectedCommands, missingCommands, registeredCount: facts.skills.length, controls };
732
787
 
733
788
  // 6. Load-blocking layout problems fail on their own — one healthy skill must not mask them.
734
789
  if (layout.length > 0) {
@@ -832,7 +887,12 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
832
887
  }));
833
888
  const allAdvisories = [...scan.advisories, ...containerAdvisories];
834
889
 
835
- if (missing.length > 0) {
890
+ if (missing.length > 0 || missingCommands.length > 0) {
891
+ const parts: string[] = [];
892
+ if (missing.length > 0) parts.push(`${missing.length} of ${expected.length} expected skill(s) did NOT register`);
893
+ if (missingCommands.length > 0) {
894
+ parts.push(`${missingCommands.length} of ${expectedCommands.length} expected command(s) did NOT register (${missingCommands.join(', ')})`);
895
+ }
836
896
  return {
837
897
  expected,
838
898
  layout,
@@ -840,7 +900,7 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
840
900
  ...common,
841
901
  missing,
842
902
  verdict: 'fail',
843
- reason: `${missing.length} of ${expected.length} expected skill(s) did NOT register`,
903
+ reason: parts.join('; '),
844
904
  };
845
905
  }
846
906
 
@@ -852,9 +912,11 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
852
912
  missing: [],
853
913
  verdict: 'pass',
854
914
  reason:
855
- expected.length === 0
915
+ expected.length === 0 && expectedCommands.length === 0
856
916
  ? 'nothing was expected to register; the session listing was read successfully'
857
- : `all ${expected.length} expected skill(s) are registered`,
917
+ : `all ${expected.length} expected skill(s)` +
918
+ (expectedCommands.length > 0 ? ` and all ${expectedCommands.length} expected command(s)` : '') +
919
+ ' are registered',
858
920
  };
859
921
  }
860
922
 
@@ -892,6 +954,13 @@ export function renderRegistrationReport(result: RegistrationResult, scan?: Stat
892
954
  out.push(' MISSING (expected but not registered):');
893
955
  for (const name of result.missing) out.push(` - ${name}`);
894
956
  }
957
+ if (result.expectedCommands.length) {
958
+ out.push(
959
+ ` commands: ${result.expectedCommands.length} expected` +
960
+ (result.controls.commandsListPresent ? '' : ' · slash_commands listing UNREADABLE'),
961
+ );
962
+ for (const name of result.missingCommands) out.push(` - MISSING ${name}`);
963
+ }
895
964
  if (result.layout.length) {
896
965
  out.push(' layout problems (these can never register):');
897
966
  for (const f of result.layout) out.push(` [${f.kind}] ${f.detail}`);
@@ -906,6 +975,35 @@ export function renderRegistrationReport(result: RegistrationResult, scan?: Stat
906
975
  return out.join('\n');
907
976
  }
908
977
 
978
+ /**
979
+ * The names a plugin's OWN manifest says its commands and skills should register under, so a
980
+ * `--plugin-dir` gate run can default its expectation to the manifest instead of to a hand-typed
981
+ * list that drifts from it.
982
+ *
983
+ * The naming rules are MEASURED, not assumed (Claude Code 2.1.233, fixture probe 2026-08-17):
984
+ * · a command registers as `<plugin>:<file basename without .md>` — its frontmatter `name:` does
985
+ * NOT rename it (a fixture declaring `name: renamed-second` registered as `probeplug:second`);
986
+ * · a skill registers as `<plugin>:<directory basename>` — likewise not its frontmatter name.
987
+ *
988
+ * Returns `null` when the manifest is missing/unreadable/nameless: an unreadable manifest must not
989
+ * silently become an EMPTY expectation, which is the shape that passes without checking anything.
990
+ */
991
+ export function declaredPluginSurface(pluginDir: string): { name: string; skills: string[]; commands: string[] } | null {
992
+ let obj: Record<string, unknown>;
993
+ try {
994
+ obj = JSON.parse(readFileSync(join(pluginDir, '.claude-plugin', 'plugin.json'), 'utf8')) as Record<string, unknown>;
995
+ } catch {
996
+ return null;
997
+ }
998
+ const name = typeof obj.name === 'string' && obj.name ? obj.name : null;
999
+ if (name === null) return null;
1000
+ const list = (value: unknown): string[] =>
1001
+ Array.isArray(value) ? value.filter((x): x is string => typeof x === 'string') : [];
1002
+ const skills = list(obj.skills).map((rel) => `${name}:${basename(rel.replace(/\/+$/, ''))}`);
1003
+ const commands = list(obj.commands).map((rel) => `${name}:${basename(rel).replace(/\.md$/i, '')}`);
1004
+ return { name, skills, commands };
1005
+ }
1006
+
909
1007
  // ── The publish-time guard fact ─────────────────────────────────────
910
1008
 
911
1009
  /**