skill-probe 0.6.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 +76 -9
- package/dist/cli.js +30 -2
- package/dist/context.js +1 -7
- package/dist/diagnose.d.ts +68 -0
- package/dist/diagnose.js +154 -0
- package/dist/orchestrator.d.ts +4 -0
- package/dist/orchestrator.js +6 -0
- package/dist/report.d.ts +3 -0
- package/dist/report.js +31 -0
- package/package.json +1 -1
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.
|
|
@@ -62,7 +75,7 @@ the live (costing) auth check.
|
|
|
62
75
|
Don't want to hand-write the cases? Draft them from your skills, then review:
|
|
63
76
|
|
|
64
77
|
```bash
|
|
65
|
-
|
|
78
|
+
skill-probe gen --cwd . > probe.config.json
|
|
66
79
|
```
|
|
67
80
|
It reads each `.claude/skills/*/SKILL.md`, and an LLM drafts realistic should-fire prompts per
|
|
68
81
|
skill, cross-skill **near-misses** (to surface mis-routing), and off-topic **decoys**. Hallucinated
|
|
@@ -72,9 +85,9 @@ skill names are dropped automatically. Flags: `--per-skill <n>` (default 3), `--
|
|
|
72
85
|
> **Always review the draft before running.** Generators lean toward obvious keyword-matches; you
|
|
73
86
|
> add the messy, oblique phrasings real users type (that's where triggering actually fails).
|
|
74
87
|
|
|
75
|
-
`gen`
|
|
76
|
-
**`claude` CLI** — so on a Claude subscription the whole tool
|
|
77
|
-
|
|
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**.
|
|
78
91
|
|
|
79
92
|
## Use
|
|
80
93
|
|
|
@@ -183,6 +196,47 @@ in the library is a config error (typo), surfaced before any probes run. Exit `1
|
|
|
183
196
|
shows interference, `2` if a case is untrustworthy (infra errors dominated — never a silent pass),
|
|
184
197
|
else `0`.
|
|
185
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
|
+
|
|
186
240
|
## Fix a failing skill (`skill-probe fix`)
|
|
187
241
|
|
|
188
242
|
Rewrite a skill's trigger description and **prove the lift is real** before keeping it:
|
|
@@ -212,14 +266,27 @@ doesn't measurably help is reverted.
|
|
|
212
266
|
|
|
213
267
|
## Status
|
|
214
268
|
|
|
215
|
-
Early
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
`
|
|
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.
|
|
219
282
|
|
|
220
283
|
**Context (`skill-probe context`):** isolation-vs-co-loaded activation rates, with **Fisher's exact
|
|
221
284
|
test** on the drop — catches skills that fire alone but are suppressed under the full library's load.
|
|
222
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
|
+
|
|
223
290
|
**Fix (`skill-probe fix`):** uses the **Bayesian Beta-Binomial** to gate description rewrites on a
|
|
224
291
|
*proven* lift (interleaved before/after, applied only if P(improvement) clears the bar).
|
|
225
292
|
|
package/dist/cli.js
CHANGED
|
@@ -8,15 +8,17 @@ 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";
|
|
11
12
|
import { runDoctor } from "./doctor.js";
|
|
12
13
|
import { listSkills, generateCases, genConfig } from "./gen.js";
|
|
13
14
|
import { loadConfig, parseProbability, parseIntFlag, ConfigError } from "./config.js";
|
|
14
|
-
import { renderTable, renderMarkdown, renderJson, renderFix, renderContext, renderDoctor } from "./report.js";
|
|
15
|
+
import { renderTable, renderMarkdown, renderJson, renderFix, renderContext, renderDoctor, renderDiagnose } from "./report.js";
|
|
15
16
|
const HELP = `skill-probe — audit a co-loaded agent skill library by real activation behavior.
|
|
16
17
|
|
|
17
18
|
USAGE
|
|
18
19
|
skill-probe --config <file.json> [options] # audit
|
|
19
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
|
|
20
22
|
skill-probe doctor [--cwd <dir>] [--config <file.json>] # preflight: setup/auth/config sanity
|
|
21
23
|
skill-probe gen [--cwd <dir>] > probe.config.json # draft a test config from your skills
|
|
22
24
|
skill-probe fix --config <file.json> --skill <name> [opts] # rewrite a description, prove the lift
|
|
@@ -78,6 +80,17 @@ function makeContextProgress(values) {
|
|
|
78
80
|
progressLen = line.length;
|
|
79
81
|
};
|
|
80
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
|
+
}
|
|
81
94
|
async function audit(values) {
|
|
82
95
|
const cfg = loadConfig(values["config"], {
|
|
83
96
|
runtime: values["runtime"], model: values["model"],
|
|
@@ -93,6 +106,20 @@ async function audit(values) {
|
|
|
93
106
|
console.log(text);
|
|
94
107
|
return result.exitCode;
|
|
95
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
|
+
}
|
|
96
123
|
async function doctor(values) {
|
|
97
124
|
const result = await runDoctor({
|
|
98
125
|
cwd: values["cwd"] ?? ".",
|
|
@@ -191,7 +218,8 @@ async function main() {
|
|
|
191
218
|
}
|
|
192
219
|
code = cmd === "fix" ? await fix(values)
|
|
193
220
|
: cmd === "context" ? await context(values)
|
|
194
|
-
: await
|
|
221
|
+
: cmd === "diagnose" ? await diagnose(values)
|
|
222
|
+
: await audit(values);
|
|
195
223
|
}
|
|
196
224
|
process.exit(code);
|
|
197
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/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
|
@@ -2,6 +2,7 @@ import type { AuditResult } from "./eval.ts";
|
|
|
2
2
|
import type { FixResult } from "./fix.ts";
|
|
3
3
|
import type { ContextAuditResult } from "./context.ts";
|
|
4
4
|
import type { DoctorResult } from "./doctor.ts";
|
|
5
|
+
import type { DiagnoseResult } from "./diagnose.ts";
|
|
5
6
|
/** Smallest k at which an all-success run could certify reliability >= threshold. */
|
|
6
7
|
export declare function kToCertify(threshold: number, conf: number): number;
|
|
7
8
|
export interface RenderOpts {
|
|
@@ -11,6 +12,8 @@ export declare function renderTable(a: AuditResult, opts?: RenderOpts): string;
|
|
|
11
12
|
/** Markdown table — paste into a PR or README. */
|
|
12
13
|
export declare function renderMarkdown(a: AuditResult, opts?: RenderOpts): string;
|
|
13
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;
|
|
14
17
|
export declare function renderDoctor(d: DoctorResult): string;
|
|
15
18
|
/** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
|
|
16
19
|
export declare function renderContext(a: ContextAuditResult, opts?: RenderOpts): string;
|
package/dist/report.js
CHANGED
|
@@ -81,6 +81,37 @@ 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
|
+
}
|
|
84
115
|
export function renderDoctor(d) {
|
|
85
116
|
const out = ["skill-probe doctor", ""];
|
|
86
117
|
for (const c of d.checks)
|
package/package.json
CHANGED