skill-probe 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -38
- 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.d.ts +56 -0
- package/dist/baseline.js +121 -0
- package/dist/cli.js +99 -37
- package/dist/config.d.ts +4 -0
- package/dist/config.js +37 -0
- package/dist/context.d.ts +32 -9
- package/dist/context.js +124 -95
- package/dist/diagnose.d.ts +14 -5
- package/dist/diagnose.js +45 -24
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +72 -38
- package/dist/eval.d.ts +20 -5
- package/dist/eval.js +56 -21
- 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 +40 -0
- package/dist/manifest.js +73 -0
- 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 +7 -2
- package/dist/report.js +121 -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
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { runProcess } from "../proc.js";
|
|
2
|
+
const MODEL_OUTPUT_TYPES = new Set([
|
|
3
|
+
"step_start", "step_finish", "step-start", "step-finish", "text", "tool", "tool_use", "reasoning",
|
|
4
|
+
]);
|
|
5
|
+
function errorMessage(o) {
|
|
6
|
+
const e = o["error"];
|
|
7
|
+
if (typeof e === "string")
|
|
8
|
+
return e;
|
|
9
|
+
if (e && typeof e === "object") {
|
|
10
|
+
const eo = e;
|
|
11
|
+
const data = eo["data"];
|
|
12
|
+
const msg = data?.["message"] ?? eo["message"] ?? eo["name"];
|
|
13
|
+
if (typeof msg === "string")
|
|
14
|
+
return msg;
|
|
15
|
+
}
|
|
16
|
+
return typeof o["message"] === "string" ? o["message"] : "opencode reported an error";
|
|
17
|
+
}
|
|
6
18
|
/** Recursively scan parsed OpenCode output for a fired skill + tool trajectory. Defensive:
|
|
7
19
|
* handles "skill name in input.name" or "input.skill", single JSON or JSONL of events. */
|
|
8
20
|
export function extractFromOpenCode(parsed) {
|
|
9
21
|
let skillFired = null;
|
|
10
22
|
let cost = 0;
|
|
23
|
+
let sawModelOutput = false;
|
|
24
|
+
let error;
|
|
11
25
|
const trajectory = [];
|
|
12
26
|
const visit = (node) => {
|
|
13
27
|
if (Array.isArray(node)) {
|
|
@@ -17,7 +31,12 @@ export function extractFromOpenCode(parsed) {
|
|
|
17
31
|
if (!node || typeof node !== "object")
|
|
18
32
|
return;
|
|
19
33
|
const o = node;
|
|
20
|
-
|
|
34
|
+
const type = o["type"];
|
|
35
|
+
if (type === "error" && error === undefined)
|
|
36
|
+
error = errorMessage(o);
|
|
37
|
+
if (typeof type === "string" && MODEL_OUTPUT_TYPES.has(type))
|
|
38
|
+
sawModelOutput = true;
|
|
39
|
+
if (type === "tool" && typeof o["tool"] === "string") {
|
|
21
40
|
const tool = o["tool"];
|
|
22
41
|
trajectory.push(tool);
|
|
23
42
|
if (tool === "skill") {
|
|
@@ -34,13 +53,14 @@ export function extractFromOpenCode(parsed) {
|
|
|
34
53
|
visit(v);
|
|
35
54
|
};
|
|
36
55
|
visit(parsed);
|
|
37
|
-
return { skillFired, trajectory, cost };
|
|
56
|
+
return { skillFired, trajectory, cost, sawModelOutput, ...(error !== undefined ? { error } : {}) };
|
|
38
57
|
}
|
|
39
58
|
/** Parse stdout (whole-JSON or JSONL) and report how many events were seen (0 = no output). */
|
|
40
59
|
export function parseOutput(stdout) {
|
|
41
60
|
const trimmed = stdout.trim();
|
|
42
|
-
if (!trimmed)
|
|
43
|
-
return { result: { skillFired: null, trajectory: [], cost: 0 }, eventCount: 0 };
|
|
61
|
+
if (!trimmed) {
|
|
62
|
+
return { result: { skillFired: null, trajectory: [], cost: 0, sawModelOutput: false }, eventCount: 0 };
|
|
63
|
+
}
|
|
44
64
|
try {
|
|
45
65
|
const whole = JSON.parse(trimmed);
|
|
46
66
|
return { result: extractFromOpenCode(whole), eventCount: Array.isArray(whole) ? whole.length : 1 };
|
|
@@ -59,51 +79,48 @@ export function parseOutput(stdout) {
|
|
|
59
79
|
return { result: extractFromOpenCode(events), eventCount: events.length };
|
|
60
80
|
}
|
|
61
81
|
}
|
|
82
|
+
/** Decide what a finished (non-timeout) run means. Exported for testing. */
|
|
83
|
+
export function interpretOpenCode(stdout, stderr) {
|
|
84
|
+
const { result, eventCount } = parseOutput(stdout);
|
|
85
|
+
const fail = (error) => ({ status: "error", skillFired: null, trajectory: result.trajectory, cost: result.cost, error });
|
|
86
|
+
if (eventCount === 0) {
|
|
87
|
+
// surface whatever diagnostic we have: stderr, else a non-JSON stdout log (opencode
|
|
88
|
+
// prints provider/model errors to stdout), else a generic hint.
|
|
89
|
+
const diag = stderr.trim() || stdout.trim();
|
|
90
|
+
return fail(diag ? diag.slice(-200) : "no output (auth/model/hang? check `opencode auth list`)");
|
|
91
|
+
}
|
|
92
|
+
if (result.error !== undefined)
|
|
93
|
+
return fail(`opencode error: ${result.error.slice(0, 200)}`);
|
|
94
|
+
if (!result.sawModelOutput) {
|
|
95
|
+
return fail("no model step in the output — the model did not actually run (check `opencode auth list`)");
|
|
96
|
+
}
|
|
97
|
+
return { status: "ok", skillFired: result.skillFired, trajectory: result.trajectory, cost: result.cost };
|
|
98
|
+
}
|
|
62
99
|
export const openCodeAdapter = {
|
|
63
100
|
name: "opencode",
|
|
64
|
-
|
|
101
|
+
// `opencode run` always loads its global config and has no turn limit — say so, don't pretend
|
|
102
|
+
isolatesUserConfig: false,
|
|
103
|
+
supportsMaxTurns: false,
|
|
104
|
+
async probe(prompt, { cwd, timeoutMs = 120_000, model }) {
|
|
65
105
|
// config/CLI --model wins; env var is a fallback convenience
|
|
66
106
|
const effectiveModel = model ?? process.env["SKILL_PROBE_OPENCODE_MODEL"];
|
|
67
107
|
const args = ["run", prompt, "--format", "json"];
|
|
68
108
|
if (effectiveModel)
|
|
69
109
|
args.push("--model", effectiveModel);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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({
|
|
110
|
+
const r = await runProcess("opencode", args, { cwd, timeoutMs });
|
|
111
|
+
if (r.spawnError) {
|
|
112
|
+
return {
|
|
85
113
|
status: "error", skillFired: null, trajectory: [], cost: 0,
|
|
86
|
-
error: `spawn failed: ${
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
});
|
|
114
|
+
error: `spawn failed: ${r.spawnError} (is 'opencode' installed and on PATH?)`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (r.timedOut) {
|
|
118
|
+
const { result } = parseOutput(r.stdout);
|
|
119
|
+
return {
|
|
120
|
+
status: "error", skillFired: null, trajectory: result.trajectory, cost: result.cost,
|
|
121
|
+
error: `timed out after ${timeoutMs}ms`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return interpretOpenCode(r.stdout, r.stderr);
|
|
108
125
|
},
|
|
109
126
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AuditResult } from "./eval.ts";
|
|
2
|
+
import type { RunManifest } from "./manifest.ts";
|
|
3
|
+
export interface BaselineCase {
|
|
4
|
+
prompt: string;
|
|
5
|
+
expected: string | null;
|
|
6
|
+
hits: number;
|
|
7
|
+
n: number;
|
|
8
|
+
}
|
|
9
|
+
export interface BaselineFile {
|
|
10
|
+
manifest: RunManifest;
|
|
11
|
+
cases: BaselineCase[];
|
|
12
|
+
}
|
|
13
|
+
export declare function toBaseline(result: AuditResult): BaselineFile;
|
|
14
|
+
export declare function saveBaseline(result: AuditResult, path: string): void;
|
|
15
|
+
export declare function loadBaseline(path: string): BaselineFile;
|
|
16
|
+
export interface RegressionCase {
|
|
17
|
+
prompt: string;
|
|
18
|
+
expected: string | null;
|
|
19
|
+
before: {
|
|
20
|
+
hits: number;
|
|
21
|
+
n: number;
|
|
22
|
+
pHat: number;
|
|
23
|
+
};
|
|
24
|
+
after: {
|
|
25
|
+
hits: number;
|
|
26
|
+
n: number;
|
|
27
|
+
pHat: number;
|
|
28
|
+
};
|
|
29
|
+
/** after.pHat - before.pHat (negative = dropped since baseline) */
|
|
30
|
+
delta: number;
|
|
31
|
+
fisherP: number;
|
|
32
|
+
/** BH-adjusted across all compared cases — regression is decided on THIS */
|
|
33
|
+
fisherPAdj: number;
|
|
34
|
+
/** one side has no valid probes — not comparable (never reads as "no regression") */
|
|
35
|
+
untrustworthy: boolean;
|
|
36
|
+
/** significant drop since baseline (BH-corrected) */
|
|
37
|
+
regressed: boolean;
|
|
38
|
+
/** significant rise since baseline (informational — consider re-saving the baseline) */
|
|
39
|
+
improved: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface BaselineComparison {
|
|
42
|
+
baselineDate: string;
|
|
43
|
+
baselineVersion: string;
|
|
44
|
+
/** apples-to-oranges warnings: config hash / runtime / model drift between runs */
|
|
45
|
+
mismatches: string[];
|
|
46
|
+
cases: RegressionCase[];
|
|
47
|
+
/** prompts in the current run with no baseline entry (measured, but not gated) */
|
|
48
|
+
newCases: string[];
|
|
49
|
+
/** prompts in the baseline that the current run no longer has */
|
|
50
|
+
missingCases: string[];
|
|
51
|
+
regressions: number;
|
|
52
|
+
improvements: number;
|
|
53
|
+
/** 1 = at least one significant regression (CI should fail) */
|
|
54
|
+
exitCode: 0 | 1;
|
|
55
|
+
}
|
|
56
|
+
export declare function compareToBaseline(result: AuditResult, base: BaselineFile): BaselineComparison;
|
package/dist/baseline.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Baseline / regression gating — the CI story. `--save-baseline` records a run's per-case
|
|
2
|
+
* counts; `--baseline` compares a new run against them with Fisher's exact test per case and
|
|
3
|
+
* Benjamini-Hochberg across cases, failing ONLY on a statistically significant drop. Raw-rate
|
|
4
|
+
* comparisons make CI flaky (activation is stochastic — rates wobble run to run); testing the
|
|
5
|
+
* drop makes the gate honest: noise passes, real regressions fail. */
|
|
6
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { fisherExact2x2, bhAdjust } from "./stats.js";
|
|
8
|
+
import { ConfigError } from "./config.js";
|
|
9
|
+
export function toBaseline(result) {
|
|
10
|
+
return {
|
|
11
|
+
manifest: result.manifest,
|
|
12
|
+
cases: result.cases.map((c) => ({
|
|
13
|
+
prompt: c.prompt, expected: c.expected, hits: c.stats.hits, n: c.stats.n,
|
|
14
|
+
})),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function saveBaseline(result, path) {
|
|
18
|
+
writeFileSync(path, JSON.stringify(toBaseline(result), null, 2) + "\n");
|
|
19
|
+
}
|
|
20
|
+
export function loadBaseline(path) {
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = readFileSync(path, "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
throw new ConfigError([`cannot read baseline file ${path}: ${e instanceof Error ? e.message : e}`]);
|
|
27
|
+
}
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new ConfigError([`baseline file ${path} is not valid JSON`]);
|
|
34
|
+
}
|
|
35
|
+
const b = parsed;
|
|
36
|
+
if (!b || typeof b !== "object" || !Array.isArray(b.cases) || !b.manifest ||
|
|
37
|
+
b.cases.some((c) => typeof c?.prompt !== "string" || typeof c?.hits !== "number" || typeof c?.n !== "number")) {
|
|
38
|
+
throw new ConfigError([
|
|
39
|
+
`baseline file ${path} doesn't look like a skill-probe baseline ` +
|
|
40
|
+
`(expected { manifest, cases: [{prompt, expected, hits, n}] } — create one with --save-baseline)`,
|
|
41
|
+
]);
|
|
42
|
+
}
|
|
43
|
+
return b;
|
|
44
|
+
}
|
|
45
|
+
const caseKey = (prompt, expected) => `${prompt}\u0000${expected ?? "\u0001null"}`;
|
|
46
|
+
export function compareToBaseline(result, base) {
|
|
47
|
+
const mismatches = [];
|
|
48
|
+
const bm = base.manifest, cm = result.manifest;
|
|
49
|
+
if (bm.configHash !== cm.configHash) {
|
|
50
|
+
mismatches.push(`config changed since baseline (${bm.configHash} → ${cm.configHash}) — ` +
|
|
51
|
+
`only cases matching by prompt+expected are gated`);
|
|
52
|
+
}
|
|
53
|
+
if (bm.runtime !== cm.runtime)
|
|
54
|
+
mismatches.push(`runtime changed: ${bm.runtime} → ${cm.runtime}`);
|
|
55
|
+
if (bm.model !== cm.model)
|
|
56
|
+
mismatches.push(`model changed: ${bm.model} → ${cm.model}`);
|
|
57
|
+
// what the runtime ACTUALLY ran with (older baselines don't record it — skip silently)
|
|
58
|
+
const be = bm.environment, ce = cm.environment;
|
|
59
|
+
if (be?.resolvedModel && ce?.resolvedModel && be.resolvedModel !== ce.resolvedModel) {
|
|
60
|
+
mismatches.push(`the runtime used a different model: ${be.resolvedModel} → ${ce.resolvedModel}`);
|
|
61
|
+
}
|
|
62
|
+
if (be?.runtimeVersion && ce?.runtimeVersion && be.runtimeVersion !== ce.runtimeVersion) {
|
|
63
|
+
mismatches.push(`runtime version changed: ${be.runtimeVersion} → ${ce.runtimeVersion} ` +
|
|
64
|
+
`(built-in skills and routing can differ between versions)`);
|
|
65
|
+
}
|
|
66
|
+
if (be && ce && be.userConfig !== ce.userConfig) {
|
|
67
|
+
mismatches.push(`personal runtime config was ${ce.userConfig ? "loaded" : "excluded"} this run but ` +
|
|
68
|
+
`${be.userConfig ? "loaded" : "excluded"} in the baseline`);
|
|
69
|
+
}
|
|
70
|
+
if (be?.extraSkills && ce?.extraSkills) {
|
|
71
|
+
const was = new Set(be.extraSkills), now = new Set(ce.extraSkills);
|
|
72
|
+
const added = ce.extraSkills.filter((s) => !was.has(s));
|
|
73
|
+
const removed = be.extraSkills.filter((s) => !now.has(s));
|
|
74
|
+
if (added.length || removed.length) {
|
|
75
|
+
mismatches.push(`skills loaded alongside the library changed: ` +
|
|
76
|
+
[added.length ? `+${added.join(", +")}` : "", removed.length ? `−${removed.join(", −")}` : ""]
|
|
77
|
+
.filter(Boolean).join(" "));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const baseByKey = new Map(base.cases.map((c) => [caseKey(c.prompt, c.expected), c]));
|
|
81
|
+
const currentKeys = new Set(result.cases.map((c) => caseKey(c.prompt, c.expected)));
|
|
82
|
+
const compared = [];
|
|
83
|
+
const newCases = [];
|
|
84
|
+
for (const c of result.cases) {
|
|
85
|
+
const b = baseByKey.get(caseKey(c.prompt, c.expected));
|
|
86
|
+
if (!b) {
|
|
87
|
+
newCases.push(c.prompt);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const after = { hits: c.stats.hits, n: c.stats.n, pHat: c.stats.n ? c.stats.hits / c.stats.n : 0 };
|
|
91
|
+
const before = { hits: b.hits, n: b.n, pHat: b.n ? b.hits / b.n : 0 };
|
|
92
|
+
const untrustworthy = before.n === 0 || after.n === 0;
|
|
93
|
+
const fisherP = untrustworthy ? 1
|
|
94
|
+
: fisherExact2x2(before.hits, before.n - before.hits, after.hits, after.n - after.hits);
|
|
95
|
+
compared.push({
|
|
96
|
+
prompt: c.prompt, expected: c.expected, before, after,
|
|
97
|
+
delta: after.pHat - before.pHat,
|
|
98
|
+
fisherP, fisherPAdj: 1, untrustworthy,
|
|
99
|
+
regressed: false, improved: false,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const missingCases = base.cases
|
|
103
|
+
.filter((b) => !currentKeys.has(caseKey(b.prompt, b.expected)))
|
|
104
|
+
.map((b) => b.prompt);
|
|
105
|
+
// one Fisher test per gated case = a family — correct before deciding, same as context
|
|
106
|
+
const family = compared.filter((r) => !r.untrustworthy);
|
|
107
|
+
const adj = bhAdjust(family.map((r) => r.fisherP));
|
|
108
|
+
family.forEach((r, j) => { r.fisherPAdj = adj[j]; });
|
|
109
|
+
for (const r of compared) {
|
|
110
|
+
r.regressed = !r.untrustworthy && r.delta < 0 && r.fisherPAdj < 0.05;
|
|
111
|
+
r.improved = !r.untrustworthy && r.delta > 0 && r.fisherPAdj < 0.05;
|
|
112
|
+
}
|
|
113
|
+
const regressions = compared.filter((r) => r.regressed).length;
|
|
114
|
+
return {
|
|
115
|
+
baselineDate: bm.date, baselineVersion: bm.version,
|
|
116
|
+
mismatches, cases: compared, newCases, missingCases,
|
|
117
|
+
regressions,
|
|
118
|
+
improvements: compared.filter((r) => r.improved).length,
|
|
119
|
+
exitCode: regressions > 0 ? 1 : 0,
|
|
120
|
+
};
|
|
121
|
+
}
|
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/
|
|
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";
|
|
@@ -12,11 +13,16 @@ import { runDiagnose } from "./diagnose.js";
|
|
|
12
13
|
import { runDoctor } from "./doctor.js";
|
|
13
14
|
import { listSkills, generateCases, genConfig } from "./gen.js";
|
|
14
15
|
import { loadConfig, parseProbability, parseIntFlag, ConfigError } from "./config.js";
|
|
15
|
-
import {
|
|
16
|
+
import { loadBaseline, saveBaseline, compareToBaseline } from "./baseline.js";
|
|
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"]);
|
|
16
22
|
const HELP = `skill-probe — audit a co-loaded agent skill library by real activation behavior.
|
|
17
23
|
|
|
18
24
|
USAGE
|
|
19
|
-
skill-probe --config <file.json> [options]
|
|
25
|
+
skill-probe [audit] --config <file.json> [options] # audit
|
|
20
26
|
skill-probe context --config <file.json> [--ablate] # isolation vs co-loaded activation rates
|
|
21
27
|
skill-probe diagnose --config <file.json> [options] # why a skill fails: routing-miss vs description
|
|
22
28
|
skill-probe doctor [--cwd <dir>] [--config <file.json>] # preflight: setup/auth/config sanity
|
|
@@ -31,11 +37,28 @@ OPTIONS
|
|
|
31
37
|
Recorded in the report — REQUIRED for a fair cross-runtime comparison.
|
|
32
38
|
--k <n> max VALID repeats per prompt (positive integer; default 10)
|
|
33
39
|
--threshold <p> min acceptable reliability, strictly 0..1 (default 0.7 = smoke test)
|
|
34
|
-
--conf <p> confidence
|
|
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
|
|
35
51
|
--json emit JSON instead of a table
|
|
36
52
|
--markdown emit a Markdown table (paste into a PR/README)
|
|
37
53
|
--no-cost hide the cost line (e.g. when on a Claude subscription)
|
|
38
54
|
--quiet suppress the live progress line on stderr
|
|
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
|
|
58
|
+
--save-baseline <f> after the run, save per-case counts + a run manifest to <f>
|
|
59
|
+
--baseline <f> compare this run against a saved baseline: per-case Fisher's exact,
|
|
60
|
+
Benjamini-Hochberg corrected — exit 1 ONLY on a significant regression
|
|
61
|
+
(noise passes; real drops fail)
|
|
39
62
|
context only:
|
|
40
63
|
--ablate leave-one-out: for each interference case, re-run with each suspect
|
|
41
64
|
sibling removed and NAME the thief when activation recovers (Fisher +
|
|
@@ -44,7 +67,7 @@ OPTIONS
|
|
|
44
67
|
doctor only:
|
|
45
68
|
--cwd <dir> project dir containing .claude/skills/ (default: .)
|
|
46
69
|
--config <file> also sanity-check a config (expected skills exist, k/threshold feasible)
|
|
47
|
-
--skip-probe don't run the harmless live
|
|
70
|
+
--skip-probe don't run the harmless live probes (skip the auth/load checks; no cost)
|
|
48
71
|
gen only (uses ANTHROPIC_API_KEY if set, else the logged-in 'claude' CLI):
|
|
49
72
|
--cwd <dir> project dir containing .claude/skills/ (default: .)
|
|
50
73
|
--per-skill <n> should-fire prompts to draft per skill (default 3)
|
|
@@ -54,8 +77,8 @@ OPTIONS
|
|
|
54
77
|
--apply-threshold <p> min P(improvement) to keep the rewrite (default 0.9)
|
|
55
78
|
-h, --help show this help
|
|
56
79
|
|
|
57
|
-
Reliability carries a Wilson CI + sample size k. fix
|
|
58
|
-
|
|
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.`;
|
|
59
82
|
let progressLen = 0;
|
|
60
83
|
function makeProgress(values) {
|
|
61
84
|
if (values["json"] || values["quiet"] || !process.stderr.isTTY)
|
|
@@ -97,30 +120,55 @@ function makeDiagProgress(values) {
|
|
|
97
120
|
progressLen = line.length;
|
|
98
121
|
};
|
|
99
122
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
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"]),
|
|
105
131
|
});
|
|
106
|
-
|
|
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 } : {}) });
|
|
107
149
|
clearProgress();
|
|
108
150
|
const showCost = !values["no-cost"];
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
151
|
+
let cmp;
|
|
152
|
+
if (values["baseline"])
|
|
153
|
+
cmp = compareToBaseline(result, loadBaseline(values["baseline"]));
|
|
154
|
+
if (values["save-baseline"]) {
|
|
155
|
+
saveBaseline(result, values["save-baseline"]);
|
|
156
|
+
console.error(`baseline saved to ${values["save-baseline"]}`);
|
|
157
|
+
}
|
|
158
|
+
const text = values["json"] ? renderJson(cmp ? { ...result, baseline: cmp } : result)
|
|
159
|
+
: values["markdown"] ? renderMarkdown(result, { showCost }) + (cmp ? "\n\n" + renderBaseline(cmp) : "")
|
|
160
|
+
: renderTable(result, { showCost }) + (cmp ? "\n\n" + renderBaseline(cmp) : "");
|
|
112
161
|
console.log(text);
|
|
162
|
+
// precedence mirrors the rest of the tool: a behavioral signal (fail/regression, 1) outranks
|
|
163
|
+
// inconclusive/infra (2), which outranks clean (0).
|
|
164
|
+
if (result.exitCode === 1 || cmp?.exitCode === 1)
|
|
165
|
+
return 1;
|
|
113
166
|
return result.exitCode;
|
|
114
167
|
}
|
|
115
168
|
async function diagnose(values) {
|
|
116
|
-
const cfg =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
conf: values["conf"],
|
|
120
|
-
});
|
|
121
|
-
const result = await runDiagnose(cfg, {
|
|
122
|
-
...(makeDiagProgress(values) ? { onProgress: makeDiagProgress(values) } : {}),
|
|
123
|
-
});
|
|
169
|
+
const cfg = configFrom(values);
|
|
170
|
+
const onProgress = makeDiagProgress(values);
|
|
171
|
+
const result = await runDiagnose(cfg, { ...runOpts(values), ...(onProgress ? { onProgress } : {}) });
|
|
124
172
|
clearProgress();
|
|
125
173
|
const showCost = !values["no-cost"];
|
|
126
174
|
console.log(values["json"] ? renderJson(result) : renderDiagnose(result, { showCost }));
|
|
@@ -133,20 +181,17 @@ async function doctor(values) {
|
|
|
133
181
|
...(values["runtime"] ? { runtime: values["runtime"] } : {}),
|
|
134
182
|
...(values["model"] ? { model: values["model"] } : {}),
|
|
135
183
|
...(values["skip-probe"] ? { skipProbe: true } : {}),
|
|
184
|
+
...(values["user-config"] ? { userConfig: true } : {}),
|
|
136
185
|
});
|
|
137
186
|
console.log(values["json"] ? renderJson(result) : renderDoctor(result));
|
|
138
187
|
return result.exitCode;
|
|
139
188
|
}
|
|
140
189
|
async function context(values) {
|
|
141
|
-
const cfg =
|
|
142
|
-
runtime: values["runtime"], model: values["model"],
|
|
143
|
-
k: values["k"], threshold: values["threshold"],
|
|
144
|
-
conf: values["conf"],
|
|
145
|
-
});
|
|
190
|
+
const cfg = configFrom(values);
|
|
146
191
|
const suspects = parseIntFlag(values["suspects"], "--suspects", 3, 1);
|
|
147
192
|
const onProgress = makeContextProgress(values);
|
|
148
193
|
const result = await runContextAudit(cfg, {
|
|
149
|
-
ablate: Boolean(values["ablate"]), suspects,
|
|
194
|
+
ablate: Boolean(values["ablate"]), suspects, ...runOpts(values),
|
|
150
195
|
...(onProgress ? { onProgress } : {}),
|
|
151
196
|
});
|
|
152
197
|
clearProgress();
|
|
@@ -178,12 +223,11 @@ async function fix(values) {
|
|
|
178
223
|
console.error("fix requires --skill <name>");
|
|
179
224
|
return 2;
|
|
180
225
|
}
|
|
181
|
-
const cfg =
|
|
182
|
-
runtime: values["runtime"], model: values["model"],
|
|
183
|
-
k: values["k"], conf: values["conf"],
|
|
184
|
-
});
|
|
226
|
+
const cfg = configFrom(values, { threshold: false });
|
|
185
227
|
const applyBar = parseProbability(values["apply-threshold"], "--apply-threshold", 0.9);
|
|
186
|
-
const result = await runFix(cfg, {
|
|
228
|
+
const result = await runFix(cfg, {
|
|
229
|
+
skill, k: cfg.k, applyBar, ...runOpts(values), ...(cfg.model ? { model: cfg.model } : {}),
|
|
230
|
+
});
|
|
187
231
|
console.log(values["json"] ? renderJson(result) : renderFix(result));
|
|
188
232
|
return result.applied ? 0 : 1;
|
|
189
233
|
}
|
|
@@ -204,7 +248,16 @@ async function main() {
|
|
|
204
248
|
decoys: { type: "string" },
|
|
205
249
|
ablate: { type: "boolean", default: false },
|
|
206
250
|
suspects: { type: "string" },
|
|
251
|
+
baseline: { type: "string" },
|
|
252
|
+
"save-baseline": { type: "string" },
|
|
207
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 },
|
|
208
261
|
json: { type: "boolean", default: false },
|
|
209
262
|
markdown: { type: "boolean", default: false },
|
|
210
263
|
"no-cost": { type: "boolean", default: false },
|
|
@@ -216,7 +269,16 @@ async function main() {
|
|
|
216
269
|
console.log(HELP);
|
|
217
270
|
process.exit(0);
|
|
218
271
|
}
|
|
219
|
-
|
|
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();
|
|
220
282
|
let code;
|
|
221
283
|
if (cmd === "gen") {
|
|
222
284
|
code = await gen(values);
|
|
@@ -226,7 +288,7 @@ async function main() {
|
|
|
226
288
|
}
|
|
227
289
|
else {
|
|
228
290
|
if (!values.config) {
|
|
229
|
-
console.
|
|
291
|
+
console.error(HELP);
|
|
230
292
|
process.exit(2);
|
|
231
293
|
}
|
|
232
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
|
}
|