skill-probe 0.8.0 → 0.10.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/dist/context.d.ts CHANGED
@@ -1,12 +1,20 @@
1
- import type { Config } from "./types.ts";
1
+ /** `skill-probe context` — activation-rate-BY-CONTEXT. The audit measures every skill co-loaded
2
+ * (the real, hard condition). This command additionally measures each skill in ISOLATION (only that
3
+ * skill present) and compares the two: it catches the "fires fine alone, fails under load" case that
4
+ * a single aggregate rate hides. The drop is tested with Fisher's exact test, so interference is
5
+ * reported as a real effect (p-value), not eyeballed.
6
+ *
7
+ * Every condition (isolation, co-loaded, each leave-one-out ablation) runs in a sandbox built the
8
+ * same way — see sandbox.ts — so the ONLY difference between conditions is which skills are
9
+ * present. When the runtime reports what it loaded, each condition is checked against what it was
10
+ * meant to contain; a mismatch makes the comparison untrustworthy instead of silently wrong. */
11
+ import { type Config } from "./types.ts";
2
12
  import { reliability } from "./stats.ts";
3
- import { type RunStats } from "./orchestrator.ts";
4
- /** Build a throwaway project dir whose .claude/skills/ holds ONLY the named skills (copied from
5
- * srcCwd). Returns its path plus a cleanup(). The probe runs against this dir to load a subset. */
6
- export declare function buildContextDir(srcCwd: string, skillNames: string[]): {
7
- cwd: string;
8
- cleanup: () => void;
9
- };
13
+ import { type Budget, type RunStats } from "./orchestrator.ts";
14
+ import { buildSandbox } from "./sandbox.ts";
15
+ import { type RunManifest } from "./manifest.ts";
16
+ /** Kept for compatibility: a sandbox holding only the named skills (plus instruction files). */
17
+ export declare const buildContextDir: typeof buildSandbox;
10
18
  type Rel = ReturnType<typeof reliability>;
11
19
  interface Condition {
12
20
  stats: RunStats;
@@ -24,6 +32,8 @@ export interface AblationResult {
24
32
  fisherP: number;
25
33
  fisherPAdj: number;
26
34
  untrustworthy: boolean;
35
+ /** why the run isn't a valid comparison, when the runtime loaded the wrong skill set */
36
+ loadIssue?: string;
27
37
  culprit: boolean;
28
38
  }
29
39
  export interface ContextCaseResult {
@@ -37,8 +47,14 @@ export interface ContextCaseResult {
37
47
  * test per case is a FAMILY, and raw p<0.05 over a 40-case library expects ~2 false flags by
38
48
  * chance alone. Interference is decided on THIS value. (1 for untrustworthy cases.) */
39
49
  fisherPAdj: number;
40
- /** infra failures dominated one side — the comparison is not trustworthy (NOT a behavioral result) */
50
+ /** infra failures dominated one side, or a condition loaded the wrong skills — the comparison is
51
+ * not trustworthy (NOT a behavioral result) */
41
52
  untrustworthy: boolean;
53
+ /** why a condition isn't valid, when the runtime loaded the wrong skill set */
54
+ loadIssue?: string;
55
+ /** skills OUTSIDE the library (e.g. runtime built-ins) that fired in the co-loaded run — they
56
+ * can't be ablated, only avoided (narrow or rename the expected skill) */
57
+ externalThieves?: string[];
42
58
  /** fires reliably alone but drops significantly (BH-corrected) when the library is co-loaded */
43
59
  interference: boolean;
44
60
  /** leave-one-out runs (only with { ablate: true }, only for interference cases). Empty array =
@@ -56,6 +72,10 @@ export interface ContextAuditResult {
56
72
  totalCost: number;
57
73
  /** 0 = no interference; 1 = a skill is suppressed under load; 2 = an untrustworthy (infra) case */
58
74
  exitCode: 0 | 1 | 2;
75
+ /** who/what/when of this run — makes reports citable and comparable */
76
+ manifest: RunManifest;
77
+ /** run-level caveats (built-in name clashes, budget cut-offs) */
78
+ warnings: string[];
59
79
  }
60
80
  export interface ContextProgress {
61
81
  caseIndex: number;
@@ -74,6 +94,9 @@ export interface ContextOpts {
74
94
  /** max suspect siblings to ablate per flagged case, ranked by probes stolen (default 3) */
75
95
  suspects?: number;
76
96
  onProgress?: (e: ContextProgress) => void;
97
+ /** cases (and ablation runs) probed in parallel (default 1) */
98
+ concurrency?: number;
99
+ budget?: Budget;
77
100
  }
78
101
  export declare function runContextAudit(cfg: Config, optsOrProgress?: ContextOpts | ((e: ContextProgress) => void)): Promise<ContextAuditResult>;
79
102
  export {};
package/dist/context.js CHANGED
@@ -2,38 +2,41 @@
2
2
  * (the real, hard condition). This command additionally measures each skill in ISOLATION (only that
3
3
  * skill present) and compares the two: it catches the "fires fine alone, fails under load" case that
4
4
  * a single aggregate rate hides. The drop is tested with Fisher's exact test, so interference is
5
- * reported as a real effect (p-value), not eyeballed. */
6
- import { mkdtempSync, mkdirSync, cpSync, rmSync, existsSync } from "node:fs";
7
- import { tmpdir } from "node:os";
8
- import { join } from "node:path";
5
+ * reported as a real effect (p-value), not eyeballed.
6
+ *
7
+ * Every condition (isolation, co-loaded, each leave-one-out ablation) runs in a sandbox built the
8
+ * same way — see sandbox.ts — so the ONLY difference between conditions is which skills are
9
+ * present. When the runtime reports what it loaded, each condition is checked against what it was
10
+ * meant to contain; a mismatch makes the comparison untrustworthy instead of silently wrong. */
11
+ import { probeSettings } from "./types.js";
9
12
  import { reliability, fisherExact2x2, bhAdjust } from "./stats.js";
10
- import { measure, infraUntrustworthy } from "./orchestrator.js";
13
+ import { measure, mapPool, infraUntrustworthy, loadProblems, extraSkills, builtinClashes, } from "./orchestrator.js";
11
14
  import { getAdapter } from "./adapters/index.js";
12
- import { listSkills } from "./gen.js";
15
+ import { listSkills } from "./skills.js";
16
+ import { buildSandbox, withSandbox } from "./sandbox.js";
13
17
  import { ConfigError } from "./config.js";
14
- /** Build a throwaway project dir whose .claude/skills/ holds ONLY the named skills (copied from
15
- * srcCwd). Returns its path plus a cleanup(). The probe runs against this dir to load a subset. */
16
- export function buildContextDir(srcCwd, skillNames) {
17
- const root = mkdtempSync(join(tmpdir(), "skill-probe-ctx-"));
18
- const destSkills = join(root, ".claude", "skills");
19
- mkdirSync(destSkills, { recursive: true });
20
- for (const name of skillNames) {
21
- const src = join(srcCwd, ".claude", "skills", name);
22
- if (!existsSync(src))
23
- throw new Error(`skill not found in ${srcCwd}: ${name}`);
24
- cpSync(src, join(destSkills, name), { recursive: true });
25
- }
26
- return { cwd: root, cleanup: () => rmSync(root, { recursive: true, force: true }) };
18
+ import { buildManifest, buildEnvironment, capabilityWarnings } from "./manifest.js";
19
+ /** Kept for compatibility: a sandbox holding only the named skills (plus instruction files). */
20
+ export const buildContextDir = buildSandbox;
21
+ /** "" when the condition loaded exactly what it should have; otherwise a human-readable reason. */
22
+ function describeLoadIssue(label, stats, intended, library, ambiguous) {
23
+ const { missing, unexpected } = loadProblems(stats.env, intended, library, ambiguous);
24
+ const parts = [];
25
+ if (missing.length)
26
+ parts.push(`did not load ${missing.join(", ")}`);
27
+ if (unexpected.length)
28
+ parts.push(`also loaded ${unexpected.join(", ")}`);
29
+ return parts.length ? `${label}: the runtime ${parts.join(" and ")}` : "";
27
30
  }
31
+ const fisherVs = (a, b) => (a.n > 0 && b.n > 0) ? fisherExact2x2(a.hits, a.n - a.hits, b.hits, b.n - b.hits) : 1;
28
32
  export async function runContextAudit(cfg, optsOrProgress = {}) {
29
33
  const opts = typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress;
30
34
  const onProgress = opts.onProgress;
31
35
  const adapter = getAdapter(cfg.runtime);
32
36
  const library = listSkills(cfg.cwd);
33
- const present = new Set(library.map((s) => s.name));
34
- const measured = [];
37
+ const libraryNames = library.map((s) => s.name);
38
+ const present = new Set(libraryNames);
35
39
  const skipped = [];
36
- let totalCost = 0;
37
40
  // decoys (null expected) have nothing to isolate → skipped. An expected skill that ISN'T in the
38
41
  // library is almost always a config typo: fail loud BEFORE spending probes, never silently skip.
39
42
  const eligible = [];
@@ -44,54 +47,73 @@ export async function runContextAudit(cfg, optsOrProgress = {}) {
44
47
  else if (!present.has(c.expected))
45
48
  unknown.push(c.expected);
46
49
  else
47
- eligible.push(c);
50
+ eligible.push({ prompt: c.prompt, expected: c.expected });
48
51
  }
49
52
  if (unknown.length) {
50
53
  throw new ConfigError([
51
54
  `expected skill(s) not in ${cfg.cwd}/.claude/skills/: ${[...new Set(unknown)].join(", ")} ` +
52
- `(typo?). Available: ${library.map((s) => s.name).join(", ") || "(none)"}.`,
55
+ `(typo?). Available: ${libraryNames.join(", ") || "(none)"}.`,
53
56
  ]);
54
57
  }
55
- const common = { maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf, ...(cfg.model ? { model: cfg.model } : {}) };
56
- for (let i = 0; i < eligible.length; i++) {
57
- const c = eligible[i];
58
- const expected = c.expected;
59
- // isolation: a temp project containing only this skill
60
- const ctx = buildContextDir(cfg.cwd, [expected]);
61
- let iso;
62
- try {
63
- iso = await measure(adapter, c.prompt, expected, {
64
- cwd: ctx.cwd, ...common,
65
- ...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, condition: "isolation", validN, maxK }) } : {}),
66
- });
67
- }
68
- finally {
69
- ctx.cleanup();
70
- }
71
- // co-loaded: the real library at cfg.cwd
72
- const co = await measure(adapter, c.prompt, expected, {
73
- cwd: cfg.cwd, ...common,
74
- ...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, condition: "co-loaded", validN, maxK }) } : {}),
58
+ const common = {
59
+ maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf, ...probeSettings(cfg),
60
+ ...(opts.budget ? { budget: opts.budget } : {}),
61
+ };
62
+ const progress = (caseIndex, caseTotal, prompt, condition, removed) => onProgress
63
+ ? { onProbe: (validN, maxK) => onProgress({ caseIndex, caseTotal, prompt, condition, validN, maxK, ...(removed ? { removed } : {}) }) }
64
+ : {};
65
+ // the full library, shared read-only by every co-loaded run
66
+ const measured = await withSandbox(cfg.cwd, libraryNames, (full) => mapPool(eligible, opts.concurrency ?? 1, async (c, i) => {
67
+ const iso = await withSandbox(cfg.cwd, [c.expected], (sb) => measure(adapter, c.prompt, c.expected, {
68
+ cwd: sb.cwd, ...common, ...progress(i + 1, eligible.length, c.prompt, "isolation"),
69
+ }));
70
+ const co = await measure(adapter, c.prompt, c.expected, {
71
+ cwd: full.cwd, ...common, ...progress(i + 1, eligible.length, c.prompt, "co-loaded"),
75
72
  });
76
- totalCost += iso.totalCost + co.totalCost;
77
73
  const isoRel = reliability(iso.hits, iso.n, cfg.conf);
78
74
  const coRel = reliability(co.hits, co.n, cfg.conf);
79
- // 2x2: rows = isolation/co-loaded, cols = hit/miss
80
- const fisherP = (iso.n > 0 && co.n > 0)
81
- ? fisherExact2x2(iso.hits, iso.n - iso.hits, co.hits, co.n - co.hits)
82
- : 1;
83
- const deltaP = coRel.pHat - isoRel.pHat;
84
- // if infra failures dominated either side, the comparison isn't a behavioral result at all —
85
- // it must never read as "no interference / pass" just because fisherP defaulted to 1.
75
+ // load-set integrity is judged after all cases (it may need the runtime's built-in names)
86
76
  const untrustworthy = infraUntrustworthy(iso) || infraUntrustworthy(co);
87
- // interference is decided AFTER the loop, once the whole family of comparisons is known
88
- // (Benjamini-Hochberg) — placeholders here.
89
- measured.push({
90
- prompt: c.prompt, expected,
77
+ const externalThieves = Object.keys(co.dist).filter((s) => s !== c.expected && s !== "None" && !present.has(s));
78
+ // interference is decided AFTER all cases, once the whole family is known (BH) — placeholders
79
+ return {
80
+ prompt: c.prompt, expected: c.expected,
91
81
  isolation: { stats: iso, rel: isoRel },
92
82
  coLoaded: { stats: co, rel: coRel },
93
- deltaP, fisherP, fisherPAdj: 1, untrustworthy, interference: false,
94
- });
83
+ deltaP: coRel.pHat - isoRel.pHat, fisherP: fisherVs(iso, co), fisherPAdj: 1,
84
+ untrustworthy, interference: false,
85
+ ...(externalThieves.length ? { externalThieves } : {}),
86
+ };
87
+ }));
88
+ // Did each condition load exactly the skills it should? A library skill that shares a name with a
89
+ // runtime built-in looks "loaded" everywhere, so when anything looks wrong, learn the built-in
90
+ // names once (an empty sandbox) and leave those ambiguous names out of the check.
91
+ let ambiguous = new Set();
92
+ let extraCost = 0;
93
+ let builtinsProbe;
94
+ // memoized promise (not a flag): concurrent ablation runs must all wait for the same answer
95
+ const learnBuiltins = () => (builtinsProbe ??= (async () => {
96
+ const r = await withSandbox(cfg.cwd, [], (sb) => adapter.probe("Reply with exactly: ok", { cwd: sb.cwd, ...probeSettings(cfg) }));
97
+ extraCost += r.cost;
98
+ opts.budget?.add(r.cost);
99
+ if (r.status === "ok" && r.env?.loadedSkills)
100
+ ambiguous = new Set(builtinClashes(r.env.loadedSkills, library));
101
+ })());
102
+ const looksWrong = (st, intended) => loadProblems(st.env, intended, library).unexpected.length > 0;
103
+ if (measured.some((m) => looksWrong(m.isolation.stats, [m.expected]) || looksWrong(m.coLoaded.stats, libraryNames))) {
104
+ await learnBuiltins();
105
+ }
106
+ for (const m of measured) {
107
+ const loadIssue = [
108
+ describeLoadIssue("isolation", m.isolation.stats, [m.expected], library, ambiguous),
109
+ describeLoadIssue("co-loaded", m.coLoaded.stats, libraryNames, library, ambiguous),
110
+ ].filter(Boolean).join("; ");
111
+ // a condition that didn't contain what it should isn't a behavioral result — it must never
112
+ // read as "no interference / pass".
113
+ if (loadIssue) {
114
+ m.loadIssue = loadIssue;
115
+ m.untrustworthy = true;
116
+ }
95
117
  }
96
118
  // One Fisher test per case = a family of comparisons: correct across the trustworthy ones so a
97
119
  // big library doesn't buy false interference flags by chance (raw p<0.05 × 40 cases ≈ 2 flukes).
@@ -111,46 +133,39 @@ export async function runContextAudit(cfg, optsOrProgress = {}) {
111
133
  // BH-corrected across all ablation runs (their own family). A significant recovery NAMES the
112
134
  // thief; no suspects in the outcome distribution means the drop was dilution, not theft.
113
135
  if (opts.ablate) {
114
- const libraryNames = library.map((s) => s.name);
115
136
  const flagged = measured.filter((m) => m.interference);
116
137
  const cap = opts.suspects ?? 3;
117
- const allRuns = [];
118
- for (let fi = 0; fi < flagged.length; fi++) {
119
- const m = flagged[fi];
120
- const suspects = Object.entries(m.coLoaded.stats.dist)
121
- .filter(([name]) => name !== m.expected && name !== "None")
122
- .sort((x, y) => y[1] - x[1])
123
- .slice(0, cap);
138
+ const jobs = [];
139
+ flagged.forEach((m, fi) => {
124
140
  m.ablation = [];
125
- for (const [suspect, stolen] of suspects) {
126
- const ctx = buildContextDir(cfg.cwd, libraryNames.filter((n) => n !== suspect));
127
- let st;
128
- try {
129
- st = await measure(adapter, m.prompt, m.expected, {
130
- cwd: ctx.cwd, ...common,
131
- ...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: fi + 1, caseTotal: flagged.length, prompt: m.prompt, condition: "ablation", removed: suspect, validN, maxK }) } : {}),
132
- });
133
- }
134
- finally {
135
- ctx.cleanup();
136
- }
137
- totalCost += st.totalCost;
138
- const rel = reliability(st.hits, st.n, cfg.conf);
139
- const co = m.coLoaded.stats;
140
- const fisherP = (st.n > 0 && co.n > 0)
141
- ? fisherExact2x2(st.hits, st.n - st.hits, co.hits, co.n - co.hits)
142
- : 1;
143
- const abl = {
144
- removed: suspect, stolen, stats: st, rel,
145
- deltaVsCoLoaded: rel.pHat - m.coLoaded.rel.pHat,
146
- fisherP, fisherPAdj: 1,
147
- untrustworthy: infraUntrustworthy(st),
148
- culprit: false,
149
- };
150
- m.ablation.push(abl);
151
- allRuns.push(abl);
152
- }
153
- }
141
+ Object.entries(m.coLoaded.stats.dist)
142
+ .filter(([name]) => name !== m.expected && name !== "None" && present.has(name))
143
+ .sort((x, y) => y[1] - x[1])
144
+ .slice(0, cap)
145
+ .forEach(([suspect, stolen]) => jobs.push({ m, fi, suspect, stolen }));
146
+ });
147
+ const runs = await mapPool(jobs, opts.concurrency ?? 1, async ({ m, fi, suspect, stolen }) => {
148
+ const kept = libraryNames.filter((n) => n !== suspect);
149
+ const st = await withSandbox(cfg.cwd, kept, (sb) => measure(adapter, m.prompt, m.expected, {
150
+ cwd: sb.cwd, ...common, ...progress(fi + 1, flagged.length, m.prompt, "ablation", suspect),
151
+ }));
152
+ const rel = reliability(st.hits, st.n, cfg.conf);
153
+ if (looksWrong(st, kept))
154
+ await learnBuiltins();
155
+ const loadIssue = describeLoadIssue(`without ${suspect}`, st, kept, library, ambiguous);
156
+ const abl = {
157
+ removed: suspect, stolen, stats: st, rel,
158
+ deltaVsCoLoaded: rel.pHat - m.coLoaded.rel.pHat,
159
+ fisherP: fisherVs(st, m.coLoaded.stats), fisherPAdj: 1,
160
+ untrustworthy: infraUntrustworthy(st) || Boolean(loadIssue),
161
+ ...(loadIssue ? { loadIssue } : {}),
162
+ culprit: false,
163
+ };
164
+ return { m, abl };
165
+ });
166
+ for (const { m, abl } of runs)
167
+ m.ablation.push(abl);
168
+ const allRuns = runs.map((r) => r.abl);
154
169
  const ablFamily = allRuns.filter((r) => !r.untrustworthy);
155
170
  const ablAdj = bhAdjust(ablFamily.map((r) => r.fisherP));
156
171
  ablFamily.forEach((r, j) => { r.fisherPAdj = ablAdj[j]; });
@@ -163,9 +178,23 @@ export async function runContextAudit(cfg, optsOrProgress = {}) {
163
178
  const exitCode = measured.some((m) => m.interference) ? 1
164
179
  : measured.some((m) => m.untrustworthy) ? 2
165
180
  : 0;
181
+ const totalCost = extraCost + measured.reduce((s, m) => s + m.isolation.stats.totalCost + m.coLoaded.stats.totalCost +
182
+ (m.ablation ?? []).reduce((t, a) => t + a.stats.totalCost, 0), 0);
183
+ const env = measured.find((m) => m.coLoaded.stats.env)?.coLoaded.stats.env;
184
+ const warnings = capabilityWarnings(cfg, adapter);
185
+ if (ambiguous.size) {
186
+ warnings.push(`${[...ambiguous].join(", ")} share${ambiguous.size === 1 ? "s" : ""} a name with a ${cfg.runtime} ` +
187
+ `built-in skill — the two compete for the same trigger, and load checks can't tell them apart (rename recommended)`);
188
+ }
189
+ const allStats = measured.flatMap((m) => [m.isolation.stats, m.coLoaded.stats, ...(m.ablation ?? []).map((a) => a.stats)]);
190
+ if (allStats.some((st) => st.budgetExhausted)) {
191
+ warnings.push(`--max-cost budget reached — some conditions were cut short (inconclusive or error, never a clean result)`);
192
+ }
166
193
  return {
167
194
  runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
168
195
  threshold: cfg.threshold, conf: cfg.conf, librarySize: library.length,
169
196
  cases: measured, skipped, totalCost, exitCode,
197
+ manifest: buildManifest("context", cfg, buildEnvironment(cfg, env, extraSkills(env, library), adapter)),
198
+ warnings,
170
199
  };
171
200
  }
@@ -11,10 +11,11 @@
11
11
  * We measure ACTUAL activation (the real runtime probe) and INTENDED routing (a forced-choice: the
12
12
  * model is shown the skill descriptions and asked which one applies, with no auto-execution), then
13
13
  * classify the gap between them. */
14
- import type { Config } from "./types.ts";
14
+ import { type Config } from "./types.ts";
15
15
  import { reliability } from "./stats.ts";
16
- import { type RunStats } from "./orchestrator.ts";
17
- import { type SkillInfo } from "./gen.ts";
16
+ import { type Budget, type RunStats } from "./orchestrator.ts";
17
+ import { type SkillInfo } from "./skills.ts";
18
+ import { type RunManifest } from "./manifest.ts";
18
19
  export type DiagVerdict = "routes-ok" | "routing-miss" | "description-problem" | "inconclusive" | "error";
19
20
  /** Returns the chosen skill name, "none", or null when the answer is unusable (LLM error/garbage). */
20
21
  export type ChoiceFn = (skills: SkillInfo[], prompt: string, model?: string) => Promise<string | null>;
@@ -50,6 +51,10 @@ export interface DiagnoseResult {
50
51
  skipped: string[];
51
52
  totalCost: number;
52
53
  exitCode: 0 | 1 | 2;
54
+ /** who/what/when of this run — makes reports citable and comparable */
55
+ manifest: RunManifest;
56
+ /** run-level caveats (runtime capabilities, budget cut-offs) */
57
+ warnings: string[];
53
58
  }
54
59
  export interface DiagProgress {
55
60
  caseIndex: number;
@@ -61,8 +66,12 @@ export interface DiagProgress {
61
66
  }
62
67
  export declare function normalizeChoice(text: string, skills: SkillInfo[]): string | null;
63
68
  export declare const defaultChoice: ChoiceFn;
64
- export declare function runDiagnose(cfg: Config, deps?: {
69
+ export interface DiagnoseOpts {
65
70
  choice?: ChoiceFn;
66
71
  onProgress?: (e: DiagProgress) => void;
67
- }): Promise<DiagnoseResult>;
72
+ /** cases diagnosed in parallel (default 1) */
73
+ concurrency?: number;
74
+ budget?: Budget;
75
+ }
76
+ export declare function runDiagnose(cfg: Config, deps?: DiagnoseOpts): Promise<DiagnoseResult>;
68
77
  export {};
package/dist/diagnose.js CHANGED
@@ -1,9 +1,24 @@
1
- import { reliability, sequentialDecision } from "./stats.js";
2
- import { measure, infraUntrustworthy } from "./orchestrator.js";
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 { probeSettings } from "./types.js";
15
+ import { reliability, planDecision, sequentialPlan } from "./stats.js";
16
+ import { measure, mapPool, infraUntrustworthy, extraSkills } from "./orchestrator.js";
3
17
  import { getAdapter } from "./adapters/index.js";
4
- import { listSkills } from "./gen.js";
18
+ import { listSkills } from "./skills.js";
5
19
  import { complete } from "./llm.js";
6
20
  import { ConfigError } from "./config.js";
21
+ import { buildManifest, buildEnvironment, capabilityWarnings } from "./manifest.js";
7
22
  const ROUTER_SYSTEM = "You are a skill router. Given a user request and a list of available skills (name + " +
8
23
  "description), decide which single skill should handle the request. Reply with EXACTLY one skill " +
9
24
  "name from the list, or the word none if no skill fits. Output only that one word — no punctuation, " +
@@ -56,12 +71,12 @@ async function measureIntended(skills, prompt, expected, o) {
56
71
  hits++;
57
72
  n++;
58
73
  o.onProbe?.(n, o.maxK);
59
- if (n >= 3 && sequentialDecision(hits, n, o.threshold, o.conf) !== "undecided")
74
+ if (planDecision(o.plan, hits, n) !== "undecided")
60
75
  break;
61
76
  }
62
77
  return { dist, hits, n, errors };
63
78
  }
64
- function classify(actual, actualRel, intended, expected, threshold, conf) {
79
+ function classify(actual, actualRel, intended, expected, plan) {
65
80
  const competitor = Object.entries(actual.dist)
66
81
  .filter(([s]) => s !== expected && s !== "None")
67
82
  .sort((p, q) => q[1] - p[1])[0]?.[0] ?? null;
@@ -69,11 +84,11 @@ function classify(actual, actualRel, intended, expected, threshold, conf) {
69
84
  return { verdict: "error", competitor,
70
85
  remedy: "infrastructure/LLM errors dominated — rerun; the result isn't trustworthy." };
71
86
  }
72
- const actualDecision = sequentialDecision(actual.hits, actual.n, threshold, conf);
87
+ const actualDecision = planDecision(plan, actual.hits, actual.n);
73
88
  if (actualDecision === "above") {
74
89
  // actual is the ground truth. But if the forced-choice router rarely picks it, the description
75
90
  // reads ambiguously in isolation — a leading indicator that activation may be fragile.
76
- const ambiguous = sequentialDecision(intended.hits, intended.n, threshold, conf) === "below";
91
+ const ambiguous = planDecision(plan, intended.hits, intended.n) === "below";
77
92
  const note = ambiguous
78
93
  ? ` — but the description reads ambiguously in isolation (intended only ${Math.round((intended.hits / intended.n) * 100)}%), ` +
79
94
  `so activation may be fragile across models/contexts`
@@ -81,7 +96,7 @@ function classify(actual, actualRel, intended, expected, threshold, conf) {
81
96
  return { verdict: "routes-ok", competitor: null,
82
97
  remedy: `fires reliably (${Math.round(actualRel.pHat * 100)}%) — no action needed${note}.` };
83
98
  }
84
- const intendedDecision = sequentialDecision(intended.hits, intended.n, threshold, conf);
99
+ const intendedDecision = planDecision(plan, intended.hits, intended.n);
85
100
  if (intendedDecision === "above") {
86
101
  return { verdict: "routing-miss", competitor,
87
102
  remedy: `the model picks "${expected}" when asked, but ${competitor ? `"${competitor}"` : "another skill"} ` +
@@ -101,6 +116,7 @@ export async function runDiagnose(cfg, deps = {}) {
101
116
  const choice = deps.choice ?? defaultChoice;
102
117
  const skills = listSkills(cfg.cwd);
103
118
  const present = new Set(skills.map((s) => s.name));
119
+ const plan = sequentialPlan(cfg.k, cfg.threshold, cfg.conf);
104
120
  const skipped = [];
105
121
  const unknown = [];
106
122
  const eligible = [];
@@ -110,7 +126,7 @@ export async function runDiagnose(cfg, deps = {}) {
110
126
  else if (!present.has(c.expected))
111
127
  unknown.push(c.expected);
112
128
  else
113
- eligible.push(c);
129
+ eligible.push({ prompt: c.prompt, expected: c.expected });
114
130
  }
115
131
  if (unknown.length) {
116
132
  throw new ConfigError([
@@ -118,37 +134,42 @@ export async function runDiagnose(cfg, deps = {}) {
118
134
  `(typo?). Available: ${skills.map((s) => s.name).join(", ") || "(none)"}.`,
119
135
  ]);
120
136
  }
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;
137
+ const onProgress = deps.onProgress;
138
+ const cases = await mapPool(eligible, deps.concurrency ?? 1, async (c, i) => {
139
+ const { expected } = c;
126
140
  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 }) } : {}),
141
+ cwd: cfg.cwd, maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf, ...probeSettings(cfg),
142
+ ...(deps.budget ? { budget: deps.budget } : {}),
143
+ ...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, phase: "actual", validN, maxK }) } : {}),
130
144
  });
131
145
  const intended = await measureIntended(skills, c.prompt, expected, {
132
- maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf, choice,
146
+ maxK: cfg.k, plan, choice,
133
147
  ...(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 }) } : {}),
148
+ ...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, phase: "intended", validN, maxK }) } : {}),
135
149
  });
136
- totalCost += actual.totalCost;
137
150
  const actualRel = reliability(actual.hits, actual.n, cfg.conf);
138
151
  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({
152
+ const { verdict, competitor, remedy } = classify(actual, actualRel, intended, expected, plan);
153
+ return {
141
154
  prompt: c.prompt, expected,
142
155
  actual: { stats: actual, rel: actualRel },
143
156
  intended: { stats: intended, rel: intendedRel },
144
157
  verdict, competitor, remedy,
145
- });
146
- }
158
+ };
159
+ });
160
+ const totalCost = cases.reduce((s, c) => s + c.actual.stats.totalCost, 0);
147
161
  const hasProblem = cases.some((c) => c.verdict === "routing-miss" || c.verdict === "description-problem");
148
162
  const hasBad = cases.some((c) => c.verdict === "error" || c.verdict === "inconclusive");
149
163
  const exitCode = hasProblem ? 1 : hasBad ? 2 : 0;
164
+ const env = cases.find((c) => c.actual.stats.env)?.actual.stats.env;
165
+ const warnings = capabilityWarnings(cfg, adapter);
166
+ if (cases.some((c) => c.actual.stats.budgetExhausted)) {
167
+ warnings.push(`--max-cost budget reached — cases cut short are inconclusive (error if they never started)`);
168
+ }
150
169
  return {
151
170
  runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
152
171
  threshold: cfg.threshold, conf: cfg.conf, cases, skipped, totalCost, exitCode,
172
+ manifest: buildManifest("diagnose", cfg, buildEnvironment(cfg, env, extraSkills(env, skills), adapter)),
173
+ warnings,
153
174
  };
154
175
  }
package/dist/doctor.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ProbeResult, ProbeOpts } from "./types.ts";
1
+ import { type ProbeResult, type ProbeOpts } from "./types.ts";
2
2
  export type CheckStatus = "pass" | "warn" | "fail" | "info";
3
3
  export interface Check {
4
4
  status: CheckStatus;
@@ -14,6 +14,7 @@ export interface DoctorOpts {
14
14
  runtime?: string;
15
15
  model?: string;
16
16
  skipProbe?: boolean;
17
+ userConfig?: boolean;
17
18
  }
18
19
  /** Injection seam so tests don't shell out to a real runtime. */
19
20
  export interface DoctorDeps {