@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.
- package/.dz-manifest.json +104 -28
- package/README.md +15 -4
- package/dist/backlog.d.ts +35 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +167 -3
- package/dist/backlog.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +1 -0
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-lint.d.ts +6 -2
- package/dist/loop-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +54 -0
- package/dist/loop-lint.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +49 -0
- package/dist/loop-plan-graph.d.ts.map +1 -0
- package/dist/loop-plan-graph.js +128 -0
- package/dist/loop-plan-graph.js.map +1 -0
- package/dist/loop-plan.d.ts +17 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +18 -15
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +8 -0
- package/dist/loop-render.js.map +1 -1
- package/dist/model-recommender.d.ts +91 -0
- package/dist/model-recommender.d.ts.map +1 -0
- package/dist/model-recommender.js +186 -0
- package/dist/model-recommender.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -1
- package/dist/registry.js.map +1 -1
- package/dist/skills-verify.d.ts +40 -0
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +80 -10
- package/dist/skills-verify.js.map +1 -1
- package/dist/trace-bundle.d.ts +209 -0
- package/dist/trace-bundle.d.ts.map +1 -0
- package/dist/trace-bundle.js +601 -0
- package/dist/trace-bundle.js.map +1 -0
- package/dist/usage.d.ts +7 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +30 -2
- package/dist/usage.js.map +1 -1
- package/package.json +18 -18
- package/sbom.json +217 -27
- package/src/backlog.ts +176 -3
- package/src/index.ts +3 -0
- package/src/loop-blobs.generated.ts +1 -0
- package/src/loop-lint.ts +55 -1
- package/src/loop-plan-graph.ts +132 -0
- package/src/loop-plan.ts +35 -15
- package/src/loop-render.ts +8 -0
- package/src/model-recommender.ts +228 -0
- package/src/registry.ts +4 -1
- package/src/skills-verify.ts +108 -10
- package/src/trace-bundle.ts +743 -0
- package/src/usage.ts +42 -2
- 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
|
package/src/skills-verify.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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)
|
|
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
|
/**
|