skill-probe 0.9.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,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,11 +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
9
  import type { BaselineComparison } from "./baseline.ts";
7
- /** Smallest k at which an all-success run could certify reliability >= threshold. */
8
- export declare function kToCertify(threshold: number, conf: number): number;
10
+ export { kToCertify };
9
11
  export interface RenderOpts {
10
12
  showCost?: boolean;
11
13
  }
package/dist/report.js CHANGED
@@ -1,24 +1,47 @@
1
1
  /** Human table + machine JSON rendering. Every number ships with its CI and k; infrastructure
2
2
  * errors are shown distinctly from behavioral results. */
3
- import { wilsonInterval } from "./stats.js";
3
+ import { kToCertify } from "./stats.js";
4
+ export { kToCertify };
4
5
  const pct = (x) => `${Math.round(x * 100)}%`;
5
6
  const pval = (p) => (p < 0.001 ? "<0.001" : p.toFixed(3));
6
7
  const LABEL = {
7
8
  pass: "PASS ", fail: "FAIL ",
8
9
  inconclusive: "INCONCLUSIVE", error: "ERROR ",
9
10
  };
10
- /** Smallest k at which an all-success run could certify reliability >= threshold. */
11
- export function kToCertify(threshold, conf) {
12
- for (let k = 1; k <= 500; k++) {
13
- if (wilsonInterval(k, k, conf).lo > threshold)
14
- return k;
11
+ /** One line describing what the runtime actually ran with, when it told us. */
12
+ function environmentLine(m) {
13
+ const e = m?.environment;
14
+ if (!e)
15
+ return null;
16
+ const bits = [];
17
+ if (e.runtimeVersion)
18
+ bits.push(`runtime ${e.runtimeVersion}`);
19
+ if (e.resolvedModel && m.model === "(runtime default)")
20
+ bits.push(`model resolved to ${e.resolvedModel}`);
21
+ if (e.extraSkills) {
22
+ bits.push(e.extraSkills.length
23
+ ? `${e.extraSkills.length} skill(s) outside the library also loaded (${e.extraSkills.slice(0, 6).join(", ")}${e.extraSkills.length > 6 ? ", …" : ""})`
24
+ : "no skills outside the library loaded");
15
25
  }
16
- return 500;
26
+ bits.push(e.userConfig ? "personal runtime config INCLUDED"
27
+ : e.hermetic ? "hermetic (personal runtime config excluded)"
28
+ : "personal runtime config NOT isolated (this runtime can't exclude it)");
29
+ return ` ${bits.join(" · ")}`;
30
+ }
31
+ function theftLine(c) {
32
+ const counts = c.theft.map((s) => `${s} ${c.stats.dist[s]}/${c.stats.n}`).join(", ");
33
+ return ` ⚠ trigger-theft by: ${counts}` +
34
+ (c.verdict === "pass" ? " (reliability still clears the bar; --fail-on-theft makes this fail)" : "");
17
35
  }
18
36
  export function renderTable(a, opts = {}) {
19
37
  const showCost = opts.showCost ?? true;
20
38
  const out = [];
21
39
  out.push(`skill-probe — runtime: ${a.runtime} model: ${a.model} threshold: ${pct(a.threshold)}`);
40
+ const env = environmentLine(a.manifest);
41
+ if (env)
42
+ out.push(env);
43
+ for (const w of a.warnings ?? [])
44
+ out.push(` ⚠ ${w}`);
22
45
  out.push("");
23
46
  for (const c of a.cases) {
24
47
  const r = c.reliability;
@@ -35,11 +58,17 @@ export function renderTable(a, opts = {}) {
35
58
  const dist = Object.entries(c.stats.dist)
36
59
  .sort((p, q) => q[1] - p[1]).map(([s, n]) => `${s}×${n}`).join(", ");
37
60
  out.push(` outcomes: ${dist}`);
61
+ if (c.notLoaded)
62
+ out.push(` ⛔ the runtime never loaded "${c.expected}" — it cannot fire (check its SKILL.md)`);
38
63
  if (c.theft.length)
39
- out.push(` ⚠ trigger-theft by: ${c.theft.join(", ")}`);
64
+ out.push(theftLine(c));
65
+ if (c.expected !== null && c.stats.preempted > 0 && c.verdict !== "pass") {
66
+ out.push(` ⓘ ${c.stats.preempted}/${c.stats.n} probes called another tool first and loaded no skill — ` +
67
+ `activation may be under-counted (try --max-turns 2)`);
68
+ }
40
69
  if (c.verdict === "inconclusive") {
41
- out.push(` ↳ CI straddles threshold — need ~k=${kToCertify(a.threshold, r.conf)} ` +
42
- `all-success runs to certify ≥${pct(a.threshold)} (currently k=${r.k})`);
70
+ out.push(` ↳ not statistically decided at k=${r.k} — certifying ≥${pct(a.threshold)} needs ` +
71
+ `k≥${kToCertify(a.threshold, r.conf)} with every run firing (more if some miss)`);
43
72
  }
44
73
  }
45
74
  out.push("");
@@ -57,6 +86,11 @@ export function renderMarkdown(a, opts = {}) {
57
86
  const out = [];
58
87
  const conf = a.cases[0]?.reliability.conf ?? 0.95;
59
88
  out.push(`### skill-probe — \`${a.runtime}\` · model \`${a.model}\` · threshold ${pct(a.threshold)}`);
89
+ const env = environmentLine(a.manifest);
90
+ if (env)
91
+ out.push("", `_${env.trim()}_`);
92
+ for (const w of a.warnings ?? [])
93
+ out.push("", `> ⚠ ${w}`);
60
94
  out.push("");
61
95
  out.push(`| Verdict | Skill | Reliability (${pct(conf)} CI) | k | Notes |`);
62
96
  out.push("|---|---|---|---|---|");
@@ -66,10 +100,12 @@ export function renderMarkdown(a, opts = {}) {
66
100
  let notes = "";
67
101
  if (c.verdict === "error")
68
102
  notes = `infra error: ${c.stats.lastError ?? "untrustworthy"}`;
103
+ else if (c.notLoaded)
104
+ notes = `⛔ runtime never loaded \`${c.expected}\``;
69
105
  else if (c.theft.length)
70
- notes = `⚠ trigger-theft by ${c.theft.join(", ")}`;
106
+ notes = `⚠ trigger-theft by ${c.theft.map((s) => `${s} ${c.stats.dist[s]}/${c.stats.n}`).join(", ")}`;
71
107
  else if (c.verdict === "inconclusive")
72
- notes = `need ~k=${kToCertify(a.threshold, r.conf)} to certify`;
108
+ notes = `need k≥${kToCertify(a.threshold, r.conf)} to certify`;
73
109
  const rel = c.verdict === "error" ? "—" : `${pct(r.pHat)} [${pct(r.ciLow)}, ${pct(r.ciHigh)}]`;
74
110
  out.push(`| ${mark[c.verdict]} | \`${expect}\` | ${rel} | ${r.k} | ${notes} |`);
75
111
  }
@@ -91,6 +127,11 @@ export function renderDiagnose(d, opts = {}) {
91
127
  const out = [];
92
128
  out.push(`skill-probe diagnose — runtime: ${d.runtime} model: ${d.model} threshold: ${pct(d.threshold)}`);
93
129
  out.push(` actual = real activation · intended = forced-choice routing (does the model even pick it?)`);
130
+ const env = environmentLine(d.manifest);
131
+ if (env)
132
+ out.push(env);
133
+ for (const w of d.warnings ?? [])
134
+ out.push(` ⚠ ${w}`);
94
135
  out.push("");
95
136
  const dist = (m) => Object.entries(m).sort((p, q) => q[1] - p[1]).map(([s, n]) => `${s}×${n}`).join(", ") || "(none)";
96
137
  for (const c of d.cases) {
@@ -131,6 +172,12 @@ export function renderContext(a, opts = {}) {
131
172
  out.push(`skill-probe context — runtime: ${a.runtime} model: ${a.model} ` +
132
173
  `threshold: ${pct(a.threshold)} library: ${a.librarySize} skills`);
133
174
  out.push(` isolation = only that skill loaded · co-loaded = the full library (${a.librarySize}) loaded`);
175
+ out.push(` every condition runs in an identical sandbox (skills + project CLAUDE.md/AGENTS.md) — only the skill set differs`);
176
+ const env = environmentLine(a.manifest);
177
+ if (env)
178
+ out.push(env);
179
+ for (const w of a.warnings ?? [])
180
+ out.push(` ⚠ ${w}`);
134
181
  const familyN = a.cases.filter((c) => !c.untrustworthy).length;
135
182
  if (familyN > 1) {
136
183
  out.push(` ${familyN} comparisons — interference decided on Benjamini-Hochberg adjusted p<0.05` +
@@ -141,6 +188,11 @@ export function renderContext(a, opts = {}) {
141
188
  for (const c of a.cases) {
142
189
  if (c.untrustworthy) {
143
190
  out.push(` [⛔ ERROR] ${c.expected} | ${c.prompt}`);
191
+ if (c.loadIssue) {
192
+ out.push(` the runtime loaded the wrong skill set — comparison NOT trustworthy`);
193
+ out.push(` ${c.loadIssue}`);
194
+ continue;
195
+ }
144
196
  out.push(` infrastructure errors dominated — comparison NOT trustworthy ` +
145
197
  `(isolation ${c.isolation.stats.errors} err/${c.isolation.stats.n} valid, ` +
146
198
  `co-loaded ${c.coLoaded.stats.errors} err/${c.coLoaded.stats.n} valid)`);
@@ -156,12 +208,18 @@ export function renderContext(a, opts = {}) {
156
208
  out.push(` Δ ${c.deltaP >= 0 ? "+" : ""}${pct(c.deltaP)} under load Fisher p=${pval(c.fisherP)}` +
157
209
  (familyN > 1 ? ` · BH-adj p=${pval(c.fisherPAdj)}` : ""));
158
210
  if (c.interference) {
211
+ const external = new Set(c.externalThieves ?? []);
159
212
  const thieves = Object.keys(c.coLoaded.stats.dist)
160
- .filter((s) => s !== c.expected && s !== "None");
213
+ .filter((s) => s !== c.expected && s !== "None")
214
+ .map((s) => (external.has(s) ? `${s} (outside the library)` : s));
161
215
  out.push(` ↳ fires reliably alone but is suppressed when the library is co-loaded` +
162
216
  (thieves.length ? ` (stolen by: ${thieves.join(", ")})` : ""));
163
217
  if (c.ablation) {
164
- if (c.ablation.length === 0) {
218
+ if (c.ablation.length === 0 && external.size) {
219
+ out.push(` leave-one-out: only skills outside the library stole these probes ` +
220
+ `(${[...external].join(", ")}) — they can't be removed; narrow or rename "${c.expected}" to stop colliding`);
221
+ }
222
+ else if (c.ablation.length === 0) {
165
223
  out.push(` leave-one-out: no sibling stole these probes (suppressed to None) — ` +
166
224
  `dilution, not theft; nothing to ablate`);
167
225
  }
@@ -169,7 +227,7 @@ export function renderContext(a, opts = {}) {
169
227
  out.push(` leave-one-out (each suspect removed, recovery vs co-loaded):`);
170
228
  for (const abl of c.ablation) {
171
229
  if (abl.untrustworthy) {
172
- out.push(` − ${abl.removed}: infra errors dominated — run not trustworthy`);
230
+ out.push(` − ${abl.removed}: ${abl.loadIssue ?? "infra errors dominated"} — run not trustworthy`);
173
231
  continue;
174
232
  }
175
233
  const tag = abl.culprit
@@ -241,19 +299,25 @@ export function renderFix(f) {
241
299
  out.push(` old: ${f.oldDesc}`);
242
300
  out.push(` new: ${f.newDesc}`);
243
301
  out.push("");
244
- out.push(` before: ${pct(f.before.pHat)} [${pct(f.before.ciLow)}, ${pct(f.before.ciHigh)}] ` +
245
- `after: ${pct(f.after.pHat)} [${pct(f.after.ciLow)}, ${pct(f.after.ciHigh)}] ` +
246
- `(${f.n} paired runs${f.errors ? `, ${f.errors} dropped to infra errors` : ""})`);
247
- out.push(` P(rewrite improved reliability) = ${pct(f.pImprove)} ` +
248
- `Δ = ${f.deltaMean >= 0 ? "+" : ""}${pct(f.deltaMean)} [${pct(f.deltaCi[0])}, ${pct(f.deltaCi[1])}]`);
249
- out.push("");
250
- if (f.applied) {
302
+ if (f.n > 0 || f.errors > 0) {
303
+ out.push(` before: ${pct(f.before.pHat)} [${pct(f.before.ciLow)}, ${pct(f.before.ciHigh)}] ` +
304
+ `after: ${pct(f.after.pHat)} [${pct(f.after.ciLow)}, ${pct(f.after.ciHigh)}] ` +
305
+ `(${f.n} paired runs${f.errors ? `, ${f.errors} dropped to infra errors` : ""})` +
306
+ (f.totalCost ? ` cost $${f.totalCost.toFixed(4)}` : ""));
307
+ out.push(` P(rewrite improved reliability) = ${pct(f.pImprove)} ` +
308
+ `Δ = ${f.deltaMean >= 0 ? "+" : ""}${pct(f.deltaMean)} [${pct(f.deltaCi[0])}, ${pct(f.deltaCi[1])}]`);
309
+ out.push("");
310
+ }
311
+ if (f.note) {
312
+ out.push(` ↩ NOT APPLIED — ${f.note}. Description unchanged.`);
313
+ }
314
+ else if (f.applied) {
251
315
  out.push(` ✅ APPLIED — P(improvement) ${pct(f.pImprove)} ≥ bar ${pct(f.applyBar)} and Δ>0.`);
252
316
  out.push(` original backed up to: ${f.backupPath}`);
253
317
  }
254
318
  else {
255
- out.push(` ↩ REVERTED — not enough evidence the rewrite helps ` +
256
- `(need P(improvement) ≥ ${pct(f.applyBar)} and Δ>0). Description unchanged, no backup kept.`);
319
+ out.push(` ↩ NOT APPLIED — not enough evidence the rewrite helps ` +
320
+ `(need P(improvement) ≥ ${pct(f.applyBar)} and Δ>0). Description unchanged.`);
257
321
  }
258
322
  return out.join("\n");
259
323
  }
@@ -0,0 +1,18 @@
1
+ /** Project instruction files copied into every sandbox, relative to the project root. */
2
+ export declare const INSTRUCTION_FILES: string[];
3
+ export interface Sandbox {
4
+ cwd: string;
5
+ skills: string[];
6
+ cleanup: () => void;
7
+ }
8
+ /** Claude Code creates `<config>/projects/<cwd with non-alphanumerics as "-">/memory/` for every
9
+ * working directory it starts in, even with --no-session-persistence. Each sandbox is a fresh path,
10
+ * so without this a big context/ablation run would leave dozens of empty dirs per run. Removes the
11
+ * dir for THIS sandbox path only, and only if it contains nothing but empty directories — it never
12
+ * deletes a file. Exported for testing. */
13
+ export declare function removeRuntimeStateFor(sandboxPath: string, configDir?: string): void;
14
+ /** Build a sandbox whose .claude/skills/ holds exactly `skillNames` (copied from srcCwd, with any
15
+ * supporting files) plus srcCwd's instruction files. Removed on cleanup() or on process exit. */
16
+ export declare function buildSandbox(srcCwd: string, skillNames: string[]): Sandbox;
17
+ /** Run `fn` inside a sandbox, always cleaning up. */
18
+ export declare function withSandbox<T>(srcCwd: string, skillNames: string[], fn: (sb: Sandbox) => Promise<T>): Promise<T>;
@@ -0,0 +1,98 @@
1
+ /** Probe sandboxes: throwaway project dirs holding a chosen subset of the library.
2
+ *
3
+ * Every condition that is COMPARED against another (context's isolation / co-loaded / ablation runs,
4
+ * fix's before / after) must be built the same way, differing ONLY in which skills are present.
5
+ * Comparing a sandbox against the real project dir confounds the skill set with everything else in
6
+ * the project (CLAUDE.md, settings, files): a drop caused by the project's CLAUDE.md would read as
7
+ * "interference", and every leave-one-out run would "recover" and name an innocent sibling.
8
+ *
9
+ * Each sandbox gets the project's instruction files (they shape routing, so they're held constant)
10
+ * but not its settings/hooks/MCP config (side effects; can't run safely from another directory). */
11
+ import { mkdtempSync, mkdirSync, cpSync, rmSync, existsSync, readdirSync, realpathSync, statSync } from "node:fs";
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { skillsRoot } from "./skills.js";
15
+ import { trackTempDir, untrackTempDir } from "./proc.js";
16
+ /** Project instruction files copied into every sandbox, relative to the project root. */
17
+ export const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md", join(".claude", "CLAUDE.md")];
18
+ /** True if `dir` holds nothing but (recursively) empty directories. */
19
+ function onlyEmptyDirs(dir) {
20
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
21
+ if (!e.isDirectory() || !onlyEmptyDirs(join(dir, e.name)))
22
+ return false;
23
+ }
24
+ return true;
25
+ }
26
+ /** Claude Code creates `<config>/projects/<cwd with non-alphanumerics as "-">/memory/` for every
27
+ * working directory it starts in, even with --no-session-persistence. Each sandbox is a fresh path,
28
+ * so without this a big context/ablation run would leave dozens of empty dirs per run. Removes the
29
+ * dir for THIS sandbox path only, and only if it contains nothing but empty directories — it never
30
+ * deletes a file. Exported for testing. */
31
+ export function removeRuntimeStateFor(sandboxPath, configDir) {
32
+ const root = join(configDir ?? process.env["CLAUDE_CONFIG_DIR"] ?? join(homedir(), ".claude"), "projects");
33
+ const paths = new Set([sandboxPath]);
34
+ try {
35
+ paths.add(realpathSync(sandboxPath));
36
+ }
37
+ catch { /* already removed */ }
38
+ for (const p of paths) {
39
+ const dir = join(root, p.replace(/[^a-zA-Z0-9]/g, "-"));
40
+ try {
41
+ if (statSync(dir).isDirectory() && onlyEmptyDirs(dir))
42
+ rmSync(dir, { recursive: true });
43
+ }
44
+ catch { /* absent or unreadable — leave it */ }
45
+ }
46
+ }
47
+ /** Build a sandbox whose .claude/skills/ holds exactly `skillNames` (copied from srcCwd, with any
48
+ * supporting files) plus srcCwd's instruction files. Removed on cleanup() or on process exit. */
49
+ export function buildSandbox(srcCwd, skillNames) {
50
+ const root = mkdtempSync(join(tmpdir(), "skill-probe-"));
51
+ let real = root;
52
+ try {
53
+ real = realpathSync(root);
54
+ }
55
+ catch { /* keep root */ }
56
+ const forgetState = () => { removeRuntimeStateFor(root); if (real !== root)
57
+ removeRuntimeStateFor(real); };
58
+ trackTempDir(root, forgetState);
59
+ const cleanup = () => {
60
+ rmSync(root, { recursive: true, force: true });
61
+ forgetState();
62
+ untrackTempDir(root);
63
+ };
64
+ try {
65
+ const destSkills = skillsRoot(root);
66
+ mkdirSync(destSkills, { recursive: true });
67
+ for (const name of skillNames) {
68
+ const src = join(skillsRoot(srcCwd), name);
69
+ if (!existsSync(src))
70
+ throw new Error(`skill not found in ${srcCwd}: ${name}`);
71
+ // dereference: a symlinked SKILL.md (or skill folder) must become a real copy, or a write
72
+ // into the sandbox would land in the user's real file
73
+ cpSync(src, join(destSkills, name), { recursive: true, dereference: true });
74
+ }
75
+ for (const rel of INSTRUCTION_FILES) {
76
+ const src = join(srcCwd, rel);
77
+ if (!existsSync(src))
78
+ continue;
79
+ mkdirSync(dirname(join(root, rel)), { recursive: true });
80
+ cpSync(src, join(root, rel), { dereference: true });
81
+ }
82
+ }
83
+ catch (e) {
84
+ cleanup();
85
+ throw e;
86
+ }
87
+ return { cwd: root, skills: [...skillNames], cleanup };
88
+ }
89
+ /** Run `fn` inside a sandbox, always cleaning up. */
90
+ export async function withSandbox(srcCwd, skillNames, fn) {
91
+ const sb = buildSandbox(srcCwd, skillNames);
92
+ try {
93
+ return await fn(sb);
94
+ }
95
+ finally {
96
+ sb.cleanup();
97
+ }
98
+ }