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/dist/cli.js CHANGED
@@ -2,7 +2,8 @@
2
2
  /** skill-probe CLI.
3
3
  * skill-probe --config <f> audit a co-loaded skill library
4
4
  * skill-probe fix --config <f> --skill <n> rewrite a skill's description & prove the lift
5
- * Exit: 0 = pass/applied, 1 = behavioral fail/reverted, 2 = inconclusive/infra error/usage. */
5
+ * Exit: 0 = pass/applied, 1 = behavioral fail/not applied, 2 = inconclusive/infra error/usage,
6
+ * 130/143 = interrupted (children killed, sandboxes removed). */
6
7
  import { parseArgs } from "node:util";
7
8
  import { resolve } from "node:path";
8
9
  import { runAudit } from "./eval.js";
@@ -14,10 +15,14 @@ import { listSkills, generateCases, genConfig } from "./gen.js";
14
15
  import { loadConfig, parseProbability, parseIntFlag, ConfigError } from "./config.js";
15
16
  import { loadBaseline, saveBaseline, compareToBaseline } from "./baseline.js";
16
17
  import { renderTable, renderMarkdown, renderJson, renderFix, renderContext, renderDoctor, renderDiagnose, renderBaseline } from "./report.js";
18
+ import { Budget } from "./orchestrator.js";
19
+ import { toolVersion } from "./manifest.js";
20
+ import { installSignalHandlers } from "./proc.js";
21
+ const COMMANDS = new Set(["audit", "context", "diagnose", "doctor", "gen", "fix"]);
17
22
  const HELP = `skill-probe — audit a co-loaded agent skill library by real activation behavior.
18
23
 
19
24
  USAGE
20
- skill-probe --config <file.json> [options] # audit
25
+ skill-probe [audit] --config <file.json> [options] # audit
21
26
  skill-probe context --config <file.json> [--ablate] # isolation vs co-loaded activation rates
22
27
  skill-probe diagnose --config <file.json> [options] # why a skill fails: routing-miss vs description
23
28
  skill-probe doctor [--cwd <dir>] [--config <file.json>] # preflight: setup/auth/config sanity
@@ -32,12 +37,24 @@ OPTIONS
32
37
  Recorded in the report — REQUIRED for a fair cross-runtime comparison.
33
38
  --k <n> max VALID repeats per prompt (positive integer; default 10)
34
39
  --threshold <p> min acceptable reliability, strictly 0..1 (default 0.7 = smoke test)
35
- --conf <p> confidence level: 0.9 | 0.95 | 0.99 (default 0.95)
40
+ --conf <p> confidence: 0.9 | 0.95 | 0.99 (default 0.95). A verdict is wrong with
41
+ probability <= 1-conf, however early the run stops.
42
+ --max-turns <n> agent turns per probe before reading the outcome (default 1)
43
+ --timeout <sec> per-probe timeout in seconds (default 120)
44
+ --user-config also load your personal runtime config (user settings, plugins, MCP
45
+ servers) into probes. Off by default (claude-code): results then
46
+ reproduce in CI. opencode always loads its global config.
47
+ --concurrency <n> cases probed in parallel (default 1; mind your rate limits)
48
+ --max-cost <usd> stop probing once runtime probes have spent this much; cases cut short
49
+ are inconclusive (error if never started), never a pass. LLM calls
50
+ (gen, fix's rewrite, diagnose's forced choice) aren't counted
36
51
  --json emit JSON instead of a table
37
52
  --markdown emit a Markdown table (paste into a PR/README)
38
53
  --no-cost hide the cost line (e.g. when on a Claude subscription)
39
54
  --quiet suppress the live progress line on stderr
40
- audit only (CI gating):
55
+ -v, --version print the version
56
+ audit only:
57
+ --fail-on-theft fail a case when ANY other skill fires, even if reliability clears the bar
41
58
  --save-baseline <f> after the run, save per-case counts + a run manifest to <f>
42
59
  --baseline <f> compare this run against a saved baseline: per-case Fisher's exact,
43
60
  Benjamini-Hochberg corrected — exit 1 ONLY on a significant regression
@@ -50,7 +67,7 @@ OPTIONS
50
67
  doctor only:
51
68
  --cwd <dir> project dir containing .claude/skills/ (default: .)
52
69
  --config <file> also sanity-check a config (expected skills exist, k/threshold feasible)
53
- --skip-probe don't run the harmless live probe (skip the auth check; no cost)
70
+ --skip-probe don't run the harmless live probes (skip the auth/load checks; no cost)
54
71
  gen only (uses ANTHROPIC_API_KEY if set, else the logged-in 'claude' CLI):
55
72
  --cwd <dir> project dir containing .claude/skills/ (default: .)
56
73
  --per-skill <n> should-fire prompts to draft per skill (default 3)
@@ -60,8 +77,8 @@ OPTIONS
60
77
  --apply-threshold <p> min P(improvement) to keep the rewrite (default 0.9)
61
78
  -h, --help show this help
62
79
 
63
- Reliability carries a Wilson CI + sample size k. fix rewrites the description, runs an INTERLEAVED
64
- before/after, and applies it only if the Bayesian P(improvement) clears the apply-threshold.`;
80
+ Reliability carries a Wilson CI + sample size k. fix runs a PAIRED before/after in two sandboxes and
81
+ writes the new description only if the Bayesian P(improvement) clears the apply-threshold.`;
65
82
  let progressLen = 0;
66
83
  function makeProgress(values) {
67
84
  if (values["json"] || values["quiet"] || !process.stderr.isTTY)
@@ -103,13 +120,32 @@ function makeDiagProgress(values) {
103
120
  progressLen = line.length;
104
121
  };
105
122
  }
106
- async function audit(values) {
107
- const cfg = loadConfig(values["config"], {
108
- runtime: values["runtime"], model: values["model"],
109
- k: values["k"], threshold: values["threshold"],
110
- conf: values["conf"],
123
+ /** The config plus every CLI override that changes what is measured. */
124
+ function configFrom(values, opts = {}) {
125
+ const str = (k) => values[k];
126
+ return loadConfig(values["config"], {
127
+ runtime: str("runtime"), model: str("model"), k: str("k"), conf: str("conf"),
128
+ ...(opts.threshold === false ? {} : { threshold: str("threshold") }),
129
+ maxTurns: str("max-turns"), timeout: str("timeout"),
130
+ userConfig: Boolean(values["user-config"]), failOnTheft: Boolean(values["fail-on-theft"]),
111
131
  });
112
- const result = await runAudit(cfg, makeProgress(values));
132
+ }
133
+ /** Run-time knobs that DON'T change what is measured (parallelism, spend cap). */
134
+ function runOpts(values) {
135
+ const concurrency = parseIntFlag(values["concurrency"], "--concurrency", 1, 1);
136
+ const raw = values["max-cost"];
137
+ if (raw === undefined)
138
+ return { concurrency };
139
+ const max = Number(raw);
140
+ if (!Number.isFinite(max) || max <= 0) {
141
+ throw new ConfigError([`--max-cost must be a positive number of USD (got ${JSON.stringify(raw)})`]);
142
+ }
143
+ return { concurrency, budget: new Budget(max) };
144
+ }
145
+ async function audit(values) {
146
+ const cfg = configFrom(values);
147
+ const progress = makeProgress(values);
148
+ const result = await runAudit(cfg, { ...runOpts(values), ...(progress ? { onProgress: progress } : {}) });
113
149
  clearProgress();
114
150
  const showCost = !values["no-cost"];
115
151
  let cmp;
@@ -130,14 +166,9 @@ async function audit(values) {
130
166
  return result.exitCode;
131
167
  }
132
168
  async function diagnose(values) {
133
- const cfg = loadConfig(values["config"], {
134
- runtime: values["runtime"], model: values["model"],
135
- k: values["k"], threshold: values["threshold"],
136
- conf: values["conf"],
137
- });
138
- const result = await runDiagnose(cfg, {
139
- ...(makeDiagProgress(values) ? { onProgress: makeDiagProgress(values) } : {}),
140
- });
169
+ const cfg = configFrom(values);
170
+ const onProgress = makeDiagProgress(values);
171
+ const result = await runDiagnose(cfg, { ...runOpts(values), ...(onProgress ? { onProgress } : {}) });
141
172
  clearProgress();
142
173
  const showCost = !values["no-cost"];
143
174
  console.log(values["json"] ? renderJson(result) : renderDiagnose(result, { showCost }));
@@ -150,20 +181,17 @@ async function doctor(values) {
150
181
  ...(values["runtime"] ? { runtime: values["runtime"] } : {}),
151
182
  ...(values["model"] ? { model: values["model"] } : {}),
152
183
  ...(values["skip-probe"] ? { skipProbe: true } : {}),
184
+ ...(values["user-config"] ? { userConfig: true } : {}),
153
185
  });
154
186
  console.log(values["json"] ? renderJson(result) : renderDoctor(result));
155
187
  return result.exitCode;
156
188
  }
157
189
  async function context(values) {
158
- const cfg = loadConfig(values["config"], {
159
- runtime: values["runtime"], model: values["model"],
160
- k: values["k"], threshold: values["threshold"],
161
- conf: values["conf"],
162
- });
190
+ const cfg = configFrom(values);
163
191
  const suspects = parseIntFlag(values["suspects"], "--suspects", 3, 1);
164
192
  const onProgress = makeContextProgress(values);
165
193
  const result = await runContextAudit(cfg, {
166
- ablate: Boolean(values["ablate"]), suspects,
194
+ ablate: Boolean(values["ablate"]), suspects, ...runOpts(values),
167
195
  ...(onProgress ? { onProgress } : {}),
168
196
  });
169
197
  clearProgress();
@@ -195,12 +223,11 @@ async function fix(values) {
195
223
  console.error("fix requires --skill <name>");
196
224
  return 2;
197
225
  }
198
- const cfg = loadConfig(values["config"], {
199
- runtime: values["runtime"], model: values["model"],
200
- k: values["k"], conf: values["conf"],
201
- });
226
+ const cfg = configFrom(values, { threshold: false });
202
227
  const applyBar = parseProbability(values["apply-threshold"], "--apply-threshold", 0.9);
203
- const result = await runFix(cfg, { skill, k: cfg.k, applyBar, ...(cfg.model ? { model: cfg.model } : {}) });
228
+ const result = await runFix(cfg, {
229
+ skill, k: cfg.k, applyBar, ...runOpts(values), ...(cfg.model ? { model: cfg.model } : {}),
230
+ });
204
231
  console.log(values["json"] ? renderJson(result) : renderFix(result));
205
232
  return result.applied ? 0 : 1;
206
233
  }
@@ -224,6 +251,13 @@ async function main() {
224
251
  baseline: { type: "string" },
225
252
  "save-baseline": { type: "string" },
226
253
  "skip-probe": { type: "boolean", default: false },
254
+ "max-turns": { type: "string" },
255
+ timeout: { type: "string" },
256
+ "user-config": { type: "boolean", default: false },
257
+ "fail-on-theft": { type: "boolean", default: false },
258
+ concurrency: { type: "string" },
259
+ "max-cost": { type: "string" },
260
+ version: { type: "boolean", short: "v", default: false },
227
261
  json: { type: "boolean", default: false },
228
262
  markdown: { type: "boolean", default: false },
229
263
  "no-cost": { type: "boolean", default: false },
@@ -235,7 +269,16 @@ async function main() {
235
269
  console.log(HELP);
236
270
  process.exit(0);
237
271
  }
238
- const cmd = positionals[0];
272
+ if (values.version) {
273
+ console.log(toolVersion());
274
+ process.exit(0);
275
+ }
276
+ const cmd = positionals[0] ?? "audit";
277
+ if (!COMMANDS.has(cmd) || positionals.length > 1) {
278
+ console.error(`unknown command: ${positionals.join(" ")} (expected one of: ${[...COMMANDS].join(", ")})`);
279
+ process.exit(2);
280
+ }
281
+ installSignalHandlers();
239
282
  let code;
240
283
  if (cmd === "gen") {
241
284
  code = await gen(values);
@@ -245,7 +288,7 @@ async function main() {
245
288
  }
246
289
  else {
247
290
  if (!values.config) {
248
- console.log(HELP);
291
+ console.error(HELP);
249
292
  process.exit(2);
250
293
  }
251
294
  code = cmd === "fix" ? await fix(values)
package/dist/config.d.ts CHANGED
@@ -18,5 +18,9 @@ export interface Overrides {
18
18
  k?: string | undefined;
19
19
  threshold?: string | undefined;
20
20
  conf?: string | undefined;
21
+ maxTurns?: string | undefined;
22
+ timeout?: string | undefined;
23
+ userConfig?: boolean | undefined;
24
+ failOnTheft?: boolean | undefined;
21
25
  }
22
26
  export declare function loadConfig(configPath: string, overrides?: Overrides): Config;
package/dist/config.js CHANGED
@@ -3,6 +3,10 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { resolve, dirname } from "node:path";
5
5
  const VALID_CONF = new Set([0.9, 0.95, 0.99]);
6
+ const KNOWN_KEYS = new Set([
7
+ "runtime", "model", "cwd", "k", "threshold", "conf", "cases",
8
+ "maxTurns", "timeout", "userConfig", "failOnTheft",
9
+ ]);
6
10
  export class ConfigError extends Error {
7
11
  errors;
8
12
  constructor(errors) {
@@ -17,6 +21,13 @@ export class ConfigError extends Error {
17
21
  export function validateConfig(raw, configPath) {
18
22
  const r = (raw ?? {});
19
23
  const errs = [];
24
+ // a typo'd key ("treshold") would otherwise silently fall back to the default — fail loud.
25
+ // "$schema" and "_"-prefixed keys are allowed for tooling and comments.
26
+ for (const key of Object.keys(r)) {
27
+ if (!KNOWN_KEYS.has(key) && !key.startsWith("$") && !key.startsWith("_")) {
28
+ errs.push(`unknown config key "${key}" (known: ${[...KNOWN_KEYS].join(", ")})`);
29
+ }
30
+ }
20
31
  const runtime = typeof r["runtime"] === "string" && r["runtime"] ? r["runtime"] : "claude-code";
21
32
  let model;
22
33
  if (r["model"] !== undefined) {
@@ -41,6 +52,20 @@ export function validateConfig(raw, configPath) {
41
52
  if (typeof conf !== "number" || !VALID_CONF.has(conf)) {
42
53
  errs.push(`conf must be one of 0.9, 0.95, 0.99 (got ${JSON.stringify(r["conf"])})`);
43
54
  }
55
+ const maxTurns = r["maxTurns"];
56
+ if (maxTurns !== undefined && (typeof maxTurns !== "number" || !Number.isInteger(maxTurns) || maxTurns < 1)) {
57
+ errs.push(`maxTurns must be a positive integer (got ${JSON.stringify(maxTurns)})`);
58
+ }
59
+ // seconds in the file / on the CLI; ms internally
60
+ const timeout = r["timeout"];
61
+ if (timeout !== undefined && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0)) {
62
+ errs.push(`timeout must be a positive number of seconds (got ${JSON.stringify(timeout)})`);
63
+ }
64
+ for (const key of ["userConfig", "failOnTheft"]) {
65
+ if (r[key] !== undefined && typeof r[key] !== "boolean") {
66
+ errs.push(`${key} must be true or false (got ${JSON.stringify(r[key])})`);
67
+ }
68
+ }
44
69
  const cases = Array.isArray(r["cases"]) ? r["cases"] : [];
45
70
  if (cases.length === 0)
46
71
  errs.push("config needs a non-empty `cases` array");
@@ -63,6 +88,10 @@ export function validateConfig(raw, configPath) {
63
88
  threshold: threshold,
64
89
  conf: conf,
65
90
  cases: cases,
91
+ ...(maxTurns !== undefined ? { maxTurns: maxTurns } : {}),
92
+ ...(timeout !== undefined ? { timeoutMs: Math.round(timeout * 1000) } : {}),
93
+ ...(r["userConfig"] === true ? { userConfig: true } : {}),
94
+ ...(r["failOnTheft"] === true ? { failOnTheft: true } : {}),
66
95
  };
67
96
  }
68
97
  /** Validate a probability-ish flag (e.g. --apply-threshold): must be a number strictly in (0,1). */
@@ -104,5 +133,13 @@ export function loadConfig(configPath, overrides = {}) {
104
133
  merged["threshold"] = Number(overrides.threshold);
105
134
  if (overrides.conf !== undefined)
106
135
  merged["conf"] = Number(overrides.conf);
136
+ if (overrides.maxTurns !== undefined)
137
+ merged["maxTurns"] = Number(overrides.maxTurns);
138
+ if (overrides.timeout !== undefined)
139
+ merged["timeout"] = Number(overrides.timeout);
140
+ if (overrides.userConfig)
141
+ merged["userConfig"] = true;
142
+ if (overrides.failOnTheft)
143
+ merged["failOnTheft"] = true;
107
144
  return validateConfig(merged, configPath);
108
145
  }
package/dist/context.d.ts CHANGED
@@ -1,13 +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";
13
+ import { type Budget, type RunStats } from "./orchestrator.ts";
14
+ import { buildSandbox } from "./sandbox.ts";
4
15
  import { type RunManifest } from "./manifest.ts";
5
- /** Build a throwaway project dir whose .claude/skills/ holds ONLY the named skills (copied from
6
- * srcCwd). Returns its path plus a cleanup(). The probe runs against this dir to load a subset. */
7
- export declare function buildContextDir(srcCwd: string, skillNames: string[]): {
8
- cwd: string;
9
- cleanup: () => void;
10
- };
16
+ /** Kept for compatibility: a sandbox holding only the named skills (plus instruction files). */
17
+ export declare const buildContextDir: typeof buildSandbox;
11
18
  type Rel = ReturnType<typeof reliability>;
12
19
  interface Condition {
13
20
  stats: RunStats;
@@ -25,6 +32,8 @@ export interface AblationResult {
25
32
  fisherP: number;
26
33
  fisherPAdj: number;
27
34
  untrustworthy: boolean;
35
+ /** why the run isn't a valid comparison, when the runtime loaded the wrong skill set */
36
+ loadIssue?: string;
28
37
  culprit: boolean;
29
38
  }
30
39
  export interface ContextCaseResult {
@@ -38,8 +47,14 @@ export interface ContextCaseResult {
38
47
  * test per case is a FAMILY, and raw p<0.05 over a 40-case library expects ~2 false flags by
39
48
  * chance alone. Interference is decided on THIS value. (1 for untrustworthy cases.) */
40
49
  fisherPAdj: number;
41
- /** 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) */
42
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[];
43
58
  /** fires reliably alone but drops significantly (BH-corrected) when the library is co-loaded */
44
59
  interference: boolean;
45
60
  /** leave-one-out runs (only with { ablate: true }, only for interference cases). Empty array =
@@ -59,6 +74,8 @@ export interface ContextAuditResult {
59
74
  exitCode: 0 | 1 | 2;
60
75
  /** who/what/when of this run — makes reports citable and comparable */
61
76
  manifest: RunManifest;
77
+ /** run-level caveats (built-in name clashes, budget cut-offs) */
78
+ warnings: string[];
62
79
  }
63
80
  export interface ContextProgress {
64
81
  caseIndex: number;
@@ -77,6 +94,9 @@ export interface ContextOpts {
77
94
  /** max suspect siblings to ablate per flagged case, ranked by probes stolen (default 3) */
78
95
  suspects?: number;
79
96
  onProgress?: (e: ContextProgress) => void;
97
+ /** cases (and ablation runs) probed in parallel (default 1) */
98
+ concurrency?: number;
99
+ budget?: Budget;
80
100
  }
81
101
  export declare function runContextAudit(cfg: Config, optsOrProgress?: ContextOpts | ((e: ContextProgress) => void)): Promise<ContextAuditResult>;
82
102
  export {};