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.
- package/README.md +118 -35
- package/assets/skill-probe-thief-demo.gif +0 -0
- package/assets/skill-probe-thief-demo.png +0 -0
- package/dist/adapters/claudeCode.d.ts +17 -1
- package/dist/adapters/claudeCode.js +49 -45
- package/dist/adapters/openCode.d.ts +14 -1
- package/dist/adapters/openCode.js +64 -47
- package/dist/baseline.js +0 -0
- package/dist/cli.js +77 -34
- package/dist/config.d.ts +4 -0
- package/dist/config.js +37 -0
- package/dist/context.d.ts +29 -9
- package/dist/context.js +124 -97
- package/dist/diagnose.d.ts +11 -5
- package/dist/diagnose.js +45 -26
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +72 -38
- package/dist/eval.d.ts +17 -5
- package/dist/eval.js +56 -23
- package/dist/fix.d.ts +9 -6
- package/dist/fix.js +82 -70
- package/dist/gen.d.ts +6 -5
- package/dist/gen.js +1 -21
- package/dist/llm.d.ts +2 -0
- package/dist/llm.js +23 -23
- package/dist/manifest.d.ts +22 -3
- package/dist/manifest.js +27 -2
- package/dist/orchestrator.d.ts +47 -5
- package/dist/orchestrator.js +82 -13
- package/dist/proc.d.ts +24 -0
- package/dist/proc.js +101 -0
- package/dist/report.d.ts +4 -2
- package/dist/report.js +88 -24
- package/dist/sandbox.d.ts +18 -0
- package/dist/sandbox.js +98 -0
- package/dist/skills.d.ts +48 -0
- package/dist/skills.js +282 -0
- package/dist/stats.d.ts +27 -5
- package/dist/stats.js +132 -8
- package/dist/types.d.ts +31 -1
- package/dist/types.js +9 -1
- package/package.json +1 -1
package/dist/orchestrator.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
45
|
+
budget?: Budget;
|
|
28
46
|
onProbe?: (validN: number, maxK: number) => void;
|
|
29
|
-
}
|
|
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;
|
package/dist/orchestrator.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
15
|
-
for (let attempt = 0; attempt < maxAttempts && n <
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
if (n
|
|
31
|
-
|
|
32
|
-
if (d !== "undecided")
|
|
33
|
-
break;
|
|
34
|
-
}
|
|
52
|
+
onProbe?.(n, maxK);
|
|
53
|
+
if (planDecision(plan, hits, n) !== "undecided")
|
|
54
|
+
break;
|
|
35
55
|
}
|
|
36
|
-
return {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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(
|
|
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(` ↳
|
|
42
|
-
`
|
|
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
|
|
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
|
-
|
|
245
|
-
`
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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(` ↩
|
|
256
|
-
`(need P(improvement) ≥ ${pct(f.applyBar)} and Δ>0). Description unchanged
|
|
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>;
|
package/dist/sandbox.js
ADDED
|
@@ -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
|
+
}
|