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.
@@ -1,13 +1,27 @@
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";
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
- if (o["type"] === "tool" && typeof o["tool"] === "string") {
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
- probe(prompt, { cwd, timeoutMs = 120_000, model }) {
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
- 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({
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: ${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
- });
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;
@@ -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/reverted, 2 = inconclusive/infra error/usage. */
5
+ * Exit: 0 = pass/applied, 1 = behavioral fail/not applied, 2 = inconclusive/infra error/usage,
6
+ * 130/143 = interrupted (children killed, sandboxes removed). */
6
7
  import { parseArgs } from "node:util";
7
8
  import { resolve } from "node:path";
8
9
  import { runAudit } from "./eval.js";
@@ -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 { renderTable, renderMarkdown, renderJson, renderFix, renderContext, renderDoctor, renderDiagnose } from "./report.js";
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] # audit
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 level: 0.9 | 0.95 | 0.99 (default 0.95)
40
+ --conf <p> confidence: 0.9 | 0.95 | 0.99 (default 0.95). A verdict is wrong with
41
+ probability <= 1-conf, however early the run stops.
42
+ --max-turns <n> agent turns per probe before reading the outcome (default 1)
43
+ --timeout <sec> per-probe timeout in seconds (default 120)
44
+ --user-config also load your personal runtime config (user settings, plugins, MCP
45
+ servers) into probes. Off by default (claude-code): results then
46
+ reproduce in CI. opencode always loads its global config.
47
+ --concurrency <n> cases probed in parallel (default 1; mind your rate limits)
48
+ --max-cost <usd> stop probing once runtime probes have spent this much; cases cut short
49
+ are inconclusive (error if never started), never a pass. LLM calls
50
+ (gen, fix's rewrite, diagnose's forced choice) aren't counted
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 probe (skip the auth check; no cost)
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 rewrites the description, runs an INTERLEAVED
58
- before/after, and applies it only if the Bayesian P(improvement) clears the apply-threshold.`;
80
+ Reliability carries a Wilson CI + sample size k. fix runs a PAIRED before/after in two sandboxes and
81
+ writes the new description only if the Bayesian P(improvement) clears the apply-threshold.`;
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
- async function audit(values) {
101
- const cfg = loadConfig(values["config"], {
102
- runtime: values["runtime"], model: values["model"],
103
- k: values["k"], threshold: values["threshold"],
104
- conf: values["conf"],
123
+ /** The config plus every CLI override that changes what is measured. */
124
+ function configFrom(values, opts = {}) {
125
+ const str = (k) => values[k];
126
+ return loadConfig(values["config"], {
127
+ runtime: str("runtime"), model: str("model"), k: str("k"), conf: str("conf"),
128
+ ...(opts.threshold === false ? {} : { threshold: str("threshold") }),
129
+ maxTurns: str("max-turns"), timeout: str("timeout"),
130
+ userConfig: Boolean(values["user-config"]), failOnTheft: Boolean(values["fail-on-theft"]),
105
131
  });
106
- const result = await runAudit(cfg, makeProgress(values));
132
+ }
133
+ /** Run-time knobs that DON'T change what is measured (parallelism, spend cap). */
134
+ function runOpts(values) {
135
+ const concurrency = parseIntFlag(values["concurrency"], "--concurrency", 1, 1);
136
+ const raw = values["max-cost"];
137
+ if (raw === undefined)
138
+ return { concurrency };
139
+ const max = Number(raw);
140
+ if (!Number.isFinite(max) || max <= 0) {
141
+ throw new ConfigError([`--max-cost must be a positive number of USD (got ${JSON.stringify(raw)})`]);
142
+ }
143
+ return { concurrency, budget: new Budget(max) };
144
+ }
145
+ async function audit(values) {
146
+ const cfg = configFrom(values);
147
+ const progress = makeProgress(values);
148
+ const result = await runAudit(cfg, { ...runOpts(values), ...(progress ? { onProgress: progress } : {}) });
107
149
  clearProgress();
108
150
  const showCost = !values["no-cost"];
109
- const text = values["json"] ? renderJson(result)
110
- : values["markdown"] ? renderMarkdown(result, { showCost })
111
- : renderTable(result, { showCost });
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 = loadConfig(values["config"], {
117
- runtime: values["runtime"], model: values["model"],
118
- k: values["k"], threshold: values["threshold"],
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 = loadConfig(values["config"], {
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 = loadConfig(values["config"], {
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, { skill, k: cfg.k, applyBar, ...(cfg.model ? { model: cfg.model } : {}) });
228
+ const result = await runFix(cfg, {
229
+ skill, k: cfg.k, applyBar, ...runOpts(values), ...(cfg.model ? { model: cfg.model } : {}),
230
+ });
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
- const cmd = positionals[0];
272
+ if (values.version) {
273
+ console.log(toolVersion());
274
+ process.exit(0);
275
+ }
276
+ const cmd = positionals[0] ?? "audit";
277
+ if (!COMMANDS.has(cmd) || positionals.length > 1) {
278
+ console.error(`unknown command: ${positionals.join(" ")} (expected one of: ${[...COMMANDS].join(", ")})`);
279
+ process.exit(2);
280
+ }
281
+ installSignalHandlers();
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.log(HELP);
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
  }