skill-probe 0.1.0 → 0.4.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 +43 -8
- package/dist/cli.js +75 -8
- package/dist/config.d.ts +4 -0
- package/dist/config.js +20 -0
- package/dist/eval.d.ts +8 -1
- package/dist/eval.js +8 -2
- package/dist/gen.d.ts +26 -0
- package/dist/gen.js +63 -0
- package/dist/llm.d.ts +13 -2
- package/dist/llm.js +62 -23
- package/dist/orchestrator.d.ts +1 -0
- package/dist/orchestrator.js +2 -0
- package/dist/report.d.ts +6 -1
- package/dist/report.js +34 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,9 +9,15 @@ 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
|
-
- Skill activation is
|
|
20
|
+
- Skill activation is stochastic: the same prompt can produce different skill-routing outcomes.
|
|
15
21
|
- A single run lies (we measured the same prompt at 0/5 one batch, 2/3 the next).
|
|
16
22
|
- skill-probe runs each prompt **k times**, reports a **Wilson 95% CI**, stops early when the
|
|
17
23
|
result is statistically decided, and flags **trigger-theft** (a sibling stealing a trigger).
|
|
@@ -22,11 +28,30 @@ Complements static linters like `skill-audit` (security/quality) — this is **b
|
|
|
22
28
|
npm i -g skill-probe # or: npx skill-probe
|
|
23
29
|
```
|
|
24
30
|
|
|
31
|
+
## Generate a config (`skill-probe gen`)
|
|
32
|
+
|
|
33
|
+
Don't want to hand-write the cases? Draft them from your skills, then review:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
ANTHROPIC_API_KEY=sk-ant-... skill-probe gen --cwd . > probe.config.json
|
|
37
|
+
```
|
|
38
|
+
It reads each `.claude/skills/*/SKILL.md`, and an LLM drafts realistic should-fire prompts per
|
|
39
|
+
skill, cross-skill **near-misses** (to surface mis-routing), and off-topic **decoys**. Hallucinated
|
|
40
|
+
skill names are dropped automatically. Flags: `--per-skill <n>` (default 3), `--decoys <n>`
|
|
41
|
+
(default 2), `--model <id>`.
|
|
42
|
+
|
|
43
|
+
> **Always review the draft before running.** Generators lean toward obvious keyword-matches; you
|
|
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**.
|
|
49
|
+
|
|
25
50
|
## Use
|
|
26
51
|
|
|
27
52
|
```bash
|
|
28
53
|
# point it at your own config (after `npm i -g skill-probe`):
|
|
29
|
-
skill-probe --config my.config.json --k
|
|
54
|
+
skill-probe --config my.config.json --k 10 --threshold 0.7 --json
|
|
30
55
|
|
|
31
56
|
# or, from a clone of this repo, try the bundled example (needs `claude` installed + auth):
|
|
32
57
|
skill-probe --config examples/audit.config.json
|
|
@@ -48,12 +73,15 @@ Output:
|
|
|
48
73
|
```
|
|
49
74
|
skill-probe — runtime: claude-code model: (runtime default) threshold: 70%
|
|
50
75
|
|
|
51
|
-
PASS
|
|
76
|
+
PASS expect=commit-writer | write a commit message
|
|
77
|
+
reliability 100% [72%, 100%] k=10
|
|
52
78
|
outcomes: commit-writer×10
|
|
53
|
-
FAIL
|
|
79
|
+
FAIL expect=pr-describer | write a pull request description
|
|
80
|
+
reliability 20% [6%, 51%] k=10
|
|
54
81
|
outcomes: None×8, commit-writer×2
|
|
55
82
|
⚠ trigger-theft by: commit-writer
|
|
56
|
-
PASS
|
|
83
|
+
PASS expect=(none) | what's the weather?
|
|
84
|
+
reliability 100% [72%, 100%] k=10
|
|
57
85
|
outcomes: None×10
|
|
58
86
|
|
|
59
87
|
Result: 2 pass / 1 fail / 0 inconclusive / 0 error | exit 1 | cost $0.18
|
|
@@ -65,6 +93,11 @@ Result: 2 pass / 1 fail / 0 inconclusive / 0 error | exit 1 | cost $0.18
|
|
|
65
93
|
- Exit code: `0` all pass, `1` a behavioral fail / trigger-theft, `2` inconclusive or an
|
|
66
94
|
infrastructure error (runtime down → never a silent pass).
|
|
67
95
|
|
|
96
|
+
**Output options:** a live `probing [2/3] …` progress line prints to stderr while it runs (use
|
|
97
|
+
`--quiet` to silence it). `--markdown` emits a table you can paste into a PR/README; `--json`
|
|
98
|
+
for machine output; `--no-cost` hides the cost line (handy on a Claude subscription, where the
|
|
99
|
+
dollar figure is just an estimate, not a charge).
|
|
100
|
+
|
|
68
101
|
**Cross-runtime comparison must pin the model.** A skill can fire on one runtime and not another
|
|
69
102
|
partly because of the *model*, not the runtime. Always set `model` (recorded in the report) so a
|
|
70
103
|
Claude Code vs OpenCode comparison is fair — otherwise you're comparing two confounded variables.
|
|
@@ -95,14 +128,16 @@ effect is positive — otherwise reverts. When applied, the original is snapshot
|
|
|
95
128
|
before: 0% [0%, 49%] after: 100% [51%, 100%] (4 paired runs)
|
|
96
129
|
P(rewrite improved reliability) = 100% Δ = +80% [36%, 99%]
|
|
97
130
|
✅ APPLIED
|
|
131
|
+
original backed up to: /path/to/SKILL.md.bak.1719000000000
|
|
98
132
|
```
|
|
99
133
|
|
|
100
|
-
`fix`
|
|
101
|
-
not on "the new one looks nicer" — a rewrite that
|
|
134
|
+
`fix` does the rewrite via `ANTHROPIC_API_KEY` if set, else the logged-in `claude` CLI (subscription).
|
|
135
|
+
It changes descriptions on statistical evidence, not on "the new one looks nicer" — a rewrite that
|
|
136
|
+
doesn't measurably help is reverted.
|
|
102
137
|
|
|
103
138
|
## Status
|
|
104
139
|
|
|
105
|
-
Early (v0.
|
|
140
|
+
Early (v0.4). **Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping +
|
|
106
141
|
four-state verdict (pass / fail / inconclusive / **error**), across two runtimes (Claude Code,
|
|
107
142
|
OpenCode). Infrastructure failures (timeout / auth / crash / empty output) are reported as
|
|
108
143
|
`error`, never as a behavioral pass/fail — a decoy can't falsely pass because the runtime was down.
|
package/dist/cli.js
CHANGED
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
* skill-probe fix --config <f> --skill <n> rewrite a skill's description & prove the lift
|
|
5
5
|
* Exit: 0 = pass/applied, 1 = behavioral fail/reverted, 2 = inconclusive/infra error/usage. */
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
|
+
import { resolve } from "node:path";
|
|
7
8
|
import { runAudit } from "./eval.js";
|
|
8
9
|
import { runFix } from "./fix.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
10
|
+
import { listSkills, generateCases, genConfig } from "./gen.js";
|
|
11
|
+
import { loadConfig, parseProbability, parseIntFlag, ConfigError } from "./config.js";
|
|
12
|
+
import { renderTable, renderMarkdown, renderJson, renderFix } from "./report.js";
|
|
11
13
|
const HELP = `skill-probe — audit a co-loaded agent skill library by real activation behavior.
|
|
12
14
|
|
|
13
15
|
USAGE
|
|
14
16
|
skill-probe --config <file.json> [options] # audit
|
|
17
|
+
skill-probe gen [--cwd <dir>] > probe.config.json # draft a test config from your skills
|
|
15
18
|
skill-probe fix --config <file.json> --skill <name> [opts] # rewrite a description, prove the lift
|
|
16
19
|
|
|
17
20
|
OPTIONS
|
|
@@ -24,6 +27,13 @@ OPTIONS
|
|
|
24
27
|
--threshold <p> min acceptable reliability, strictly 0..1 (default 0.7 = smoke test)
|
|
25
28
|
--conf <p> confidence level: 0.9 | 0.95 | 0.99 (default 0.95)
|
|
26
29
|
--json emit JSON instead of a table
|
|
30
|
+
--markdown emit a Markdown table (paste into a PR/README)
|
|
31
|
+
--no-cost hide the cost line (e.g. when on a Claude subscription)
|
|
32
|
+
--quiet suppress the live progress line on stderr
|
|
33
|
+
gen only (uses ANTHROPIC_API_KEY if set, else the logged-in 'claude' CLI):
|
|
34
|
+
--cwd <dir> project dir containing .claude/skills/ (default: .)
|
|
35
|
+
--per-skill <n> should-fire prompts to draft per skill (default 3)
|
|
36
|
+
--decoys <n> off-topic decoy prompts to draft (default 2)
|
|
27
37
|
fix only:
|
|
28
38
|
--skill <name> the skill whose description to rewrite (uses the config's cases for it)
|
|
29
39
|
--apply-threshold <p> min P(improvement) to keep the rewrite (default 0.9)
|
|
@@ -31,16 +41,57 @@ OPTIONS
|
|
|
31
41
|
|
|
32
42
|
Reliability carries a Wilson CI + sample size k. fix rewrites the description, runs an INTERLEAVED
|
|
33
43
|
before/after, and applies it only if the Bayesian P(improvement) clears the apply-threshold.`;
|
|
44
|
+
let progressLen = 0;
|
|
45
|
+
function makeProgress(values) {
|
|
46
|
+
if (values["json"] || values["quiet"] || !process.stderr.isTTY)
|
|
47
|
+
return undefined;
|
|
48
|
+
return (e) => {
|
|
49
|
+
const p = e.prompt.length > 32 ? e.prompt.slice(0, 29) + "…" : e.prompt;
|
|
50
|
+
const line = ` probing [${e.caseIndex}/${e.caseTotal}] "${p}" — run ${e.validN}/${e.maxK}`;
|
|
51
|
+
const pad = Math.max(0, progressLen - line.length);
|
|
52
|
+
process.stderr.write("\r" + line + " ".repeat(pad));
|
|
53
|
+
progressLen = line.length;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function clearProgress() {
|
|
57
|
+
if (progressLen) {
|
|
58
|
+
process.stderr.write("\r" + " ".repeat(progressLen) + "\r");
|
|
59
|
+
progressLen = 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
34
62
|
async function audit(values) {
|
|
35
63
|
const cfg = loadConfig(values["config"], {
|
|
36
64
|
runtime: values["runtime"], model: values["model"],
|
|
37
65
|
k: values["k"], threshold: values["threshold"],
|
|
38
66
|
conf: values["conf"],
|
|
39
67
|
});
|
|
40
|
-
const result = await runAudit(cfg);
|
|
41
|
-
|
|
68
|
+
const result = await runAudit(cfg, makeProgress(values));
|
|
69
|
+
clearProgress();
|
|
70
|
+
const showCost = !values["no-cost"];
|
|
71
|
+
const text = values["json"] ? renderJson(result)
|
|
72
|
+
: values["markdown"] ? renderMarkdown(result, { showCost })
|
|
73
|
+
: renderTable(result, { showCost });
|
|
74
|
+
console.log(text);
|
|
42
75
|
return result.exitCode;
|
|
43
76
|
}
|
|
77
|
+
async function gen(values) {
|
|
78
|
+
const cwd = values["cwd"] ?? ".";
|
|
79
|
+
const perSkill = parseIntFlag(values["per-skill"], "--per-skill", 3, 1);
|
|
80
|
+
const decoys = parseIntFlag(values["decoys"], "--decoys", 2, 0);
|
|
81
|
+
const skills = listSkills(resolve(cwd));
|
|
82
|
+
if (skills.length === 0) {
|
|
83
|
+
console.error(`no skills found under ${cwd}/.claude/skills/`);
|
|
84
|
+
return 2;
|
|
85
|
+
}
|
|
86
|
+
const model = values["model"];
|
|
87
|
+
const cases = await generateCases(skills, {
|
|
88
|
+
perSkill, decoys, ...(model ? { model } : {}),
|
|
89
|
+
});
|
|
90
|
+
console.log(renderJson(genConfig(cases)));
|
|
91
|
+
console.error(`# drafted ${cases.length} cases for ${skills.length} skill(s) — REVIEW before ` +
|
|
92
|
+
`running (add the messy/oblique phrasings the generator misses).`);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
44
95
|
async function fix(values) {
|
|
45
96
|
const skill = values["skill"];
|
|
46
97
|
if (!skill) {
|
|
@@ -51,7 +102,7 @@ async function fix(values) {
|
|
|
51
102
|
runtime: values["runtime"], model: values["model"],
|
|
52
103
|
k: values["k"], conf: values["conf"],
|
|
53
104
|
});
|
|
54
|
-
const applyBar = values["apply-threshold"]
|
|
105
|
+
const applyBar = parseProbability(values["apply-threshold"], "--apply-threshold", 0.9);
|
|
55
106
|
const result = await runFix(cfg, { skill, k: cfg.k, applyBar, ...(cfg.model ? { model: cfg.model } : {}) });
|
|
56
107
|
console.log(values["json"] ? renderJson(result) : renderFix(result));
|
|
57
108
|
return result.applied ? 0 : 1;
|
|
@@ -68,16 +119,32 @@ async function main() {
|
|
|
68
119
|
conf: { type: "string" },
|
|
69
120
|
skill: { type: "string" },
|
|
70
121
|
"apply-threshold": { type: "string" },
|
|
122
|
+
cwd: { type: "string" },
|
|
123
|
+
"per-skill": { type: "string" },
|
|
124
|
+
decoys: { type: "string" },
|
|
71
125
|
json: { type: "boolean", default: false },
|
|
126
|
+
markdown: { type: "boolean", default: false },
|
|
127
|
+
"no-cost": { type: "boolean", default: false },
|
|
128
|
+
quiet: { type: "boolean", default: false },
|
|
72
129
|
help: { type: "boolean", short: "h", default: false },
|
|
73
130
|
},
|
|
74
131
|
});
|
|
75
|
-
if (values.help
|
|
132
|
+
if (values.help) {
|
|
76
133
|
console.log(HELP);
|
|
77
|
-
process.exit(
|
|
134
|
+
process.exit(0);
|
|
78
135
|
}
|
|
79
136
|
const cmd = positionals[0];
|
|
80
|
-
|
|
137
|
+
let code;
|
|
138
|
+
if (cmd === "gen") {
|
|
139
|
+
code = await gen(values);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
if (!values.config) {
|
|
143
|
+
console.log(HELP);
|
|
144
|
+
process.exit(2);
|
|
145
|
+
}
|
|
146
|
+
code = cmd === "fix" ? await fix(values) : await audit(values);
|
|
147
|
+
}
|
|
81
148
|
process.exit(code);
|
|
82
149
|
}
|
|
83
150
|
main().catch((e) => {
|
package/dist/config.d.ts
CHANGED
|
@@ -7,6 +7,10 @@ export declare class ConfigError extends Error {
|
|
|
7
7
|
* `configPath` is used to resolve a relative `cwd` against the CONFIG FILE's
|
|
8
8
|
* directory — not the caller's shell (finding #3). Throws ConfigError on any problem. */
|
|
9
9
|
export declare function validateConfig(raw: unknown, configPath: string): Config;
|
|
10
|
+
/** Validate a probability-ish flag (e.g. --apply-threshold): must be a number strictly in (0,1). */
|
|
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;
|
|
10
14
|
/** CLI flag overrides (already string-typed from argv); numerics are coerced then validated. */
|
|
11
15
|
export interface Overrides {
|
|
12
16
|
runtime?: string | undefined;
|
package/dist/config.js
CHANGED
|
@@ -65,6 +65,26 @@ export function validateConfig(raw, configPath) {
|
|
|
65
65
|
cases: cases,
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
+
/** Validate a probability-ish flag (e.g. --apply-threshold): must be a number strictly in (0,1). */
|
|
69
|
+
export function parseProbability(raw, name, def) {
|
|
70
|
+
if (raw === undefined)
|
|
71
|
+
return def;
|
|
72
|
+
const v = Number(raw);
|
|
73
|
+
if (!Number.isFinite(v) || v <= 0 || v >= 1) {
|
|
74
|
+
throw new ConfigError([`${name} must be a number strictly between 0 and 1 (got ${JSON.stringify(raw)})`]);
|
|
75
|
+
}
|
|
76
|
+
return v;
|
|
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
|
+
}
|
|
68
88
|
export function loadConfig(configPath, overrides = {}) {
|
|
69
89
|
let fileRaw;
|
|
70
90
|
try {
|
package/dist/eval.d.ts
CHANGED
|
@@ -30,4 +30,11 @@ export interface AuditResult {
|
|
|
30
30
|
/** 0 = all pass; 1 = a behavioral failure/theft; 2 = inconclusive or infrastructure error */
|
|
31
31
|
exitCode: 0 | 1 | 2;
|
|
32
32
|
}
|
|
33
|
-
export
|
|
33
|
+
export interface ProgressEvent {
|
|
34
|
+
caseIndex: number;
|
|
35
|
+
caseTotal: number;
|
|
36
|
+
prompt: string;
|
|
37
|
+
validN: number;
|
|
38
|
+
maxK: number;
|
|
39
|
+
}
|
|
40
|
+
export declare function runAudit(cfg: Config, onProgress?: (e: ProgressEvent) => void): Promise<AuditResult>;
|
package/dist/eval.js
CHANGED
|
@@ -14,14 +14,20 @@ function verdictFor(stats, decision, theft) {
|
|
|
14
14
|
return "fail";
|
|
15
15
|
return "inconclusive"; // includes "k too small to decide" — report says how many runs are needed
|
|
16
16
|
}
|
|
17
|
-
export async function runAudit(cfg) {
|
|
17
|
+
export async function runAudit(cfg, onProgress) {
|
|
18
18
|
const adapter = getAdapter(cfg.runtime);
|
|
19
19
|
const cases = [];
|
|
20
20
|
let totalCost = 0;
|
|
21
|
-
for (
|
|
21
|
+
for (let ci = 0; ci < cfg.cases.length; ci++) {
|
|
22
|
+
const c = cfg.cases[ci];
|
|
22
23
|
const stats = await measure(adapter, c.prompt, c.expected, {
|
|
23
24
|
cwd: cfg.cwd, maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf,
|
|
24
25
|
...(cfg.model ? { model: cfg.model } : {}),
|
|
26
|
+
...(onProgress ? {
|
|
27
|
+
onProbe: (validN, maxK) => onProgress({
|
|
28
|
+
caseIndex: ci + 1, caseTotal: cfg.cases.length, prompt: c.prompt, validN, maxK,
|
|
29
|
+
}),
|
|
30
|
+
} : {}),
|
|
25
31
|
});
|
|
26
32
|
totalCost += stats.totalCost;
|
|
27
33
|
const rel = reliability(stats.hits, stats.n, cfg.conf);
|
package/dist/gen.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Config, EvalCase } from "./types.ts";
|
|
2
|
+
export interface SkillInfo {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function listSkills(cwd: string): SkillInfo[];
|
|
7
|
+
export interface GenOpts {
|
|
8
|
+
perSkill: number;
|
|
9
|
+
decoys: number;
|
|
10
|
+
model?: string;
|
|
11
|
+
}
|
|
12
|
+
/** A raw, untrusted candidate from a generator — sanitized by generateCases(). */
|
|
13
|
+
export interface RawCase {
|
|
14
|
+
prompt?: unknown;
|
|
15
|
+
expected?: unknown;
|
|
16
|
+
}
|
|
17
|
+
export interface GenDeps {
|
|
18
|
+
generate?: (skills: SkillInfo[], opts: GenOpts) => Promise<RawCase[]>;
|
|
19
|
+
}
|
|
20
|
+
export declare function defaultGenerate(skills: SkillInfo[], opts: GenOpts): Promise<RawCase[]>;
|
|
21
|
+
/** Runs a generator, then SANITIZES its output (applies to any generator, not just the LLM one):
|
|
22
|
+
* keep entries with a non-empty prompt and an `expected` that is a real skill name or null —
|
|
23
|
+
* dropping hallucinated skill names. */
|
|
24
|
+
export declare function generateCases(skills: SkillInfo[], opts: GenOpts, deps?: GenDeps): Promise<EvalCase[]>;
|
|
25
|
+
/** Wrap generated cases in a ready-to-run config with sensible defaults. */
|
|
26
|
+
export declare function genConfig(cases: EvalCase[]): Config;
|
package/dist/gen.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** `skill-probe gen` — draft a test config from the skills in a project. The skill descriptions
|
|
2
|
+
* already say what each skill is for, so an LLM can propose realistic should-fire prompts,
|
|
3
|
+
* cross-skill near-misses (to surface mis-routing), and decoys. ALWAYS human-reviewed before use
|
|
4
|
+
* (the generator tends toward obvious keyword-matches; you add the messy real-world phrasings). */
|
|
5
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { complete } from "./llm.js";
|
|
8
|
+
const DESC_RE = /^description:\s*(.*)$/m;
|
|
9
|
+
export function listSkills(cwd) {
|
|
10
|
+
const root = join(cwd, ".claude", "skills");
|
|
11
|
+
if (!existsSync(root))
|
|
12
|
+
throw new Error(`no .claude/skills/ directory under ${cwd}`);
|
|
13
|
+
const skills = [];
|
|
14
|
+
for (const name of readdirSync(root)) {
|
|
15
|
+
const file = join(root, name, "SKILL.md");
|
|
16
|
+
if (!existsSync(file))
|
|
17
|
+
continue;
|
|
18
|
+
const m = DESC_RE.exec(readFileSync(file, "utf8"));
|
|
19
|
+
skills.push({ name, description: m ? m[1].trim() : "" });
|
|
20
|
+
}
|
|
21
|
+
return skills;
|
|
22
|
+
}
|
|
23
|
+
export async function defaultGenerate(skills, opts) {
|
|
24
|
+
const list = skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
|
|
25
|
+
const sys = "You build a TEST SUITE that audits whether an AI coding agent loads the correct "
|
|
26
|
+
+ "skill for a user's prompt. Output ONLY a JSON array, no prose.";
|
|
27
|
+
const user = `Skills available (name: description):\n${list}\n\n`
|
|
28
|
+
+ `Produce a JSON array of {"prompt": string, "expected": string|null}:\n`
|
|
29
|
+
+ `- For EACH skill, ${opts.perSkill} realistic user prompts that SHOULD load it. Vary the `
|
|
30
|
+
+ `phrasing — direct, oblique/indirect, terse, and with surrounding context. Do NOT just echo `
|
|
31
|
+
+ `the description's keywords; write how real users actually type.\n`
|
|
32
|
+
+ `- Where two skills overlap, add a few NEAR-MISS prompts in the gray zone, with "expected" `
|
|
33
|
+
+ `set to the single most-appropriate skill (these surface mis-routing / trigger-theft).\n`
|
|
34
|
+
+ `- ${opts.decoys} DECOY prompts that match NO skill, each with "expected": null.\n`
|
|
35
|
+
+ `"expected" MUST be exactly one of the skill names above, or null. Return ONLY the JSON array.`;
|
|
36
|
+
const raw = await complete(sys, user, opts.model ? { model: opts.model, maxTokens: 2000 } : { maxTokens: 2000 });
|
|
37
|
+
const m = raw.match(/\[[\s\S]*\]/);
|
|
38
|
+
if (!m)
|
|
39
|
+
throw new Error("generator did not return a JSON array");
|
|
40
|
+
return JSON.parse(m[0]);
|
|
41
|
+
}
|
|
42
|
+
/** Runs a generator, then SANITIZES its output (applies to any generator, not just the LLM one):
|
|
43
|
+
* keep entries with a non-empty prompt and an `expected` that is a real skill name or null —
|
|
44
|
+
* dropping hallucinated skill names. */
|
|
45
|
+
export async function generateCases(skills, opts, deps = {}) {
|
|
46
|
+
const raw = await (deps.generate ?? defaultGenerate)(skills, opts);
|
|
47
|
+
const names = new Set(skills.map((s) => s.name));
|
|
48
|
+
const cases = [];
|
|
49
|
+
for (const c of raw) {
|
|
50
|
+
if (typeof c?.prompt !== "string" || !c.prompt)
|
|
51
|
+
continue;
|
|
52
|
+
const exp = c.expected;
|
|
53
|
+
if (exp === null || exp === undefined)
|
|
54
|
+
cases.push({ prompt: c.prompt, expected: null });
|
|
55
|
+
else if (typeof exp === "string" && names.has(exp))
|
|
56
|
+
cases.push({ prompt: c.prompt, expected: exp });
|
|
57
|
+
}
|
|
58
|
+
return cases;
|
|
59
|
+
}
|
|
60
|
+
/** Wrap generated cases in a ready-to-run config with sensible defaults. */
|
|
61
|
+
export function genConfig(cases) {
|
|
62
|
+
return { runtime: "claude-code", cwd: ".", k: 10, threshold: 0.7, conf: 0.95, cases };
|
|
63
|
+
}
|
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/orchestrator.d.ts
CHANGED
package/dist/orchestrator.js
CHANGED
|
@@ -12,6 +12,7 @@ export async function measure(adapter, prompt, expected, opts) {
|
|
|
12
12
|
if (r.status === "error") {
|
|
13
13
|
errors++;
|
|
14
14
|
lastError = r.error;
|
|
15
|
+
opts.onProbe?.(n, opts.maxK);
|
|
15
16
|
continue; // do NOT record as a behavioral outcome
|
|
16
17
|
}
|
|
17
18
|
const key = r.skillFired ?? "None";
|
|
@@ -19,6 +20,7 @@ export async function measure(adapter, prompt, expected, opts) {
|
|
|
19
20
|
if (key === want)
|
|
20
21
|
hits++;
|
|
21
22
|
n++;
|
|
23
|
+
opts.onProbe?.(n, opts.maxK);
|
|
22
24
|
if (n >= 3) {
|
|
23
25
|
const d = sequentialDecision(hits, n, opts.threshold, opts.conf);
|
|
24
26
|
if (d !== "undecided")
|
package/dist/report.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { AuditResult } from "./eval.ts";
|
|
2
2
|
import type { FixResult } from "./fix.ts";
|
|
3
|
-
export
|
|
3
|
+
export interface RenderOpts {
|
|
4
|
+
showCost?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function renderTable(a: AuditResult, opts?: RenderOpts): string;
|
|
7
|
+
/** Markdown table — paste into a PR or README. */
|
|
8
|
+
export declare function renderMarkdown(a: AuditResult, opts?: RenderOpts): string;
|
|
4
9
|
export declare function renderJson(a: AuditResult | unknown): string;
|
|
5
10
|
export declare function renderFix(f: FixResult): string;
|
package/dist/report.js
CHANGED
|
@@ -14,7 +14,8 @@ function kToCertify(threshold, conf) {
|
|
|
14
14
|
}
|
|
15
15
|
return 500;
|
|
16
16
|
}
|
|
17
|
-
export function renderTable(a) {
|
|
17
|
+
export function renderTable(a, opts = {}) {
|
|
18
|
+
const showCost = opts.showCost ?? true;
|
|
18
19
|
const out = [];
|
|
19
20
|
out.push(`skill-probe — runtime: ${a.runtime} model: ${a.model} threshold: ${pct(a.threshold)}`);
|
|
20
21
|
out.push("");
|
|
@@ -42,8 +43,38 @@ export function renderTable(a) {
|
|
|
42
43
|
}
|
|
43
44
|
out.push("");
|
|
44
45
|
out.push(`Result: ${a.counts.pass} pass / ${a.counts.fail} fail / ` +
|
|
45
|
-
`${a.counts.inconclusive} inconclusive / ${a.counts.error} error | ` +
|
|
46
|
-
`
|
|
46
|
+
`${a.counts.inconclusive} inconclusive / ${a.counts.error} error | exit ${a.exitCode}` +
|
|
47
|
+
(showCost ? ` | cost $${a.totalCost.toFixed(4)}` : ""));
|
|
48
|
+
return out.join("\n");
|
|
49
|
+
}
|
|
50
|
+
/** Markdown table — paste into a PR or README. */
|
|
51
|
+
export function renderMarkdown(a, opts = {}) {
|
|
52
|
+
const showCost = opts.showCost ?? true;
|
|
53
|
+
const mark = {
|
|
54
|
+
pass: "✅ pass", fail: "❌ fail", inconclusive: "⚠️ inconclusive", error: "⛔ error",
|
|
55
|
+
};
|
|
56
|
+
const out = [];
|
|
57
|
+
const conf = a.cases[0]?.reliability.conf ?? 0.95;
|
|
58
|
+
out.push(`### skill-probe — \`${a.runtime}\` · model \`${a.model}\` · threshold ${pct(a.threshold)}`);
|
|
59
|
+
out.push("");
|
|
60
|
+
out.push(`| Verdict | Skill | Reliability (${pct(conf)} CI) | k | Notes |`);
|
|
61
|
+
out.push("|---|---|---|---|---|");
|
|
62
|
+
for (const c of a.cases) {
|
|
63
|
+
const r = c.reliability;
|
|
64
|
+
const expect = c.expected ?? "(decoy)";
|
|
65
|
+
let notes = "";
|
|
66
|
+
if (c.verdict === "error")
|
|
67
|
+
notes = `infra error: ${c.stats.lastError ?? "untrustworthy"}`;
|
|
68
|
+
else if (c.theft.length)
|
|
69
|
+
notes = `⚠ trigger-theft by ${c.theft.join(", ")}`;
|
|
70
|
+
else if (c.verdict === "inconclusive")
|
|
71
|
+
notes = `need ~k=${kToCertify(a.threshold, r.conf)} to certify`;
|
|
72
|
+
const rel = c.verdict === "error" ? "—" : `${pct(r.pHat)} [${pct(r.ciLow)}, ${pct(r.ciHigh)}]`;
|
|
73
|
+
out.push(`| ${mark[c.verdict]} | \`${expect}\` | ${rel} | ${r.k} | ${notes} |`);
|
|
74
|
+
}
|
|
75
|
+
out.push("");
|
|
76
|
+
out.push(`**${a.counts.pass} pass · ${a.counts.fail} fail · ${a.counts.inconclusive} inconclusive · ` +
|
|
77
|
+
`${a.counts.error} error**` + (showCost ? ` · cost $${a.totalCost.toFixed(4)}` : ""));
|
|
47
78
|
return out.join("\n");
|
|
48
79
|
}
|
|
49
80
|
export function renderJson(a) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skill-probe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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" },
|