skill-probe 0.9.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/README.md +118 -35
- package/assets/skill-probe-thief-demo.gif +0 -0
- package/assets/skill-probe-thief-demo.png +0 -0
- package/dist/adapters/claudeCode.d.ts +17 -1
- package/dist/adapters/claudeCode.js +49 -45
- package/dist/adapters/openCode.d.ts +14 -1
- package/dist/adapters/openCode.js +64 -47
- package/dist/baseline.js +0 -0
- package/dist/cli.js +77 -34
- package/dist/config.d.ts +4 -0
- package/dist/config.js +37 -0
- package/dist/context.d.ts +29 -9
- package/dist/context.js +124 -97
- package/dist/diagnose.d.ts +11 -5
- package/dist/diagnose.js +45 -26
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +72 -38
- package/dist/eval.d.ts +17 -5
- package/dist/eval.js +56 -23
- package/dist/fix.d.ts +9 -6
- package/dist/fix.js +82 -70
- package/dist/gen.d.ts +6 -5
- package/dist/gen.js +1 -21
- package/dist/llm.d.ts +2 -0
- package/dist/llm.js +23 -23
- package/dist/manifest.d.ts +22 -3
- package/dist/manifest.js +27 -2
- package/dist/orchestrator.d.ts +47 -5
- package/dist/orchestrator.js +82 -13
- package/dist/proc.d.ts +24 -0
- package/dist/proc.js +101 -0
- package/dist/report.d.ts +4 -2
- package/dist/report.js +88 -24
- package/dist/sandbox.d.ts +18 -0
- package/dist/sandbox.js +98 -0
- package/dist/skills.d.ts +48 -0
- package/dist/skills.js +282 -0
- package/dist/stats.d.ts +27 -5
- package/dist/stats.js +132 -8
- package/dist/types.d.ts +31 -1
- package/dist/types.js +9 -1
- package/package.json +1 -1
package/dist/context.js
CHANGED
|
@@ -2,39 +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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 "./
|
|
15
|
+
import { listSkills } from "./skills.js";
|
|
16
|
+
import { buildSandbox, withSandbox } from "./sandbox.js";
|
|
13
17
|
import { ConfigError } from "./config.js";
|
|
14
|
-
import { buildManifest } from "./manifest.js";
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
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 ")}` : "";
|
|
28
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;
|
|
29
32
|
export async function runContextAudit(cfg, optsOrProgress = {}) {
|
|
30
33
|
const opts = typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress;
|
|
31
34
|
const onProgress = opts.onProgress;
|
|
32
35
|
const adapter = getAdapter(cfg.runtime);
|
|
33
36
|
const library = listSkills(cfg.cwd);
|
|
34
|
-
const
|
|
35
|
-
const
|
|
37
|
+
const libraryNames = library.map((s) => s.name);
|
|
38
|
+
const present = new Set(libraryNames);
|
|
36
39
|
const skipped = [];
|
|
37
|
-
let totalCost = 0;
|
|
38
40
|
// decoys (null expected) have nothing to isolate → skipped. An expected skill that ISN'T in the
|
|
39
41
|
// library is almost always a config typo: fail loud BEFORE spending probes, never silently skip.
|
|
40
42
|
const eligible = [];
|
|
@@ -45,54 +47,73 @@ export async function runContextAudit(cfg, optsOrProgress = {}) {
|
|
|
45
47
|
else if (!present.has(c.expected))
|
|
46
48
|
unknown.push(c.expected);
|
|
47
49
|
else
|
|
48
|
-
eligible.push(c);
|
|
50
|
+
eligible.push({ prompt: c.prompt, expected: c.expected });
|
|
49
51
|
}
|
|
50
52
|
if (unknown.length) {
|
|
51
53
|
throw new ConfigError([
|
|
52
54
|
`expected skill(s) not in ${cfg.cwd}/.claude/skills/: ${[...new Set(unknown)].join(", ")} ` +
|
|
53
|
-
`(typo?). Available: ${
|
|
55
|
+
`(typo?). Available: ${libraryNames.join(", ") || "(none)"}.`,
|
|
54
56
|
]);
|
|
55
57
|
}
|
|
56
|
-
const common = {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
ctx.cleanup();
|
|
71
|
-
}
|
|
72
|
-
// co-loaded: the real library at cfg.cwd
|
|
73
|
-
const co = await measure(adapter, c.prompt, expected, {
|
|
74
|
-
cwd: cfg.cwd, ...common,
|
|
75
|
-
...(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"),
|
|
76
72
|
});
|
|
77
|
-
totalCost += iso.totalCost + co.totalCost;
|
|
78
73
|
const isoRel = reliability(iso.hits, iso.n, cfg.conf);
|
|
79
74
|
const coRel = reliability(co.hits, co.n, cfg.conf);
|
|
80
|
-
//
|
|
81
|
-
const fisherP = (iso.n > 0 && co.n > 0)
|
|
82
|
-
? fisherExact2x2(iso.hits, iso.n - iso.hits, co.hits, co.n - co.hits)
|
|
83
|
-
: 1;
|
|
84
|
-
const deltaP = coRel.pHat - isoRel.pHat;
|
|
85
|
-
// if infra failures dominated either side, the comparison isn't a behavioral result at all —
|
|
86
|
-
// 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)
|
|
87
76
|
const untrustworthy = infraUntrustworthy(iso) || infraUntrustworthy(co);
|
|
88
|
-
|
|
89
|
-
// (
|
|
90
|
-
|
|
91
|
-
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,
|
|
92
81
|
isolation: { stats: iso, rel: isoRel },
|
|
93
82
|
coLoaded: { stats: co, rel: coRel },
|
|
94
|
-
deltaP, fisherP
|
|
95
|
-
|
|
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
|
+
}
|
|
96
117
|
}
|
|
97
118
|
// One Fisher test per case = a family of comparisons: correct across the trustworthy ones so a
|
|
98
119
|
// big library doesn't buy false interference flags by chance (raw p<0.05 × 40 cases ≈ 2 flukes).
|
|
@@ -112,46 +133,39 @@ export async function runContextAudit(cfg, optsOrProgress = {}) {
|
|
|
112
133
|
// BH-corrected across all ablation runs (their own family). A significant recovery NAMES the
|
|
113
134
|
// thief; no suspects in the outcome distribution means the drop was dilution, not theft.
|
|
114
135
|
if (opts.ablate) {
|
|
115
|
-
const libraryNames = library.map((s) => s.name);
|
|
116
136
|
const flagged = measured.filter((m) => m.interference);
|
|
117
137
|
const cap = opts.suspects ?? 3;
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
const m = flagged[fi];
|
|
121
|
-
const suspects = Object.entries(m.coLoaded.stats.dist)
|
|
122
|
-
.filter(([name]) => name !== m.expected && name !== "None")
|
|
123
|
-
.sort((x, y) => y[1] - x[1])
|
|
124
|
-
.slice(0, cap);
|
|
138
|
+
const jobs = [];
|
|
139
|
+
flagged.forEach((m, fi) => {
|
|
125
140
|
m.ablation = [];
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
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);
|
|
155
169
|
const ablFamily = allRuns.filter((r) => !r.untrustworthy);
|
|
156
170
|
const ablAdj = bhAdjust(ablFamily.map((r) => r.fisherP));
|
|
157
171
|
ablFamily.forEach((r, j) => { r.fisherPAdj = ablAdj[j]; });
|
|
@@ -164,10 +178,23 @@ export async function runContextAudit(cfg, optsOrProgress = {}) {
|
|
|
164
178
|
const exitCode = measured.some((m) => m.interference) ? 1
|
|
165
179
|
: measured.some((m) => m.untrustworthy) ? 2
|
|
166
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
|
+
}
|
|
167
193
|
return {
|
|
168
194
|
runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
|
|
169
195
|
threshold: cfg.threshold, conf: cfg.conf, librarySize: library.length,
|
|
170
196
|
cases: measured, skipped, totalCost, exitCode,
|
|
171
|
-
manifest: buildManifest("context", cfg),
|
|
197
|
+
manifest: buildManifest("context", cfg, buildEnvironment(cfg, env, extraSkills(env, library), adapter)),
|
|
198
|
+
warnings,
|
|
172
199
|
};
|
|
173
200
|
}
|
package/dist/diagnose.d.ts
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
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
|
|
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 "./
|
|
16
|
+
import { type Budget, type RunStats } from "./orchestrator.ts";
|
|
17
|
+
import { type SkillInfo } from "./skills.ts";
|
|
18
18
|
import { type RunManifest } from "./manifest.ts";
|
|
19
19
|
export type DiagVerdict = "routes-ok" | "routing-miss" | "description-problem" | "inconclusive" | "error";
|
|
20
20
|
/** Returns the chosen skill name, "none", or null when the answer is unusable (LLM error/garbage). */
|
|
@@ -53,6 +53,8 @@ export interface DiagnoseResult {
|
|
|
53
53
|
exitCode: 0 | 1 | 2;
|
|
54
54
|
/** who/what/when of this run — makes reports citable and comparable */
|
|
55
55
|
manifest: RunManifest;
|
|
56
|
+
/** run-level caveats (runtime capabilities, budget cut-offs) */
|
|
57
|
+
warnings: string[];
|
|
56
58
|
}
|
|
57
59
|
export interface DiagProgress {
|
|
58
60
|
caseIndex: number;
|
|
@@ -64,8 +66,12 @@ export interface DiagProgress {
|
|
|
64
66
|
}
|
|
65
67
|
export declare function normalizeChoice(text: string, skills: SkillInfo[]): string | null;
|
|
66
68
|
export declare const defaultChoice: ChoiceFn;
|
|
67
|
-
export
|
|
69
|
+
export interface DiagnoseOpts {
|
|
68
70
|
choice?: ChoiceFn;
|
|
69
71
|
onProgress?: (e: DiagProgress) => void;
|
|
70
|
-
|
|
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>;
|
|
71
77
|
export {};
|
package/dist/diagnose.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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 "./
|
|
18
|
+
import { listSkills } from "./skills.js";
|
|
5
19
|
import { complete } from "./llm.js";
|
|
6
20
|
import { ConfigError } from "./config.js";
|
|
7
|
-
import { buildManifest } from "./manifest.js";
|
|
21
|
+
import { buildManifest, buildEnvironment, capabilityWarnings } from "./manifest.js";
|
|
8
22
|
const ROUTER_SYSTEM = "You are a skill router. Given a user request and a list of available skills (name + " +
|
|
9
23
|
"description), decide which single skill should handle the request. Reply with EXACTLY one skill " +
|
|
10
24
|
"name from the list, or the word none if no skill fits. Output only that one word — no punctuation, " +
|
|
@@ -57,12 +71,12 @@ async function measureIntended(skills, prompt, expected, o) {
|
|
|
57
71
|
hits++;
|
|
58
72
|
n++;
|
|
59
73
|
o.onProbe?.(n, o.maxK);
|
|
60
|
-
if (
|
|
74
|
+
if (planDecision(o.plan, hits, n) !== "undecided")
|
|
61
75
|
break;
|
|
62
76
|
}
|
|
63
77
|
return { dist, hits, n, errors };
|
|
64
78
|
}
|
|
65
|
-
function classify(actual, actualRel, intended, expected,
|
|
79
|
+
function classify(actual, actualRel, intended, expected, plan) {
|
|
66
80
|
const competitor = Object.entries(actual.dist)
|
|
67
81
|
.filter(([s]) => s !== expected && s !== "None")
|
|
68
82
|
.sort((p, q) => q[1] - p[1])[0]?.[0] ?? null;
|
|
@@ -70,11 +84,11 @@ function classify(actual, actualRel, intended, expected, threshold, conf) {
|
|
|
70
84
|
return { verdict: "error", competitor,
|
|
71
85
|
remedy: "infrastructure/LLM errors dominated — rerun; the result isn't trustworthy." };
|
|
72
86
|
}
|
|
73
|
-
const actualDecision =
|
|
87
|
+
const actualDecision = planDecision(plan, actual.hits, actual.n);
|
|
74
88
|
if (actualDecision === "above") {
|
|
75
89
|
// actual is the ground truth. But if the forced-choice router rarely picks it, the description
|
|
76
90
|
// reads ambiguously in isolation — a leading indicator that activation may be fragile.
|
|
77
|
-
const ambiguous =
|
|
91
|
+
const ambiguous = planDecision(plan, intended.hits, intended.n) === "below";
|
|
78
92
|
const note = ambiguous
|
|
79
93
|
? ` — but the description reads ambiguously in isolation (intended only ${Math.round((intended.hits / intended.n) * 100)}%), ` +
|
|
80
94
|
`so activation may be fragile across models/contexts`
|
|
@@ -82,7 +96,7 @@ function classify(actual, actualRel, intended, expected, threshold, conf) {
|
|
|
82
96
|
return { verdict: "routes-ok", competitor: null,
|
|
83
97
|
remedy: `fires reliably (${Math.round(actualRel.pHat * 100)}%) — no action needed${note}.` };
|
|
84
98
|
}
|
|
85
|
-
const intendedDecision =
|
|
99
|
+
const intendedDecision = planDecision(plan, intended.hits, intended.n);
|
|
86
100
|
if (intendedDecision === "above") {
|
|
87
101
|
return { verdict: "routing-miss", competitor,
|
|
88
102
|
remedy: `the model picks "${expected}" when asked, but ${competitor ? `"${competitor}"` : "another skill"} ` +
|
|
@@ -102,6 +116,7 @@ export async function runDiagnose(cfg, deps = {}) {
|
|
|
102
116
|
const choice = deps.choice ?? defaultChoice;
|
|
103
117
|
const skills = listSkills(cfg.cwd);
|
|
104
118
|
const present = new Set(skills.map((s) => s.name));
|
|
119
|
+
const plan = sequentialPlan(cfg.k, cfg.threshold, cfg.conf);
|
|
105
120
|
const skipped = [];
|
|
106
121
|
const unknown = [];
|
|
107
122
|
const eligible = [];
|
|
@@ -111,7 +126,7 @@ export async function runDiagnose(cfg, deps = {}) {
|
|
|
111
126
|
else if (!present.has(c.expected))
|
|
112
127
|
unknown.push(c.expected);
|
|
113
128
|
else
|
|
114
|
-
eligible.push(c);
|
|
129
|
+
eligible.push({ prompt: c.prompt, expected: c.expected });
|
|
115
130
|
}
|
|
116
131
|
if (unknown.length) {
|
|
117
132
|
throw new ConfigError([
|
|
@@ -119,38 +134,42 @@ export async function runDiagnose(cfg, deps = {}) {
|
|
|
119
134
|
`(typo?). Available: ${skills.map((s) => s.name).join(", ") || "(none)"}.`,
|
|
120
135
|
]);
|
|
121
136
|
}
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const c = eligible[i];
|
|
126
|
-
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;
|
|
127
140
|
const actual = await measure(adapter, c.prompt, expected, {
|
|
128
|
-
cwd: cfg.cwd, maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf,
|
|
129
|
-
...(
|
|
130
|
-
...(
|
|
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 }) } : {}),
|
|
131
144
|
});
|
|
132
145
|
const intended = await measureIntended(skills, c.prompt, expected, {
|
|
133
|
-
maxK: cfg.k,
|
|
146
|
+
maxK: cfg.k, plan, choice,
|
|
134
147
|
...(cfg.model ? { model: cfg.model } : {}),
|
|
135
|
-
...(
|
|
148
|
+
...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, phase: "intended", validN, maxK }) } : {}),
|
|
136
149
|
});
|
|
137
|
-
totalCost += actual.totalCost;
|
|
138
150
|
const actualRel = reliability(actual.hits, actual.n, cfg.conf);
|
|
139
151
|
const intendedRel = reliability(intended.hits, intended.n, cfg.conf);
|
|
140
|
-
const { verdict, competitor, remedy } = classify(actual, actualRel, intended, expected,
|
|
141
|
-
|
|
152
|
+
const { verdict, competitor, remedy } = classify(actual, actualRel, intended, expected, plan);
|
|
153
|
+
return {
|
|
142
154
|
prompt: c.prompt, expected,
|
|
143
155
|
actual: { stats: actual, rel: actualRel },
|
|
144
156
|
intended: { stats: intended, rel: intendedRel },
|
|
145
157
|
verdict, competitor, remedy,
|
|
146
|
-
}
|
|
147
|
-
}
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
const totalCost = cases.reduce((s, c) => s + c.actual.stats.totalCost, 0);
|
|
148
161
|
const hasProblem = cases.some((c) => c.verdict === "routing-miss" || c.verdict === "description-problem");
|
|
149
162
|
const hasBad = cases.some((c) => c.verdict === "error" || c.verdict === "inconclusive");
|
|
150
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
|
+
}
|
|
151
169
|
return {
|
|
152
170
|
runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
|
|
153
171
|
threshold: cfg.threshold, conf: cfg.conf, cases, skipped, totalCost, exitCode,
|
|
154
|
-
manifest: buildManifest("diagnose", cfg),
|
|
172
|
+
manifest: buildManifest("diagnose", cfg, buildEnvironment(cfg, env, extraSkills(env, skills), adapter)),
|
|
173
|
+
warnings,
|
|
155
174
|
};
|
|
156
175
|
}
|
package/dist/doctor.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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 {
|
package/dist/doctor.js
CHANGED
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Exit: 0 = healthy, 1 = warnings only, 2 = at least one hard failure. */
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
|
-
import { existsSync
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
8
|
import { join, resolve } from "node:path";
|
|
9
|
+
import { probeSettings } from "./types.js";
|
|
9
10
|
import { loadConfig } from "./config.js";
|
|
10
|
-
import {
|
|
11
|
-
import { kToCertify } from "./report.js";
|
|
11
|
+
import { kToCertify } from "./stats.js";
|
|
12
12
|
import { getAdapter } from "./adapters/index.js";
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
import { readSkillFile, skillFolders, skillsRoot } from "./skills.js";
|
|
14
|
+
import { loadProblems } from "./orchestrator.js";
|
|
15
|
+
import { withSandbox } from "./sandbox.js";
|
|
15
16
|
const MIN_NODE = { major: 22, minor: 18 }; // package.json engines: ">=22.18"
|
|
17
|
+
const HARMLESS_PROMPT = "Reply with exactly: ok";
|
|
16
18
|
function realCliInstalled(cmd) {
|
|
17
19
|
const r = spawnSync(cmd, ["--version"], { stdio: "ignore", timeout: 15_000 });
|
|
18
20
|
// ENOENT = not on PATH. Any other result (even a non-zero exit) means the binary exists.
|
|
@@ -51,40 +53,35 @@ export async function runDoctor(opts, deps = {}) {
|
|
|
51
53
|
const skillsCwd = cfg ? cfg.cwd : resolve(opts.cwd);
|
|
52
54
|
const runtime = cfg?.runtime ?? opts.runtime ?? "claude-code";
|
|
53
55
|
// 3-6. Skills directory + each SKILL.md
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
if (!existsSync(
|
|
56
|
+
const library = [];
|
|
57
|
+
const root = skillsRoot(skillsCwd);
|
|
58
|
+
if (!existsSync(root)) {
|
|
57
59
|
add("fail", `no .claude/skills/ directory under ${skillsCwd}`);
|
|
58
60
|
}
|
|
59
61
|
else {
|
|
60
|
-
const dirs =
|
|
62
|
+
const dirs = skillFolders(skillsCwd);
|
|
61
63
|
if (dirs.length === 0) {
|
|
62
64
|
add("fail", `.claude/skills/ exists but contains no skill folders`);
|
|
63
65
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const md = join(skillsRoot, d.name, "SKILL.md");
|
|
66
|
+
for (const name of dirs) {
|
|
67
|
+
const md = join(root, name, "SKILL.md");
|
|
67
68
|
if (!existsSync(md)) {
|
|
68
|
-
add("fail", `skill "${
|
|
69
|
+
add("fail", `skill "${name}" missing SKILL.md`);
|
|
69
70
|
continue;
|
|
70
71
|
}
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
add("
|
|
76
|
-
|
|
77
|
-
add("
|
|
78
|
-
|
|
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++;
|
|
72
|
+
const s = readSkillFile(md, name);
|
|
73
|
+
if (!s.frontmatterName)
|
|
74
|
+
add("fail", `skill "${name}" missing name: (in a --- frontmatter block)`);
|
|
75
|
+
else if (s.frontmatterName !== name)
|
|
76
|
+
add("warn", `skill "${name}" has name: "${s.frontmatterName}" (doesn't match folder name)`);
|
|
77
|
+
if (!s.description)
|
|
78
|
+
add("fail", `skill "${name}" missing description: (in a --- frontmatter block)`);
|
|
79
|
+
library.push(s);
|
|
84
80
|
}
|
|
85
|
-
if (
|
|
86
|
-
add("pass", `found .claude/skills/ with ${
|
|
81
|
+
if (library.length > 0)
|
|
82
|
+
add("pass", `found .claude/skills/ with ${library.length} skill${library.length === 1 ? "" : "s"}`);
|
|
87
83
|
}
|
|
84
|
+
const skillNames = new Set(library.map((s) => s.name));
|
|
88
85
|
// 7-9. Config-dependent checks
|
|
89
86
|
if (cfg) {
|
|
90
87
|
const missing = cfg.cases
|
|
@@ -98,13 +95,18 @@ export async function runDoctor(opts, deps = {}) {
|
|
|
98
95
|
add("warn", `config has only decoy cases — no skill is being verified to fire`);
|
|
99
96
|
}
|
|
100
97
|
// a strict threshold at a small k can be mathematically impossible to certify
|
|
101
|
-
|
|
98
|
+
const need = kToCertify(cfg.threshold, cfg.conf);
|
|
99
|
+
if (need > cfg.k) {
|
|
102
100
|
add("warn", `threshold ${Math.round(cfg.threshold * 100)}% with k=${cfg.k} cannot certify a pass ` +
|
|
103
|
-
`even on a perfect run — need
|
|
101
|
+
`even on a perfect run — need k≥${need} (or lower the threshold)`);
|
|
102
|
+
}
|
|
103
|
+
if (cfg.userConfig) {
|
|
104
|
+
add("warn", `userConfig is on: probes load your personal runtime config, so results won't reproduce in CI`);
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
// 10-11. Runtime + a harmless live probe (verifies the CLI is installed AND authed)
|
|
107
|
-
|
|
108
|
+
const model = cfg?.model ?? opts.model;
|
|
109
|
+
add("info", `runtime: ${runtime} model: ${model ?? "(runtime default)"}`);
|
|
108
110
|
const known = runtime === "claude-code" || runtime === "opencode";
|
|
109
111
|
const bin = runtime === "claude-code" ? "claude" : runtime === "opencode" ? "opencode" : null;
|
|
110
112
|
let cliOk = false;
|
|
@@ -122,14 +124,46 @@ export async function runDoctor(opts, deps = {}) {
|
|
|
122
124
|
add("info", `probe skipped (--skip-probe)`);
|
|
123
125
|
}
|
|
124
126
|
else if (known && cliOk) {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
...(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
const settings = {
|
|
128
|
+
...(cfg ? probeSettings(cfg) : {}),
|
|
129
|
+
...(model ? { model } : {}),
|
|
130
|
+
...(opts.userConfig ? { userConfig: true } : {}),
|
|
131
|
+
timeoutMs: 60_000,
|
|
132
|
+
};
|
|
133
|
+
const r = await probe(runtime, HARMLESS_PROMPT, { cwd: skillsCwd, ...settings });
|
|
134
|
+
if (r.status !== "ok") {
|
|
132
135
|
add("fail", `${runtime} probe failed — check auth/login: ${r.error ?? "unknown error"}`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
add("pass", `${runtime} probe succeeded (CLI is authenticated)` +
|
|
139
|
+
(r.env?.runtimeVersion ? ` — ${bin} ${r.env.runtimeVersion}` : "") +
|
|
140
|
+
(r.env?.model && !model ? `, default model ${r.env.model}` : ""));
|
|
141
|
+
// what the runtime ACTUALLY loads (only runtimes that report it)
|
|
142
|
+
if (r.env?.loadedSkills && library.length) {
|
|
143
|
+
const { missing } = loadProblems(r.env, library.map((s) => s.name), library);
|
|
144
|
+
if (missing.length) {
|
|
145
|
+
add("fail", `the runtime did not load: ${missing.join(", ")} — it rejected or ignored these ` +
|
|
146
|
+
`SKILL.md files, so they can never fire`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
add("pass", `the runtime loads all ${library.length} library skill${library.length === 1 ? "" : "s"}`);
|
|
150
|
+
}
|
|
151
|
+
// built-ins load in every probe; a library skill sharing a built-in's name is ambiguous
|
|
152
|
+
const empty = await withSandbox(skillsCwd, [], (sb) => probe(runtime, HARMLESS_PROMPT, { cwd: sb.cwd, ...settings }));
|
|
153
|
+
const builtins = empty.status === "ok" ? empty.env?.loadedSkills ?? [] : [];
|
|
154
|
+
const clash = library.filter((s) => builtins.includes(s.name) ||
|
|
155
|
+
(s.frontmatterName !== undefined && builtins.includes(s.frontmatterName)));
|
|
156
|
+
for (const s of clash) {
|
|
157
|
+
add("warn", `skill "${s.name}" has the same name as a ${runtime} built-in skill — the two compete ` +
|
|
158
|
+
`for the same trigger; consider renaming it`);
|
|
159
|
+
}
|
|
160
|
+
if (builtins.length) {
|
|
161
|
+
add("info", `${builtins.length} ${runtime} built-in skill(s) load alongside your library in every probe ` +
|
|
162
|
+
`(${builtins.slice(0, 8).join(", ")}${builtins.length > 8 ? ", …" : ""}) — they are part of the real ` +
|
|
163
|
+
`environment and can steal triggers`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
133
167
|
}
|
|
134
168
|
const hasFail = checks.some((c) => c.status === "fail");
|
|
135
169
|
const hasWarn = checks.some((c) => c.status === "warn");
|