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 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
- ANTHROPIC_API_KEY=sk-ant-... skill-probe gen --cwd . > probe.config.json
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` (and `fix`) use `ANTHROPIC_API_KEY` if it's set, otherwise they fall back to the logged-in
47
- **`claude` CLI** — so on a Claude subscription the whole tool (audit, gen, fix) works with **no API
48
- key at all**.
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 (v0.5). **Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping +
187
- four-state verdict (pass / fail / inconclusive / **error**), across two runtimes (Claude Code,
188
- OpenCode). Infrastructure failures (timeout / auth / crash / empty output) are reported as
189
- `error`, never as a behavioral pass/fail — a decoy can't falsely pass because the runtime was down.
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 audit(values);
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 {};
@@ -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
+ }
@@ -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
+ }
@@ -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;
@@ -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.5.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": { "skill-probe": "dist/cli.js" },
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": ["agent-skills", "skill", "eval", "claude-code", "mcp", "SKILL.md", "trigger"],
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": { "type": "git", "url": "git+https://github.com/HystonKayange/skill-probe.git" },
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": { "url": "https://github.com/HystonKayange/skill-probe/issues" },
20
- "engines": { "node": ">=22.18" },
21
- "devDependencies": { "typescript": "^5.5.0", "@types/node": "^22.0.0" },
22
- "files": ["dist", "examples", "README.md", "LICENSE"]
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
  }