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 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,4 @@
1
+ /** Adapter registry — adding a runtime = registering one adapter here. */
2
+ import type { RuntimeAdapter } from "../types.ts";
3
+ export declare const ADAPTERS: Record<string, RuntimeAdapter>;
4
+ export declare function getAdapter(name: string): RuntimeAdapter;
@@ -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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ });
@@ -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>;