@dzhechkov/harness-core 0.3.129 → 0.3.130
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 +32 -8
- package/README.md +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/skills-verify.d.ts +158 -0
- package/dist/skills-verify.d.ts.map +1 -0
- package/dist/skills-verify.js +552 -0
- package/dist/skills-verify.js.map +1 -0
- package/package.json +6 -6
- package/sbom.json +67 -7
- package/src/index.ts +4 -0
- package/src/skills-verify.ts +710 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dz skills-verify` — does a project's `.claude/skills/` actually REGISTER?
|
|
3
|
+
*
|
|
4
|
+
* Origin (feature skills-verify, ADR-001): `@dzhechkov/health-advisor` 1.2.0 shipped to npm with zero
|
|
5
|
+
* skills registering while its layout test was green — the test asserted a PROXY (files on disk), not
|
|
6
|
+
* the PROPERTY (registration). This module makes the property observable.
|
|
7
|
+
*
|
|
8
|
+
* Two layers:
|
|
9
|
+
* L1 `scanSkillsLayout` — instant, no Claude session: which names CAN register, plus the three
|
|
10
|
+
* layout shapes that produced the 1.2.0 defect.
|
|
11
|
+
* L2 `parseInitFacts` — the authoritative listing, parsed from the `system/init` event of
|
|
12
|
+
* + `classifyRegistration` `claude -p --output-format stream-json --verbose`. No model prose.
|
|
13
|
+
*
|
|
14
|
+
* FAIL-CLOSED: anything that prevents an honest observation yields `inconclusive`, never `pass`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
18
|
+
import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
19
|
+
|
|
20
|
+
// ── Layer 1: static layout scan ─────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** The three non-registrable shapes, all observed in the health-advisor 1.2.0 defect. */
|
|
23
|
+
export type SkillIssueKind =
|
|
24
|
+
| 'no-skill-md' // a skill dir with no SKILL.md at depth 1 → never registers
|
|
25
|
+
| 'buried-skill-md' // a SKILL.md at depth >= 2 → the loader does not scan that deep
|
|
26
|
+
| 'plugin-manifest-trap'; // .claude-plugin/plugin.json under .claude/skills → does NOT auto-register
|
|
27
|
+
|
|
28
|
+
export interface SkillLayoutFinding {
|
|
29
|
+
readonly dir: string;
|
|
30
|
+
readonly kind: SkillIssueKind;
|
|
31
|
+
readonly detail: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StaticScan {
|
|
35
|
+
/** The project this scan describes — evidence from another project must not be believed (QE4 #4). */
|
|
36
|
+
readonly projectDir: string;
|
|
37
|
+
readonly skillsRoot: string;
|
|
38
|
+
readonly exists: boolean;
|
|
39
|
+
/** Names that CAN register: a dir with SKILL.md exactly one level deep. */
|
|
40
|
+
readonly registrable: readonly string[];
|
|
41
|
+
/** Load-blocking problems: these make the verdict FAIL. */
|
|
42
|
+
readonly findings: readonly SkillLayoutFinding[];
|
|
43
|
+
/**
|
|
44
|
+
* Reported but NOT failing. A `.claude-plugin/plugin.json` under `.claude/skills` did not register
|
|
45
|
+
* in measured practice (MEASURED — reproducer: `dz skills-verify` against the published
|
|
46
|
+
* health-advisor 1.2.0, whose skills were absent from a live session listing), but the layout class
|
|
47
|
+
* may be supported under conditions this gate cannot observe (workspace trust). Telling the user is
|
|
48
|
+
* right; failing their build over it is over-claiming.
|
|
49
|
+
*/
|
|
50
|
+
readonly advisories: readonly SkillLayoutFinding[];
|
|
51
|
+
/**
|
|
52
|
+
* Plugin-shaped containers and the skill names they would provide. The layout alone cannot say
|
|
53
|
+
* whether such a container loads (that depends on client support and workspace trust), so the
|
|
54
|
+
* verdict ASKS THE SESSION: if none of a container's candidate names appear in the live listing,
|
|
55
|
+
* it did not register and that is a failure (QE4 #1 — without this, making the container advisory
|
|
56
|
+
* re-opened the vacuous pass and the gate stopped catching health-advisor 1.2.0).
|
|
57
|
+
*/
|
|
58
|
+
readonly containers: readonly { readonly dir: string; readonly candidates: readonly string[] }[];
|
|
59
|
+
/**
|
|
60
|
+
* Set when the scan could not complete (unreadable dir, `.claude/skills` is a file, …). A failed
|
|
61
|
+
* scan must NOT read as "a clean empty project" — the classifier turns this into `inconclusive`
|
|
62
|
+
* (Codex QE #4).
|
|
63
|
+
*/
|
|
64
|
+
readonly scanError?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const MAX_BURIED_DEPTH = 4; // enough to catch extended/<name>/SKILL.md without walking the world
|
|
68
|
+
|
|
69
|
+
function findBuriedSkillMd(dir: string, depth: number, acc: string[], errors: string[]): void {
|
|
70
|
+
if (depth > MAX_BURIED_DEPTH) return;
|
|
71
|
+
let entries: string[];
|
|
72
|
+
try {
|
|
73
|
+
entries = readdirSync(dir);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// A subtree we cannot read is unknown territory, not proven-clean territory (QE2 #1).
|
|
76
|
+
errors.push(`cannot read ${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const name of entries) {
|
|
80
|
+
if (name === 'node_modules' || name === '__pycache__' || name.startsWith('.')) continue;
|
|
81
|
+
const full = join(dir, name);
|
|
82
|
+
let isDir = false;
|
|
83
|
+
try {
|
|
84
|
+
isDir = statSync(full).isDirectory();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
errors.push(`cannot stat ${full}: ${error instanceof Error ? error.message : String(error)}`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (isDir) {
|
|
90
|
+
findBuriedSkillMd(full, depth + 1, acc, errors);
|
|
91
|
+
} else if (name === 'SKILL.md' && depth >= 2) {
|
|
92
|
+
acc.push(full);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasPluginManifest(dir: string): boolean {
|
|
98
|
+
return existsSync(join(dir, '.claude-plugin', 'plugin.json'));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A directory registers only if `SKILL.md` is a regular FILE. `existsSync` also answers true for a
|
|
103
|
+
* DIRECTORY named SKILL.md, which registers nothing yet suppressed every other check (QE2 #4).
|
|
104
|
+
*/
|
|
105
|
+
function hasSkillFile(dir: string): boolean {
|
|
106
|
+
try {
|
|
107
|
+
return statSync(join(dir, 'SKILL.md')).isFile();
|
|
108
|
+
} catch {
|
|
109
|
+
return false; // ENOENT, or a dangling symlink: either way it cannot register
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Walk `<projectDir>/.claude/skills/` and report what can register and what cannot.
|
|
115
|
+
* Deterministic, needs no Claude session — safe for CI.
|
|
116
|
+
*/
|
|
117
|
+
export function scanSkillsLayout(projectDir: string): StaticScan {
|
|
118
|
+
const skillsRoot = join(resolve(projectDir), '.claude', 'skills');
|
|
119
|
+
// `existsSync` also answers false for a DANGLING SYMLINK — that is a broken tree, not an absent
|
|
120
|
+
// one, and must not scan as "clean" (QE2 #1). lstat distinguishes the two.
|
|
121
|
+
let rootLink: ReturnType<typeof lstatSync> | null = null;
|
|
122
|
+
try {
|
|
123
|
+
rootLink = lstatSync(skillsRoot);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
// Only ENOENT means "genuinely absent". ENOTDIR/EACCES mean the tree is BROKEN or unreadable —
|
|
126
|
+
// reporting that as an empty-and-clean project is a route to a false PASS (QE3 #3).
|
|
127
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
128
|
+
if (code !== 'ENOENT') {
|
|
129
|
+
return {
|
|
130
|
+
skillsRoot, projectDir: resolve(projectDir), exists: true, registrable: [], findings: [], advisories: [], containers: [],
|
|
131
|
+
scanError: `cannot stat ${skillsRoot}: ${error instanceof Error ? error.message : String(error)}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// ENOENT on .claude/skills can also mean .claude itself is a FILE or unreadable — check it.
|
|
135
|
+
const claudeDir = join(resolve(projectDir), '.claude');
|
|
136
|
+
try {
|
|
137
|
+
const st = lstatSync(claudeDir);
|
|
138
|
+
// A SYMLINK must be followed: a dangling `.claude` link is a broken tree, not an absent one.
|
|
139
|
+
if (st.isSymbolicLink() && !existsSync(claudeDir)) {
|
|
140
|
+
return {
|
|
141
|
+
skillsRoot, projectDir: resolve(projectDir), exists: true, registrable: [], findings: [], advisories: [], containers: [],
|
|
142
|
+
scanError: `${claudeDir} is a dangling symlink`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (!st.isDirectory() && !st.isSymbolicLink()) {
|
|
146
|
+
return {
|
|
147
|
+
skillsRoot, projectDir: resolve(projectDir), exists: true, registrable: [], findings: [], advisories: [], containers: [],
|
|
148
|
+
scanError: `${claudeDir} is not a directory`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
} catch (claudeError) {
|
|
152
|
+
const cCode = (claudeError as NodeJS.ErrnoException).code;
|
|
153
|
+
if (cCode !== 'ENOENT') {
|
|
154
|
+
return {
|
|
155
|
+
skillsRoot, projectDir: resolve(projectDir), exists: true, registrable: [], findings: [], advisories: [], containers: [],
|
|
156
|
+
scanError: `cannot stat ${claudeDir}: ${claudeError instanceof Error ? claudeError.message : String(claudeError)}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { skillsRoot, projectDir: resolve(projectDir), exists: false, registrable: [], findings: [], advisories: [], containers: [] }; // genuinely absent
|
|
161
|
+
}
|
|
162
|
+
if (rootLink.isSymbolicLink() && !existsSync(skillsRoot)) {
|
|
163
|
+
return {
|
|
164
|
+
skillsRoot,
|
|
165
|
+
projectDir: resolve(projectDir),
|
|
166
|
+
exists: true,
|
|
167
|
+
registrable: [],
|
|
168
|
+
findings: [],
|
|
169
|
+
advisories: [],
|
|
170
|
+
containers: [],
|
|
171
|
+
scanError: `${skillsRoot} is a dangling symlink`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// `.claude/skills` must BE a directory. A file there scans as "empty and clean" otherwise (QE #4).
|
|
176
|
+
try {
|
|
177
|
+
if (!statSync(skillsRoot).isDirectory()) {
|
|
178
|
+
return { skillsRoot, projectDir: resolve(projectDir), exists: true, registrable: [], findings: [], advisories: [], containers: [], scanError: `${skillsRoot} is not a directory` };
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return {
|
|
182
|
+
skillsRoot,
|
|
183
|
+
projectDir: resolve(projectDir),
|
|
184
|
+
exists: true,
|
|
185
|
+
registrable: [],
|
|
186
|
+
findings: [],
|
|
187
|
+
advisories: [],
|
|
188
|
+
containers: [],
|
|
189
|
+
scanError: `cannot stat ${skillsRoot}: ${error instanceof Error ? error.message : String(error)}`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const registrable: string[] = [];
|
|
194
|
+
const findings: SkillLayoutFinding[] = [];
|
|
195
|
+
const advisories: SkillLayoutFinding[] = [];
|
|
196
|
+
const containers: { dir: string; candidates: string[] }[] = [];
|
|
197
|
+
const scanErrors: string[] = [];
|
|
198
|
+
|
|
199
|
+
let entries: string[] = [];
|
|
200
|
+
try {
|
|
201
|
+
entries = readdirSync(skillsRoot);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
// An unreadable root is NOT an empty project — say so, so the verdict can be inconclusive.
|
|
204
|
+
return {
|
|
205
|
+
skillsRoot,
|
|
206
|
+
projectDir: resolve(projectDir),
|
|
207
|
+
exists: true,
|
|
208
|
+
registrable: [],
|
|
209
|
+
findings: [],
|
|
210
|
+
advisories: [],
|
|
211
|
+
containers: [],
|
|
212
|
+
scanError: `cannot read ${skillsRoot}: ${error instanceof Error ? error.message : String(error)}`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const name of entries) {
|
|
217
|
+
if (name.startsWith('.')) continue; // hidden/backup dirs are not skills
|
|
218
|
+
const dir = join(skillsRoot, name);
|
|
219
|
+
let isDir = false;
|
|
220
|
+
try {
|
|
221
|
+
isDir = statSync(dir).isDirectory();
|
|
222
|
+
} catch (error) {
|
|
223
|
+
// An entry we cannot stat is unknown, not absent — record it so the verdict stays honest.
|
|
224
|
+
scanErrors.push(`cannot stat ${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
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
|
+
if (name === 'SKILL.md') {
|
|
231
|
+
findings.push({
|
|
232
|
+
dir: '.',
|
|
233
|
+
kind: 'no-skill-md',
|
|
234
|
+
detail: 'SKILL.md sits directly in .claude/skills/ — a skill must live in its own directory to register',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const registers = hasSkillFile(dir);
|
|
241
|
+
// A manifest-backed CONTAINER (`<dir>/.claude-plugin/plugin.json`) is a plugin-shaped layout.
|
|
242
|
+
// It did not register in measured practice, but the class may be supported under workspace
|
|
243
|
+
// trust — so the WHOLE container is advisory. Emitting the generic `no-skill-md` /
|
|
244
|
+
// `buried-skill-md` findings for it killed the layout anyway, which was the over-claim (QE4 #1).
|
|
245
|
+
const isPluginContainer = !registers && hasPluginManifest(dir);
|
|
246
|
+
const bucket = isPluginContainer ? advisories : findings;
|
|
247
|
+
|
|
248
|
+
if (registers) {
|
|
249
|
+
registrable.push(name);
|
|
250
|
+
} else {
|
|
251
|
+
bucket.push({
|
|
252
|
+
dir: name,
|
|
253
|
+
kind: 'no-skill-md',
|
|
254
|
+
detail: isPluginContainer
|
|
255
|
+
? `${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`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A plugin manifest under .claude/skills does NOT auto-register (plugins load from the
|
|
261
|
+
// marketplace only). This is the exact false premise that shipped health-advisor 1.2.0.
|
|
262
|
+
// Only a report when the dir registers NOTHING: a dir with its own depth-1 SKILL.md registers
|
|
263
|
+
// fine and a stray manifest beside it is inert, not a blocker (Codex QE #8).
|
|
264
|
+
if (isPluginContainer) {
|
|
265
|
+
// What would this container provide? The dir name of every nested SKILL.md.
|
|
266
|
+
const inner: string[] = [];
|
|
267
|
+
findBuriedSkillMd(dir, 1, inner, scanErrors);
|
|
268
|
+
const candidates = [...new Set(inner.map((f) => basename(join(f, '..'))))];
|
|
269
|
+
containers.push({ dir: name, candidates });
|
|
270
|
+
advisories.push({
|
|
271
|
+
dir: name,
|
|
272
|
+
kind: 'plugin-manifest-trap',
|
|
273
|
+
detail: `${name}/.claude-plugin/plugin.json did not register in measured practice (advisory, not a failure) — a plugin normally loads from the marketplace; if you meant these to register, install it as a plugin or use bare skills`,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// SKILL.md files buried too deep never register (health-advisor 1.2.0: extended/<name>/SKILL.md).
|
|
278
|
+
//
|
|
279
|
+
// FALSE-POSITIVE GUARD (found by dogfooding this gate on a healthy project): a dir that DOES
|
|
280
|
+
// register may legitimately bundle nested SKILL.md files as its own resources — health-advisor
|
|
281
|
+
// 1.2.1 co-locates its base deps under the master exactly so they do NOT register. Only a dir
|
|
282
|
+
// that registers NOTHING while hiding SKILL.md files inside is the 1.2.0 defect shape.
|
|
283
|
+
if (!registers) {
|
|
284
|
+
const buried: string[] = [];
|
|
285
|
+
findBuriedSkillMd(dir, 1, buried, scanErrors);
|
|
286
|
+
for (const path of buried) {
|
|
287
|
+
bucket.push({
|
|
288
|
+
dir: name,
|
|
289
|
+
kind: 'buried-skill-md',
|
|
290
|
+
detail: `${path.slice(skillsRoot.length + 1)} is 2+ levels deep in a directory that registers nothing — the loader scans one level, so it never registers`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
skillsRoot,
|
|
298
|
+
projectDir: resolve(projectDir),
|
|
299
|
+
exists: true,
|
|
300
|
+
registrable: registrable.sort(),
|
|
301
|
+
findings,
|
|
302
|
+
advisories,
|
|
303
|
+
containers,
|
|
304
|
+
...(scanErrors.length ? { scanError: scanErrors.join('; ') } : {}),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Layer 2: the authoritative init-event listing ───────────────────
|
|
309
|
+
|
|
310
|
+
export interface InitPlugin {
|
|
311
|
+
readonly name: string;
|
|
312
|
+
readonly version?: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface InitFacts {
|
|
316
|
+
/** Registered skill names. `null` means the key was ABSENT (schema drift) — never "none". */
|
|
317
|
+
readonly skills: readonly string[] | null;
|
|
318
|
+
readonly plugins: readonly InitPlugin[];
|
|
319
|
+
/** The project the session actually read — the built-in control. */
|
|
320
|
+
readonly cwd: string | null;
|
|
321
|
+
readonly clientVersion: string | null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Parse the `system/init` event out of a `--output-format stream-json` stream. PURE: takes the raw
|
|
326
|
+
* text, returns facts, does no IO. Returns `null` when no init event is present (→ inconclusive).
|
|
327
|
+
*/
|
|
328
|
+
/**
|
|
329
|
+
* The first init event's facts. Use ONLY for "has the event arrived yet?" during streaming —
|
|
330
|
+
* `classifyRegistration` deliberately does NOT accept bare facts, because the count must travel
|
|
331
|
+
* with them (a caller that forgot to pass `initEventCount` got a free PASS — QE2 #2).
|
|
332
|
+
*/
|
|
333
|
+
export function parseInitFacts(streamText: string): InitFacts | null {
|
|
334
|
+
return parseAllInitFacts(streamText)[0] ?? null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Every `system/init` event in the stream. More than one means the observations may CONTRADICT each
|
|
341
|
+
* other (Codex QE #7) — the classifier refuses to pick a winner and returns `inconclusive`.
|
|
342
|
+
*/
|
|
343
|
+
export function parseAllInitFacts(streamText: string): InitFacts[] {
|
|
344
|
+
return parseStream(streamText).events;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** What a stream actually contained — facts, cardinality AND parse integrity, produced together. */
|
|
348
|
+
export interface StreamParse {
|
|
349
|
+
readonly events: InitFacts[];
|
|
350
|
+
/** A line that LOOKED like a JSON object but did not parse — possibly a truncated init (QE3 #2). */
|
|
351
|
+
readonly malformedObjectLines: number;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function parseStream(streamText: string): StreamParse {
|
|
355
|
+
const found: InitFacts[] = [];
|
|
356
|
+
let malformed = 0;
|
|
357
|
+
for (const raw of streamText.split('\n')) {
|
|
358
|
+
const line = raw.trim();
|
|
359
|
+
if (!line || line[0] !== '{') continue;
|
|
360
|
+
let obj: Record<string, unknown>;
|
|
361
|
+
try {
|
|
362
|
+
obj = JSON.parse(line) as Record<string, unknown>;
|
|
363
|
+
} catch {
|
|
364
|
+
// A truncated `{"type":"system","subtype":"init",…` silently disappeared here, so a
|
|
365
|
+
// contradictory second event could be dropped and the first one believed (QE3 #2).
|
|
366
|
+
malformed += 1;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (obj.type !== 'system' || obj.subtype !== 'init') continue;
|
|
370
|
+
|
|
371
|
+
// The listing is authoritative or it is nothing: a partially-unparseable `skills` array (a
|
|
372
|
+
// non-string element) must NOT be silently narrowed to the strings it happens to contain —
|
|
373
|
+
// that turned an unreadable listing into a PASS (Codex QE #1). ABSENT key ≠ empty list (ADR SP-3).
|
|
374
|
+
const rawSkills = obj.skills;
|
|
375
|
+
const skills = Array.isArray(rawSkills)
|
|
376
|
+
? rawSkills.every((s) => typeof s === 'string')
|
|
377
|
+
? (rawSkills as string[])
|
|
378
|
+
: null
|
|
379
|
+
: null;
|
|
380
|
+
|
|
381
|
+
const rawPlugins = obj.plugins;
|
|
382
|
+
const plugins: InitPlugin[] = Array.isArray(rawPlugins)
|
|
383
|
+
? rawPlugins
|
|
384
|
+
.filter((p): p is Record<string, unknown> => !!p && typeof p === 'object')
|
|
385
|
+
.map((p) => {
|
|
386
|
+
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 };
|
|
389
|
+
})
|
|
390
|
+
: [];
|
|
391
|
+
|
|
392
|
+
found.push({
|
|
393
|
+
skills,
|
|
394
|
+
plugins,
|
|
395
|
+
// An empty or relative cwd testifies to nothing — `resolve("")` silently becomes the caller's
|
|
396
|
+
// own cwd and can forge a match (Codex QE #6). Only an absolute path counts as evidence.
|
|
397
|
+
cwd: typeof obj.cwd === 'string' && obj.cwd !== '' && isAbsolute(obj.cwd) ? obj.cwd : null,
|
|
398
|
+
clientVersion: typeof obj.claude_code_version === 'string' ? obj.claude_code_version : null,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
return { events: found, malformedObjectLines: malformed };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Verdict ─────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
export type RegistrationVerdict = 'pass' | 'fail' | 'inconclusive';
|
|
407
|
+
|
|
408
|
+
export interface RegistrationControls {
|
|
409
|
+
/** Did the session read the target project? `null` when unknown (no init / no cwd). */
|
|
410
|
+
readonly cwdMatched: boolean | null;
|
|
411
|
+
/** Was the `skills` key present at all? A missing key is schema drift, not "nothing registered". */
|
|
412
|
+
readonly skillsListPresent: boolean;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export interface RegistrationResult {
|
|
416
|
+
readonly verdict: RegistrationVerdict;
|
|
417
|
+
readonly reason: string;
|
|
418
|
+
readonly expected: readonly string[];
|
|
419
|
+
readonly missing: readonly string[];
|
|
420
|
+
readonly registeredCount: number | null;
|
|
421
|
+
readonly clientVersion: string | null;
|
|
422
|
+
readonly plugins: readonly InitPlugin[];
|
|
423
|
+
readonly controls: RegistrationControls;
|
|
424
|
+
readonly layout: readonly SkillLayoutFinding[];
|
|
425
|
+
/** Reported, never fatal — see StaticScan.advisories. Rendering them is the policy (QE4 #5). */
|
|
426
|
+
readonly advisories: readonly SkillLayoutFinding[];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The complete evidence bundle. Every piece is REQUIRED and travels together: the previous shape let
|
|
431
|
+
* a caller omit the scan error, the layout, the provenance check or the event count and get a free
|
|
432
|
+
* PASS (Codex QE3 #1/#2/#4/#5). There is exactly one door into the verdict, and it is this one.
|
|
433
|
+
*/
|
|
434
|
+
export interface RegistrationEvidence {
|
|
435
|
+
readonly projectDir: string;
|
|
436
|
+
/** The WHOLE static scan (carries its own scanError + findings) — not hand-picked fields. */
|
|
437
|
+
readonly scan: StaticScan;
|
|
438
|
+
/** The probe outcome: either the raw stream, or the reason there is none. */
|
|
439
|
+
readonly probe: { readonly ok: true; readonly stream: string } | { readonly ok: false; readonly error: string };
|
|
440
|
+
/**
|
|
441
|
+
* Did we actually look for same-named skills OUTSIDE this project, and what did we find?
|
|
442
|
+
* `checked: false` ⇒ inconclusive: unperformed provenance discovery is not evidence of uniqueness.
|
|
443
|
+
*/
|
|
444
|
+
readonly provenance: { readonly checked: boolean; readonly ambiguous: readonly string[] };
|
|
445
|
+
/** Explicit `--expect` list; when absent the expectation is the scan's registrable set. */
|
|
446
|
+
readonly expected?: readonly string[];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export interface SkillsVerifyOptions {
|
|
450
|
+
/** Must CANONICALISE (realpath) so symlinked project paths compare equal; may throw. */
|
|
451
|
+
readonly resolvePath?: (p: string) => string;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The single entry point. PURE apart from the injected resolver. Fail-closed by construction: `pass`
|
|
456
|
+
* is reachable only from a completed scan, a probe that produced EXACTLY ONE well-formed init event
|
|
457
|
+
* from THIS project, a fully-readable skills listing, a performed provenance check with no ambiguity,
|
|
458
|
+
* and zero load-blocking layout findings.
|
|
459
|
+
*/
|
|
460
|
+
export function verifyRegistration(evidence: RegistrationEvidence, options: SkillsVerifyOptions = {}): RegistrationResult {
|
|
461
|
+
// A JavaScript caller can hand us an incomplete object; a crash is not a verdict (QE4 #7).
|
|
462
|
+
const bad = (reason: string): RegistrationResult => ({
|
|
463
|
+
verdict: 'inconclusive',
|
|
464
|
+
reason,
|
|
465
|
+
expected: [],
|
|
466
|
+
missing: [],
|
|
467
|
+
registeredCount: null,
|
|
468
|
+
clientVersion: null,
|
|
469
|
+
plugins: [],
|
|
470
|
+
advisories: [],
|
|
471
|
+
controls: { cwdMatched: null, skillsListPresent: false },
|
|
472
|
+
layout: [],
|
|
473
|
+
});
|
|
474
|
+
if (!evidence || typeof evidence !== 'object') return bad('no evidence supplied');
|
|
475
|
+
if (!evidence.scan || !evidence.probe || !evidence.provenance || typeof evidence.projectDir !== 'string') {
|
|
476
|
+
return bad('incomplete evidence (scan, probe, provenance and projectDir are all required)');
|
|
477
|
+
}
|
|
478
|
+
const { projectDir, scan, probe, provenance } = evidence;
|
|
479
|
+
// The contract is CANONICAL identity: a lexical `resolve` alone reports a symlinked project as a
|
|
480
|
+
// different one and turns a correct registration into `inconclusive` (QE4 #3).
|
|
481
|
+
const resolvePath =
|
|
482
|
+
options.resolvePath ??
|
|
483
|
+
((p: string) => {
|
|
484
|
+
try {
|
|
485
|
+
return realpathSync(p);
|
|
486
|
+
} catch {
|
|
487
|
+
return resolve(p);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
const expected = evidence.expected ?? scan.registrable;
|
|
491
|
+
const layout = scan.findings;
|
|
492
|
+
|
|
493
|
+
const fail = (
|
|
494
|
+
verdict: RegistrationVerdict,
|
|
495
|
+
reason: string,
|
|
496
|
+
extra: Partial<RegistrationResult> = {},
|
|
497
|
+
): RegistrationResult => ({
|
|
498
|
+
expected,
|
|
499
|
+
layout,
|
|
500
|
+
advisories: scan.advisories,
|
|
501
|
+
plugins: [],
|
|
502
|
+
clientVersion: null,
|
|
503
|
+
missing: [],
|
|
504
|
+
registeredCount: null,
|
|
505
|
+
controls: { cwdMatched: null, skillsListPresent: false },
|
|
506
|
+
...extra,
|
|
507
|
+
verdict,
|
|
508
|
+
reason,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// 1. The scan must describe THIS project, and must have completed.
|
|
512
|
+
let scanIsOurs: boolean;
|
|
513
|
+
try {
|
|
514
|
+
scanIsOurs = resolvePath(scan.projectDir) === resolvePath(projectDir);
|
|
515
|
+
} catch {
|
|
516
|
+
scanIsOurs = scan.projectDir === projectDir;
|
|
517
|
+
}
|
|
518
|
+
if (!scanIsOurs) {
|
|
519
|
+
return fail('inconclusive', `the layout scan describes ${scan.projectDir}, not ${projectDir} — evidence from another project`);
|
|
520
|
+
}
|
|
521
|
+
if (scan.scanError !== undefined) return fail('inconclusive', `layout scan failed: ${scan.scanError}`);
|
|
522
|
+
|
|
523
|
+
// 2. The probe must have produced a stream.
|
|
524
|
+
if (!probe.ok) return fail('inconclusive', `probe failed: ${probe.error}`);
|
|
525
|
+
|
|
526
|
+
// 3. Parsing must be intact and the evidence must be singular.
|
|
527
|
+
const parsed = parseStream(probe.stream);
|
|
528
|
+
if (parsed.malformedObjectLines > 0) {
|
|
529
|
+
return fail(
|
|
530
|
+
'inconclusive',
|
|
531
|
+
`${parsed.malformedObjectLines} malformed JSON line(s) in the stream — a truncated event may be hiding contradictory evidence`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
if (parsed.events.length !== 1) {
|
|
535
|
+
return fail(
|
|
536
|
+
'inconclusive',
|
|
537
|
+
parsed.events.length === 0
|
|
538
|
+
? 'no system/init event in the stream — could not observe registration'
|
|
539
|
+
: `${parsed.events.length} init events in one stream — contradictory observations, refusing to pick one`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
const facts = parsed.events[0]!;
|
|
543
|
+
const withFacts = { plugins: facts.plugins, clientVersion: facts.clientVersion };
|
|
544
|
+
|
|
545
|
+
// 4. The cwd control: the session must have read THIS project.
|
|
546
|
+
if (facts.cwd === null) {
|
|
547
|
+
return fail('inconclusive', 'init event carried no usable absolute cwd — cannot confirm the session read this project', withFacts);
|
|
548
|
+
}
|
|
549
|
+
let cwdMatched: boolean;
|
|
550
|
+
try {
|
|
551
|
+
cwdMatched = resolvePath(facts.cwd) === resolvePath(projectDir);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
return fail(
|
|
554
|
+
'inconclusive',
|
|
555
|
+
`cannot resolve the paths to compare (${error instanceof Error ? error.message : String(error)}) — the cwd control could not run`,
|
|
556
|
+
withFacts,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
if (!cwdMatched) {
|
|
560
|
+
return fail('inconclusive', `session read ${facts.cwd}, not ${projectDir} — its listing does not describe this project`, {
|
|
561
|
+
...withFacts,
|
|
562
|
+
registeredCount: facts.skills?.length ?? null,
|
|
563
|
+
controls: { cwdMatched: false, skillsListPresent: facts.skills !== null },
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 5. The listing must be readable in full.
|
|
568
|
+
if (facts.skills === null) {
|
|
569
|
+
return fail('inconclusive', 'the init listing has no readable `skills` array (absent or not all strings) — cannot read it', {
|
|
570
|
+
...withFacts,
|
|
571
|
+
controls: { cwdMatched: true, skillsListPresent: false },
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const controls: RegistrationControls = { cwdMatched: true, skillsListPresent: true };
|
|
576
|
+
const registered = new Set(facts.skills);
|
|
577
|
+
const missing = expected.filter((name) => !registered.has(name));
|
|
578
|
+
const common = { ...withFacts, registeredCount: facts.skills.length, controls };
|
|
579
|
+
|
|
580
|
+
// 6. Load-blocking layout problems fail on their own — one healthy skill must not mask them.
|
|
581
|
+
if (layout.length > 0) {
|
|
582
|
+
return {
|
|
583
|
+
expected,
|
|
584
|
+
layout,
|
|
585
|
+
advisories: scan.advisories,
|
|
586
|
+
...common,
|
|
587
|
+
missing,
|
|
588
|
+
verdict: 'fail',
|
|
589
|
+
reason:
|
|
590
|
+
expected.length === 0
|
|
591
|
+
? `nothing can register: ${layout.length} layout problem(s) and no registrable skill directory`
|
|
592
|
+
: `${layout.length} layout problem(s) can never register (alongside ${expected.length} expected skill(s))`,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
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) {
|
|
607
|
+
return {
|
|
608
|
+
expected,
|
|
609
|
+
layout,
|
|
610
|
+
advisories: scan.advisories,
|
|
611
|
+
...common,
|
|
612
|
+
missing,
|
|
613
|
+
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('; '),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// 7. Provenance must have been CHECKED, and must be unambiguous.
|
|
623
|
+
if (!provenance.checked) {
|
|
624
|
+
return fail('inconclusive', 'provenance was not checked — a same-named skill outside this project would forge a pass', {
|
|
625
|
+
...common,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const ambiguousExpected = expected.filter((name) => provenance.ambiguous.includes(name) && registered.has(name));
|
|
629
|
+
if (ambiguousExpected.length > 0 && missing.length === 0) {
|
|
630
|
+
return fail(
|
|
631
|
+
'inconclusive',
|
|
632
|
+
`${ambiguousExpected.length} expected name(s) also exist outside this project (${ambiguousExpected.join(', ')}) — ` +
|
|
633
|
+
'the listing carries names, not provenance, so registration cannot be attributed to this project',
|
|
634
|
+
{ ...common },
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (missing.length > 0) {
|
|
639
|
+
return {
|
|
640
|
+
expected,
|
|
641
|
+
layout,
|
|
642
|
+
advisories: scan.advisories,
|
|
643
|
+
...common,
|
|
644
|
+
missing,
|
|
645
|
+
verdict: 'fail',
|
|
646
|
+
reason: `${missing.length} of ${expected.length} expected skill(s) did NOT register`,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return {
|
|
651
|
+
expected,
|
|
652
|
+
layout,
|
|
653
|
+
advisories: scan.advisories,
|
|
654
|
+
...common,
|
|
655
|
+
missing: [],
|
|
656
|
+
verdict: 'pass',
|
|
657
|
+
reason:
|
|
658
|
+
expected.length === 0
|
|
659
|
+
? 'nothing was expected to register; the session listing was read successfully'
|
|
660
|
+
: `all ${expected.length} expected skill(s) are registered`,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Exit code contract: pass=0, fail=1, inconclusive=2 (distinct so CI can treat it separately). */
|
|
665
|
+
export function registrationExitCode(verdict: RegistrationVerdict, strict = false): number {
|
|
666
|
+
if (verdict === 'pass') return 0;
|
|
667
|
+
if (verdict === 'fail') return 1;
|
|
668
|
+
return strict ? 1 : 2;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const VERDICT_LABEL: Record<RegistrationVerdict, string> = {
|
|
672
|
+
pass: 'PASS',
|
|
673
|
+
fail: 'FAIL',
|
|
674
|
+
inconclusive: 'INCONCLUSIVE',
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
export function renderRegistrationReport(result: RegistrationResult, scan?: StaticScan): string {
|
|
678
|
+
const out: string[] = [];
|
|
679
|
+
out.push(`dz skills-verify: ${VERDICT_LABEL[result.verdict]} — ${result.reason}`);
|
|
680
|
+
|
|
681
|
+
if (scan) {
|
|
682
|
+
out.push(
|
|
683
|
+
` layout: ${scan.registrable.length} registrable skill dir(s) under ${scan.skillsRoot}` +
|
|
684
|
+
(scan.exists ? '' : ' (missing)'),
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
if (result.registeredCount !== null) {
|
|
688
|
+
out.push(
|
|
689
|
+
` session: ${result.registeredCount} skill(s) registered` +
|
|
690
|
+
(result.clientVersion ? ` · client ${result.clientVersion}` : '') +
|
|
691
|
+
(result.plugins.length ? ` · ${result.plugins.length} plugin(s) loaded` : ''),
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
if (result.missing.length) {
|
|
695
|
+
out.push(' MISSING (expected but not registered):');
|
|
696
|
+
for (const name of result.missing) out.push(` - ${name}`);
|
|
697
|
+
}
|
|
698
|
+
if (result.layout.length) {
|
|
699
|
+
out.push(' layout problems (these can never register):');
|
|
700
|
+
for (const f of result.layout) out.push(` [${f.kind}] ${f.detail}`);
|
|
701
|
+
}
|
|
702
|
+
if (result.advisories.length) {
|
|
703
|
+
out.push(' advisories (reported, not failures):');
|
|
704
|
+
for (const f of result.advisories) out.push(` [${f.kind}] ${f.detail}`);
|
|
705
|
+
}
|
|
706
|
+
if (result.verdict === 'inconclusive') {
|
|
707
|
+
out.push(' (inconclusive is never a pass — it means registration could not be observed honestly)');
|
|
708
|
+
}
|
|
709
|
+
return out.join('\n');
|
|
710
|
+
}
|