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/dist/llm.js CHANGED
@@ -3,8 +3,8 @@
3
3
  * - otherwise → fall back to the `claude` CLI (works on a Claude subscription,
4
4
  * no API key needed).
5
5
  * So the whole tool — audit, gen, fix — runs on just a logged-in `claude`. */
6
- import { spawn } from "node:child_process";
7
6
  import { tmpdir } from "node:os";
7
+ import { runProcess } from "./proc.js";
8
8
  const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
9
9
  /** Which backend complete() will use, given the environment. Exported for testing/diagnostics. */
10
10
  export function llmBackend() {
@@ -47,26 +47,26 @@ export function interpretClaudeJson(code, out, err, timedOut) {
47
47
  text = text.trim();
48
48
  return text ? { text } : { error: `claude CLI returned no text (${(err || out).slice(0, 200)})` };
49
49
  }
50
- function completeViaClaudeCli(system, user, opts) {
51
- return new Promise((resolve, reject) => {
52
- const args = ["-p", user, "--append-system-prompt", system, "--output-format", "json"];
53
- if (opts.model)
54
- args.push("--model", opts.model);
55
- // run in a neutral cwd so the project's own skills/AGENTS.md don't bias the generation
56
- const child = spawn("claude", args, { cwd: tmpdir(), stdio: ["ignore", "pipe", "pipe"] });
57
- let out = "", err = "", settled = false, timedOut = false;
58
- const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, 120_000);
59
- const done = (fn) => { if (settled)
60
- return; settled = true; clearTimeout(timer); fn(); };
61
- child.stdout.on("data", (d) => { out += d.toString(); });
62
- child.stderr.on("data", (d) => { err += d.toString(); });
63
- child.on("error", (e) => done(() => reject(new Error(`claude CLI failed: ${e.message} — set ANTHROPIC_API_KEY or install/login 'claude'.`))));
64
- child.on("close", (code) => done(() => {
65
- const r = interpretClaudeJson(code, out, err, timedOut);
66
- if (r.text)
67
- resolve(r.text);
68
- else
69
- reject(new Error(r.error ?? "claude CLI failed"));
70
- }));
71
- });
50
+ /** CLI arguments for one completion. Exported for testing. */
51
+ export function claudeCliArgs(system, user, opts) {
52
+ // hermetic + no session transcript: diagnose calls this k times per case, and neither the user's
53
+ // plugins/MCP servers nor ~/.claude/projects clutter have any business in a plain completion
54
+ const args = [
55
+ "-p", user, "--append-system-prompt", system, "--output-format", "json",
56
+ "--no-session-persistence", "--setting-sources", "project", "--strict-mcp-config",
57
+ ];
58
+ if (opts.model)
59
+ args.push("--model", opts.model);
60
+ return args;
61
+ }
62
+ async function completeViaClaudeCli(system, user, opts) {
63
+ // run in a neutral cwd so the project's own skills/AGENTS.md don't bias the generation
64
+ const r = await runProcess("claude", claudeCliArgs(system, user, opts), { cwd: tmpdir(), timeoutMs: 120_000 });
65
+ if (r.spawnError) {
66
+ throw new Error(`claude CLI failed: ${r.spawnError} — set ANTHROPIC_API_KEY or install/login 'claude'.`);
67
+ }
68
+ const out = interpretClaudeJson(r.code, r.stdout, r.stderr, r.timedOut);
69
+ if (out.text)
70
+ return out.text;
71
+ throw new Error(out.error ?? "claude CLI failed");
72
72
  }
@@ -0,0 +1,40 @@
1
+ import type { Config, ProbeEnv, RuntimeAdapter } from "./types.ts";
2
+ /** What the runtime actually ran with — reported by the runtime itself, not assumed. */
3
+ export interface RunEnvironment {
4
+ /** runtime CLI version (built-in skills and routing change between versions) */
5
+ runtimeVersion?: string;
6
+ /** the model the runtime actually used — resolves "(runtime default)" */
7
+ resolvedModel?: string;
8
+ /** skills the runtime loaded that are not in the library (e.g. Claude Code's built-ins) */
9
+ extraSkills?: string[];
10
+ /** the user's personal runtime config was requested in probes (--user-config) */
11
+ userConfig: boolean;
12
+ /** personal runtime config was actually kept out of probes (false when the runtime can't) */
13
+ hermetic?: boolean;
14
+ maxTurns: number;
15
+ }
16
+ export interface RunManifest {
17
+ tool: "skill-probe";
18
+ version: string;
19
+ command: "audit" | "context" | "diagnose";
20
+ /** ISO 8601 UTC */
21
+ date: string;
22
+ runtime: string;
23
+ model: string;
24
+ k: number;
25
+ threshold: number;
26
+ conf: number;
27
+ /** sha256 (first 16 hex chars) of the resolved config — cases + settings, NOT cwd */
28
+ configHash: string;
29
+ node: string;
30
+ environment?: RunEnvironment;
31
+ }
32
+ export declare function toolVersion(): string;
33
+ /** Hash the parts of the config that change what was measured. `cwd` is deliberately EXCLUDED:
34
+ * the same library checked out at a different path (CI runner vs laptop) is the same experiment.
35
+ * Settings added later are hashed only when non-default, so existing baselines keep their hash. */
36
+ export declare function configHash(cfg: Config): string;
37
+ export declare function buildEnvironment(cfg: Config, env: ProbeEnv | undefined, extraSkills: string[] | undefined, adapter: RuntimeAdapter): RunEnvironment;
38
+ /** Caveats when the config asks for something this runtime can't do. */
39
+ export declare function capabilityWarnings(cfg: Config, adapter: RuntimeAdapter): string[];
40
+ export declare function buildManifest(command: RunManifest["command"], cfg: Config, environment?: RunEnvironment): RunManifest;
@@ -0,0 +1,73 @@
1
+ /** Run manifest — stamps every probe-running result so two runs are comparable and a report is
2
+ * citable: tool version, command, when it ran, runtime/model, the statistical settings, and a
3
+ * hash of the resolved config. Same configHash = same cases + settings = apples to apples; the
4
+ * baseline comparison uses it to warn when a gate compares different experiments. */
5
+ import { createHash } from "node:crypto";
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ let cachedVersion;
9
+ export function toolVersion() {
10
+ if (cachedVersion === undefined) {
11
+ try {
12
+ const pkg = JSON.parse(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8"));
13
+ cachedVersion = pkg.version ?? "unknown";
14
+ }
15
+ catch {
16
+ cachedVersion = "unknown";
17
+ }
18
+ }
19
+ return cachedVersion;
20
+ }
21
+ /** Hash the parts of the config that change what was measured. `cwd` is deliberately EXCLUDED:
22
+ * the same library checked out at a different path (CI runner vs laptop) is the same experiment.
23
+ * Settings added later are hashed only when non-default, so existing baselines keep their hash. */
24
+ export function configHash(cfg) {
25
+ const canonical = JSON.stringify({
26
+ runtime: cfg.runtime,
27
+ model: cfg.model ?? null,
28
+ k: cfg.k,
29
+ threshold: cfg.threshold,
30
+ conf: cfg.conf,
31
+ cases: cfg.cases.map((c) => ({ prompt: c.prompt, expected: c.expected })),
32
+ ...((cfg.maxTurns ?? 1) !== 1 ? { maxTurns: cfg.maxTurns } : {}),
33
+ ...(cfg.userConfig ? { userConfig: true } : {}),
34
+ });
35
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
36
+ }
37
+ export function buildEnvironment(cfg, env, extraSkills, adapter) {
38
+ return {
39
+ ...(env?.runtimeVersion ? { runtimeVersion: env.runtimeVersion } : {}),
40
+ ...(env?.model ? { resolvedModel: env.model } : {}),
41
+ ...(extraSkills ? { extraSkills } : {}),
42
+ userConfig: Boolean(cfg.userConfig),
43
+ hermetic: Boolean(adapter.isolatesUserConfig) && !cfg.userConfig,
44
+ maxTurns: cfg.maxTurns ?? 1,
45
+ };
46
+ }
47
+ /** Caveats when the config asks for something this runtime can't do. */
48
+ export function capabilityWarnings(cfg, adapter) {
49
+ const w = [];
50
+ if (cfg.maxTurns !== undefined && cfg.maxTurns !== 1 && !adapter.supportsMaxTurns) {
51
+ w.push(`${adapter.name} has no turn limit — maxTurns (${cfg.maxTurns}) is ignored`);
52
+ }
53
+ if (!adapter.isolatesUserConfig && !cfg.userConfig) {
54
+ w.push(`${adapter.name} can't exclude your personal/global config from probes — results may differ between machines`);
55
+ }
56
+ return w;
57
+ }
58
+ export function buildManifest(command, cfg, environment) {
59
+ return {
60
+ tool: "skill-probe",
61
+ version: toolVersion(),
62
+ command,
63
+ date: new Date().toISOString(),
64
+ runtime: cfg.runtime,
65
+ model: cfg.model ?? "(runtime default)",
66
+ k: cfg.k,
67
+ threshold: cfg.threshold,
68
+ conf: cfg.conf,
69
+ configHash: configHash(cfg),
70
+ node: process.version,
71
+ ...(environment ? { environment } : {}),
72
+ };
73
+ }
@@ -1,7 +1,9 @@
1
1
  /** Runs one prompt up to k VALID times with sequential early-stopping vs the threshold.
2
2
  * Infra errors (status="error") are retried up to a budget and never counted as a behavioral
3
- * outcome (finding #1) — they're tracked separately so the audit can flag untrustworthy cases. */
4
- import type { RuntimeAdapter } from "./types.ts";
3
+ * outcome (finding #1) — they're tracked separately so the audit can flag untrustworthy cases.
4
+ * Early stopping follows an exact group-sequential plan (stats.ts), so peeking after every probe
5
+ * doesn't inflate the verdict error rate. */
6
+ import type { ProbeEnv, ProbeOpts, RuntimeAdapter } from "./types.ts";
5
7
  export interface RunStats {
6
8
  /** valid probes whose outcome matched the expected behavior */
7
9
  hits: number;
@@ -14,16 +16,56 @@ export interface RunStats {
14
16
  /** distribution of outcomes over valid probes: skill name (or "None") -> count */
15
17
  dist: Record<string, number>;
16
18
  totalCost: number;
19
+ /** valid probes where the model called some other tool and no skill fired — with a low
20
+ * --max-turns the skill may have been about to load, so activation can be under-counted */
21
+ preempted: number;
22
+ /** what the runtime reported loading (first probe that reported it) */
23
+ env?: ProbeEnv;
24
+ /** stopped because the shared --max-cost budget ran out (the measurement is incomplete) */
25
+ budgetExhausted?: boolean;
26
+ }
27
+ /** A shared spend cap across every probe of a run (`--max-cost`). Checked before each probe, so a
28
+ * run can overshoot by at most the probes already in flight. */
29
+ export declare class Budget {
30
+ spent: number;
31
+ readonly maxUsd: number;
32
+ constructor(maxUsd: number);
33
+ get exhausted(): boolean;
34
+ add(usd: number): void;
17
35
  }
18
36
  /** A measurement is untrustworthy when infrastructure failures dominate — no valid probes, or
19
37
  * errors at least equal the valid count. Such a run must NOT read as a behavioral result (a runtime
20
38
  * outage is not a clean pass). Shared by the audit, context, and diagnose paths. */
21
39
  export declare function infraUntrustworthy(s: RunStats): boolean;
22
- export declare function measure(adapter: RuntimeAdapter, prompt: string, expected: string | null, opts: {
40
+ export interface MeasureOpts extends Omit<ProbeOpts, "cwd"> {
23
41
  cwd: string;
24
42
  maxK: number;
25
43
  threshold: number;
26
44
  conf: number;
27
- model?: string;
45
+ budget?: Budget;
28
46
  onProbe?: (validN: number, maxK: number) => void;
29
- }): Promise<RunStats>;
47
+ }
48
+ export declare function measure(adapter: RuntimeAdapter, prompt: string, expected: string | null, opts: MeasureOpts): Promise<RunStats>;
49
+ /** Bounded-concurrency map preserving input order. concurrency 1 = strictly sequential. */
50
+ export declare function mapPool<T, R>(items: readonly T[], concurrency: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
51
+ /** Problems with what the runtime actually loaded for a condition that was meant to contain
52
+ * exactly `intended` out of `library`. Matches either folder names or frontmatter names. Skills in
53
+ * `ambiguous` (library skills sharing a name with a runtime built-in, which is "loaded" whether or
54
+ * not the library copy is) can't be judged from names alone and are skipped. */
55
+ export declare function loadProblems(env: ProbeEnv | undefined, intended: readonly string[], library: ReadonlyArray<{
56
+ name: string;
57
+ frontmatterName?: string;
58
+ }>, ambiguous?: ReadonlySet<string>): {
59
+ missing: string[];
60
+ unexpected: string[];
61
+ };
62
+ /** Library skills whose name (folder or frontmatter) is also a runtime built-in's name. */
63
+ export declare function builtinClashes(builtins: readonly string[], library: ReadonlyArray<{
64
+ name: string;
65
+ frontmatterName?: string;
66
+ }>): string[];
67
+ /** Skills the runtime loaded that are NOT part of the library (runtime built-ins, user config…). */
68
+ export declare function extraSkills(env: ProbeEnv | undefined, library: ReadonlyArray<{
69
+ name: string;
70
+ frontmatterName?: string;
71
+ }>): string[] | undefined;
@@ -1,4 +1,13 @@
1
- import { sequentialDecision } from "./stats.js";
1
+ import { planDecision, sequentialPlan } from "./stats.js";
2
+ /** A shared spend cap across every probe of a run (`--max-cost`). Checked before each probe, so a
3
+ * run can overshoot by at most the probes already in flight. */
4
+ export class Budget {
5
+ spent = 0;
6
+ maxUsd;
7
+ constructor(maxUsd) { this.maxUsd = maxUsd; }
8
+ get exhausted() { return this.spent >= this.maxUsd; }
9
+ add(usd) { this.spent += usd; }
10
+ }
2
11
  /** A measurement is untrustworthy when infrastructure failures dominate — no valid probes, or
3
12
  * errors at least equal the valid count. Such a run must NOT read as a behavioral result (a runtime
4
13
  * outage is not a clean pass). Shared by the audit, context, and diagnose paths. */
@@ -6,32 +15,92 @@ export function infraUntrustworthy(s) {
6
15
  return s.n === 0 || (s.errors > 0 && s.errors >= s.n);
7
16
  }
8
17
  export async function measure(adapter, prompt, expected, opts) {
18
+ const { maxK, threshold, conf, budget, onProbe, ...probeOpts } = opts;
9
19
  const want = expected ?? "None";
20
+ const plan = sequentialPlan(maxK, threshold, conf);
10
21
  const dist = {};
11
- let hits = 0, n = 0, errors = 0, totalCost = 0;
22
+ let hits = 0, n = 0, errors = 0, totalCost = 0, preempted = 0;
12
23
  let lastError;
24
+ let env;
25
+ let budgetExhausted = false;
13
26
  // allow up to 50% extra attempts to absorb transient infra errors without inflating k
14
- const maxAttempts = opts.maxK + Math.ceil(opts.maxK / 2);
15
- for (let attempt = 0; attempt < maxAttempts && n < opts.maxK; attempt++) {
16
- const r = await adapter.probe(prompt, { cwd: opts.cwd, ...(opts.model ? { model: opts.model } : {}) });
27
+ const maxAttempts = maxK + Math.ceil(maxK / 2);
28
+ for (let attempt = 0; attempt < maxAttempts && n < maxK; attempt++) {
29
+ if (budget?.exhausted) {
30
+ budgetExhausted = true;
31
+ lastError = `--max-cost budget ($${budget.maxUsd}) reached`;
32
+ break;
33
+ }
34
+ const r = await adapter.probe(prompt, probeOpts);
17
35
  totalCost += r.cost;
36
+ budget?.add(r.cost);
37
+ if (!env && r.env?.loadedSkills)
38
+ env = r.env;
18
39
  if (r.status === "error") {
19
40
  errors++;
20
41
  lastError = r.error;
21
- opts.onProbe?.(n, opts.maxK);
42
+ onProbe?.(n, maxK);
22
43
  continue; // do NOT record as a behavioral outcome
23
44
  }
24
45
  const key = r.skillFired ?? "None";
25
46
  dist[key] = (dist[key] ?? 0) + 1;
26
47
  if (key === want)
27
48
  hits++;
49
+ if (r.skillFired === null && r.trajectory.length > 0)
50
+ preempted++;
28
51
  n++;
29
- opts.onProbe?.(n, opts.maxK);
30
- if (n >= 3) {
31
- const d = sequentialDecision(hits, n, opts.threshold, opts.conf);
32
- if (d !== "undecided")
33
- break;
34
- }
52
+ onProbe?.(n, maxK);
53
+ if (planDecision(plan, hits, n) !== "undecided")
54
+ break;
35
55
  }
36
- return { hits, n, errors, dist, totalCost, ...(lastError ? { lastError } : {}) };
56
+ return {
57
+ hits, n, errors, dist, totalCost, preempted,
58
+ ...(lastError ? { lastError } : {}),
59
+ ...(env ? { env } : {}),
60
+ ...(budgetExhausted ? { budgetExhausted } : {}),
61
+ };
62
+ }
63
+ /** Bounded-concurrency map preserving input order. concurrency 1 = strictly sequential. */
64
+ export async function mapPool(items, concurrency, fn) {
65
+ const out = new Array(items.length);
66
+ let next = 0;
67
+ const worker = async () => {
68
+ while (next < items.length) {
69
+ const i = next++;
70
+ out[i] = await fn(items[i], i);
71
+ }
72
+ };
73
+ const width = Math.max(1, Math.min(Math.floor(concurrency) || 1, items.length));
74
+ await Promise.all(Array.from({ length: width }, worker));
75
+ return out;
76
+ }
77
+ /** Problems with what the runtime actually loaded for a condition that was meant to contain
78
+ * exactly `intended` out of `library`. Matches either folder names or frontmatter names. Skills in
79
+ * `ambiguous` (library skills sharing a name with a runtime built-in, which is "loaded" whether or
80
+ * not the library copy is) can't be judged from names alone and are skipped. */
81
+ export function loadProblems(env, intended, library, ambiguous = new Set()) {
82
+ if (!env?.loadedSkills)
83
+ return { missing: [], unexpected: [] };
84
+ const loaded = new Set(env.loadedSkills);
85
+ const isLoaded = (s) => loaded.has(s.name) || (s.frontmatterName !== undefined && loaded.has(s.frontmatterName));
86
+ const want = new Set(intended);
87
+ const judged = library.filter((s) => !ambiguous.has(s.name));
88
+ return {
89
+ missing: judged.filter((s) => want.has(s.name) && !isLoaded(s)).map((s) => s.name),
90
+ unexpected: judged.filter((s) => !want.has(s.name) && isLoaded(s)).map((s) => s.name),
91
+ };
92
+ }
93
+ /** Library skills whose name (folder or frontmatter) is also a runtime built-in's name. */
94
+ export function builtinClashes(builtins, library) {
95
+ const b = new Set(builtins);
96
+ return library
97
+ .filter((s) => b.has(s.name) || (s.frontmatterName !== undefined && b.has(s.frontmatterName)))
98
+ .map((s) => s.name);
99
+ }
100
+ /** Skills the runtime loaded that are NOT part of the library (runtime built-ins, user config…). */
101
+ export function extraSkills(env, library) {
102
+ if (!env?.loadedSkills)
103
+ return undefined;
104
+ const mine = new Set(library.flatMap((s) => [s.name, ...(s.frontmatterName ? [s.frontmatterName] : [])]));
105
+ return env.loadedSkills.filter((s) => !mine.has(s)).sort();
37
106
  }
package/dist/proc.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ export interface ProcResult {
2
+ /** exit code; null when killed by a signal or when the spawn itself failed */
3
+ code: number | null;
4
+ stdout: string;
5
+ stderr: string;
6
+ timedOut: boolean;
7
+ /** set when the binary couldn't be started at all (e.g. ENOENT: not on PATH) */
8
+ spawnError?: string;
9
+ }
10
+ /** Run `cmd args` in `cwd` with stdin closed (some CLIs hang waiting on an open stdin). Never
11
+ * rejects: failures come back as data so callers can classify them as infrastructure errors.
12
+ * Resolves within timeoutMs + PIPE_GRACE_MS no matter what the child's descendants do. */
13
+ export declare function runProcess(cmd: string, args: string[], opts: {
14
+ cwd: string;
15
+ timeoutMs: number;
16
+ env?: NodeJS.ProcessEnv;
17
+ }): Promise<ProcResult>;
18
+ /** Track a temp dir so it's removed (plus `also`) even if the run is interrupted. */
19
+ export declare function trackTempDir(dir: string, also?: () => void): void;
20
+ export declare function untrackTempDir(dir: string): void;
21
+ /** Kill live children and remove tracked temp dirs. Synchronous: safe inside an 'exit' handler. */
22
+ export declare function cleanupAll(): void;
23
+ /** For the CLI entry point only (library callers keep default signal behavior). */
24
+ export declare function installSignalHandlers(): void;
package/dist/proc.js ADDED
@@ -0,0 +1,101 @@
1
+ /** Child-process plumbing shared by the runtime adapters and the LLM CLI backend: one spawn with a
2
+ * hard timeout, plus a registry of live children and temp dirs so Ctrl-C / SIGTERM never leaves
3
+ * orphaned `claude` processes or sandbox directories behind. */
4
+ import { spawn } from "node:child_process";
5
+ import { rmSync } from "node:fs";
6
+ const liveChildren = new Set();
7
+ /** temp dir -> extra cleanup to run alongside removing it */
8
+ const liveDirs = new Map();
9
+ /** How long to wait for stdout/stderr to close after the child exits or is killed. A descendant
10
+ * that inherited our pipes can hold them open forever; the timeout must still bound wall time. */
11
+ const PIPE_GRACE_MS = 1500;
12
+ const OWN_GROUP = process.platform !== "win32";
13
+ /** Kill the child and everything it spawned. Each child leads its own process group (detached),
14
+ * so one signal to -pid reaches grandchildren too. Only while the leader is alive — once it has
15
+ * exited, its group id could in principle be recycled, and we never signal a group we don't own. */
16
+ function killTree(child) {
17
+ if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null)
18
+ return;
19
+ try {
20
+ if (OWN_GROUP)
21
+ process.kill(-child.pid, "SIGKILL");
22
+ else
23
+ child.kill("SIGKILL");
24
+ }
25
+ catch {
26
+ try {
27
+ child.kill("SIGKILL");
28
+ }
29
+ catch { /* already gone */ }
30
+ }
31
+ }
32
+ /** Run `cmd args` in `cwd` with stdin closed (some CLIs hang waiting on an open stdin). Never
33
+ * rejects: failures come back as data so callers can classify them as infrastructure errors.
34
+ * Resolves within timeoutMs + PIPE_GRACE_MS no matter what the child's descendants do. */
35
+ export function runProcess(cmd, args, opts) {
36
+ return new Promise((resolve) => {
37
+ let stdout = "", stderr = "", timedOut = false, settled = false;
38
+ let exitCode = null;
39
+ let grace;
40
+ const child = spawn(cmd, args, {
41
+ cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"], detached: OWN_GROUP,
42
+ ...(opts.env ? { env: opts.env } : {}),
43
+ });
44
+ liveChildren.add(child);
45
+ const finish = (r) => {
46
+ if (settled)
47
+ return;
48
+ settled = true;
49
+ clearTimeout(timer);
50
+ clearTimeout(grace);
51
+ liveChildren.delete(child);
52
+ child.stdout?.destroy();
53
+ child.stderr?.destroy();
54
+ resolve(r);
55
+ };
56
+ const finishSoon = () => {
57
+ grace ??= setTimeout(() => finish({ code: exitCode, stdout, stderr, timedOut }), PIPE_GRACE_MS);
58
+ };
59
+ const timer = setTimeout(() => { timedOut = true; killTree(child); finishSoon(); }, opts.timeoutMs);
60
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
61
+ child.stderr.on("data", (d) => { stderr += d.toString(); });
62
+ child.on("error", (e) => finish({ code: null, stdout, stderr, timedOut, spawnError: e.message }));
63
+ child.on("exit", (code) => { exitCode = code; finishSoon(); });
64
+ child.on("close", (code) => finish({ code, stdout, stderr, timedOut }));
65
+ });
66
+ }
67
+ /** Track a temp dir so it's removed (plus `also`) even if the run is interrupted. */
68
+ export function trackTempDir(dir, also) { liveDirs.set(dir, also); }
69
+ export function untrackTempDir(dir) { liveDirs.delete(dir); }
70
+ /** Kill live children and remove tracked temp dirs. Synchronous: safe inside an 'exit' handler. */
71
+ export function cleanupAll() {
72
+ for (const c of liveChildren)
73
+ killTree(c);
74
+ liveChildren.clear();
75
+ for (const [d, also] of liveDirs) {
76
+ try {
77
+ rmSync(d, { recursive: true, force: true });
78
+ }
79
+ catch { /* best effort */ }
80
+ try {
81
+ also?.();
82
+ }
83
+ catch { /* best effort */ }
84
+ }
85
+ liveDirs.clear();
86
+ }
87
+ let installed = false;
88
+ /** For the CLI entry point only (library callers keep default signal behavior). */
89
+ export function installSignalHandlers() {
90
+ if (installed)
91
+ return;
92
+ installed = true;
93
+ process.on("exit", cleanupAll);
94
+ for (const [sig, code] of [["SIGINT", 130], ["SIGTERM", 143]]) {
95
+ process.on(sig, () => {
96
+ process.stderr.write(`\nskill-probe: interrupted (${sig}) — stopping probes and cleaning up\n`);
97
+ cleanupAll();
98
+ process.exit(code);
99
+ });
100
+ }
101
+ }
package/dist/report.d.ts CHANGED
@@ -1,10 +1,13 @@
1
+ /** Human table + machine JSON rendering. Every number ships with its CI and k; infrastructure
2
+ * errors are shown distinctly from behavioral results. */
3
+ import { kToCertify } from "./stats.ts";
1
4
  import type { AuditResult } from "./eval.ts";
2
5
  import type { FixResult } from "./fix.ts";
3
6
  import type { ContextAuditResult } from "./context.ts";
4
7
  import type { DoctorResult } from "./doctor.ts";
5
8
  import type { DiagnoseResult } from "./diagnose.ts";
6
- /** Smallest k at which an all-success run could certify reliability >= threshold. */
7
- export declare function kToCertify(threshold: number, conf: number): number;
9
+ import type { BaselineComparison } from "./baseline.ts";
10
+ export { kToCertify };
8
11
  export interface RenderOpts {
9
12
  showCost?: boolean;
10
13
  }
@@ -17,4 +20,6 @@ export declare function renderDiagnose(d: DiagnoseResult, opts?: RenderOpts): st
17
20
  export declare function renderDoctor(d: DoctorResult): string;
18
21
  /** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
19
22
  export declare function renderContext(a: ContextAuditResult, opts?: RenderOpts): string;
23
+ /** regression gate vs a saved baseline: only significant (BH-corrected) drops fail the build. */
24
+ export declare function renderBaseline(cmp: BaselineComparison): string;
20
25
  export declare function renderFix(f: FixResult): string;