skill-probe 0.5.0 → 0.7.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 +105 -9
- package/dist/cli.js +51 -2
- package/dist/context.js +1 -7
- package/dist/diagnose.d.ts +68 -0
- package/dist/diagnose.js +154 -0
- package/dist/doctor.d.ts +23 -0
- package/dist/doctor.js +138 -0
- package/dist/orchestrator.d.ts +4 -0
- package/dist/orchestrator.js +6 -0
- package/dist/report.d.ts +7 -0
- package/dist/report.js +44 -1
- package/package.json +33 -8
package/README.md
CHANGED
|
@@ -12,9 +12,22 @@ Complements static linters like `skill-audit` (security/quality) — this is **b
|
|
|
12
12
|
> **How it works / requirements.** skill-probe is a **terminal CLI**, *not* an in-agent slash
|
|
13
13
|
> command. It drives your local runtime CLI under the hood, so you need that runtime installed and
|
|
14
14
|
> authenticated: **`claude`** (logged in) for `runtime: claude-code`, or **`opencode`** for
|
|
15
|
-
> `runtime: opencode`. `gen`/`fix` use `ANTHROPIC_API_KEY` if set, otherwise the local `claude` CLI.
|
|
15
|
+
> `runtime: opencode`. `gen`/`fix`/`diagnose` use `ANTHROPIC_API_KEY` if set, otherwise the local `claude` CLI.
|
|
16
16
|
> Codex/Gemini runtimes aren't supported yet (their traces don't expose which skill fired).
|
|
17
17
|
|
|
18
|
+
The workflow:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
doctor → gen → audit → context → diagnose → fix
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `doctor`: verify setup, auth, runtime, skills, and config before spending probes.
|
|
25
|
+
- `gen`: draft a probe config from your existing skills.
|
|
26
|
+
- `audit`: measure which skill actually fires when the whole library is co-loaded.
|
|
27
|
+
- `context`: compare isolation vs co-loaded activation to catch library interference.
|
|
28
|
+
- `diagnose`: explain whether a failure is a routing miss or a description problem.
|
|
29
|
+
- `fix`: rewrite a skill description and keep it only if the measured reliability improves.
|
|
30
|
+
|
|
18
31
|
## Why
|
|
19
32
|
|
|
20
33
|
- Skill activation is stochastic: the same prompt can produce different skill-routing outcomes.
|
|
@@ -28,12 +41,41 @@ Complements static linters like `skill-audit` (security/quality) — this is **b
|
|
|
28
41
|
npm i -g skill-probe # or: npx skill-probe
|
|
29
42
|
```
|
|
30
43
|
|
|
44
|
+
## Start here (`skill-probe doctor`)
|
|
45
|
+
|
|
46
|
+
Run this first — it catches the setup problems (no skills dir, runtime not installed or not
|
|
47
|
+
authenticated, a config typo) before you spend any probes:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
skill-probe doctor --cwd . # check the project + runtime
|
|
51
|
+
skill-probe doctor --config probe.config.json # also sanity-check a config
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
skill-probe doctor
|
|
56
|
+
|
|
57
|
+
PASS Node 22.18.0
|
|
58
|
+
PASS config parsed: probe.config.json
|
|
59
|
+
PASS found .claude/skills/ with 4 skills
|
|
60
|
+
PASS expected skills all exist
|
|
61
|
+
PASS claude CLI found
|
|
62
|
+
PASS claude-code probe succeeded (CLI is authenticated)
|
|
63
|
+
WARN threshold 90% with k=10 cannot certify a pass — need ~k=35
|
|
64
|
+
FAIL skill "greeter" missing description:
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
It checks: Node version, `.claude/skills/` exists, every skill has a `SKILL.md` with `name:` +
|
|
68
|
+
`description:`, names match folders, the config parses, every `expected` skill exists, the
|
|
69
|
+
threshold/k are statistically achievable, the runtime CLI is installed, and a harmless live probe
|
|
70
|
+
authenticates. Exit `0` healthy · `1` warnings only · `2` hard failures. Use `--skip-probe` to skip
|
|
71
|
+
the live (costing) auth check.
|
|
72
|
+
|
|
31
73
|
## Generate a config (`skill-probe gen`)
|
|
32
74
|
|
|
33
75
|
Don't want to hand-write the cases? Draft them from your skills, then review:
|
|
34
76
|
|
|
35
77
|
```bash
|
|
36
|
-
|
|
78
|
+
skill-probe gen --cwd . > probe.config.json
|
|
37
79
|
```
|
|
38
80
|
It reads each `.claude/skills/*/SKILL.md`, and an LLM drafts realistic should-fire prompts per
|
|
39
81
|
skill, cross-skill **near-misses** (to surface mis-routing), and off-topic **decoys**. Hallucinated
|
|
@@ -43,9 +85,9 @@ skill names are dropped automatically. Flags: `--per-skill <n>` (default 3), `--
|
|
|
43
85
|
> **Always review the draft before running.** Generators lean toward obvious keyword-matches; you
|
|
44
86
|
> add the messy, oblique phrasings real users type (that's where triggering actually fails).
|
|
45
87
|
|
|
46
|
-
`gen`
|
|
47
|
-
**`claude` CLI** — so on a Claude subscription the whole tool
|
|
48
|
-
|
|
88
|
+
`gen`, `fix`, and `diagnose` use `ANTHROPIC_API_KEY` if it's set, otherwise they fall back to the
|
|
89
|
+
logged-in **`claude` CLI** — so on a Claude subscription the whole tool works with **no API key at
|
|
90
|
+
all**.
|
|
49
91
|
|
|
50
92
|
## Use
|
|
51
93
|
|
|
@@ -154,6 +196,47 @@ in the library is a config error (typo), surfaced before any probes run. Exit `1
|
|
|
154
196
|
shows interference, `2` if a case is untrustworthy (infra errors dominated — never a silent pass),
|
|
155
197
|
else `0`.
|
|
156
198
|
|
|
199
|
+
## Why a skill fails (`skill-probe diagnose`)
|
|
200
|
+
|
|
201
|
+
When a skill doesn't fire, there are two very different root causes — and the activation rate alone
|
|
202
|
+
can't tell them apart:
|
|
203
|
+
|
|
204
|
+
- **Description problem** — the model doesn't even recognise the skill applies. Remedy: rewrite the
|
|
205
|
+
description (`skill-probe fix`).
|
|
206
|
+
- **Routing miss** — the model *does* know the right skill, but a sibling wins at activation time.
|
|
207
|
+
Remedy: deconflict the sibling / use `context`. Rewriting *this* skill's description won't help.
|
|
208
|
+
|
|
209
|
+
`diagnose` separates them by measuring **actual** activation (the real runtime probe) against
|
|
210
|
+
**intended** routing — a *forced choice* where the model is shown the skill descriptions and asked
|
|
211
|
+
which one should handle the request, with no auto-execution:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
skill-probe diagnose --config probe.config.json
|
|
215
|
+
```
|
|
216
|
+
```
|
|
217
|
+
[🔀 ROUTING-MISS] greeter | write a greeting for my new teammate
|
|
218
|
+
actual fires 20% [6%, 51%] k=10 (welcomer×8, greeter×2)
|
|
219
|
+
intended picks 100% [72%, 100%] k=10 (greeter×10)
|
|
220
|
+
→ the model picks "greeter" when asked, but "welcomer" wins at activation. Remedy:
|
|
221
|
+
deconflict the sibling or inspect with `skill-probe context`. Rewriting "greeter" won't help.
|
|
222
|
+
|
|
223
|
+
[✍ DESCRIPTION-PROBLEM] greeter | say something nice to the new hire
|
|
224
|
+
actual fires 0% [0%, 28%] k=10 (welcomer×10)
|
|
225
|
+
intended picks 10% [2%, 40%] k=10 (welcomer×9, greeter×1)
|
|
226
|
+
→ the model doesn't recognise "greeter" applies. Remedy: `skill-probe fix --skill greeter`.
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
> **`intended` is a proxy, not the runtime's router.** The forced choice asks a *fresh* model which
|
|
230
|
+
> skill fits, given only the descriptions — it can disagree with how the runtime actually routes
|
|
231
|
+
> (we've seen `actual` route a skill 100% while `intended` picked a sibling). So treat the
|
|
232
|
+
> routing-miss vs description-problem split as a strong **heuristic**, not ground truth. A useful
|
|
233
|
+
> side effect: when a skill routes fine but `intended` is low, the description reads *ambiguously in
|
|
234
|
+
> isolation* — a leading indicator that activation may be fragile across models or contexts, which
|
|
235
|
+
> `diagnose` calls out on the `routes-ok` line.
|
|
236
|
+
|
|
237
|
+
Decoys are skipped; a typo'd `expected` skill is a config error. Exit `1` if any case is a
|
|
238
|
+
routing-miss or description-problem, `2` if inconclusive/untrustworthy, else `0`.
|
|
239
|
+
|
|
157
240
|
## Fix a failing skill (`skill-probe fix`)
|
|
158
241
|
|
|
159
242
|
Rewrite a skill's trigger description and **prove the lift is real** before keeping it:
|
|
@@ -183,14 +266,27 @@ doesn't measurably help is reverted.
|
|
|
183
266
|
|
|
184
267
|
## Status
|
|
185
268
|
|
|
186
|
-
Early
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
`
|
|
269
|
+
Early, but usable end to end.
|
|
270
|
+
|
|
271
|
+
**Doctor (`skill-probe doctor`):** setup/auth/config preflight. Checks Node, `.claude/skills/`,
|
|
272
|
+
`SKILL.md` frontmatter, config expected skills, runtime CLI availability, and optional live auth
|
|
273
|
+
probe.
|
|
274
|
+
|
|
275
|
+
**Gen (`skill-probe gen`):** drafts a reviewable probe config from existing skills, including
|
|
276
|
+
should-fire prompts, near-misses, and decoys.
|
|
277
|
+
|
|
278
|
+
**Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping + four-state verdict
|
|
279
|
+
(pass / fail / inconclusive / **error**), across two runtimes (Claude Code, OpenCode).
|
|
280
|
+
Infrastructure failures (timeout / auth / crash / empty output) are reported as `error`, never as
|
|
281
|
+
a behavioral pass/fail — a decoy can't falsely pass because the runtime was down.
|
|
190
282
|
|
|
191
283
|
**Context (`skill-probe context`):** isolation-vs-co-loaded activation rates, with **Fisher's exact
|
|
192
284
|
test** on the drop — catches skills that fire alone but are suppressed under the full library's load.
|
|
193
285
|
|
|
286
|
+
**Diagnose (`skill-probe diagnose`):** compares actual runtime activation against intended
|
|
287
|
+
forced-choice routing to classify failures as routing-miss vs description-problem. Intended routing
|
|
288
|
+
is a heuristic, not ground truth, and the README calls that out explicitly.
|
|
289
|
+
|
|
194
290
|
**Fix (`skill-probe fix`):** uses the **Bayesian Beta-Binomial** to gate description rewrites on a
|
|
195
291
|
*proven* lift (interleaved before/after, applied only if P(improvement) clears the bar).
|
|
196
292
|
|
package/dist/cli.js
CHANGED
|
@@ -8,14 +8,18 @@ import { resolve } from "node:path";
|
|
|
8
8
|
import { runAudit } from "./eval.js";
|
|
9
9
|
import { runFix } from "./fix.js";
|
|
10
10
|
import { runContextAudit } from "./context.js";
|
|
11
|
+
import { runDiagnose } from "./diagnose.js";
|
|
12
|
+
import { runDoctor } from "./doctor.js";
|
|
11
13
|
import { listSkills, generateCases, genConfig } from "./gen.js";
|
|
12
14
|
import { loadConfig, parseProbability, parseIntFlag, ConfigError } from "./config.js";
|
|
13
|
-
import { renderTable, renderMarkdown, renderJson, renderFix, renderContext } from "./report.js";
|
|
15
|
+
import { renderTable, renderMarkdown, renderJson, renderFix, renderContext, renderDoctor, renderDiagnose } from "./report.js";
|
|
14
16
|
const HELP = `skill-probe — audit a co-loaded agent skill library by real activation behavior.
|
|
15
17
|
|
|
16
18
|
USAGE
|
|
17
19
|
skill-probe --config <file.json> [options] # audit
|
|
18
20
|
skill-probe context --config <file.json> [options] # isolation vs co-loaded activation rates
|
|
21
|
+
skill-probe diagnose --config <file.json> [options] # why a skill fails: routing-miss vs description
|
|
22
|
+
skill-probe doctor [--cwd <dir>] [--config <file.json>] # preflight: setup/auth/config sanity
|
|
19
23
|
skill-probe gen [--cwd <dir>] > probe.config.json # draft a test config from your skills
|
|
20
24
|
skill-probe fix --config <file.json> --skill <name> [opts] # rewrite a description, prove the lift
|
|
21
25
|
|
|
@@ -32,6 +36,10 @@ OPTIONS
|
|
|
32
36
|
--markdown emit a Markdown table (paste into a PR/README)
|
|
33
37
|
--no-cost hide the cost line (e.g. when on a Claude subscription)
|
|
34
38
|
--quiet suppress the live progress line on stderr
|
|
39
|
+
doctor only:
|
|
40
|
+
--cwd <dir> project dir containing .claude/skills/ (default: .)
|
|
41
|
+
--config <file> also sanity-check a config (expected skills exist, k/threshold feasible)
|
|
42
|
+
--skip-probe don't run the harmless live probe (skip the auth check; no cost)
|
|
35
43
|
gen only (uses ANTHROPIC_API_KEY if set, else the logged-in 'claude' CLI):
|
|
36
44
|
--cwd <dir> project dir containing .claude/skills/ (default: .)
|
|
37
45
|
--per-skill <n> should-fire prompts to draft per skill (default 3)
|
|
@@ -72,6 +80,17 @@ function makeContextProgress(values) {
|
|
|
72
80
|
progressLen = line.length;
|
|
73
81
|
};
|
|
74
82
|
}
|
|
83
|
+
function makeDiagProgress(values) {
|
|
84
|
+
if (values["json"] || values["quiet"] || !process.stderr.isTTY)
|
|
85
|
+
return undefined;
|
|
86
|
+
return (e) => {
|
|
87
|
+
const p = e.prompt.length > 28 ? e.prompt.slice(0, 25) + "…" : e.prompt;
|
|
88
|
+
const line = ` [${e.caseIndex}/${e.caseTotal}] ${e.phase.padEnd(8)} "${p}" — run ${e.validN}/${e.maxK}`;
|
|
89
|
+
const pad = Math.max(0, progressLen - line.length);
|
|
90
|
+
process.stderr.write("\r" + line + " ".repeat(pad));
|
|
91
|
+
progressLen = line.length;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
75
94
|
async function audit(values) {
|
|
76
95
|
const cfg = loadConfig(values["config"], {
|
|
77
96
|
runtime: values["runtime"], model: values["model"],
|
|
@@ -87,6 +106,31 @@ async function audit(values) {
|
|
|
87
106
|
console.log(text);
|
|
88
107
|
return result.exitCode;
|
|
89
108
|
}
|
|
109
|
+
async function diagnose(values) {
|
|
110
|
+
const cfg = loadConfig(values["config"], {
|
|
111
|
+
runtime: values["runtime"], model: values["model"],
|
|
112
|
+
k: values["k"], threshold: values["threshold"],
|
|
113
|
+
conf: values["conf"],
|
|
114
|
+
});
|
|
115
|
+
const result = await runDiagnose(cfg, {
|
|
116
|
+
...(makeDiagProgress(values) ? { onProgress: makeDiagProgress(values) } : {}),
|
|
117
|
+
});
|
|
118
|
+
clearProgress();
|
|
119
|
+
const showCost = !values["no-cost"];
|
|
120
|
+
console.log(values["json"] ? renderJson(result) : renderDiagnose(result, { showCost }));
|
|
121
|
+
return result.exitCode;
|
|
122
|
+
}
|
|
123
|
+
async function doctor(values) {
|
|
124
|
+
const result = await runDoctor({
|
|
125
|
+
cwd: values["cwd"] ?? ".",
|
|
126
|
+
...(values["config"] ? { configPath: values["config"] } : {}),
|
|
127
|
+
...(values["runtime"] ? { runtime: values["runtime"] } : {}),
|
|
128
|
+
...(values["model"] ? { model: values["model"] } : {}),
|
|
129
|
+
...(values["skip-probe"] ? { skipProbe: true } : {}),
|
|
130
|
+
});
|
|
131
|
+
console.log(values["json"] ? renderJson(result) : renderDoctor(result));
|
|
132
|
+
return result.exitCode;
|
|
133
|
+
}
|
|
90
134
|
async function context(values) {
|
|
91
135
|
const cfg = loadConfig(values["config"], {
|
|
92
136
|
runtime: values["runtime"], model: values["model"],
|
|
@@ -147,6 +191,7 @@ async function main() {
|
|
|
147
191
|
cwd: { type: "string" },
|
|
148
192
|
"per-skill": { type: "string" },
|
|
149
193
|
decoys: { type: "string" },
|
|
194
|
+
"skip-probe": { type: "boolean", default: false },
|
|
150
195
|
json: { type: "boolean", default: false },
|
|
151
196
|
markdown: { type: "boolean", default: false },
|
|
152
197
|
"no-cost": { type: "boolean", default: false },
|
|
@@ -163,6 +208,9 @@ async function main() {
|
|
|
163
208
|
if (cmd === "gen") {
|
|
164
209
|
code = await gen(values);
|
|
165
210
|
}
|
|
211
|
+
else if (cmd === "doctor") {
|
|
212
|
+
code = await doctor(values); // config is optional for doctor
|
|
213
|
+
}
|
|
166
214
|
else {
|
|
167
215
|
if (!values.config) {
|
|
168
216
|
console.log(HELP);
|
|
@@ -170,7 +218,8 @@ async function main() {
|
|
|
170
218
|
}
|
|
171
219
|
code = cmd === "fix" ? await fix(values)
|
|
172
220
|
: cmd === "context" ? await context(values)
|
|
173
|
-
: await
|
|
221
|
+
: cmd === "diagnose" ? await diagnose(values)
|
|
222
|
+
: await audit(values);
|
|
174
223
|
}
|
|
175
224
|
process.exit(code);
|
|
176
225
|
}
|
package/dist/context.js
CHANGED
|
@@ -7,16 +7,10 @@ import { mkdtempSync, mkdirSync, cpSync, rmSync, existsSync } from "node:fs";
|
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { reliability, fisherExact2x2 } from "./stats.js";
|
|
10
|
-
import { measure } from "./orchestrator.js";
|
|
10
|
+
import { measure, infraUntrustworthy } from "./orchestrator.js";
|
|
11
11
|
import { getAdapter } from "./adapters/index.js";
|
|
12
12
|
import { listSkills } from "./gen.js";
|
|
13
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
14
|
/** Build a throwaway project dir whose .claude/skills/ holds ONLY the named skills (copied from
|
|
21
15
|
* srcCwd). Returns its path plus a cleanup(). The probe runs against this dir to load a subset. */
|
|
22
16
|
export function buildContextDir(srcCwd, skillNames) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** `skill-probe diagnose` — when a skill fails to fire, WHY? Two very different root causes need
|
|
2
|
+
* two very different fixes, and you can't tell them apart from the activation rate alone:
|
|
3
|
+
*
|
|
4
|
+
* - DESCRIPTION PROBLEM: the model, asked point-blank "which skill handles this?", doesn't even
|
|
5
|
+
* pick the expected skill. Its description fails to communicate what it's for. Remedy: rewrite
|
|
6
|
+
* the description (`skill-probe fix`).
|
|
7
|
+
* - ROUTING MISS: the model DOES pick the expected skill when asked — it understands the
|
|
8
|
+
* description — but a sibling wins at actual activation time. Remedy: deconflict the sibling /
|
|
9
|
+
* inspect with `skill-probe context`. Rewriting this skill's description won't help.
|
|
10
|
+
*
|
|
11
|
+
* We measure ACTUAL activation (the real runtime probe) and INTENDED routing (a forced-choice: the
|
|
12
|
+
* model is shown the skill descriptions and asked which one applies, with no auto-execution), then
|
|
13
|
+
* classify the gap between them. */
|
|
14
|
+
import type { Config } from "./types.ts";
|
|
15
|
+
import { reliability } from "./stats.ts";
|
|
16
|
+
import { type RunStats } from "./orchestrator.ts";
|
|
17
|
+
import { type SkillInfo } from "./gen.ts";
|
|
18
|
+
export type DiagVerdict = "routes-ok" | "routing-miss" | "description-problem" | "inconclusive" | "error";
|
|
19
|
+
/** Returns the chosen skill name, "none", or null when the answer is unusable (LLM error/garbage). */
|
|
20
|
+
export type ChoiceFn = (skills: SkillInfo[], prompt: string, model?: string) => Promise<string | null>;
|
|
21
|
+
type Rel = ReturnType<typeof reliability>;
|
|
22
|
+
interface IntendedStats {
|
|
23
|
+
dist: Record<string, number>;
|
|
24
|
+
hits: number;
|
|
25
|
+
n: number;
|
|
26
|
+
errors: number;
|
|
27
|
+
}
|
|
28
|
+
export interface DiagCaseResult {
|
|
29
|
+
prompt: string;
|
|
30
|
+
expected: string;
|
|
31
|
+
actual: {
|
|
32
|
+
stats: RunStats;
|
|
33
|
+
rel: Rel;
|
|
34
|
+
};
|
|
35
|
+
intended: {
|
|
36
|
+
stats: IntendedStats;
|
|
37
|
+
rel: Rel;
|
|
38
|
+
};
|
|
39
|
+
verdict: DiagVerdict;
|
|
40
|
+
/** the sibling that wins activation in a routing-miss (most-fired non-expected skill), if any */
|
|
41
|
+
competitor: string | null;
|
|
42
|
+
remedy: string;
|
|
43
|
+
}
|
|
44
|
+
export interface DiagnoseResult {
|
|
45
|
+
runtime: string;
|
|
46
|
+
model: string;
|
|
47
|
+
threshold: number;
|
|
48
|
+
conf: number;
|
|
49
|
+
cases: DiagCaseResult[];
|
|
50
|
+
skipped: string[];
|
|
51
|
+
totalCost: number;
|
|
52
|
+
exitCode: 0 | 1 | 2;
|
|
53
|
+
}
|
|
54
|
+
export interface DiagProgress {
|
|
55
|
+
caseIndex: number;
|
|
56
|
+
caseTotal: number;
|
|
57
|
+
prompt: string;
|
|
58
|
+
phase: "actual" | "intended";
|
|
59
|
+
validN: number;
|
|
60
|
+
maxK: number;
|
|
61
|
+
}
|
|
62
|
+
export declare function normalizeChoice(text: string, skills: SkillInfo[]): string | null;
|
|
63
|
+
export declare const defaultChoice: ChoiceFn;
|
|
64
|
+
export declare function runDiagnose(cfg: Config, deps?: {
|
|
65
|
+
choice?: ChoiceFn;
|
|
66
|
+
onProgress?: (e: DiagProgress) => void;
|
|
67
|
+
}): Promise<DiagnoseResult>;
|
|
68
|
+
export {};
|
package/dist/diagnose.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { reliability, sequentialDecision } from "./stats.js";
|
|
2
|
+
import { measure, infraUntrustworthy } from "./orchestrator.js";
|
|
3
|
+
import { getAdapter } from "./adapters/index.js";
|
|
4
|
+
import { listSkills } from "./gen.js";
|
|
5
|
+
import { complete } from "./llm.js";
|
|
6
|
+
import { ConfigError } from "./config.js";
|
|
7
|
+
const ROUTER_SYSTEM = "You are a skill router. Given a user request and a list of available skills (name + " +
|
|
8
|
+
"description), decide which single skill should handle the request. Reply with EXACTLY one skill " +
|
|
9
|
+
"name from the list, or the word none if no skill fits. Output only that one word — no punctuation, " +
|
|
10
|
+
"no explanation.";
|
|
11
|
+
export function normalizeChoice(text, skills) {
|
|
12
|
+
const lower = text.trim().toLowerCase();
|
|
13
|
+
// detect "no skill" answers on the raw text, before punctuation is stripped (so "n/a" survives)
|
|
14
|
+
if (/^(none|null|no skill|no-skill|n\/a|n\.a\.?)$/.test(lower))
|
|
15
|
+
return "None";
|
|
16
|
+
const t = lower.replace(/[^a-z0-9_\-\s]/g, "").trim();
|
|
17
|
+
if (!t)
|
|
18
|
+
return null;
|
|
19
|
+
for (const s of skills)
|
|
20
|
+
if (t === s.name.toLowerCase())
|
|
21
|
+
return s.name;
|
|
22
|
+
// tolerate "the greeter skill" / "use greeter" by substring match on a known name
|
|
23
|
+
for (const s of skills)
|
|
24
|
+
if (new RegExp(`\\b${s.name.toLowerCase()}\\b`).test(t))
|
|
25
|
+
return s.name;
|
|
26
|
+
if (/\bnone\b/.test(t))
|
|
27
|
+
return "None";
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export const defaultChoice = async (skills, prompt, model) => {
|
|
31
|
+
const list = skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
|
|
32
|
+
const user = `Available skills:\n${list}\n\nUser request: "${prompt}"\n\n` +
|
|
33
|
+
`Which ONE skill should handle this request? Reply with exactly one skill name, or "none".`;
|
|
34
|
+
let text;
|
|
35
|
+
try {
|
|
36
|
+
text = await complete(ROUTER_SYSTEM, user, { ...(model ? { model } : {}), maxTokens: 16 });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return normalizeChoice(text, skills);
|
|
42
|
+
};
|
|
43
|
+
async function measureIntended(skills, prompt, expected, o) {
|
|
44
|
+
const dist = {};
|
|
45
|
+
let hits = 0, n = 0, errors = 0;
|
|
46
|
+
const maxAttempts = o.maxK + Math.ceil(o.maxK / 2);
|
|
47
|
+
for (let a = 0; a < maxAttempts && n < o.maxK; a++) {
|
|
48
|
+
const c = await o.choice(skills, prompt, o.model);
|
|
49
|
+
if (c === null) {
|
|
50
|
+
errors++;
|
|
51
|
+
o.onProbe?.(n, o.maxK);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
dist[c] = (dist[c] ?? 0) + 1;
|
|
55
|
+
if (c === expected)
|
|
56
|
+
hits++;
|
|
57
|
+
n++;
|
|
58
|
+
o.onProbe?.(n, o.maxK);
|
|
59
|
+
if (n >= 3 && sequentialDecision(hits, n, o.threshold, o.conf) !== "undecided")
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
return { dist, hits, n, errors };
|
|
63
|
+
}
|
|
64
|
+
function classify(actual, actualRel, intended, expected, threshold, conf) {
|
|
65
|
+
const competitor = Object.entries(actual.dist)
|
|
66
|
+
.filter(([s]) => s !== expected && s !== "None")
|
|
67
|
+
.sort((p, q) => q[1] - p[1])[0]?.[0] ?? null;
|
|
68
|
+
if (infraUntrustworthy(actual) || intended.n === 0) {
|
|
69
|
+
return { verdict: "error", competitor,
|
|
70
|
+
remedy: "infrastructure/LLM errors dominated — rerun; the result isn't trustworthy." };
|
|
71
|
+
}
|
|
72
|
+
const actualDecision = sequentialDecision(actual.hits, actual.n, threshold, conf);
|
|
73
|
+
if (actualDecision === "above") {
|
|
74
|
+
// actual is the ground truth. But if the forced-choice router rarely picks it, the description
|
|
75
|
+
// reads ambiguously in isolation — a leading indicator that activation may be fragile.
|
|
76
|
+
const ambiguous = sequentialDecision(intended.hits, intended.n, threshold, conf) === "below";
|
|
77
|
+
const note = ambiguous
|
|
78
|
+
? ` — but the description reads ambiguously in isolation (intended only ${Math.round((intended.hits / intended.n) * 100)}%), ` +
|
|
79
|
+
`so activation may be fragile across models/contexts`
|
|
80
|
+
: "";
|
|
81
|
+
return { verdict: "routes-ok", competitor: null,
|
|
82
|
+
remedy: `fires reliably (${Math.round(actualRel.pHat * 100)}%) — no action needed${note}.` };
|
|
83
|
+
}
|
|
84
|
+
const intendedDecision = sequentialDecision(intended.hits, intended.n, threshold, conf);
|
|
85
|
+
if (intendedDecision === "above") {
|
|
86
|
+
return { verdict: "routing-miss", competitor,
|
|
87
|
+
remedy: `the model picks "${expected}" when asked, but ${competitor ? `"${competitor}"` : "another skill"} ` +
|
|
88
|
+
`wins at activation. Remedy: deconflict the sibling (narrow its description) or inspect with ` +
|
|
89
|
+
`\`skill-probe context\`. Rewriting "${expected}" likely won't help.` };
|
|
90
|
+
}
|
|
91
|
+
if (intendedDecision === "below") {
|
|
92
|
+
return { verdict: "description-problem", competitor,
|
|
93
|
+
remedy: `the model doesn't recognize "${expected}" applies to this request. Remedy: ` +
|
|
94
|
+
`\`skill-probe fix --skill ${expected}\` to rewrite its trigger description.` };
|
|
95
|
+
}
|
|
96
|
+
return { verdict: "inconclusive", competitor,
|
|
97
|
+
remedy: "neither activation nor intent is statistically decided — raise k and rerun." };
|
|
98
|
+
}
|
|
99
|
+
export async function runDiagnose(cfg, deps = {}) {
|
|
100
|
+
const adapter = getAdapter(cfg.runtime);
|
|
101
|
+
const choice = deps.choice ?? defaultChoice;
|
|
102
|
+
const skills = listSkills(cfg.cwd);
|
|
103
|
+
const present = new Set(skills.map((s) => s.name));
|
|
104
|
+
const skipped = [];
|
|
105
|
+
const unknown = [];
|
|
106
|
+
const eligible = [];
|
|
107
|
+
for (const c of cfg.cases) {
|
|
108
|
+
if (c.expected === null)
|
|
109
|
+
skipped.push(c.prompt);
|
|
110
|
+
else if (!present.has(c.expected))
|
|
111
|
+
unknown.push(c.expected);
|
|
112
|
+
else
|
|
113
|
+
eligible.push(c);
|
|
114
|
+
}
|
|
115
|
+
if (unknown.length) {
|
|
116
|
+
throw new ConfigError([
|
|
117
|
+
`expected skill(s) not in ${cfg.cwd}/.claude/skills/: ${[...new Set(unknown)].join(", ")} ` +
|
|
118
|
+
`(typo?). Available: ${skills.map((s) => s.name).join(", ") || "(none)"}.`,
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
const cases = [];
|
|
122
|
+
let totalCost = 0;
|
|
123
|
+
for (let i = 0; i < eligible.length; i++) {
|
|
124
|
+
const c = eligible[i];
|
|
125
|
+
const expected = c.expected;
|
|
126
|
+
const actual = await measure(adapter, c.prompt, expected, {
|
|
127
|
+
cwd: cfg.cwd, maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf,
|
|
128
|
+
...(cfg.model ? { model: cfg.model } : {}),
|
|
129
|
+
...(deps.onProgress ? { onProbe: (validN, maxK) => deps.onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, phase: "actual", validN, maxK }) } : {}),
|
|
130
|
+
});
|
|
131
|
+
const intended = await measureIntended(skills, c.prompt, expected, {
|
|
132
|
+
maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf, choice,
|
|
133
|
+
...(cfg.model ? { model: cfg.model } : {}),
|
|
134
|
+
...(deps.onProgress ? { onProbe: (validN, maxK) => deps.onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, phase: "intended", validN, maxK }) } : {}),
|
|
135
|
+
});
|
|
136
|
+
totalCost += actual.totalCost;
|
|
137
|
+
const actualRel = reliability(actual.hits, actual.n, cfg.conf);
|
|
138
|
+
const intendedRel = reliability(intended.hits, intended.n, cfg.conf);
|
|
139
|
+
const { verdict, competitor, remedy } = classify(actual, actualRel, intended, expected, cfg.threshold, cfg.conf);
|
|
140
|
+
cases.push({
|
|
141
|
+
prompt: c.prompt, expected,
|
|
142
|
+
actual: { stats: actual, rel: actualRel },
|
|
143
|
+
intended: { stats: intended, rel: intendedRel },
|
|
144
|
+
verdict, competitor, remedy,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const hasProblem = cases.some((c) => c.verdict === "routing-miss" || c.verdict === "description-problem");
|
|
148
|
+
const hasBad = cases.some((c) => c.verdict === "error" || c.verdict === "inconclusive");
|
|
149
|
+
const exitCode = hasProblem ? 1 : hasBad ? 2 : 0;
|
|
150
|
+
return {
|
|
151
|
+
runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
|
|
152
|
+
threshold: cfg.threshold, conf: cfg.conf, cases, skipped, totalCost, exitCode,
|
|
153
|
+
};
|
|
154
|
+
}
|
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/orchestrator.d.ts
CHANGED
|
@@ -15,6 +15,10 @@ export interface RunStats {
|
|
|
15
15
|
dist: Record<string, number>;
|
|
16
16
|
totalCost: number;
|
|
17
17
|
}
|
|
18
|
+
/** A measurement is untrustworthy when infrastructure failures dominate — no valid probes, or
|
|
19
|
+
* errors at least equal the valid count. Such a run must NOT read as a behavioral result (a runtime
|
|
20
|
+
* outage is not a clean pass). Shared by the audit, context, and diagnose paths. */
|
|
21
|
+
export declare function infraUntrustworthy(s: RunStats): boolean;
|
|
18
22
|
export declare function measure(adapter: RuntimeAdapter, prompt: string, expected: string | null, opts: {
|
|
19
23
|
cwd: string;
|
|
20
24
|
maxK: number;
|
package/dist/orchestrator.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { sequentialDecision } from "./stats.js";
|
|
2
|
+
/** A measurement is untrustworthy when infrastructure failures dominate — no valid probes, or
|
|
3
|
+
* errors at least equal the valid count. Such a run must NOT read as a behavioral result (a runtime
|
|
4
|
+
* outage is not a clean pass). Shared by the audit, context, and diagnose paths. */
|
|
5
|
+
export function infraUntrustworthy(s) {
|
|
6
|
+
return s.n === 0 || (s.errors > 0 && s.errors >= s.n);
|
|
7
|
+
}
|
|
2
8
|
export async function measure(adapter, prompt, expected, opts) {
|
|
3
9
|
const want = expected ?? "None";
|
|
4
10
|
const dist = {};
|
package/dist/report.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { AuditResult } from "./eval.ts";
|
|
2
2
|
import type { FixResult } from "./fix.ts";
|
|
3
3
|
import type { ContextAuditResult } from "./context.ts";
|
|
4
|
+
import type { DoctorResult } from "./doctor.ts";
|
|
5
|
+
import type { DiagnoseResult } from "./diagnose.ts";
|
|
6
|
+
/** Smallest k at which an all-success run could certify reliability >= threshold. */
|
|
7
|
+
export declare function kToCertify(threshold: number, conf: number): number;
|
|
4
8
|
export interface RenderOpts {
|
|
5
9
|
showCost?: boolean;
|
|
6
10
|
}
|
|
@@ -8,6 +12,9 @@ export declare function renderTable(a: AuditResult, opts?: RenderOpts): string;
|
|
|
8
12
|
/** Markdown table — paste into a PR or README. */
|
|
9
13
|
export declare function renderMarkdown(a: AuditResult, opts?: RenderOpts): string;
|
|
10
14
|
export declare function renderJson(a: AuditResult | unknown): string;
|
|
15
|
+
/** intended (forced-choice routing) vs actual (real activation), classified into a remedy. */
|
|
16
|
+
export declare function renderDiagnose(d: DiagnoseResult, opts?: RenderOpts): string;
|
|
17
|
+
export declare function renderDoctor(d: DoctorResult): string;
|
|
11
18
|
/** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
|
|
12
19
|
export declare function renderContext(a: ContextAuditResult, opts?: RenderOpts): string;
|
|
13
20
|
export declare function renderFix(f: FixResult): string;
|
package/dist/report.js
CHANGED
|
@@ -8,7 +8,7 @@ const LABEL = {
|
|
|
8
8
|
inconclusive: "INCONCLUSIVE", error: "ERROR ",
|
|
9
9
|
};
|
|
10
10
|
/** Smallest k at which an all-success run could certify reliability >= threshold. */
|
|
11
|
-
function kToCertify(threshold, conf) {
|
|
11
|
+
export function kToCertify(threshold, conf) {
|
|
12
12
|
for (let k = 1; k <= 500; k++) {
|
|
13
13
|
if (wilsonInterval(k, k, conf).lo > threshold)
|
|
14
14
|
return k;
|
|
@@ -81,6 +81,49 @@ export function renderMarkdown(a, opts = {}) {
|
|
|
81
81
|
export function renderJson(a) {
|
|
82
82
|
return JSON.stringify(a, null, 2);
|
|
83
83
|
}
|
|
84
|
+
/** intended (forced-choice routing) vs actual (real activation), classified into a remedy. */
|
|
85
|
+
export function renderDiagnose(d, opts = {}) {
|
|
86
|
+
const showCost = opts.showCost ?? true;
|
|
87
|
+
const LBL = {
|
|
88
|
+
"routes-ok": "✅ ROUTES-OK", "routing-miss": "🔀 ROUTING-MISS",
|
|
89
|
+
"description-problem": "✍ DESCRIPTION-PROBLEM", inconclusive: "⚠️ INCONCLUSIVE", error: "⛔ ERROR",
|
|
90
|
+
};
|
|
91
|
+
const out = [];
|
|
92
|
+
out.push(`skill-probe diagnose — runtime: ${d.runtime} model: ${d.model} threshold: ${pct(d.threshold)}`);
|
|
93
|
+
out.push(` actual = real activation · intended = forced-choice routing (does the model even pick it?)`);
|
|
94
|
+
out.push("");
|
|
95
|
+
const dist = (m) => Object.entries(m).sort((p, q) => q[1] - p[1]).map(([s, n]) => `${s}×${n}`).join(", ") || "(none)";
|
|
96
|
+
for (const c of d.cases) {
|
|
97
|
+
out.push(` [${LBL[c.verdict]}] ${c.expected} | ${c.prompt}`);
|
|
98
|
+
out.push(` actual fires ${pct(c.actual.rel.pHat)} [${pct(c.actual.rel.ciLow)}, ${pct(c.actual.rel.ciHigh)}] ` +
|
|
99
|
+
`k=${c.actual.rel.k} (${dist(c.actual.stats.dist)})`);
|
|
100
|
+
out.push(` intended picks ${pct(c.intended.rel.pHat)} [${pct(c.intended.rel.ciLow)}, ${pct(c.intended.rel.ciHigh)}] ` +
|
|
101
|
+
`k=${c.intended.rel.k} (${dist(c.intended.stats.dist)})`);
|
|
102
|
+
out.push(` → ${c.remedy}`);
|
|
103
|
+
}
|
|
104
|
+
if (d.skipped.length) {
|
|
105
|
+
out.push("");
|
|
106
|
+
out.push(` skipped ${d.skipped.length} decoy case(s) — diagnose explains why an expected skill doesn't fire.`);
|
|
107
|
+
}
|
|
108
|
+
out.push("");
|
|
109
|
+
const counts = (v) => d.cases.filter((c) => c.verdict === v).length;
|
|
110
|
+
out.push(`Result: ${counts("routes-ok")} ok / ${counts("routing-miss")} routing-miss / ` +
|
|
111
|
+
`${counts("description-problem")} description-problem / ${counts("inconclusive") + counts("error")} other ` +
|
|
112
|
+
`| exit ${d.exitCode}` + (showCost ? ` | cost $${d.totalCost.toFixed(4)}` : ""));
|
|
113
|
+
return out.join("\n");
|
|
114
|
+
}
|
|
115
|
+
export function renderDoctor(d) {
|
|
116
|
+
const out = ["skill-probe doctor", ""];
|
|
117
|
+
for (const c of d.checks)
|
|
118
|
+
out.push(`${c.status.toUpperCase().padEnd(4)} ${c.message}`);
|
|
119
|
+
out.push("");
|
|
120
|
+
const f = d.checks.filter((c) => c.status === "fail").length;
|
|
121
|
+
const w = d.checks.filter((c) => c.status === "warn").length;
|
|
122
|
+
out.push(f ? `${f} failure(s)` + (w ? `, ${w} warning(s)` : "") + ` | exit ${d.exitCode}`
|
|
123
|
+
: w ? `${w} warning(s) | exit ${d.exitCode}`
|
|
124
|
+
: `all healthy | exit 0`);
|
|
125
|
+
return out.join("\n");
|
|
126
|
+
}
|
|
84
127
|
/** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
|
|
85
128
|
export function renderContext(a, opts = {}) {
|
|
86
129
|
const showCost = opts.showCost ?? true;
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skill-probe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
}
|