skill-probe 0.3.0 → 0.5.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 +67 -8
- package/dist/cli.js +33 -12
- package/dist/config.d.ts +2 -0
- package/dist/config.js +10 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.js +112 -0
- package/dist/llm.d.ts +13 -2
- package/dist/llm.js +62 -23
- package/dist/report.d.ts +3 -0
- package/dist/report.js +45 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,12 @@ which skill *actually* fires (by real activation, not keyword matching) when you
|
|
|
9
9
|
|
|
10
10
|
Complements static linters like `skill-audit` (security/quality) — this is **behavioral**.
|
|
11
11
|
|
|
12
|
+
> **How it works / requirements.** skill-probe is a **terminal CLI**, *not* an in-agent slash
|
|
13
|
+
> command. It drives your local runtime CLI under the hood, so you need that runtime installed and
|
|
14
|
+
> authenticated: **`claude`** (logged in) for `runtime: claude-code`, or **`opencode`** for
|
|
15
|
+
> `runtime: opencode`. `gen`/`fix` use `ANTHROPIC_API_KEY` if set, otherwise the local `claude` CLI.
|
|
16
|
+
> Codex/Gemini runtimes aren't supported yet (their traces don't expose which skill fired).
|
|
17
|
+
|
|
12
18
|
## Why
|
|
13
19
|
|
|
14
20
|
- Skill activation is stochastic: the same prompt can produce different skill-routing outcomes.
|
|
@@ -35,8 +41,11 @@ skill names are dropped automatically. Flags: `--per-skill <n>` (default 3), `--
|
|
|
35
41
|
(default 2), `--model <id>`.
|
|
36
42
|
|
|
37
43
|
> **Always review the draft before running.** Generators lean toward obvious keyword-matches; you
|
|
38
|
-
> add the messy, oblique phrasings real users type (that's where triggering actually fails).
|
|
39
|
-
|
|
44
|
+
> add the messy, oblique phrasings real users type (that's where triggering actually fails).
|
|
45
|
+
|
|
46
|
+
`gen` (and `fix`) use `ANTHROPIC_API_KEY` if it's set, otherwise they fall back to the logged-in
|
|
47
|
+
**`claude` CLI** — so on a Claude subscription the whole tool (audit, gen, fix) works with **no API
|
|
48
|
+
key at all**.
|
|
40
49
|
|
|
41
50
|
## Use
|
|
42
51
|
|
|
@@ -99,6 +108,52 @@ Claude Code vs OpenCode comparison is fair — otherwise you're comparing two co
|
|
|
99
108
|
case is inconclusive the report prints the exact k for your threshold. Don't set `threshold
|
|
100
109
|
0.9, k 10` and expect a pass — that's statistically impossible and the tool will say so.
|
|
101
110
|
|
|
111
|
+
## CI usage
|
|
112
|
+
|
|
113
|
+
skill-probe exits non-zero on a real problem, so it drops straight into a pipeline:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
# .github/workflows/skills.yml (the claude/opencode CLI must be installed + authed on the runner)
|
|
117
|
+
- run: npx skill-probe --config probe.config.json --quiet --no-cost
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Exit codes: **`0`** all pass · **`1`** a behavioral fail / trigger-theft / interference · **`2`**
|
|
121
|
+
inconclusive or an infrastructure error — so a runtime outage *fails* the build instead of silently
|
|
122
|
+
passing. Add `--json` to archive the full result as a build artifact.
|
|
123
|
+
|
|
124
|
+
## Activation rate by context (`skill-probe context`)
|
|
125
|
+
|
|
126
|
+
The audit measures every skill **co-loaded** — the real, hard condition. But a skill can fire
|
|
127
|
+
*fine on its own* and only fail *under load*, when the rest of your library is competing for the
|
|
128
|
+
same trigger. `context` exposes exactly that:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
skill-probe context --config probe.config.json
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
For each case it measures the expected skill **in isolation** (a throwaway project with only that
|
|
135
|
+
one skill) **and co-loaded** (your full library), then tests the drop with **Fisher's exact test**
|
|
136
|
+
— so interference is reported as a real effect with a p-value, not eyeballed:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
skill-probe context — runtime: claude-code model: (runtime default) threshold: 70% library: 4 skills
|
|
140
|
+
isolation = only that skill loaded · co-loaded = the full library (4) loaded
|
|
141
|
+
|
|
142
|
+
[⚠ INTERFERENCE] greeter | write a birthday greeting
|
|
143
|
+
isolation 100% [72%, 100%] k=10
|
|
144
|
+
co-loaded 30% [11%, 60%] k=10
|
|
145
|
+
Δ -70% under load Fisher p=0.003
|
|
146
|
+
↳ fires reliably alone but is suppressed when the library is co-loaded (stolen by: welcomer)
|
|
147
|
+
|
|
148
|
+
Result: 1 interference / 3 measured | exit 1
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
This is the "fires in isolation, fails under load X" case the aggregate rate hides. Decoy cases
|
|
152
|
+
(`expected: null`) are skipped — there's no single skill to isolate; an `expected` skill that isn't
|
|
153
|
+
in the library is a config error (typo), surfaced before any probes run. Exit `1` if any skill
|
|
154
|
+
shows interference, `2` if a case is untrustworthy (infra errors dominated — never a silent pass),
|
|
155
|
+
else `0`.
|
|
156
|
+
|
|
102
157
|
## Fix a failing skill (`skill-probe fix`)
|
|
103
158
|
|
|
104
159
|
Rewrite a skill's trigger description and **prove the lift is real** before keeping it:
|
|
@@ -122,22 +177,26 @@ P(rewrite improved reliability) = 100% Δ = +80% [36%, 99%]
|
|
|
122
177
|
original backed up to: /path/to/SKILL.md.bak.1719000000000
|
|
123
178
|
```
|
|
124
179
|
|
|
125
|
-
`fix`
|
|
126
|
-
not on "the new one looks nicer" — a rewrite that
|
|
180
|
+
`fix` does the rewrite via `ANTHROPIC_API_KEY` if set, else the logged-in `claude` CLI (subscription).
|
|
181
|
+
It changes descriptions on statistical evidence, not on "the new one looks nicer" — a rewrite that
|
|
182
|
+
doesn't measurably help is reverted.
|
|
127
183
|
|
|
128
184
|
## Status
|
|
129
185
|
|
|
130
|
-
Early (v0.
|
|
186
|
+
Early (v0.5). **Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping +
|
|
131
187
|
four-state verdict (pass / fail / inconclusive / **error**), across two runtimes (Claude Code,
|
|
132
188
|
OpenCode). Infrastructure failures (timeout / auth / crash / empty output) are reported as
|
|
133
189
|
`error`, never as a behavioral pass/fail — a decoy can't falsely pass because the runtime was down.
|
|
134
190
|
|
|
191
|
+
**Context (`skill-probe context`):** isolation-vs-co-loaded activation rates, with **Fisher's exact
|
|
192
|
+
test** on the drop — catches skills that fire alone but are suppressed under the full library's load.
|
|
193
|
+
|
|
135
194
|
**Fix (`skill-probe fix`):** uses the **Bayesian Beta-Binomial** to gate description rewrites on a
|
|
136
195
|
*proven* lift (interleaved before/after, applied only if P(improvement) clears the bar).
|
|
137
196
|
|
|
138
|
-
**Implemented + unit-tested but NOT yet used by any command:**
|
|
139
|
-
|
|
140
|
-
|
|
197
|
+
**Implemented + unit-tested but NOT yet used by any command:** Benjamini-Hochberg FDR
|
|
198
|
+
(`src/stats.ts`) — reserved for cross-case multiplicity control (a planned enhancement). Don't read
|
|
199
|
+
its presence as the audit using it. (Fisher's exact test is now wired into `context`.)
|
|
141
200
|
|
|
142
201
|
## Dev
|
|
143
202
|
|
package/dist/cli.js
CHANGED
|
@@ -7,13 +7,15 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
8
|
import { runAudit } from "./eval.js";
|
|
9
9
|
import { runFix } from "./fix.js";
|
|
10
|
+
import { runContextAudit } from "./context.js";
|
|
10
11
|
import { listSkills, generateCases, genConfig } from "./gen.js";
|
|
11
|
-
import { loadConfig, parseProbability, ConfigError } from "./config.js";
|
|
12
|
-
import { renderTable, renderMarkdown, renderJson, renderFix } from "./report.js";
|
|
12
|
+
import { loadConfig, parseProbability, parseIntFlag, ConfigError } from "./config.js";
|
|
13
|
+
import { renderTable, renderMarkdown, renderJson, renderFix, renderContext } from "./report.js";
|
|
13
14
|
const HELP = `skill-probe — audit a co-loaded agent skill library by real activation behavior.
|
|
14
15
|
|
|
15
16
|
USAGE
|
|
16
17
|
skill-probe --config <file.json> [options] # audit
|
|
18
|
+
skill-probe context --config <file.json> [options] # isolation vs co-loaded activation rates
|
|
17
19
|
skill-probe gen [--cwd <dir>] > probe.config.json # draft a test config from your skills
|
|
18
20
|
skill-probe fix --config <file.json> --skill <name> [opts] # rewrite a description, prove the lift
|
|
19
21
|
|
|
@@ -30,7 +32,7 @@ OPTIONS
|
|
|
30
32
|
--markdown emit a Markdown table (paste into a PR/README)
|
|
31
33
|
--no-cost hide the cost line (e.g. when on a Claude subscription)
|
|
32
34
|
--quiet suppress the live progress line on stderr
|
|
33
|
-
gen only (
|
|
35
|
+
gen only (uses ANTHROPIC_API_KEY if set, else the logged-in 'claude' CLI):
|
|
34
36
|
--cwd <dir> project dir containing .claude/skills/ (default: .)
|
|
35
37
|
--per-skill <n> should-fire prompts to draft per skill (default 3)
|
|
36
38
|
--decoys <n> off-topic decoy prompts to draft (default 2)
|
|
@@ -59,6 +61,17 @@ function clearProgress() {
|
|
|
59
61
|
progressLen = 0;
|
|
60
62
|
}
|
|
61
63
|
}
|
|
64
|
+
function makeContextProgress(values) {
|
|
65
|
+
if (values["json"] || values["quiet"] || !process.stderr.isTTY)
|
|
66
|
+
return undefined;
|
|
67
|
+
return (e) => {
|
|
68
|
+
const p = e.prompt.length > 28 ? e.prompt.slice(0, 25) + "…" : e.prompt;
|
|
69
|
+
const line = ` [${e.caseIndex}/${e.caseTotal}] ${e.condition.padEnd(9)} "${p}" — run ${e.validN}/${e.maxK}`;
|
|
70
|
+
const pad = Math.max(0, progressLen - line.length);
|
|
71
|
+
process.stderr.write("\r" + line + " ".repeat(pad));
|
|
72
|
+
progressLen = line.length;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
62
75
|
async function audit(values) {
|
|
63
76
|
const cfg = loadConfig(values["config"], {
|
|
64
77
|
runtime: values["runtime"], model: values["model"],
|
|
@@ -74,14 +87,22 @@ async function audit(values) {
|
|
|
74
87
|
console.log(text);
|
|
75
88
|
return result.exitCode;
|
|
76
89
|
}
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
async function context(values) {
|
|
91
|
+
const cfg = loadConfig(values["config"], {
|
|
92
|
+
runtime: values["runtime"], model: values["model"],
|
|
93
|
+
k: values["k"], threshold: values["threshold"],
|
|
94
|
+
conf: values["conf"],
|
|
95
|
+
});
|
|
96
|
+
const result = await runContextAudit(cfg, makeContextProgress(values));
|
|
97
|
+
clearProgress();
|
|
98
|
+
const showCost = !values["no-cost"];
|
|
99
|
+
console.log(values["json"] ? renderJson(result) : renderContext(result, { showCost }));
|
|
100
|
+
return result.exitCode;
|
|
82
101
|
}
|
|
83
102
|
async function gen(values) {
|
|
84
103
|
const cwd = values["cwd"] ?? ".";
|
|
104
|
+
const perSkill = parseIntFlag(values["per-skill"], "--per-skill", 3, 1);
|
|
105
|
+
const decoys = parseIntFlag(values["decoys"], "--decoys", 2, 0);
|
|
85
106
|
const skills = listSkills(resolve(cwd));
|
|
86
107
|
if (skills.length === 0) {
|
|
87
108
|
console.error(`no skills found under ${cwd}/.claude/skills/`);
|
|
@@ -89,9 +110,7 @@ async function gen(values) {
|
|
|
89
110
|
}
|
|
90
111
|
const model = values["model"];
|
|
91
112
|
const cases = await generateCases(skills, {
|
|
92
|
-
perSkill
|
|
93
|
-
decoys: intArg(values["decoys"], 2, 0),
|
|
94
|
-
...(model ? { model } : {}),
|
|
113
|
+
perSkill, decoys, ...(model ? { model } : {}),
|
|
95
114
|
});
|
|
96
115
|
console.log(renderJson(genConfig(cases)));
|
|
97
116
|
console.error(`# drafted ${cases.length} cases for ${skills.length} skill(s) — REVIEW before ` +
|
|
@@ -149,7 +168,9 @@ async function main() {
|
|
|
149
168
|
console.log(HELP);
|
|
150
169
|
process.exit(2);
|
|
151
170
|
}
|
|
152
|
-
code = cmd === "fix" ? await fix(values)
|
|
171
|
+
code = cmd === "fix" ? await fix(values)
|
|
172
|
+
: cmd === "context" ? await context(values)
|
|
173
|
+
: await audit(values);
|
|
153
174
|
}
|
|
154
175
|
process.exit(code);
|
|
155
176
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare class ConfigError extends Error {
|
|
|
9
9
|
export declare function validateConfig(raw: unknown, configPath: string): Config;
|
|
10
10
|
/** Validate a probability-ish flag (e.g. --apply-threshold): must be a number strictly in (0,1). */
|
|
11
11
|
export declare function parseProbability(raw: string | undefined, name: string, def: number): number;
|
|
12
|
+
/** Validate an integer flag (e.g. --per-skill, --decoys): must be an integer >= min if provided. */
|
|
13
|
+
export declare function parseIntFlag(raw: string | undefined, name: string, def: number, min: number): number;
|
|
12
14
|
/** CLI flag overrides (already string-typed from argv); numerics are coerced then validated. */
|
|
13
15
|
export interface Overrides {
|
|
14
16
|
runtime?: string | undefined;
|
package/dist/config.js
CHANGED
|
@@ -75,6 +75,16 @@ export function parseProbability(raw, name, def) {
|
|
|
75
75
|
}
|
|
76
76
|
return v;
|
|
77
77
|
}
|
|
78
|
+
/** Validate an integer flag (e.g. --per-skill, --decoys): must be an integer >= min if provided. */
|
|
79
|
+
export function parseIntFlag(raw, name, def, min) {
|
|
80
|
+
if (raw === undefined)
|
|
81
|
+
return def;
|
|
82
|
+
const v = Number(raw);
|
|
83
|
+
if (!Number.isInteger(v) || v < min) {
|
|
84
|
+
throw new ConfigError([`${name} must be an integer >= ${min} (got ${JSON.stringify(raw)})`]);
|
|
85
|
+
}
|
|
86
|
+
return v;
|
|
87
|
+
}
|
|
78
88
|
export function loadConfig(configPath, overrides = {}) {
|
|
79
89
|
let fileRaw;
|
|
80
90
|
try {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Config } from "./types.ts";
|
|
2
|
+
import { reliability } from "./stats.ts";
|
|
3
|
+
import { type RunStats } from "./orchestrator.ts";
|
|
4
|
+
/** Build a throwaway project dir whose .claude/skills/ holds ONLY the named skills (copied from
|
|
5
|
+
* srcCwd). Returns its path plus a cleanup(). The probe runs against this dir to load a subset. */
|
|
6
|
+
export declare function buildContextDir(srcCwd: string, skillNames: string[]): {
|
|
7
|
+
cwd: string;
|
|
8
|
+
cleanup: () => void;
|
|
9
|
+
};
|
|
10
|
+
type Rel = ReturnType<typeof reliability>;
|
|
11
|
+
interface Condition {
|
|
12
|
+
stats: RunStats;
|
|
13
|
+
rel: Rel;
|
|
14
|
+
}
|
|
15
|
+
export interface ContextCaseResult {
|
|
16
|
+
prompt: string;
|
|
17
|
+
expected: string;
|
|
18
|
+
isolation: Condition;
|
|
19
|
+
coLoaded: Condition;
|
|
20
|
+
deltaP: number;
|
|
21
|
+
fisherP: number;
|
|
22
|
+
/** infra failures dominated one side — the comparison is not trustworthy (NOT a behavioral result) */
|
|
23
|
+
untrustworthy: boolean;
|
|
24
|
+
/** fires reliably alone but drops significantly when the full library is co-loaded */
|
|
25
|
+
interference: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface ContextAuditResult {
|
|
28
|
+
runtime: string;
|
|
29
|
+
model: string;
|
|
30
|
+
threshold: number;
|
|
31
|
+
conf: number;
|
|
32
|
+
librarySize: number;
|
|
33
|
+
cases: ContextCaseResult[];
|
|
34
|
+
skipped: string[];
|
|
35
|
+
totalCost: number;
|
|
36
|
+
/** 0 = no interference; 1 = a skill is suppressed under load; 2 = an untrustworthy (infra) case */
|
|
37
|
+
exitCode: 0 | 1 | 2;
|
|
38
|
+
}
|
|
39
|
+
export interface ContextProgress {
|
|
40
|
+
caseIndex: number;
|
|
41
|
+
caseTotal: number;
|
|
42
|
+
prompt: string;
|
|
43
|
+
condition: "isolation" | "co-loaded";
|
|
44
|
+
validN: number;
|
|
45
|
+
maxK: number;
|
|
46
|
+
}
|
|
47
|
+
export declare function runContextAudit(cfg: Config, onProgress?: (e: ContextProgress) => void): Promise<ContextAuditResult>;
|
|
48
|
+
export {};
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** `skill-probe context` — activation-rate-BY-CONTEXT. The audit measures every skill co-loaded
|
|
2
|
+
* (the real, hard condition). This command additionally measures each skill in ISOLATION (only that
|
|
3
|
+
* skill present) and compares the two: it catches the "fires fine alone, fails under load" case that
|
|
4
|
+
* a single aggregate rate hides. The drop is tested with Fisher's exact test, so interference is
|
|
5
|
+
* reported as a real effect (p-value), not eyeballed. */
|
|
6
|
+
import { mkdtempSync, mkdirSync, cpSync, rmSync, existsSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { reliability, fisherExact2x2 } from "./stats.js";
|
|
10
|
+
import { measure } from "./orchestrator.js";
|
|
11
|
+
import { getAdapter } from "./adapters/index.js";
|
|
12
|
+
import { listSkills } from "./gen.js";
|
|
13
|
+
import { ConfigError } from "./config.js";
|
|
14
|
+
/** Same rule the audit uses (eval.ts): a measurement is untrustworthy when infrastructure
|
|
15
|
+
* failures dominate — no valid probes, or errors at least equal the valid count. Such a
|
|
16
|
+
* condition must NOT be read as a behavioral result (a runtime outage is not a clean pass). */
|
|
17
|
+
function infraUntrustworthy(s) {
|
|
18
|
+
return s.n === 0 || (s.errors > 0 && s.errors >= s.n);
|
|
19
|
+
}
|
|
20
|
+
/** Build a throwaway project dir whose .claude/skills/ holds ONLY the named skills (copied from
|
|
21
|
+
* srcCwd). Returns its path plus a cleanup(). The probe runs against this dir to load a subset. */
|
|
22
|
+
export function buildContextDir(srcCwd, skillNames) {
|
|
23
|
+
const root = mkdtempSync(join(tmpdir(), "skill-probe-ctx-"));
|
|
24
|
+
const destSkills = join(root, ".claude", "skills");
|
|
25
|
+
mkdirSync(destSkills, { recursive: true });
|
|
26
|
+
for (const name of skillNames) {
|
|
27
|
+
const src = join(srcCwd, ".claude", "skills", name);
|
|
28
|
+
if (!existsSync(src))
|
|
29
|
+
throw new Error(`skill not found in ${srcCwd}: ${name}`);
|
|
30
|
+
cpSync(src, join(destSkills, name), { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
return { cwd: root, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
33
|
+
}
|
|
34
|
+
export async function runContextAudit(cfg, onProgress) {
|
|
35
|
+
const adapter = getAdapter(cfg.runtime);
|
|
36
|
+
const library = listSkills(cfg.cwd);
|
|
37
|
+
const present = new Set(library.map((s) => s.name));
|
|
38
|
+
const measured = [];
|
|
39
|
+
const skipped = [];
|
|
40
|
+
let totalCost = 0;
|
|
41
|
+
// decoys (null expected) have nothing to isolate → skipped. An expected skill that ISN'T in the
|
|
42
|
+
// library is almost always a config typo: fail loud BEFORE spending probes, never silently skip.
|
|
43
|
+
const eligible = [];
|
|
44
|
+
const unknown = [];
|
|
45
|
+
for (const c of cfg.cases) {
|
|
46
|
+
if (c.expected === null)
|
|
47
|
+
skipped.push(c.prompt);
|
|
48
|
+
else if (!present.has(c.expected))
|
|
49
|
+
unknown.push(c.expected);
|
|
50
|
+
else
|
|
51
|
+
eligible.push(c);
|
|
52
|
+
}
|
|
53
|
+
if (unknown.length) {
|
|
54
|
+
throw new ConfigError([
|
|
55
|
+
`expected skill(s) not in ${cfg.cwd}/.claude/skills/: ${[...new Set(unknown)].join(", ")} ` +
|
|
56
|
+
`(typo?). Available: ${library.map((s) => s.name).join(", ") || "(none)"}.`,
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
const common = { maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf, ...(cfg.model ? { model: cfg.model } : {}) };
|
|
60
|
+
for (let i = 0; i < eligible.length; i++) {
|
|
61
|
+
const c = eligible[i];
|
|
62
|
+
const expected = c.expected;
|
|
63
|
+
// isolation: a temp project containing only this skill
|
|
64
|
+
const ctx = buildContextDir(cfg.cwd, [expected]);
|
|
65
|
+
let iso;
|
|
66
|
+
try {
|
|
67
|
+
iso = await measure(adapter, c.prompt, expected, {
|
|
68
|
+
cwd: ctx.cwd, ...common,
|
|
69
|
+
...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, condition: "isolation", validN, maxK }) } : {}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
ctx.cleanup();
|
|
74
|
+
}
|
|
75
|
+
// co-loaded: the real library at cfg.cwd
|
|
76
|
+
const co = await measure(adapter, c.prompt, expected, {
|
|
77
|
+
cwd: cfg.cwd, ...common,
|
|
78
|
+
...(onProgress ? { onProbe: (validN, maxK) => onProgress({ caseIndex: i + 1, caseTotal: eligible.length, prompt: c.prompt, condition: "co-loaded", validN, maxK }) } : {}),
|
|
79
|
+
});
|
|
80
|
+
totalCost += iso.totalCost + co.totalCost;
|
|
81
|
+
const isoRel = reliability(iso.hits, iso.n, cfg.conf);
|
|
82
|
+
const coRel = reliability(co.hits, co.n, cfg.conf);
|
|
83
|
+
// 2x2: rows = isolation/co-loaded, cols = hit/miss
|
|
84
|
+
const fisherP = (iso.n > 0 && co.n > 0)
|
|
85
|
+
? fisherExact2x2(iso.hits, iso.n - iso.hits, co.hits, co.n - co.hits)
|
|
86
|
+
: 1;
|
|
87
|
+
const deltaP = coRel.pHat - isoRel.pHat;
|
|
88
|
+
// if infra failures dominated either side, the comparison isn't a behavioral result at all —
|
|
89
|
+
// it must never read as "no interference / pass" just because fisherP defaulted to 1.
|
|
90
|
+
const untrustworthy = infraUntrustworthy(iso) || infraUntrustworthy(co);
|
|
91
|
+
const interference = !untrustworthy &&
|
|
92
|
+
isoRel.pHat >= cfg.threshold && // fires reliably alone
|
|
93
|
+
deltaP < 0 && // and drops when co-loaded
|
|
94
|
+
fisherP < 0.05; // and the drop is statistically real
|
|
95
|
+
measured.push({
|
|
96
|
+
prompt: c.prompt, expected,
|
|
97
|
+
isolation: { stats: iso, rel: isoRel },
|
|
98
|
+
coLoaded: { stats: co, rel: coRel },
|
|
99
|
+
deltaP, fisherP, untrustworthy, interference,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
// precedence mirrors the audit: a real behavioral signal (interference, exit 1) outranks an
|
|
103
|
+
// untrustworthy/infra case (exit 2), which outranks a clean run (exit 0).
|
|
104
|
+
const exitCode = measured.some((m) => m.interference) ? 1
|
|
105
|
+
: measured.some((m) => m.untrustworthy) ? 2
|
|
106
|
+
: 0;
|
|
107
|
+
return {
|
|
108
|
+
runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
|
|
109
|
+
threshold: cfg.threshold, conf: cfg.conf, librarySize: library.length,
|
|
110
|
+
cases: measured, skipped, totalCost, exitCode,
|
|
111
|
+
};
|
|
112
|
+
}
|
package/dist/llm.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
export
|
|
1
|
+
export type LlmBackend = "api" | "cli";
|
|
2
|
+
/** Which backend complete() will use, given the environment. Exported for testing/diagnostics. */
|
|
3
|
+
export declare function llmBackend(): LlmBackend;
|
|
4
|
+
export interface CompleteOpts {
|
|
2
5
|
model?: string;
|
|
3
6
|
maxTokens?: number;
|
|
4
|
-
}
|
|
7
|
+
}
|
|
8
|
+
export declare function complete(system: string, user: string, opts?: CompleteOpts): Promise<string>;
|
|
9
|
+
/** Pure interpreter of a `claude -p --output-format json` run. Treats a non-zero exit, an
|
|
10
|
+
* `is_error: true` payload, a timeout, or empty output as ERRORS — so an error string can never
|
|
11
|
+
* be mistaken for a valid generation (critical: fix would otherwise write it as a description). */
|
|
12
|
+
export declare function interpretClaudeJson(code: number | null, out: string, err: string, timedOut: boolean): {
|
|
13
|
+
text?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
};
|
package/dist/llm.js
CHANGED
|
@@ -1,33 +1,72 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
1
|
+
/** LLM text completion for `gen` and `fix`. Unified auth (v0.4):
|
|
2
|
+
* - ANTHROPIC_API_KEY set → call the Anthropic API directly (fetch, Node >=18).
|
|
3
|
+
* - otherwise → fall back to the `claude` CLI (works on a Claude subscription,
|
|
4
|
+
* no API key needed).
|
|
5
|
+
* So the whole tool — audit, gen, fix — runs on just a logged-in `claude`. */
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
3
8
|
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
|
|
9
|
+
/** Which backend complete() will use, given the environment. Exported for testing/diagnostics. */
|
|
10
|
+
export function llmBackend() {
|
|
11
|
+
return process.env["ANTHROPIC_API_KEY"] ? "api" : "cli";
|
|
12
|
+
}
|
|
4
13
|
export async function complete(system, user, opts = {}) {
|
|
14
|
+
return llmBackend() === "api"
|
|
15
|
+
? completeViaApi(system, user, opts)
|
|
16
|
+
: completeViaClaudeCli(system, user, opts);
|
|
17
|
+
}
|
|
18
|
+
async function completeViaApi(system, user, opts) {
|
|
5
19
|
const key = process.env["ANTHROPIC_API_KEY"];
|
|
6
|
-
if (!key) {
|
|
7
|
-
throw new Error("ANTHROPIC_API_KEY is required for `skill-probe fix` (it rewrites the description).");
|
|
8
|
-
}
|
|
9
20
|
const model = opts.model ?? process.env["SKILL_PROBE_FIX_MODEL"] ?? "claude-sonnet-4-6";
|
|
10
21
|
const res = await fetch(ANTHROPIC_URL, {
|
|
11
22
|
method: "POST",
|
|
12
|
-
headers: {
|
|
13
|
-
|
|
14
|
-
"x-api-key": key,
|
|
15
|
-
"anthropic-version": "2023-06-01",
|
|
16
|
-
},
|
|
17
|
-
body: JSON.stringify({
|
|
18
|
-
model,
|
|
19
|
-
max_tokens: opts.maxTokens ?? 400,
|
|
20
|
-
system,
|
|
21
|
-
messages: [{ role: "user", content: user }],
|
|
22
|
-
}),
|
|
23
|
+
headers: { "content-type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01" },
|
|
24
|
+
body: JSON.stringify({ model, max_tokens: opts.maxTokens ?? 400, system, messages: [{ role: "user", content: user }] }),
|
|
23
25
|
});
|
|
24
|
-
if (!res.ok)
|
|
26
|
+
if (!res.ok)
|
|
25
27
|
throw new Error(`Anthropic API ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
26
|
-
}
|
|
27
28
|
const body = (await res.json());
|
|
28
|
-
return (body.content ?? [])
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
return (body.content ?? []).filter((b) => b.type === "text").map((b) => b.text ?? "").join("").trim();
|
|
30
|
+
}
|
|
31
|
+
/** Pure interpreter of a `claude -p --output-format json` run. Treats a non-zero exit, an
|
|
32
|
+
* `is_error: true` payload, a timeout, or empty output as ERRORS — so an error string can never
|
|
33
|
+
* be mistaken for a valid generation (critical: fix would otherwise write it as a description). */
|
|
34
|
+
export function interpretClaudeJson(code, out, err, timedOut) {
|
|
35
|
+
if (timedOut)
|
|
36
|
+
return { error: "claude CLI timed out after 120s" };
|
|
37
|
+
let parsed = null;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(out);
|
|
40
|
+
}
|
|
41
|
+
catch { /* may be plain text */ }
|
|
42
|
+
if (code !== 0 || parsed?.is_error === true) {
|
|
43
|
+
const ctx = (parsed && typeof parsed.result === "string" ? parsed.result : "") || err || out;
|
|
44
|
+
return { error: `claude CLI error (exit ${code}): ${ctx.trim().slice(0, 200)}` };
|
|
45
|
+
}
|
|
46
|
+
let text = parsed ? (typeof parsed.result === "string" ? parsed.result : "") : out;
|
|
47
|
+
text = text.trim();
|
|
48
|
+
return text ? { text } : { error: `claude CLI returned no text (${(err || out).slice(0, 200)})` };
|
|
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
|
+
});
|
|
33
72
|
}
|
package/dist/report.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AuditResult } from "./eval.ts";
|
|
2
2
|
import type { FixResult } from "./fix.ts";
|
|
3
|
+
import type { ContextAuditResult } from "./context.ts";
|
|
3
4
|
export interface RenderOpts {
|
|
4
5
|
showCost?: boolean;
|
|
5
6
|
}
|
|
@@ -7,4 +8,6 @@ export declare function renderTable(a: AuditResult, opts?: RenderOpts): string;
|
|
|
7
8
|
/** Markdown table — paste into a PR or README. */
|
|
8
9
|
export declare function renderMarkdown(a: AuditResult, opts?: RenderOpts): string;
|
|
9
10
|
export declare function renderJson(a: AuditResult | unknown): string;
|
|
11
|
+
/** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
|
|
12
|
+
export declare function renderContext(a: ContextAuditResult, opts?: RenderOpts): string;
|
|
10
13
|
export declare function renderFix(f: FixResult): string;
|
package/dist/report.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* errors are shown distinctly from behavioral results. */
|
|
3
3
|
import { wilsonInterval } from "./stats.js";
|
|
4
4
|
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
5
|
+
const pval = (p) => (p < 0.001 ? "<0.001" : p.toFixed(3));
|
|
5
6
|
const LABEL = {
|
|
6
7
|
pass: "PASS ", fail: "FAIL ",
|
|
7
8
|
inconclusive: "INCONCLUSIVE", error: "ERROR ",
|
|
@@ -80,6 +81,50 @@ export function renderMarkdown(a, opts = {}) {
|
|
|
80
81
|
export function renderJson(a) {
|
|
81
82
|
return JSON.stringify(a, null, 2);
|
|
82
83
|
}
|
|
84
|
+
/** activation-rate-by-context: isolation vs co-loaded, with a Fisher's-exact p on the drop. */
|
|
85
|
+
export function renderContext(a, opts = {}) {
|
|
86
|
+
const showCost = opts.showCost ?? true;
|
|
87
|
+
const out = [];
|
|
88
|
+
out.push(`skill-probe context — runtime: ${a.runtime} model: ${a.model} ` +
|
|
89
|
+
`threshold: ${pct(a.threshold)} library: ${a.librarySize} skills`);
|
|
90
|
+
out.push(` isolation = only that skill loaded · co-loaded = the full library (${a.librarySize}) loaded`);
|
|
91
|
+
out.push("");
|
|
92
|
+
const ci = (r) => `${pct(r.pHat)} [${pct(r.ciLow)}, ${pct(r.ciHigh)}] k=${r.k}`;
|
|
93
|
+
for (const c of a.cases) {
|
|
94
|
+
if (c.untrustworthy) {
|
|
95
|
+
out.push(` [⛔ ERROR] ${c.expected} | ${c.prompt}`);
|
|
96
|
+
out.push(` infrastructure errors dominated — comparison NOT trustworthy ` +
|
|
97
|
+
`(isolation ${c.isolation.stats.errors} err/${c.isolation.stats.n} valid, ` +
|
|
98
|
+
`co-loaded ${c.coLoaded.stats.errors} err/${c.coLoaded.stats.n} valid)`);
|
|
99
|
+
const reason = c.isolation.stats.lastError ?? c.coLoaded.stats.lastError;
|
|
100
|
+
if (reason)
|
|
101
|
+
out.push(` reason: ${reason}`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const flag = c.interference ? "⚠ INTERFERENCE" : "ok";
|
|
105
|
+
out.push(` [${flag}] ${c.expected} | ${c.prompt}`);
|
|
106
|
+
out.push(` isolation ${ci(c.isolation.rel)}`);
|
|
107
|
+
out.push(` co-loaded ${ci(c.coLoaded.rel)}`);
|
|
108
|
+
out.push(` Δ ${c.deltaP >= 0 ? "+" : ""}${pct(c.deltaP)} under load Fisher p=${pval(c.fisherP)}`);
|
|
109
|
+
if (c.interference) {
|
|
110
|
+
const thieves = Object.keys(c.coLoaded.stats.dist)
|
|
111
|
+
.filter((s) => s !== c.expected && s !== "None");
|
|
112
|
+
out.push(` ↳ fires reliably alone but is suppressed when the library is co-loaded` +
|
|
113
|
+
(thieves.length ? ` (stolen by: ${thieves.join(", ")})` : ""));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (a.skipped.length) {
|
|
117
|
+
out.push("");
|
|
118
|
+
out.push(` skipped ${a.skipped.length} decoy case(s) — no single skill to isolate.`);
|
|
119
|
+
}
|
|
120
|
+
out.push("");
|
|
121
|
+
const interfN = a.cases.filter((c) => c.interference).length;
|
|
122
|
+
const errN = a.cases.filter((c) => c.untrustworthy).length;
|
|
123
|
+
out.push(`Result: ${interfN} interference / ${a.cases.length} measured` +
|
|
124
|
+
(errN ? ` / ${errN} error` : "") + ` | exit ${a.exitCode}` +
|
|
125
|
+
(showCost ? ` | cost $${a.totalCost.toFixed(4)}` : ""));
|
|
126
|
+
return out.join("\n");
|
|
127
|
+
}
|
|
83
128
|
export function renderFix(f) {
|
|
84
129
|
const out = [];
|
|
85
130
|
out.push(`skill-probe fix — skill: ${f.skill}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skill-probe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Runtime-agnostic, co-loaded-aware auditor for AI agent skill libraries: measures which skill actually fires, with statistical confidence.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": { "skill-probe": "dist/cli.js" },
|