skill-probe 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,6 +108,52 @@ Claude Code vs OpenCode comparison is fair — otherwise you're comparing two co
108
108
  case is inconclusive the report prints the exact k for your threshold. Don't set `threshold
109
109
  0.9, k 10` and expect a pass — that's statistically impossible and the tool will say so.
110
110
 
111
+ ## CI usage
112
+
113
+ skill-probe exits non-zero on a real problem, so it drops straight into a pipeline:
114
+
115
+ ```yaml
116
+ # .github/workflows/skills.yml (the claude/opencode CLI must be installed + authed on the runner)
117
+ - run: npx skill-probe --config probe.config.json --quiet --no-cost
118
+ ```
119
+
120
+ Exit codes: **`0`** all pass · **`1`** a behavioral fail / trigger-theft / interference · **`2`**
121
+ inconclusive or an infrastructure error — so a runtime outage *fails* the build instead of silently
122
+ passing. Add `--json` to archive the full result as a build artifact.
123
+
124
+ ## Activation rate by context (`skill-probe context`)
125
+
126
+ The audit measures every skill **co-loaded** — the real, hard condition. But a skill can fire
127
+ *fine on its own* and only fail *under load*, when the rest of your library is competing for the
128
+ same trigger. `context` exposes exactly that:
129
+
130
+ ```bash
131
+ skill-probe context --config probe.config.json
132
+ ```
133
+
134
+ For each case it measures the expected skill **in isolation** (a throwaway project with only that
135
+ one skill) **and co-loaded** (your full library), then tests the drop with **Fisher's exact test**
136
+ — so interference is reported as a real effect with a p-value, not eyeballed:
137
+
138
+ ```
139
+ skill-probe context — runtime: claude-code model: (runtime default) threshold: 70% library: 4 skills
140
+ isolation = only that skill loaded · co-loaded = the full library (4) loaded
141
+
142
+ [⚠ INTERFERENCE] greeter | write a birthday greeting
143
+ isolation 100% [72%, 100%] k=10
144
+ co-loaded 30% [11%, 60%] k=10
145
+ Δ -70% under load Fisher p=0.003
146
+ ↳ fires reliably alone but is suppressed when the library is co-loaded (stolen by: welcomer)
147
+
148
+ Result: 1 interference / 3 measured | exit 1
149
+ ```
150
+
151
+ This is the "fires in isolation, fails under load X" case the aggregate rate hides. Decoy cases
152
+ (`expected: null`) are skipped — there's no single skill to isolate; an `expected` skill that isn't
153
+ in the library is a config error (typo), surfaced before any probes run. Exit `1` if any skill
154
+ shows interference, `2` if a case is untrustworthy (infra errors dominated — never a silent pass),
155
+ else `0`.
156
+
111
157
  ## Fix a failing skill (`skill-probe fix`)
112
158
 
113
159
  Rewrite a skill's trigger description and **prove the lift is real** before keeping it:
@@ -137,17 +183,20 @@ doesn't measurably help is reverted.
137
183
 
138
184
  ## Status
139
185
 
140
- Early (v0.4). **Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping +
186
+ Early (v0.5). **Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping +
141
187
  four-state verdict (pass / fail / inconclusive / **error**), across two runtimes (Claude Code,
142
188
  OpenCode). Infrastructure failures (timeout / auth / crash / empty output) are reported as
143
189
  `error`, never as a behavioral pass/fail — a decoy can't falsely pass because the runtime was down.
144
190
 
191
+ **Context (`skill-probe context`):** isolation-vs-co-loaded activation rates, with **Fisher's exact
192
+ test** on the drop — catches skills that fire alone but are suppressed under the full library's load.
193
+
145
194
  **Fix (`skill-probe fix`):** uses the **Bayesian Beta-Binomial** to gate description rewrites on a
146
195
  *proven* lift (interleaved before/after, applied only if P(improvement) clears the bar).
147
196
 
148
- **Implemented + unit-tested but NOT yet used by any command:** Fisher's exact test and
149
- Benjamini-Hochberg FDR (`src/stats.ts`) — reserved for cross-case multiplicity control (a planned
150
- enhancement). Don't read the presence of these functions as the audit using them.
197
+ **Implemented + unit-tested but NOT yet used by any command:** Benjamini-Hochberg FDR
198
+ (`src/stats.ts`) — reserved for cross-case multiplicity control (a planned enhancement). Don't read
199
+ its presence as the audit using it. (Fisher's exact test is now wired into `context`.)
151
200
 
152
201
  ## Dev
153
202
 
package/dist/cli.js CHANGED
@@ -7,13 +7,15 @@ import { parseArgs } from "node:util";
7
7
  import { resolve } from "node:path";
8
8
  import { runAudit } from "./eval.js";
9
9
  import { runFix } from "./fix.js";
10
+ import { runContextAudit } from "./context.js";
10
11
  import { listSkills, generateCases, genConfig } from "./gen.js";
11
12
  import { loadConfig, parseProbability, parseIntFlag, ConfigError } from "./config.js";
12
- import { renderTable, renderMarkdown, renderJson, renderFix } from "./report.js";
13
+ import { renderTable, renderMarkdown, renderJson, renderFix, renderContext } from "./report.js";
13
14
  const HELP = `skill-probe — audit a co-loaded agent skill library by real activation behavior.
14
15
 
15
16
  USAGE
16
17
  skill-probe --config <file.json> [options] # audit
18
+ skill-probe context --config <file.json> [options] # isolation vs co-loaded activation rates
17
19
  skill-probe gen [--cwd <dir>] > probe.config.json # draft a test config from your skills
18
20
  skill-probe fix --config <file.json> --skill <name> [opts] # rewrite a description, prove the lift
19
21
 
@@ -59,6 +61,17 @@ function clearProgress() {
59
61
  progressLen = 0;
60
62
  }
61
63
  }
64
+ function makeContextProgress(values) {
65
+ if (values["json"] || values["quiet"] || !process.stderr.isTTY)
66
+ return undefined;
67
+ return (e) => {
68
+ const p = e.prompt.length > 28 ? e.prompt.slice(0, 25) + "…" : e.prompt;
69
+ const line = ` [${e.caseIndex}/${e.caseTotal}] ${e.condition.padEnd(9)} "${p}" — run ${e.validN}/${e.maxK}`;
70
+ const pad = Math.max(0, progressLen - line.length);
71
+ process.stderr.write("\r" + line + " ".repeat(pad));
72
+ progressLen = line.length;
73
+ };
74
+ }
62
75
  async function audit(values) {
63
76
  const cfg = loadConfig(values["config"], {
64
77
  runtime: values["runtime"], model: values["model"],
@@ -74,6 +87,18 @@ async function audit(values) {
74
87
  console.log(text);
75
88
  return result.exitCode;
76
89
  }
90
+ async function context(values) {
91
+ const cfg = loadConfig(values["config"], {
92
+ runtime: values["runtime"], model: values["model"],
93
+ k: values["k"], threshold: values["threshold"],
94
+ conf: values["conf"],
95
+ });
96
+ const result = await runContextAudit(cfg, makeContextProgress(values));
97
+ clearProgress();
98
+ const showCost = !values["no-cost"];
99
+ console.log(values["json"] ? renderJson(result) : renderContext(result, { showCost }));
100
+ return result.exitCode;
101
+ }
77
102
  async function gen(values) {
78
103
  const cwd = values["cwd"] ?? ".";
79
104
  const perSkill = parseIntFlag(values["per-skill"], "--per-skill", 3, 1);
@@ -143,7 +168,9 @@ async function main() {
143
168
  console.log(HELP);
144
169
  process.exit(2);
145
170
  }
146
- code = cmd === "fix" ? await fix(values) : await audit(values);
171
+ code = cmd === "fix" ? await fix(values)
172
+ : cmd === "context" ? await context(values)
173
+ : await audit(values);
147
174
  }
148
175
  process.exit(code);
149
176
  }
@@ -0,0 +1,48 @@
1
+ import type { Config } from "./types.ts";
2
+ 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
+ };
10
+ type Rel = ReturnType<typeof reliability>;
11
+ interface Condition {
12
+ stats: RunStats;
13
+ rel: Rel;
14
+ }
15
+ export interface ContextCaseResult {
16
+ prompt: string;
17
+ expected: string;
18
+ isolation: Condition;
19
+ coLoaded: Condition;
20
+ deltaP: number;
21
+ fisherP: number;
22
+ /** infra failures dominated one side — the comparison is not trustworthy (NOT a behavioral result) */
23
+ untrustworthy: boolean;
24
+ /** fires reliably alone but drops significantly when the full library is co-loaded */
25
+ interference: boolean;
26
+ }
27
+ export interface ContextAuditResult {
28
+ runtime: string;
29
+ model: string;
30
+ threshold: number;
31
+ conf: number;
32
+ librarySize: number;
33
+ cases: ContextCaseResult[];
34
+ skipped: string[];
35
+ totalCost: number;
36
+ /** 0 = no interference; 1 = a skill is suppressed under load; 2 = an untrustworthy (infra) case */
37
+ exitCode: 0 | 1 | 2;
38
+ }
39
+ export interface ContextProgress {
40
+ caseIndex: number;
41
+ caseTotal: number;
42
+ prompt: string;
43
+ condition: "isolation" | "co-loaded";
44
+ validN: number;
45
+ maxK: number;
46
+ }
47
+ export declare function runContextAudit(cfg: Config, onProgress?: (e: ContextProgress) => void): Promise<ContextAuditResult>;
48
+ export {};
@@ -0,0 +1,112 @@
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
+ import { mkdtempSync, mkdirSync, cpSync, rmSync, existsSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { reliability, fisherExact2x2 } from "./stats.js";
10
+ import { measure } from "./orchestrator.js";
11
+ import { getAdapter } from "./adapters/index.js";
12
+ import { listSkills } from "./gen.js";
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
+ /** Build a throwaway project dir whose .claude/skills/ holds ONLY the named skills (copied from
21
+ * srcCwd). Returns its path plus a cleanup(). The probe runs against this dir to load a subset. */
22
+ export function buildContextDir(srcCwd, skillNames) {
23
+ const root = mkdtempSync(join(tmpdir(), "skill-probe-ctx-"));
24
+ const destSkills = join(root, ".claude", "skills");
25
+ mkdirSync(destSkills, { recursive: true });
26
+ for (const name of skillNames) {
27
+ const src = join(srcCwd, ".claude", "skills", name);
28
+ if (!existsSync(src))
29
+ throw new Error(`skill not found in ${srcCwd}: ${name}`);
30
+ cpSync(src, join(destSkills, name), { recursive: true });
31
+ }
32
+ return { cwd: root, cleanup: () => rmSync(root, { recursive: true, force: true }) };
33
+ }
34
+ export async function runContextAudit(cfg, onProgress) {
35
+ const adapter = getAdapter(cfg.runtime);
36
+ const library = listSkills(cfg.cwd);
37
+ const present = new Set(library.map((s) => s.name));
38
+ const measured = [];
39
+ const skipped = [];
40
+ let totalCost = 0;
41
+ // decoys (null expected) have nothing to isolate → skipped. An expected skill that ISN'T in the
42
+ // library is almost always a config typo: fail loud BEFORE spending probes, never silently skip.
43
+ const eligible = [];
44
+ const unknown = [];
45
+ for (const c of cfg.cases) {
46
+ if (c.expected === null)
47
+ skipped.push(c.prompt);
48
+ else if (!present.has(c.expected))
49
+ unknown.push(c.expected);
50
+ else
51
+ eligible.push(c);
52
+ }
53
+ if (unknown.length) {
54
+ throw new ConfigError([
55
+ `expected skill(s) not in ${cfg.cwd}/.claude/skills/: ${[...new Set(unknown)].join(", ")} ` +
56
+ `(typo?). Available: ${library.map((s) => s.name).join(", ") || "(none)"}.`,
57
+ ]);
58
+ }
59
+ const common = { maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf, ...(cfg.model ? { model: cfg.model } : {}) };
60
+ for (let i = 0; i < eligible.length; i++) {
61
+ const c = eligible[i];
62
+ const expected = c.expected;
63
+ // isolation: a temp project containing only this skill
64
+ const ctx = buildContextDir(cfg.cwd, [expected]);
65
+ let iso;
66
+ try {
67
+ iso = await measure(adapter, c.prompt, expected, {
68
+ cwd: ctx.cwd, ...common,
69
+ ...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, condition: "isolation", validN, maxK }) } : {}),
70
+ });
71
+ }
72
+ finally {
73
+ ctx.cleanup();
74
+ }
75
+ // co-loaded: the real library at cfg.cwd
76
+ const co = await measure(adapter, c.prompt, expected, {
77
+ cwd: cfg.cwd, ...common,
78
+ ...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, condition: "co-loaded", validN, maxK }) } : {}),
79
+ });
80
+ totalCost += iso.totalCost + co.totalCost;
81
+ const isoRel = reliability(iso.hits, iso.n, cfg.conf);
82
+ const coRel = reliability(co.hits, co.n, cfg.conf);
83
+ // 2x2: rows = isolation/co-loaded, cols = hit/miss
84
+ const fisherP = (iso.n > 0 && co.n > 0)
85
+ ? fisherExact2x2(iso.hits, iso.n - iso.hits, co.hits, co.n - co.hits)
86
+ : 1;
87
+ const deltaP = coRel.pHat - isoRel.pHat;
88
+ // if infra failures dominated either side, the comparison isn't a behavioral result at all —
89
+ // it must never read as "no interference / pass" just because fisherP defaulted to 1.
90
+ const untrustworthy = infraUntrustworthy(iso) || infraUntrustworthy(co);
91
+ const interference = !untrustworthy &&
92
+ isoRel.pHat >= cfg.threshold && // fires reliably alone
93
+ deltaP < 0 && // and drops when co-loaded
94
+ fisherP < 0.05; // and the drop is statistically real
95
+ measured.push({
96
+ prompt: c.prompt, expected,
97
+ isolation: { stats: iso, rel: isoRel },
98
+ coLoaded: { stats: co, rel: coRel },
99
+ deltaP, fisherP, untrustworthy, interference,
100
+ });
101
+ }
102
+ // precedence mirrors the audit: a real behavioral signal (interference, exit 1) outranks an
103
+ // untrustworthy/infra case (exit 2), which outranks a clean run (exit 0).
104
+ const exitCode = measured.some((m) => m.interference) ? 1
105
+ : measured.some((m) => m.untrustworthy) ? 2
106
+ : 0;
107
+ return {
108
+ runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
109
+ threshold: cfg.threshold, conf: cfg.conf, librarySize: library.length,
110
+ cases: measured, skipped, totalCost, exitCode,
111
+ };
112
+ }
package/dist/report.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AuditResult } from "./eval.ts";
2
2
  import type { FixResult } from "./fix.ts";
3
+ import type { ContextAuditResult } from "./context.ts";
3
4
  export interface RenderOpts {
4
5
  showCost?: boolean;
5
6
  }
@@ -7,4 +8,6 @@ export declare function renderTable(a: AuditResult, opts?: RenderOpts): string;
7
8
  /** Markdown table — paste into a PR or README. */
8
9
  export declare function renderMarkdown(a: AuditResult, opts?: RenderOpts): string;
9
10
  export declare function renderJson(a: AuditResult | unknown): string;
11
+ /** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
12
+ export declare function renderContext(a: ContextAuditResult, opts?: RenderOpts): string;
10
13
  export declare function renderFix(f: FixResult): string;
package/dist/report.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * errors are shown distinctly from behavioral results. */
3
3
  import { wilsonInterval } from "./stats.js";
4
4
  const pct = (x) => `${Math.round(x * 100)}%`;
5
+ const pval = (p) => (p < 0.001 ? "<0.001" : p.toFixed(3));
5
6
  const LABEL = {
6
7
  pass: "PASS ", fail: "FAIL ",
7
8
  inconclusive: "INCONCLUSIVE", error: "ERROR ",
@@ -80,6 +81,50 @@ export function renderMarkdown(a, opts = {}) {
80
81
  export function renderJson(a) {
81
82
  return JSON.stringify(a, null, 2);
82
83
  }
84
+ /** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
85
+ export function renderContext(a, opts = {}) {
86
+ const showCost = opts.showCost ?? true;
87
+ const out = [];
88
+ out.push(`skill-probe context — runtime: ${a.runtime} model: ${a.model} ` +
89
+ `threshold: ${pct(a.threshold)} library: ${a.librarySize} skills`);
90
+ out.push(` isolation = only that skill loaded · co-loaded = the full library (${a.librarySize}) loaded`);
91
+ out.push("");
92
+ const ci = (r) => `${pct(r.pHat)} [${pct(r.ciLow)}, ${pct(r.ciHigh)}] k=${r.k}`;
93
+ for (const c of a.cases) {
94
+ if (c.untrustworthy) {
95
+ out.push(` [⛔ ERROR] ${c.expected} | ${c.prompt}`);
96
+ out.push(` infrastructure errors dominated — comparison NOT trustworthy ` +
97
+ `(isolation ${c.isolation.stats.errors} err/${c.isolation.stats.n} valid, ` +
98
+ `co-loaded ${c.coLoaded.stats.errors} err/${c.coLoaded.stats.n} valid)`);
99
+ const reason = c.isolation.stats.lastError ?? c.coLoaded.stats.lastError;
100
+ if (reason)
101
+ out.push(` reason: ${reason}`);
102
+ continue;
103
+ }
104
+ const flag = c.interference ? "⚠ INTERFERENCE" : "ok";
105
+ out.push(` [${flag}] ${c.expected} | ${c.prompt}`);
106
+ out.push(` isolation ${ci(c.isolation.rel)}`);
107
+ out.push(` co-loaded ${ci(c.coLoaded.rel)}`);
108
+ out.push(` Δ ${c.deltaP >= 0 ? "+" : ""}${pct(c.deltaP)} under load Fisher p=${pval(c.fisherP)}`);
109
+ if (c.interference) {
110
+ const thieves = Object.keys(c.coLoaded.stats.dist)
111
+ .filter((s) => s !== c.expected && s !== "None");
112
+ out.push(` ↳ fires reliably alone but is suppressed when the library is co-loaded` +
113
+ (thieves.length ? ` (stolen by: ${thieves.join(", ")})` : ""));
114
+ }
115
+ }
116
+ if (a.skipped.length) {
117
+ out.push("");
118
+ out.push(` skipped ${a.skipped.length} decoy case(s) — no single skill to isolate.`);
119
+ }
120
+ out.push("");
121
+ const interfN = a.cases.filter((c) => c.interference).length;
122
+ const errN = a.cases.filter((c) => c.untrustworthy).length;
123
+ out.push(`Result: ${interfN} interference / ${a.cases.length} measured` +
124
+ (errN ? ` / ${errN} error` : "") + ` | exit ${a.exitCode}` +
125
+ (showCost ? ` | cost $${a.totalCost.toFixed(4)}` : ""));
126
+ return out.join("\n");
127
+ }
83
128
  export function renderFix(f) {
84
129
  const out = [];
85
130
  out.push(`skill-probe fix — skill: ${f.skill}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skill-probe",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Runtime-agnostic, co-loaded-aware auditor for AI agent skill libraries: measures which skill actually fires, with statistical confidence.",
5
5
  "type": "module",
6
6
  "bin": { "skill-probe": "dist/cli.js" },