skill-probe 0.4.0 → 0.6.0
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/README.md +82 -4
- package/dist/cli.js +50 -2
- package/dist/context.d.ts +48 -0
- package/dist/context.js +112 -0
- package/dist/doctor.d.ts +23 -0
- package/dist/doctor.js +138 -0
- package/dist/report.d.ts +7 -0
- package/dist/report.js +58 -1
- package/package.json +33 -8
package/README.md
CHANGED
|
@@ -28,6 +28,35 @@ Complements static linters like `skill-audit` (security/quality) — this is **b
|
|
|
28
28
|
npm i -g skill-probe # or: npx skill-probe
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
## Start here (`skill-probe doctor`)
|
|
32
|
+
|
|
33
|
+
Run this first — it catches the setup problems (no skills dir, runtime not installed or not
|
|
34
|
+
authenticated, a config typo) before you spend any probes:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
skill-probe doctor --cwd . # check the project + runtime
|
|
38
|
+
skill-probe doctor --config probe.config.json # also sanity-check a config
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
skill-probe doctor
|
|
43
|
+
|
|
44
|
+
PASS Node 22.18.0
|
|
45
|
+
PASS config parsed: probe.config.json
|
|
46
|
+
PASS found .claude/skills/ with 4 skills
|
|
47
|
+
PASS expected skills all exist
|
|
48
|
+
PASS claude CLI found
|
|
49
|
+
PASS claude-code probe succeeded (CLI is authenticated)
|
|
50
|
+
WARN threshold 90% with k=10 cannot certify a pass — need ~k=35
|
|
51
|
+
FAIL skill "greeter" missing description:
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It checks: Node version, `.claude/skills/` exists, every skill has a `SKILL.md` with `name:` +
|
|
55
|
+
`description:`, names match folders, the config parses, every `expected` skill exists, the
|
|
56
|
+
threshold/k are statistically achievable, the runtime CLI is installed, and a harmless live probe
|
|
57
|
+
authenticates. Exit `0` healthy · `1` warnings only · `2` hard failures. Use `--skip-probe` to skip
|
|
58
|
+
the live (costing) auth check.
|
|
59
|
+
|
|
31
60
|
## Generate a config (`skill-probe gen`)
|
|
32
61
|
|
|
33
62
|
Don't want to hand-write the cases? Draft them from your skills, then review:
|
|
@@ -108,6 +137,52 @@ Claude Code vs OpenCode comparison is fair — otherwise you're comparing two co
|
|
|
108
137
|
case is inconclusive the report prints the exact k for your threshold. Don't set `threshold
|
|
109
138
|
0.9, k 10` and expect a pass — that's statistically impossible and the tool will say so.
|
|
110
139
|
|
|
140
|
+
## CI usage
|
|
141
|
+
|
|
142
|
+
skill-probe exits non-zero on a real problem, so it drops straight into a pipeline:
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
# .github/workflows/skills.yml (the claude/opencode CLI must be installed + authed on the runner)
|
|
146
|
+
- run: npx skill-probe --config probe.config.json --quiet --no-cost
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Exit codes: **`0`** all pass · **`1`** a behavioral fail / trigger-theft / interference · **`2`**
|
|
150
|
+
inconclusive or an infrastructure error — so a runtime outage *fails* the build instead of silently
|
|
151
|
+
passing. Add `--json` to archive the full result as a build artifact.
|
|
152
|
+
|
|
153
|
+
## Activation rate by context (`skill-probe context`)
|
|
154
|
+
|
|
155
|
+
The audit measures every skill **co-loaded** — the real, hard condition. But a skill can fire
|
|
156
|
+
*fine on its own* and only fail *under load*, when the rest of your library is competing for the
|
|
157
|
+
same trigger. `context` exposes exactly that:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
skill-probe context --config probe.config.json
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
For each case it measures the expected skill **in isolation** (a throwaway project with only that
|
|
164
|
+
one skill) **and co-loaded** (your full library), then tests the drop with **Fisher's exact test**
|
|
165
|
+
— so interference is reported as a real effect with a p-value, not eyeballed:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
skill-probe context — runtime: claude-code model: (runtime default) threshold: 70% library: 4 skills
|
|
169
|
+
isolation = only that skill loaded · co-loaded = the full library (4) loaded
|
|
170
|
+
|
|
171
|
+
[⚠ INTERFERENCE] greeter | write a birthday greeting
|
|
172
|
+
isolation 100% [72%, 100%] k=10
|
|
173
|
+
co-loaded 30% [11%, 60%] k=10
|
|
174
|
+
Δ -70% under load Fisher p=0.003
|
|
175
|
+
↳ fires reliably alone but is suppressed when the library is co-loaded (stolen by: welcomer)
|
|
176
|
+
|
|
177
|
+
Result: 1 interference / 3 measured | exit 1
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
This is the "fires in isolation, fails under load X" case the aggregate rate hides. Decoy cases
|
|
181
|
+
(`expected: null`) are skipped — there's no single skill to isolate; an `expected` skill that isn't
|
|
182
|
+
in the library is a config error (typo), surfaced before any probes run. Exit `1` if any skill
|
|
183
|
+
shows interference, `2` if a case is untrustworthy (infra errors dominated — never a silent pass),
|
|
184
|
+
else `0`.
|
|
185
|
+
|
|
111
186
|
## Fix a failing skill (`skill-probe fix`)
|
|
112
187
|
|
|
113
188
|
Rewrite a skill's trigger description and **prove the lift is real** before keeping it:
|
|
@@ -137,17 +212,20 @@ doesn't measurably help is reverted.
|
|
|
137
212
|
|
|
138
213
|
## Status
|
|
139
214
|
|
|
140
|
-
Early (v0.
|
|
215
|
+
Early (v0.5). **Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping +
|
|
141
216
|
four-state verdict (pass / fail / inconclusive / **error**), across two runtimes (Claude Code,
|
|
142
217
|
OpenCode). Infrastructure failures (timeout / auth / crash / empty output) are reported as
|
|
143
218
|
`error`, never as a behavioral pass/fail — a decoy can't falsely pass because the runtime was down.
|
|
144
219
|
|
|
220
|
+
**Context (`skill-probe context`):** isolation-vs-co-loaded activation rates, with **Fisher's exact
|
|
221
|
+
test** on the drop — catches skills that fire alone but are suppressed under the full library's load.
|
|
222
|
+
|
|
145
223
|
**Fix (`skill-probe fix`):** uses the **Bayesian Beta-Binomial** to gate description rewrites on a
|
|
146
224
|
*proven* lift (interleaved before/after, applied only if P(improvement) clears the bar).
|
|
147
225
|
|
|
148
|
-
**Implemented + unit-tested but NOT yet used by any command:**
|
|
149
|
-
|
|
150
|
-
|
|
226
|
+
**Implemented + unit-tested but NOT yet used by any command:** Benjamini-Hochberg FDR
|
|
227
|
+
(`src/stats.ts`) — reserved for cross-case multiplicity control (a planned enhancement). Don't read
|
|
228
|
+
its presence as the audit using it. (Fisher's exact test is now wired into `context`.)
|
|
151
229
|
|
|
152
230
|
## Dev
|
|
153
231
|
|
package/dist/cli.js
CHANGED
|
@@ -7,13 +7,17 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
8
|
import { runAudit } from "./eval.js";
|
|
9
9
|
import { runFix } from "./fix.js";
|
|
10
|
+
import { runContextAudit } from "./context.js";
|
|
11
|
+
import { runDoctor } from "./doctor.js";
|
|
10
12
|
import { listSkills, generateCases, genConfig } from "./gen.js";
|
|
11
13
|
import { loadConfig, parseProbability, parseIntFlag, ConfigError } from "./config.js";
|
|
12
|
-
import { renderTable, renderMarkdown, renderJson, renderFix } from "./report.js";
|
|
14
|
+
import { renderTable, renderMarkdown, renderJson, renderFix, renderContext, renderDoctor } from "./report.js";
|
|
13
15
|
const HELP = `skill-probe — audit a co-loaded agent skill library by real activation behavior.
|
|
14
16
|
|
|
15
17
|
USAGE
|
|
16
18
|
skill-probe --config <file.json> [options] # audit
|
|
19
|
+
skill-probe context --config <file.json> [options] # isolation vs co-loaded activation rates
|
|
20
|
+
skill-probe doctor [--cwd <dir>] [--config <file.json>] # preflight: setup/auth/config sanity
|
|
17
21
|
skill-probe gen [--cwd <dir>] > probe.config.json # draft a test config from your skills
|
|
18
22
|
skill-probe fix --config <file.json> --skill <name> [opts] # rewrite a description, prove the lift
|
|
19
23
|
|
|
@@ -30,6 +34,10 @@ OPTIONS
|
|
|
30
34
|
--markdown emit a Markdown table (paste into a PR/README)
|
|
31
35
|
--no-cost hide the cost line (e.g. when on a Claude subscription)
|
|
32
36
|
--quiet suppress the live progress line on stderr
|
|
37
|
+
doctor only:
|
|
38
|
+
--cwd <dir> project dir containing .claude/skills/ (default: .)
|
|
39
|
+
--config <file> also sanity-check a config (expected skills exist, k/threshold feasible)
|
|
40
|
+
--skip-probe don't run the harmless live probe (skip the auth check; no cost)
|
|
33
41
|
gen only (uses ANTHROPIC_API_KEY if set, else the logged-in 'claude' CLI):
|
|
34
42
|
--cwd <dir> project dir containing .claude/skills/ (default: .)
|
|
35
43
|
--per-skill <n> should-fire prompts to draft per skill (default 3)
|
|
@@ -59,6 +67,17 @@ function clearProgress() {
|
|
|
59
67
|
progressLen = 0;
|
|
60
68
|
}
|
|
61
69
|
}
|
|
70
|
+
function makeContextProgress(values) {
|
|
71
|
+
if (values["json"] || values["quiet"] || !process.stderr.isTTY)
|
|
72
|
+
return undefined;
|
|
73
|
+
return (e) => {
|
|
74
|
+
const p = e.prompt.length > 28 ? e.prompt.slice(0, 25) + "…" : e.prompt;
|
|
75
|
+
const line = ` [${e.caseIndex}/${e.caseTotal}] ${e.condition.padEnd(9)} "${p}" — run ${e.validN}/${e.maxK}`;
|
|
76
|
+
const pad = Math.max(0, progressLen - line.length);
|
|
77
|
+
process.stderr.write("\r" + line + " ".repeat(pad));
|
|
78
|
+
progressLen = line.length;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
62
81
|
async function audit(values) {
|
|
63
82
|
const cfg = loadConfig(values["config"], {
|
|
64
83
|
runtime: values["runtime"], model: values["model"],
|
|
@@ -74,6 +93,29 @@ async function audit(values) {
|
|
|
74
93
|
console.log(text);
|
|
75
94
|
return result.exitCode;
|
|
76
95
|
}
|
|
96
|
+
async function doctor(values) {
|
|
97
|
+
const result = await runDoctor({
|
|
98
|
+
cwd: values["cwd"] ?? ".",
|
|
99
|
+
...(values["config"] ? { configPath: values["config"] } : {}),
|
|
100
|
+
...(values["runtime"] ? { runtime: values["runtime"] } : {}),
|
|
101
|
+
...(values["model"] ? { model: values["model"] } : {}),
|
|
102
|
+
...(values["skip-probe"] ? { skipProbe: true } : {}),
|
|
103
|
+
});
|
|
104
|
+
console.log(values["json"] ? renderJson(result) : renderDoctor(result));
|
|
105
|
+
return result.exitCode;
|
|
106
|
+
}
|
|
107
|
+
async function context(values) {
|
|
108
|
+
const cfg = loadConfig(values["config"], {
|
|
109
|
+
runtime: values["runtime"], model: values["model"],
|
|
110
|
+
k: values["k"], threshold: values["threshold"],
|
|
111
|
+
conf: values["conf"],
|
|
112
|
+
});
|
|
113
|
+
const result = await runContextAudit(cfg, makeContextProgress(values));
|
|
114
|
+
clearProgress();
|
|
115
|
+
const showCost = !values["no-cost"];
|
|
116
|
+
console.log(values["json"] ? renderJson(result) : renderContext(result, { showCost }));
|
|
117
|
+
return result.exitCode;
|
|
118
|
+
}
|
|
77
119
|
async function gen(values) {
|
|
78
120
|
const cwd = values["cwd"] ?? ".";
|
|
79
121
|
const perSkill = parseIntFlag(values["per-skill"], "--per-skill", 3, 1);
|
|
@@ -122,6 +164,7 @@ async function main() {
|
|
|
122
164
|
cwd: { type: "string" },
|
|
123
165
|
"per-skill": { type: "string" },
|
|
124
166
|
decoys: { type: "string" },
|
|
167
|
+
"skip-probe": { type: "boolean", default: false },
|
|
125
168
|
json: { type: "boolean", default: false },
|
|
126
169
|
markdown: { type: "boolean", default: false },
|
|
127
170
|
"no-cost": { type: "boolean", default: false },
|
|
@@ -138,12 +181,17 @@ async function main() {
|
|
|
138
181
|
if (cmd === "gen") {
|
|
139
182
|
code = await gen(values);
|
|
140
183
|
}
|
|
184
|
+
else if (cmd === "doctor") {
|
|
185
|
+
code = await doctor(values); // config is optional for doctor
|
|
186
|
+
}
|
|
141
187
|
else {
|
|
142
188
|
if (!values.config) {
|
|
143
189
|
console.log(HELP);
|
|
144
190
|
process.exit(2);
|
|
145
191
|
}
|
|
146
|
-
code = cmd === "fix" ? await fix(values)
|
|
192
|
+
code = cmd === "fix" ? await fix(values)
|
|
193
|
+
: cmd === "context" ? await context(values)
|
|
194
|
+
: await audit(values);
|
|
147
195
|
}
|
|
148
196
|
process.exit(code);
|
|
149
197
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Config } from "./types.ts";
|
|
2
|
+
import { reliability } from "./stats.ts";
|
|
3
|
+
import { type RunStats } from "./orchestrator.ts";
|
|
4
|
+
/** Build a throwaway project dir whose .claude/skills/ holds ONLY the named skills (copied from
|
|
5
|
+
* srcCwd). Returns its path plus a cleanup(). The probe runs against this dir to load a subset. */
|
|
6
|
+
export declare function buildContextDir(srcCwd: string, skillNames: string[]): {
|
|
7
|
+
cwd: string;
|
|
8
|
+
cleanup: () => void;
|
|
9
|
+
};
|
|
10
|
+
type Rel = ReturnType<typeof reliability>;
|
|
11
|
+
interface Condition {
|
|
12
|
+
stats: RunStats;
|
|
13
|
+
rel: Rel;
|
|
14
|
+
}
|
|
15
|
+
export interface ContextCaseResult {
|
|
16
|
+
prompt: string;
|
|
17
|
+
expected: string;
|
|
18
|
+
isolation: Condition;
|
|
19
|
+
coLoaded: Condition;
|
|
20
|
+
deltaP: number;
|
|
21
|
+
fisherP: number;
|
|
22
|
+
/** infra failures dominated one side — the comparison is not trustworthy (NOT a behavioral result) */
|
|
23
|
+
untrustworthy: boolean;
|
|
24
|
+
/** fires reliably alone but drops significantly when the full library is co-loaded */
|
|
25
|
+
interference: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface ContextAuditResult {
|
|
28
|
+
runtime: string;
|
|
29
|
+
model: string;
|
|
30
|
+
threshold: number;
|
|
31
|
+
conf: number;
|
|
32
|
+
librarySize: number;
|
|
33
|
+
cases: ContextCaseResult[];
|
|
34
|
+
skipped: string[];
|
|
35
|
+
totalCost: number;
|
|
36
|
+
/** 0 = no interference; 1 = a skill is suppressed under load; 2 = an untrustworthy (infra) case */
|
|
37
|
+
exitCode: 0 | 1 | 2;
|
|
38
|
+
}
|
|
39
|
+
export interface ContextProgress {
|
|
40
|
+
caseIndex: number;
|
|
41
|
+
caseTotal: number;
|
|
42
|
+
prompt: string;
|
|
43
|
+
condition: "isolation" | "co-loaded";
|
|
44
|
+
validN: number;
|
|
45
|
+
maxK: number;
|
|
46
|
+
}
|
|
47
|
+
export declare function runContextAudit(cfg: Config, onProgress?: (e: ContextProgress) => void): Promise<ContextAuditResult>;
|
|
48
|
+
export {};
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** `skill-probe context` — activation-rate-BY-CONTEXT. The audit measures every skill co-loaded
|
|
2
|
+
* (the real, hard condition). This command additionally measures each skill in ISOLATION (only that
|
|
3
|
+
* skill present) and compares the two: it catches the "fires fine alone, fails under load" case that
|
|
4
|
+
* a single aggregate rate hides. The drop is tested with Fisher's exact test, so interference is
|
|
5
|
+
* reported as a real effect (p-value), not eyeballed. */
|
|
6
|
+
import { mkdtempSync, mkdirSync, cpSync, rmSync, existsSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { reliability, fisherExact2x2 } from "./stats.js";
|
|
10
|
+
import { measure } from "./orchestrator.js";
|
|
11
|
+
import { getAdapter } from "./adapters/index.js";
|
|
12
|
+
import { listSkills } from "./gen.js";
|
|
13
|
+
import { ConfigError } from "./config.js";
|
|
14
|
+
/** Same rule the audit uses (eval.ts): a measurement is untrustworthy when infrastructure
|
|
15
|
+
* failures dominate — no valid probes, or errors at least equal the valid count. Such a
|
|
16
|
+
* condition must NOT be read as a behavioral result (a runtime outage is not a clean pass). */
|
|
17
|
+
function infraUntrustworthy(s) {
|
|
18
|
+
return s.n === 0 || (s.errors > 0 && s.errors >= s.n);
|
|
19
|
+
}
|
|
20
|
+
/** Build a throwaway project dir whose .claude/skills/ holds ONLY the named skills (copied from
|
|
21
|
+
* srcCwd). Returns its path plus a cleanup(). The probe runs against this dir to load a subset. */
|
|
22
|
+
export function buildContextDir(srcCwd, skillNames) {
|
|
23
|
+
const root = mkdtempSync(join(tmpdir(), "skill-probe-ctx-"));
|
|
24
|
+
const destSkills = join(root, ".claude", "skills");
|
|
25
|
+
mkdirSync(destSkills, { recursive: true });
|
|
26
|
+
for (const name of skillNames) {
|
|
27
|
+
const src = join(srcCwd, ".claude", "skills", name);
|
|
28
|
+
if (!existsSync(src))
|
|
29
|
+
throw new Error(`skill not found in ${srcCwd}: ${name}`);
|
|
30
|
+
cpSync(src, join(destSkills, name), { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
return { cwd: root, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
33
|
+
}
|
|
34
|
+
export async function runContextAudit(cfg, onProgress) {
|
|
35
|
+
const adapter = getAdapter(cfg.runtime);
|
|
36
|
+
const library = listSkills(cfg.cwd);
|
|
37
|
+
const present = new Set(library.map((s) => s.name));
|
|
38
|
+
const measured = [];
|
|
39
|
+
const skipped = [];
|
|
40
|
+
let totalCost = 0;
|
|
41
|
+
// decoys (null expected) have nothing to isolate → skipped. An expected skill that ISN'T in the
|
|
42
|
+
// library is almost always a config typo: fail loud BEFORE spending probes, never silently skip.
|
|
43
|
+
const eligible = [];
|
|
44
|
+
const unknown = [];
|
|
45
|
+
for (const c of cfg.cases) {
|
|
46
|
+
if (c.expected === null)
|
|
47
|
+
skipped.push(c.prompt);
|
|
48
|
+
else if (!present.has(c.expected))
|
|
49
|
+
unknown.push(c.expected);
|
|
50
|
+
else
|
|
51
|
+
eligible.push(c);
|
|
52
|
+
}
|
|
53
|
+
if (unknown.length) {
|
|
54
|
+
throw new ConfigError([
|
|
55
|
+
`expected skill(s) not in ${cfg.cwd}/.claude/skills/: ${[...new Set(unknown)].join(", ")} ` +
|
|
56
|
+
`(typo?). Available: ${library.map((s) => s.name).join(", ") || "(none)"}.`,
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
const common = { maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf, ...(cfg.model ? { model: cfg.model } : {}) };
|
|
60
|
+
for (let i = 0; i < eligible.length; i++) {
|
|
61
|
+
const c = eligible[i];
|
|
62
|
+
const expected = c.expected;
|
|
63
|
+
// isolation: a temp project containing only this skill
|
|
64
|
+
const ctx = buildContextDir(cfg.cwd, [expected]);
|
|
65
|
+
let iso;
|
|
66
|
+
try {
|
|
67
|
+
iso = await measure(adapter, c.prompt, expected, {
|
|
68
|
+
cwd: ctx.cwd, ...common,
|
|
69
|
+
...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, condition: "isolation", validN, maxK }) } : {}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
ctx.cleanup();
|
|
74
|
+
}
|
|
75
|
+
// co-loaded: the real library at cfg.cwd
|
|
76
|
+
const co = await measure(adapter, c.prompt, expected, {
|
|
77
|
+
cwd: cfg.cwd, ...common,
|
|
78
|
+
...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, condition: "co-loaded", validN, maxK }) } : {}),
|
|
79
|
+
});
|
|
80
|
+
totalCost += iso.totalCost + co.totalCost;
|
|
81
|
+
const isoRel = reliability(iso.hits, iso.n, cfg.conf);
|
|
82
|
+
const coRel = reliability(co.hits, co.n, cfg.conf);
|
|
83
|
+
// 2x2: rows = isolation/co-loaded, cols = hit/miss
|
|
84
|
+
const fisherP = (iso.n > 0 && co.n > 0)
|
|
85
|
+
? fisherExact2x2(iso.hits, iso.n - iso.hits, co.hits, co.n - co.hits)
|
|
86
|
+
: 1;
|
|
87
|
+
const deltaP = coRel.pHat - isoRel.pHat;
|
|
88
|
+
// if infra failures dominated either side, the comparison isn't a behavioral result at all —
|
|
89
|
+
// it must never read as "no interference / pass" just because fisherP defaulted to 1.
|
|
90
|
+
const untrustworthy = infraUntrustworthy(iso) || infraUntrustworthy(co);
|
|
91
|
+
const interference = !untrustworthy &&
|
|
92
|
+
isoRel.pHat >= cfg.threshold && // fires reliably alone
|
|
93
|
+
deltaP < 0 && // and drops when co-loaded
|
|
94
|
+
fisherP < 0.05; // and the drop is statistically real
|
|
95
|
+
measured.push({
|
|
96
|
+
prompt: c.prompt, expected,
|
|
97
|
+
isolation: { stats: iso, rel: isoRel },
|
|
98
|
+
coLoaded: { stats: co, rel: coRel },
|
|
99
|
+
deltaP, fisherP, untrustworthy, interference,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
// precedence mirrors the audit: a real behavioral signal (interference, exit 1) outranks an
|
|
103
|
+
// untrustworthy/infra case (exit 2), which outranks a clean run (exit 0).
|
|
104
|
+
const exitCode = measured.some((m) => m.interference) ? 1
|
|
105
|
+
: measured.some((m) => m.untrustworthy) ? 2
|
|
106
|
+
: 0;
|
|
107
|
+
return {
|
|
108
|
+
runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
|
|
109
|
+
threshold: cfg.threshold, conf: cfg.conf, librarySize: library.length,
|
|
110
|
+
cases: measured, skipped, totalCost, exitCode,
|
|
111
|
+
};
|
|
112
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ProbeResult, ProbeOpts } from "./types.ts";
|
|
2
|
+
export type CheckStatus = "pass" | "warn" | "fail" | "info";
|
|
3
|
+
export interface Check {
|
|
4
|
+
status: CheckStatus;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DoctorResult {
|
|
8
|
+
checks: Check[];
|
|
9
|
+
exitCode: 0 | 1 | 2;
|
|
10
|
+
}
|
|
11
|
+
export interface DoctorOpts {
|
|
12
|
+
cwd: string;
|
|
13
|
+
configPath?: string;
|
|
14
|
+
runtime?: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
skipProbe?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** Injection seam so tests don't shell out to a real runtime. */
|
|
19
|
+
export interface DoctorDeps {
|
|
20
|
+
cliInstalled?: (cmd: string) => boolean;
|
|
21
|
+
probe?: (runtime: string, prompt: string, opts: ProbeOpts) => Promise<ProbeResult>;
|
|
22
|
+
}
|
|
23
|
+
export declare function runDoctor(opts: DoctorOpts, deps?: DoctorDeps): Promise<DoctorResult>;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** `skill-probe doctor` — preflight checks. Now that the tool is public, the first failures users
|
|
2
|
+
* hit are SETUP failures (no skills dir, runtime not installed, not authed, a config typo), not deep
|
|
3
|
+
* eval problems. doctor surfaces those in one cheap pass before they spend probes on a broken setup.
|
|
4
|
+
*
|
|
5
|
+
* Exit: 0 = healthy, 1 = warnings only, 2 = at least one hard failure. */
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { loadConfig } from "./config.js";
|
|
10
|
+
import { wilsonInterval } from "./stats.js";
|
|
11
|
+
import { kToCertify } from "./report.js";
|
|
12
|
+
import { getAdapter } from "./adapters/index.js";
|
|
13
|
+
const NAME_RE = /^name:\s*(.*)$/m;
|
|
14
|
+
const DESC_RE = /^description:\s*(.*)$/m;
|
|
15
|
+
const MIN_NODE = { major: 22, minor: 18 }; // package.json engines: ">=22.18"
|
|
16
|
+
function realCliInstalled(cmd) {
|
|
17
|
+
const r = spawnSync(cmd, ["--version"], { stdio: "ignore", timeout: 15_000 });
|
|
18
|
+
// ENOENT = not on PATH. Any other result (even a non-zero exit) means the binary exists.
|
|
19
|
+
return !(r.error && r.error.code === "ENOENT");
|
|
20
|
+
}
|
|
21
|
+
export async function runDoctor(opts, deps = {}) {
|
|
22
|
+
const cliInstalled = deps.cliInstalled ?? realCliInstalled;
|
|
23
|
+
const probe = deps.probe ?? ((rt, p, o) => getAdapter(rt).probe(p, o));
|
|
24
|
+
const checks = [];
|
|
25
|
+
const add = (status, message) => { checks.push({ status, message }); };
|
|
26
|
+
// 1. Node version (declared engine)
|
|
27
|
+
const [maj = 0, min = 0] = process.versions.node.split(".").map(Number);
|
|
28
|
+
if (maj > MIN_NODE.major || (maj === MIN_NODE.major && min >= MIN_NODE.minor)) {
|
|
29
|
+
add("pass", `Node ${process.versions.node}`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
add("warn", `Node ${process.versions.node} is below the declared engine >=22.18 ` +
|
|
33
|
+
`(the compiled CLI may still run, but this is untested)`);
|
|
34
|
+
}
|
|
35
|
+
// 2. Config (optional). Load first — it can redirect the skills dir (cwd resolves to the config's dir).
|
|
36
|
+
let cfg = null;
|
|
37
|
+
if (opts.configPath) {
|
|
38
|
+
if (!existsSync(resolve(opts.configPath))) {
|
|
39
|
+
add("fail", `config not found: ${opts.configPath}`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
try {
|
|
43
|
+
cfg = loadConfig(opts.configPath, { ...(opts.runtime ? { runtime: opts.runtime } : {}) });
|
|
44
|
+
add("pass", `config parsed: ${opts.configPath}`);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
add("fail", `config ${opts.configPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const skillsCwd = cfg ? cfg.cwd : resolve(opts.cwd);
|
|
52
|
+
const runtime = cfg?.runtime ?? opts.runtime ?? "claude-code";
|
|
53
|
+
// 3-6. Skills directory + each SKILL.md
|
|
54
|
+
const skillNames = new Set();
|
|
55
|
+
const skillsRoot = join(skillsCwd, ".claude", "skills");
|
|
56
|
+
if (!existsSync(skillsRoot)) {
|
|
57
|
+
add("fail", `no .claude/skills/ directory under ${skillsCwd}`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const dirs = readdirSync(skillsRoot, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
61
|
+
if (dirs.length === 0) {
|
|
62
|
+
add("fail", `.claude/skills/ exists but contains no skill folders`);
|
|
63
|
+
}
|
|
64
|
+
let valid = 0;
|
|
65
|
+
for (const d of dirs) {
|
|
66
|
+
const md = join(skillsRoot, d.name, "SKILL.md");
|
|
67
|
+
if (!existsSync(md)) {
|
|
68
|
+
add("fail", `skill "${d.name}" missing SKILL.md`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const text = readFileSync(md, "utf8");
|
|
72
|
+
const name = NAME_RE.exec(text)?.[1]?.trim() ?? "";
|
|
73
|
+
const desc = DESC_RE.exec(text)?.[1]?.trim() ?? "";
|
|
74
|
+
if (!name)
|
|
75
|
+
add("fail", `skill "${d.name}" missing name:`);
|
|
76
|
+
else if (name !== d.name)
|
|
77
|
+
add("warn", `skill "${d.name}" has name: "${name}" (doesn't match folder name)`);
|
|
78
|
+
if (!desc)
|
|
79
|
+
add("fail", `skill "${d.name}" missing description:`);
|
|
80
|
+
else if (desc === "|" || desc === ">")
|
|
81
|
+
add("warn", `skill "${d.name}" uses a multi-line description block — a single-line description triggers more reliably`);
|
|
82
|
+
skillNames.add(d.name);
|
|
83
|
+
valid++;
|
|
84
|
+
}
|
|
85
|
+
if (valid > 0)
|
|
86
|
+
add("pass", `found .claude/skills/ with ${valid} skill${valid === 1 ? "" : "s"}`);
|
|
87
|
+
}
|
|
88
|
+
// 7-9. Config-dependent checks
|
|
89
|
+
if (cfg) {
|
|
90
|
+
const missing = cfg.cases
|
|
91
|
+
.map((c) => c.expected).filter((s) => s !== null)
|
|
92
|
+
.filter((s) => !skillNames.has(s));
|
|
93
|
+
if (missing.length)
|
|
94
|
+
add("fail", `config expects skill(s) not in the library: ${[...new Set(missing)].join(", ")}`);
|
|
95
|
+
else
|
|
96
|
+
add("pass", `expected skills all exist`);
|
|
97
|
+
if (cfg.cases.every((c) => c.expected === null)) {
|
|
98
|
+
add("warn", `config has only decoy cases — no skill is being verified to fire`);
|
|
99
|
+
}
|
|
100
|
+
// a strict threshold at a small k can be mathematically impossible to certify
|
|
101
|
+
if (wilsonInterval(cfg.k, cfg.k, cfg.conf).lo <= cfg.threshold) {
|
|
102
|
+
add("warn", `threshold ${Math.round(cfg.threshold * 100)}% with k=${cfg.k} cannot certify a pass ` +
|
|
103
|
+
`even on a perfect run — need ~k=${kToCertify(cfg.threshold, cfg.conf)} (or lower the threshold)`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 10-11. Runtime + a harmless live probe (verifies the CLI is installed AND authed)
|
|
107
|
+
add("info", `runtime: ${runtime} model: ${cfg?.model ?? opts.model ?? "(runtime default)"}`);
|
|
108
|
+
const known = runtime === "claude-code" || runtime === "opencode";
|
|
109
|
+
const bin = runtime === "claude-code" ? "claude" : runtime === "opencode" ? "opencode" : null;
|
|
110
|
+
let cliOk = false;
|
|
111
|
+
if (!known) {
|
|
112
|
+
add("fail", `unknown runtime "${runtime}" (expected claude-code or opencode)`);
|
|
113
|
+
}
|
|
114
|
+
else if (bin && cliInstalled(bin)) {
|
|
115
|
+
add("pass", `${bin} CLI found`);
|
|
116
|
+
cliOk = true;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
add("fail", `${bin} CLI not found on PATH — install it and authenticate to run ${runtime}`);
|
|
120
|
+
}
|
|
121
|
+
if (opts.skipProbe) {
|
|
122
|
+
add("info", `probe skipped (--skip-probe)`);
|
|
123
|
+
}
|
|
124
|
+
else if (known && cliOk) {
|
|
125
|
+
const r = await probe(runtime, "Reply with exactly: ok", {
|
|
126
|
+
cwd: skillsCwd, timeoutMs: 60_000,
|
|
127
|
+
...(cfg?.model ?? opts.model ? { model: (cfg?.model ?? opts.model) } : {}),
|
|
128
|
+
});
|
|
129
|
+
if (r.status === "ok")
|
|
130
|
+
add("pass", `${runtime} probe succeeded (CLI is authenticated)`);
|
|
131
|
+
else
|
|
132
|
+
add("fail", `${runtime} probe failed — check auth/login: ${r.error ?? "unknown error"}`);
|
|
133
|
+
}
|
|
134
|
+
const hasFail = checks.some((c) => c.status === "fail");
|
|
135
|
+
const hasWarn = checks.some((c) => c.status === "warn");
|
|
136
|
+
const exitCode = hasFail ? 2 : hasWarn ? 1 : 0;
|
|
137
|
+
return { checks, exitCode };
|
|
138
|
+
}
|
package/dist/report.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { AuditResult } from "./eval.ts";
|
|
2
2
|
import type { FixResult } from "./fix.ts";
|
|
3
|
+
import type { ContextAuditResult } from "./context.ts";
|
|
4
|
+
import type { DoctorResult } from "./doctor.ts";
|
|
5
|
+
/** Smallest k at which an all-success run could certify reliability >= threshold. */
|
|
6
|
+
export declare function kToCertify(threshold: number, conf: number): number;
|
|
3
7
|
export interface RenderOpts {
|
|
4
8
|
showCost?: boolean;
|
|
5
9
|
}
|
|
@@ -7,4 +11,7 @@ export declare function renderTable(a: AuditResult, opts?: RenderOpts): string;
|
|
|
7
11
|
/** Markdown table — paste into a PR or README. */
|
|
8
12
|
export declare function renderMarkdown(a: AuditResult, opts?: RenderOpts): string;
|
|
9
13
|
export declare function renderJson(a: AuditResult | unknown): string;
|
|
14
|
+
export declare function renderDoctor(d: DoctorResult): string;
|
|
15
|
+
/** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
|
|
16
|
+
export declare function renderContext(a: ContextAuditResult, opts?: RenderOpts): string;
|
|
10
17
|
export declare function renderFix(f: FixResult): string;
|
package/dist/report.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* errors are shown distinctly from behavioral results. */
|
|
3
3
|
import { wilsonInterval } from "./stats.js";
|
|
4
4
|
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
5
|
+
const pval = (p) => (p < 0.001 ? "<0.001" : p.toFixed(3));
|
|
5
6
|
const LABEL = {
|
|
6
7
|
pass: "PASS ", fail: "FAIL ",
|
|
7
8
|
inconclusive: "INCONCLUSIVE", error: "ERROR ",
|
|
8
9
|
};
|
|
9
10
|
/** Smallest k at which an all-success run could certify reliability >= threshold. */
|
|
10
|
-
function kToCertify(threshold, conf) {
|
|
11
|
+
export function kToCertify(threshold, conf) {
|
|
11
12
|
for (let k = 1; k <= 500; k++) {
|
|
12
13
|
if (wilsonInterval(k, k, conf).lo > threshold)
|
|
13
14
|
return k;
|
|
@@ -80,6 +81,62 @@ export function renderMarkdown(a, opts = {}) {
|
|
|
80
81
|
export function renderJson(a) {
|
|
81
82
|
return JSON.stringify(a, null, 2);
|
|
82
83
|
}
|
|
84
|
+
export function renderDoctor(d) {
|
|
85
|
+
const out = ["skill-probe doctor", ""];
|
|
86
|
+
for (const c of d.checks)
|
|
87
|
+
out.push(`${c.status.toUpperCase().padEnd(4)} ${c.message}`);
|
|
88
|
+
out.push("");
|
|
89
|
+
const f = d.checks.filter((c) => c.status === "fail").length;
|
|
90
|
+
const w = d.checks.filter((c) => c.status === "warn").length;
|
|
91
|
+
out.push(f ? `${f} failure(s)` + (w ? `, ${w} warning(s)` : "") + ` | exit ${d.exitCode}`
|
|
92
|
+
: w ? `${w} warning(s) | exit ${d.exitCode}`
|
|
93
|
+
: `all healthy | exit 0`);
|
|
94
|
+
return out.join("\n");
|
|
95
|
+
}
|
|
96
|
+
/** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
|
|
97
|
+
export function renderContext(a, opts = {}) {
|
|
98
|
+
const showCost = opts.showCost ?? true;
|
|
99
|
+
const out = [];
|
|
100
|
+
out.push(`skill-probe context — runtime: ${a.runtime} model: ${a.model} ` +
|
|
101
|
+
`threshold: ${pct(a.threshold)} library: ${a.librarySize} skills`);
|
|
102
|
+
out.push(` isolation = only that skill loaded · co-loaded = the full library (${a.librarySize}) loaded`);
|
|
103
|
+
out.push("");
|
|
104
|
+
const ci = (r) => `${pct(r.pHat)} [${pct(r.ciLow)}, ${pct(r.ciHigh)}] k=${r.k}`;
|
|
105
|
+
for (const c of a.cases) {
|
|
106
|
+
if (c.untrustworthy) {
|
|
107
|
+
out.push(` [⛔ ERROR] ${c.expected} | ${c.prompt}`);
|
|
108
|
+
out.push(` infrastructure errors dominated — comparison NOT trustworthy ` +
|
|
109
|
+
`(isolation ${c.isolation.stats.errors} err/${c.isolation.stats.n} valid, ` +
|
|
110
|
+
`co-loaded ${c.coLoaded.stats.errors} err/${c.coLoaded.stats.n} valid)`);
|
|
111
|
+
const reason = c.isolation.stats.lastError ?? c.coLoaded.stats.lastError;
|
|
112
|
+
if (reason)
|
|
113
|
+
out.push(` reason: ${reason}`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const flag = c.interference ? "⚠ INTERFERENCE" : "ok";
|
|
117
|
+
out.push(` [${flag}] ${c.expected} | ${c.prompt}`);
|
|
118
|
+
out.push(` isolation ${ci(c.isolation.rel)}`);
|
|
119
|
+
out.push(` co-loaded ${ci(c.coLoaded.rel)}`);
|
|
120
|
+
out.push(` Δ ${c.deltaP >= 0 ? "+" : ""}${pct(c.deltaP)} under load Fisher p=${pval(c.fisherP)}`);
|
|
121
|
+
if (c.interference) {
|
|
122
|
+
const thieves = Object.keys(c.coLoaded.stats.dist)
|
|
123
|
+
.filter((s) => s !== c.expected && s !== "None");
|
|
124
|
+
out.push(` ↳ fires reliably alone but is suppressed when the library is co-loaded` +
|
|
125
|
+
(thieves.length ? ` (stolen by: ${thieves.join(", ")})` : ""));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (a.skipped.length) {
|
|
129
|
+
out.push("");
|
|
130
|
+
out.push(` skipped ${a.skipped.length} decoy case(s) — no single skill to isolate.`);
|
|
131
|
+
}
|
|
132
|
+
out.push("");
|
|
133
|
+
const interfN = a.cases.filter((c) => c.interference).length;
|
|
134
|
+
const errN = a.cases.filter((c) => c.untrustworthy).length;
|
|
135
|
+
out.push(`Result: ${interfN} interference / ${a.cases.length} measured` +
|
|
136
|
+
(errN ? ` / ${errN} error` : "") + ` | exit ${a.exitCode}` +
|
|
137
|
+
(showCost ? ` | cost $${a.totalCost.toFixed(4)}` : ""));
|
|
138
|
+
return out.join("\n");
|
|
139
|
+
}
|
|
83
140
|
export function renderFix(f) {
|
|
84
141
|
const out = [];
|
|
85
142
|
out.push(`skill-probe fix — skill: ${f.skill}`);
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skill-probe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Runtime-agnostic, co-loaded-aware auditor for AI agent skill libraries: measures which skill actually fires, with statistical confidence.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
6
|
+
"bin": {
|
|
7
|
+
"skill-probe": "dist/cli.js"
|
|
8
|
+
},
|
|
7
9
|
"scripts": {
|
|
8
10
|
"dev": "node src/cli.ts",
|
|
9
11
|
"test": "node --test test/*.test.ts",
|
|
@@ -11,13 +13,36 @@
|
|
|
11
13
|
"build": "tsc",
|
|
12
14
|
"prepublishOnly": "npm run build && npm test"
|
|
13
15
|
},
|
|
14
|
-
"keywords": [
|
|
16
|
+
"keywords": [
|
|
17
|
+
"agent-skills",
|
|
18
|
+
"skill",
|
|
19
|
+
"eval",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"mcp",
|
|
22
|
+
"SKILL.md",
|
|
23
|
+
"trigger"
|
|
24
|
+
],
|
|
15
25
|
"author": "HystonKayange",
|
|
16
26
|
"license": "MIT",
|
|
17
|
-
"repository": {
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/HystonKayange/skill-probe.git"
|
|
30
|
+
},
|
|
18
31
|
"homepage": "https://github.com/HystonKayange/skill-probe#readme",
|
|
19
|
-
"bugs": {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/HystonKayange/skill-probe/issues"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.18"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"typescript": "^5.5.0",
|
|
40
|
+
"@types/node": "^22.0.0"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"examples",
|
|
45
|
+
"README.md",
|
|
46
|
+
"LICENSE"
|
|
47
|
+
]
|
|
23
48
|
}
|