skill-probe 0.1.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/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/adapters/claudeCode.d.ts +10 -0
- package/dist/adapters/claudeCode.js +84 -0
- package/dist/adapters/index.d.ts +4 -0
- package/dist/adapters/index.js +13 -0
- package/dist/adapters/openCode.d.ts +15 -0
- package/dist/adapters/openCode.js +109 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +89 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +88 -0
- package/dist/eval.d.ts +33 -0
- package/dist/eval.js +50 -0
- package/dist/fix.d.ts +37 -0
- package/dist/fix.js +111 -0
- package/dist/llm.d.ts +4 -0
- package/dist/llm.js +33 -0
- package/dist/orchestrator.d.ts +24 -0
- package/dist/orchestrator.js +29 -0
- package/dist/report.d.ts +5 -0
- package/dist/report.js +74 -0
- package/dist/stats.d.ts +34 -0
- package/dist/stats.js +154 -0
- package/dist/types.d.ts +45 -0
- package/dist/types.js +2 -0
- package/examples/audit.config.json +14 -0
- package/examples/fix.config.json +9 -0
- package/examples/sample-skills/.claude/skills/commit-writer/SKILL.md +7 -0
- package/examples/sample-skills/.claude/skills/pr-describer/SKILL.md +7 -0
- package/package.json +23 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 skill-probe authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# skill-probe
|
|
2
|
+
|
|
3
|
+
Runtime-agnostic, **co-loaded-aware** auditor for an AI agent's skill library. It measures
|
|
4
|
+
which skill *actually* fires (by real activation, not keyword matching) when your whole
|
|
5
|
+
`SKILL.md` library is loaded together — with **statistical confidence**, not single-shot guesses.
|
|
6
|
+
|
|
7
|
+
> Most skill tooling tests one skill, in isolation, once. Skills only conflict when loaded
|
|
8
|
+
> *together*, and activation is *stochastic*. skill-probe is the tool that tests the real thing.
|
|
9
|
+
|
|
10
|
+
Complements static linters like `skill-audit` (security/quality) — this is **behavioral**.
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
- Skill activation is a coin flip: independent studies measured 55–87% trigger rates.
|
|
15
|
+
- A single run lies (we measured the same prompt at 0/5 one batch, 2/3 the next).
|
|
16
|
+
- skill-probe runs each prompt **k times**, reports a **Wilson 95% CI**, stops early when the
|
|
17
|
+
result is statistically decided, and flags **trigger-theft** (a sibling stealing a trigger).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm i -g skill-probe # or: npx skill-probe
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Use
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# point it at your own config (after `npm i -g skill-probe`):
|
|
29
|
+
skill-probe --config my.config.json --k 15 --threshold 0.9 --json
|
|
30
|
+
|
|
31
|
+
# or, from a clone of this repo, try the bundled example (needs `claude` installed + auth):
|
|
32
|
+
skill-probe --config examples/audit.config.json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Config (`skill-probe.config.json`):
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"runtime": "claude-code",
|
|
39
|
+
"cwd": "./my-project",
|
|
40
|
+
"k": 10, "threshold": 0.7, "conf": 0.95,
|
|
41
|
+
"cases": [
|
|
42
|
+
{ "prompt": "write a commit message", "expected": "commit-writer" },
|
|
43
|
+
{ "prompt": "what's the weather?", "expected": null }
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
Output:
|
|
48
|
+
```
|
|
49
|
+
skill-probe — runtime: claude-code model: (runtime default) threshold: 70%
|
|
50
|
+
|
|
51
|
+
PASS reliability 100% [72%, 100%] k=10 expect=commit-writer
|
|
52
|
+
outcomes: commit-writer×10
|
|
53
|
+
FAIL reliability 20% [6%, 51%] k=10 expect=pr-describer
|
|
54
|
+
outcomes: None×8, commit-writer×2
|
|
55
|
+
⚠ trigger-theft by: commit-writer
|
|
56
|
+
PASS reliability 100% [72%, 100%] k=10 expect=(none)
|
|
57
|
+
outcomes: None×10
|
|
58
|
+
|
|
59
|
+
Result: 2 pass / 1 fail / 0 inconclusive / 0 error | exit 1 | cost $0.18
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- `cwd` (relative paths resolve against the **config file's** directory) is a project dir
|
|
63
|
+
containing `.claude/skills/`.
|
|
64
|
+
- `expected: null` = a decoy that should fire nothing.
|
|
65
|
+
- Exit code: `0` all pass, `1` a behavioral fail / trigger-theft, `2` inconclusive or an
|
|
66
|
+
infrastructure error (runtime down → never a silent pass).
|
|
67
|
+
|
|
68
|
+
**Cross-runtime comparison must pin the model.** A skill can fire on one runtime and not another
|
|
69
|
+
partly because of the *model*, not the runtime. Always set `model` (recorded in the report) so a
|
|
70
|
+
Claude Code vs OpenCode comparison is fair — otherwise you're comparing two confounded variables.
|
|
71
|
+
|
|
72
|
+
**Two modes** — because confidence intervals are wide at small k:
|
|
73
|
+
- **Smoke (default):** `threshold 0.7, k 10` — a clean run certifies; cheap; good for CI.
|
|
74
|
+
- **Certify:** a strict bar needs more runs. To certify **≥0.9** you need **~k=35**; when a
|
|
75
|
+
case is inconclusive the report prints the exact k for your threshold. Don't set `threshold
|
|
76
|
+
0.9, k 10` and expect a pass — that's statistically impossible and the tool will say so.
|
|
77
|
+
|
|
78
|
+
## Fix a failing skill (`skill-probe fix`)
|
|
79
|
+
|
|
80
|
+
Rewrite a skill's trigger description and **prove the lift is real** before keeping it:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
ANTHROPIC_API_KEY=sk-ant-... \
|
|
84
|
+
skill-probe fix --config examples/fix.config.json --skill commit-writer
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
It (1) LLM-rewrites the description, told the sibling skills so it won't steal their triggers;
|
|
88
|
+
(2) runs an **interleaved** before/after (old desc → probe → new desc → probe, paired, to control
|
|
89
|
+
for drift); (3) computes the **Bayesian** P(improvement) + a credible interval on the change; and
|
|
90
|
+
(4) **applies the rewrite only if** `P(improvement) ≥ --apply-threshold` (default 0.9) **and** the
|
|
91
|
+
effect is positive — otherwise reverts. When applied, the original is snapshotted to a timestamped
|
|
92
|
+
`SKILL.md.bak.<timestamp>` (no backup is left behind on a revert).
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
before: 0% [0%, 49%] after: 100% [51%, 100%] (4 paired runs)
|
|
96
|
+
P(rewrite improved reliability) = 100% Δ = +80% [36%, 99%]
|
|
97
|
+
✅ APPLIED
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`fix` needs `ANTHROPIC_API_KEY` (for the rewrite). It changes descriptions on statistical evidence,
|
|
101
|
+
not on "the new one looks nicer" — a rewrite that doesn't measurably help is reverted.
|
|
102
|
+
|
|
103
|
+
## Status
|
|
104
|
+
|
|
105
|
+
Early (v0.1). **Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping +
|
|
106
|
+
four-state verdict (pass / fail / inconclusive / **error**), across two runtimes (Claude Code,
|
|
107
|
+
OpenCode). Infrastructure failures (timeout / auth / crash / empty output) are reported as
|
|
108
|
+
`error`, never as a behavioral pass/fail — a decoy can't falsely pass because the runtime was down.
|
|
109
|
+
|
|
110
|
+
**Fix (`skill-probe fix`):** uses the **Bayesian Beta-Binomial** to gate description rewrites on a
|
|
111
|
+
*proven* lift (interleaved before/after, applied only if P(improvement) clears the bar).
|
|
112
|
+
|
|
113
|
+
**Implemented + unit-tested but NOT yet used by any command:** Fisher's exact test and
|
|
114
|
+
Benjamini-Hochberg FDR (`src/stats.ts`) — reserved for cross-case multiplicity control (a planned
|
|
115
|
+
enhancement). Don't read the presence of these functions as the audit using them.
|
|
116
|
+
|
|
117
|
+
## Dev
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
node --test test/*.test.ts # run tests (zero deps; Node >= 22.18 strips types)
|
|
121
|
+
node src/cli.ts --help
|
|
122
|
+
npm run typecheck # tsc --noEmit (needs `npm i`)
|
|
123
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RuntimeAdapter } from "../types.ts";
|
|
2
|
+
export interface ParseInfo {
|
|
3
|
+
skillFired: string | null;
|
|
4
|
+
trajectory: string[];
|
|
5
|
+
cost: number;
|
|
6
|
+
sawInit: boolean;
|
|
7
|
+
sawAssistant: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function parseStream(stdout: string): ParseInfo;
|
|
10
|
+
export declare const claudeCodeAdapter: RuntimeAdapter;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Claude Code adapter — the proven probe. Spawns `claude -p` headless, parses stream-json
|
|
2
|
+
* for `tool_use name=="Skill"`. Distinguishes a real behavioral outcome (the model ran) from
|
|
3
|
+
* an infrastructure failure (timeout / auth / crash / empty output) — finding #1.
|
|
4
|
+
*
|
|
5
|
+
* NOTE: with `--max-turns 1` the process exits NON-ZERO on success (error_max_turns), so exit
|
|
6
|
+
* code can't signal validity. We treat a probe as valid iff the model produced an assistant
|
|
7
|
+
* message; otherwise it's an infra error. */
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
export function parseStream(stdout) {
|
|
10
|
+
let skillFired = null;
|
|
11
|
+
let cost = 0, sawInit = false, sawAssistant = false;
|
|
12
|
+
const trajectory = [];
|
|
13
|
+
for (const raw of stdout.split("\n")) {
|
|
14
|
+
const line = raw.trim();
|
|
15
|
+
if (!line)
|
|
16
|
+
continue;
|
|
17
|
+
let e;
|
|
18
|
+
try {
|
|
19
|
+
e = JSON.parse(line);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (e.type === "system" && e.subtype === "init")
|
|
25
|
+
sawInit = true;
|
|
26
|
+
else if (e.type === "assistant") {
|
|
27
|
+
sawAssistant = true;
|
|
28
|
+
for (const b of e.message?.content ?? []) {
|
|
29
|
+
if (b?.type === "tool_use" && b.name) {
|
|
30
|
+
trajectory.push(b.name);
|
|
31
|
+
if (b.name === "Skill" && b.input?.skill)
|
|
32
|
+
skillFired = b.input.skill;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (e.type === "result" && typeof e.total_cost_usd === "number") {
|
|
37
|
+
cost = e.total_cost_usd;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { skillFired, trajectory, cost, sawInit, sawAssistant };
|
|
41
|
+
}
|
|
42
|
+
export const claudeCodeAdapter = {
|
|
43
|
+
name: "claude-code",
|
|
44
|
+
probe(prompt, { cwd, timeoutMs = 120_000, model }) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
let settled = false, timedOut = false;
|
|
47
|
+
const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", "1"];
|
|
48
|
+
if (model)
|
|
49
|
+
args.push("--model", model);
|
|
50
|
+
const child = spawn("claude", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
51
|
+
let out = "", err = "";
|
|
52
|
+
const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutMs);
|
|
53
|
+
const finish = (r) => {
|
|
54
|
+
if (settled)
|
|
55
|
+
return;
|
|
56
|
+
settled = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
resolve(r);
|
|
59
|
+
};
|
|
60
|
+
child.stdout.on("data", (d) => { out += d.toString(); });
|
|
61
|
+
child.stderr.on("data", (d) => { err += d.toString(); });
|
|
62
|
+
child.on("error", (e) => finish({
|
|
63
|
+
status: "error", skillFired: null, trajectory: [], cost: 0,
|
|
64
|
+
error: `spawn failed: ${e.message} (is 'claude' installed and on PATH?)`,
|
|
65
|
+
}));
|
|
66
|
+
child.on("close", () => {
|
|
67
|
+
const info = parseStream(out);
|
|
68
|
+
if (timedOut) {
|
|
69
|
+
return finish({
|
|
70
|
+
status: "error", skillFired: null, trajectory: info.trajectory, cost: info.cost,
|
|
71
|
+
error: `timed out after ${timeoutMs}ms`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (!info.sawAssistant) {
|
|
75
|
+
return finish({
|
|
76
|
+
status: "error", skillFired: null, trajectory: info.trajectory, cost: info.cost,
|
|
77
|
+
error: err.trim() ? err.trim().slice(-200) : "no assistant output (auth/config/empty response?)",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
finish({ status: "ok", skillFired: info.skillFired, trajectory: info.trajectory, cost: info.cost });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { claudeCodeAdapter } from "./claudeCode.js";
|
|
2
|
+
import { openCodeAdapter } from "./openCode.js";
|
|
3
|
+
export const ADAPTERS = {
|
|
4
|
+
[claudeCodeAdapter.name]: claudeCodeAdapter,
|
|
5
|
+
[openCodeAdapter.name]: openCodeAdapter,
|
|
6
|
+
};
|
|
7
|
+
export function getAdapter(name) {
|
|
8
|
+
const a = ADAPTERS[name];
|
|
9
|
+
if (!a) {
|
|
10
|
+
throw new Error(`unknown runtime '${name}'. available: ${Object.keys(ADAPTERS).join(", ")}`);
|
|
11
|
+
}
|
|
12
|
+
return a;
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RuntimeAdapter } from "../types.ts";
|
|
2
|
+
export interface CoreResult {
|
|
3
|
+
skillFired: string | null;
|
|
4
|
+
trajectory: string[];
|
|
5
|
+
cost: number;
|
|
6
|
+
}
|
|
7
|
+
/** Recursively scan parsed OpenCode output for a fired skill + tool trajectory. Defensive:
|
|
8
|
+
* handles "skill name in input.name" or "input.skill", single JSON or JSONL of events. */
|
|
9
|
+
export declare function extractFromOpenCode(parsed: unknown): CoreResult;
|
|
10
|
+
/** Parse stdout (whole-JSON or JSONL) and report how many events were seen (0 = no output). */
|
|
11
|
+
export declare function parseOutput(stdout: string): {
|
|
12
|
+
result: CoreResult;
|
|
13
|
+
eventCount: number;
|
|
14
|
+
};
|
|
15
|
+
export declare const openCodeAdapter: RuntimeAdapter;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** OpenCode adapter — the second runtime (cross-vendor; BYO-key). Reads `.claude/skills/`.
|
|
2
|
+
* Activation surfaces as a tool part named "skill" with the name in state.input.name (confirmed
|
|
3
|
+
* against opencode v1.17.9 `run --format json`). Distinguishes behavioral outcome from infra
|
|
4
|
+
* failure (finding #1). GOTCHA: opencode `run` hangs unless stdin is closed — we use stdio ignore. */
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
/** Recursively scan parsed OpenCode output for a fired skill + tool trajectory. Defensive:
|
|
7
|
+
* handles "skill name in input.name" or "input.skill", single JSON or JSONL of events. */
|
|
8
|
+
export function extractFromOpenCode(parsed) {
|
|
9
|
+
let skillFired = null;
|
|
10
|
+
let cost = 0;
|
|
11
|
+
const trajectory = [];
|
|
12
|
+
const visit = (node) => {
|
|
13
|
+
if (Array.isArray(node)) {
|
|
14
|
+
node.forEach(visit);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (!node || typeof node !== "object")
|
|
18
|
+
return;
|
|
19
|
+
const o = node;
|
|
20
|
+
if (o["type"] === "tool" && typeof o["tool"] === "string") {
|
|
21
|
+
const tool = o["tool"];
|
|
22
|
+
trajectory.push(tool);
|
|
23
|
+
if (tool === "skill") {
|
|
24
|
+
const state = o["state"];
|
|
25
|
+
const input = (state?.["input"] ?? o["input"]);
|
|
26
|
+
const name = (input?.["name"] ?? input?.["skill"]);
|
|
27
|
+
if (name)
|
|
28
|
+
skillFired = name;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (typeof o["cost"] === "number")
|
|
32
|
+
cost = o["cost"];
|
|
33
|
+
for (const v of Object.values(o))
|
|
34
|
+
visit(v);
|
|
35
|
+
};
|
|
36
|
+
visit(parsed);
|
|
37
|
+
return { skillFired, trajectory, cost };
|
|
38
|
+
}
|
|
39
|
+
/** Parse stdout (whole-JSON or JSONL) and report how many events were seen (0 = no output). */
|
|
40
|
+
export function parseOutput(stdout) {
|
|
41
|
+
const trimmed = stdout.trim();
|
|
42
|
+
if (!trimmed)
|
|
43
|
+
return { result: { skillFired: null, trajectory: [], cost: 0 }, eventCount: 0 };
|
|
44
|
+
try {
|
|
45
|
+
const whole = JSON.parse(trimmed);
|
|
46
|
+
return { result: extractFromOpenCode(whole), eventCount: Array.isArray(whole) ? whole.length : 1 };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
const events = [];
|
|
50
|
+
for (const line of trimmed.split("\n")) {
|
|
51
|
+
const s = line.trim();
|
|
52
|
+
if (!s)
|
|
53
|
+
continue;
|
|
54
|
+
try {
|
|
55
|
+
events.push(JSON.parse(s));
|
|
56
|
+
}
|
|
57
|
+
catch { /* skip non-json log lines */ }
|
|
58
|
+
}
|
|
59
|
+
return { result: extractFromOpenCode(events), eventCount: events.length };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export const openCodeAdapter = {
|
|
63
|
+
name: "opencode",
|
|
64
|
+
probe(prompt, { cwd, timeoutMs = 120_000, model }) {
|
|
65
|
+
// config/CLI --model wins; env var is a fallback convenience
|
|
66
|
+
const effectiveModel = model ?? process.env["SKILL_PROBE_OPENCODE_MODEL"];
|
|
67
|
+
const args = ["run", prompt, "--format", "json"];
|
|
68
|
+
if (effectiveModel)
|
|
69
|
+
args.push("--model", effectiveModel);
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
let settled = false, timedOut = false;
|
|
72
|
+
const child = spawn("opencode", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
73
|
+
let out = "", err = "";
|
|
74
|
+
const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutMs);
|
|
75
|
+
const finish = (r) => {
|
|
76
|
+
if (settled)
|
|
77
|
+
return;
|
|
78
|
+
settled = true;
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
resolve(r);
|
|
81
|
+
};
|
|
82
|
+
child.stdout.on("data", (d) => { out += d.toString(); });
|
|
83
|
+
child.stderr.on("data", (d) => { err += d.toString(); });
|
|
84
|
+
child.on("error", (e) => finish({
|
|
85
|
+
status: "error", skillFired: null, trajectory: [], cost: 0,
|
|
86
|
+
error: `spawn failed: ${e.message} (is 'opencode' installed and on PATH?)`,
|
|
87
|
+
}));
|
|
88
|
+
child.on("close", () => {
|
|
89
|
+
const { result, eventCount } = parseOutput(out);
|
|
90
|
+
if (timedOut) {
|
|
91
|
+
return finish({
|
|
92
|
+
status: "error", skillFired: null, trajectory: result.trajectory, cost: result.cost,
|
|
93
|
+
error: `timed out after ${timeoutMs}ms`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (eventCount === 0) {
|
|
97
|
+
// surface whatever diagnostic we have: stderr, else a non-JSON stdout log (opencode
|
|
98
|
+
// prints provider/model errors to stdout), else a generic hint.
|
|
99
|
+
const diag = err.trim() || out.trim();
|
|
100
|
+
return finish({
|
|
101
|
+
status: "error", skillFired: null, trajectory: [], cost: 0,
|
|
102
|
+
error: diag ? diag.slice(-200) : "no output (auth/model/hang? check `opencode auth list`)",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
finish({ status: "ok", skillFired: result.skillFired, trajectory: result.trajectory, cost: result.cost });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
};
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** skill-probe CLI.
|
|
3
|
+
* skill-probe --config <f> audit a co-loaded skill library
|
|
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. */
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { runAudit } from "./eval.js";
|
|
8
|
+
import { runFix } from "./fix.js";
|
|
9
|
+
import { loadConfig, ConfigError } from "./config.js";
|
|
10
|
+
import { renderTable, renderJson, renderFix } from "./report.js";
|
|
11
|
+
const HELP = `skill-probe — audit a co-loaded agent skill library by real activation behavior.
|
|
12
|
+
|
|
13
|
+
USAGE
|
|
14
|
+
skill-probe --config <file.json> [options] # audit
|
|
15
|
+
skill-probe fix --config <file.json> --skill <name> [opts] # rewrite a description, prove the lift
|
|
16
|
+
|
|
17
|
+
OPTIONS
|
|
18
|
+
-c, --config <file> JSON config { cwd, cases:[{prompt,expected}], ... } (required).
|
|
19
|
+
Relative cwd resolves against the CONFIG FILE's directory.
|
|
20
|
+
--runtime <name> claude-code | opencode (default: claude-code)
|
|
21
|
+
--model <id> pin the model (e.g. claude-sonnet-4-6 or anthropic/claude-haiku-4-5).
|
|
22
|
+
Recorded in the report — REQUIRED for a fair cross-runtime comparison.
|
|
23
|
+
--k <n> max VALID repeats per prompt (positive integer; default 10)
|
|
24
|
+
--threshold <p> min acceptable reliability, strictly 0..1 (default 0.7 = smoke test)
|
|
25
|
+
--conf <p> confidence level: 0.9 | 0.95 | 0.99 (default 0.95)
|
|
26
|
+
--json emit JSON instead of a table
|
|
27
|
+
fix only:
|
|
28
|
+
--skill <name> the skill whose description to rewrite (uses the config's cases for it)
|
|
29
|
+
--apply-threshold <p> min P(improvement) to keep the rewrite (default 0.9)
|
|
30
|
+
-h, --help show this help
|
|
31
|
+
|
|
32
|
+
Reliability carries a Wilson CI + sample size k. fix rewrites the description, runs an INTERLEAVED
|
|
33
|
+
before/after, and applies it only if the Bayesian P(improvement) clears the apply-threshold.`;
|
|
34
|
+
async function audit(values) {
|
|
35
|
+
const cfg = loadConfig(values["config"], {
|
|
36
|
+
runtime: values["runtime"], model: values["model"],
|
|
37
|
+
k: values["k"], threshold: values["threshold"],
|
|
38
|
+
conf: values["conf"],
|
|
39
|
+
});
|
|
40
|
+
const result = await runAudit(cfg);
|
|
41
|
+
console.log(values["json"] ? renderJson(result) : renderTable(result));
|
|
42
|
+
return result.exitCode;
|
|
43
|
+
}
|
|
44
|
+
async function fix(values) {
|
|
45
|
+
const skill = values["skill"];
|
|
46
|
+
if (!skill) {
|
|
47
|
+
console.error("fix requires --skill <name>");
|
|
48
|
+
return 2;
|
|
49
|
+
}
|
|
50
|
+
const cfg = loadConfig(values["config"], {
|
|
51
|
+
runtime: values["runtime"], model: values["model"],
|
|
52
|
+
k: values["k"], conf: values["conf"],
|
|
53
|
+
});
|
|
54
|
+
const applyBar = values["apply-threshold"] ? Number(values["apply-threshold"]) : 0.9;
|
|
55
|
+
const result = await runFix(cfg, { skill, k: cfg.k, applyBar, ...(cfg.model ? { model: cfg.model } : {}) });
|
|
56
|
+
console.log(values["json"] ? renderJson(result) : renderFix(result));
|
|
57
|
+
return result.applied ? 0 : 1;
|
|
58
|
+
}
|
|
59
|
+
async function main() {
|
|
60
|
+
const { values, positionals } = parseArgs({
|
|
61
|
+
allowPositionals: true,
|
|
62
|
+
options: {
|
|
63
|
+
config: { type: "string", short: "c" },
|
|
64
|
+
runtime: { type: "string" },
|
|
65
|
+
model: { type: "string" },
|
|
66
|
+
k: { type: "string" },
|
|
67
|
+
threshold: { type: "string" },
|
|
68
|
+
conf: { type: "string" },
|
|
69
|
+
skill: { type: "string" },
|
|
70
|
+
"apply-threshold": { type: "string" },
|
|
71
|
+
json: { type: "boolean", default: false },
|
|
72
|
+
help: { type: "boolean", short: "h", default: false },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
if (values.help || !values.config) {
|
|
76
|
+
console.log(HELP);
|
|
77
|
+
process.exit(values.help ? 0 : 2);
|
|
78
|
+
}
|
|
79
|
+
const cmd = positionals[0];
|
|
80
|
+
const code = cmd === "fix" ? await fix(values) : await audit(values);
|
|
81
|
+
process.exit(code);
|
|
82
|
+
}
|
|
83
|
+
main().catch((e) => {
|
|
84
|
+
if (e instanceof ConfigError)
|
|
85
|
+
console.error(e.message);
|
|
86
|
+
else
|
|
87
|
+
console.error("skill-probe failed:", e instanceof Error ? e.message : e);
|
|
88
|
+
process.exit(2);
|
|
89
|
+
});
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Config } from "./types.ts";
|
|
2
|
+
export declare class ConfigError extends Error {
|
|
3
|
+
readonly errors: string[];
|
|
4
|
+
constructor(errors: string[]);
|
|
5
|
+
}
|
|
6
|
+
/** Validate a raw config object (already merged with CLI flag overrides).
|
|
7
|
+
* `configPath` is used to resolve a relative `cwd` against the CONFIG FILE's
|
|
8
|
+
* directory — not the caller's shell (finding #3). Throws ConfigError on any problem. */
|
|
9
|
+
export declare function validateConfig(raw: unknown, configPath: string): Config;
|
|
10
|
+
/** CLI flag overrides (already string-typed from argv); numerics are coerced then validated. */
|
|
11
|
+
export interface Overrides {
|
|
12
|
+
runtime?: string | undefined;
|
|
13
|
+
model?: string | undefined;
|
|
14
|
+
k?: string | undefined;
|
|
15
|
+
threshold?: string | undefined;
|
|
16
|
+
conf?: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
export declare function loadConfig(configPath: string, overrides?: Overrides): Config;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Config loading + validation. Bad inputs must fail loudly with a clear message, never
|
|
2
|
+
* silently produce NaN intervals or zero-run audits. */
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { resolve, dirname } from "node:path";
|
|
5
|
+
const VALID_CONF = new Set([0.9, 0.95, 0.99]);
|
|
6
|
+
export class ConfigError extends Error {
|
|
7
|
+
errors;
|
|
8
|
+
constructor(errors) {
|
|
9
|
+
super("invalid config:\n - " + errors.join("\n - "));
|
|
10
|
+
this.name = "ConfigError";
|
|
11
|
+
this.errors = errors;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Validate a raw config object (already merged with CLI flag overrides).
|
|
15
|
+
* `configPath` is used to resolve a relative `cwd` against the CONFIG FILE's
|
|
16
|
+
* directory — not the caller's shell (finding #3). Throws ConfigError on any problem. */
|
|
17
|
+
export function validateConfig(raw, configPath) {
|
|
18
|
+
const r = (raw ?? {});
|
|
19
|
+
const errs = [];
|
|
20
|
+
const runtime = typeof r["runtime"] === "string" && r["runtime"] ? r["runtime"] : "claude-code";
|
|
21
|
+
let model;
|
|
22
|
+
if (r["model"] !== undefined) {
|
|
23
|
+
if (typeof r["model"] !== "string" || !r["model"])
|
|
24
|
+
errs.push(`model must be a non-empty string if set`);
|
|
25
|
+
else
|
|
26
|
+
model = r["model"];
|
|
27
|
+
}
|
|
28
|
+
const cwdRaw = typeof r["cwd"] === "string" ? r["cwd"] : ".";
|
|
29
|
+
const cwd = resolve(dirname(resolve(configPath)), cwdRaw);
|
|
30
|
+
const k = r["k"] ?? 10;
|
|
31
|
+
if (typeof k !== "number" || !Number.isInteger(k) || k <= 0) {
|
|
32
|
+
errs.push(`k must be a positive integer (got ${JSON.stringify(r["k"])})`);
|
|
33
|
+
}
|
|
34
|
+
// Default 0.7 = a "smoke test" bar: a clean 10/10 at default k=10 certifies (Wilson lo ≈ 0.72).
|
|
35
|
+
// For strict certification (e.g. >=0.9) raise BOTH threshold and k — the report says how much.
|
|
36
|
+
const threshold = r["threshold"] ?? 0.7;
|
|
37
|
+
if (typeof threshold !== "number" || Number.isNaN(threshold) || threshold <= 0 || threshold >= 1) {
|
|
38
|
+
errs.push(`threshold must be a number strictly between 0 and 1 (got ${JSON.stringify(r["threshold"])})`);
|
|
39
|
+
}
|
|
40
|
+
const conf = r["conf"] ?? 0.95;
|
|
41
|
+
if (typeof conf !== "number" || !VALID_CONF.has(conf)) {
|
|
42
|
+
errs.push(`conf must be one of 0.9, 0.95, 0.99 (got ${JSON.stringify(r["conf"])})`);
|
|
43
|
+
}
|
|
44
|
+
const cases = Array.isArray(r["cases"]) ? r["cases"] : [];
|
|
45
|
+
if (cases.length === 0)
|
|
46
|
+
errs.push("config needs a non-empty `cases` array");
|
|
47
|
+
cases.forEach((c, i) => {
|
|
48
|
+
const cc = (c ?? {});
|
|
49
|
+
if (typeof cc["prompt"] !== "string" || !cc["prompt"]) {
|
|
50
|
+
errs.push(`cases[${i}].prompt must be a non-empty string`);
|
|
51
|
+
}
|
|
52
|
+
if (!(cc["expected"] === null || typeof cc["expected"] === "string")) {
|
|
53
|
+
errs.push(`cases[${i}].expected must be a skill name (string) or null`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
if (errs.length)
|
|
57
|
+
throw new ConfigError(errs);
|
|
58
|
+
return {
|
|
59
|
+
runtime,
|
|
60
|
+
...(model ? { model } : {}),
|
|
61
|
+
cwd,
|
|
62
|
+
k: k,
|
|
63
|
+
threshold: threshold,
|
|
64
|
+
conf: conf,
|
|
65
|
+
cases: cases,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function loadConfig(configPath, overrides = {}) {
|
|
69
|
+
let fileRaw;
|
|
70
|
+
try {
|
|
71
|
+
fileRaw = JSON.parse(readFileSync(resolve(configPath), "utf8"));
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
throw new ConfigError([`could not read/parse config '${configPath}': ${e instanceof Error ? e.message : e}`]);
|
|
75
|
+
}
|
|
76
|
+
const merged = { ...fileRaw };
|
|
77
|
+
if (overrides.runtime !== undefined)
|
|
78
|
+
merged["runtime"] = overrides.runtime;
|
|
79
|
+
if (overrides.model !== undefined)
|
|
80
|
+
merged["model"] = overrides.model;
|
|
81
|
+
if (overrides.k !== undefined)
|
|
82
|
+
merged["k"] = Number(overrides.k);
|
|
83
|
+
if (overrides.threshold !== undefined)
|
|
84
|
+
merged["threshold"] = Number(overrides.threshold);
|
|
85
|
+
if (overrides.conf !== undefined)
|
|
86
|
+
merged["conf"] = Number(overrides.conf);
|
|
87
|
+
return validateConfig(merged, configPath);
|
|
88
|
+
}
|
package/dist/eval.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The audit: run every case co-loaded, compute reliability (with CI), detect trigger-theft,
|
|
2
|
+
* and keep infrastructure failure distinct from behavioral failure (finding #1). */
|
|
3
|
+
import type { Config } from "./types.ts";
|
|
4
|
+
import { reliability } from "./stats.ts";
|
|
5
|
+
import { type RunStats } from "./orchestrator.ts";
|
|
6
|
+
/** Four honest states. `error` = the runtime failed enough that the measurement is untrustworthy. */
|
|
7
|
+
export type Verdict = "pass" | "fail" | "inconclusive" | "error";
|
|
8
|
+
export interface CaseResult {
|
|
9
|
+
prompt: string;
|
|
10
|
+
expected: string | null;
|
|
11
|
+
stats: RunStats;
|
|
12
|
+
reliability: ReturnType<typeof reliability>;
|
|
13
|
+
/** non-expected, non-None skills that fired = interference / trigger-theft */
|
|
14
|
+
theft: string[];
|
|
15
|
+
decision: "above" | "below" | "undecided";
|
|
16
|
+
verdict: Verdict;
|
|
17
|
+
}
|
|
18
|
+
export interface AuditResult {
|
|
19
|
+
runtime: string;
|
|
20
|
+
model: string;
|
|
21
|
+
threshold: number;
|
|
22
|
+
cases: CaseResult[];
|
|
23
|
+
totalCost: number;
|
|
24
|
+
counts: {
|
|
25
|
+
pass: number;
|
|
26
|
+
fail: number;
|
|
27
|
+
inconclusive: number;
|
|
28
|
+
error: number;
|
|
29
|
+
};
|
|
30
|
+
/** 0 = all pass; 1 = a behavioral failure/theft; 2 = inconclusive or infrastructure error */
|
|
31
|
+
exitCode: 0 | 1 | 2;
|
|
32
|
+
}
|
|
33
|
+
export declare function runAudit(cfg: Config): Promise<AuditResult>;
|