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 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 a coin flip: independent studies measured 55–87% trigger rates.
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 15 --threshold 0.9 --json
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 reliability 100% [72%, 100%] k=10 expect=commit-writer
76
+ PASS expect=commit-writer | write a commit message
77
+ reliability 100% [72%, 100%] k=10
52
78
  outcomes: commit-writer×10
53
- FAIL reliability 20% [6%, 51%] k=10 expect=pr-describer
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 reliability 100% [72%, 100%] k=10 expect=(none)
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` needs `ANTHROPIC_API_KEY` (for the rewrite). It changes descriptions on statistical evidence,
101
- not on "the new one looks nicer" — a rewrite that doesn't measurably help is reverted.
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.1). **Audit (`skill-probe`):** Wilson confidence intervals + sequential stopping +
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 { loadConfig, ConfigError } from "./config.js";
10
- import { renderTable, renderJson, renderFix } from "./report.js";
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
- console.log(values["json"] ? renderJson(result) : renderTable(result));
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"] ? Number(values["apply-threshold"]) : 0.9;
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 || !values.config) {
132
+ if (values.help) {
76
133
  console.log(HELP);
77
- process.exit(values.help ? 0 : 2);
134
+ process.exit(0);
78
135
  }
79
136
  const cmd = positionals[0];
80
- const code = cmd === "fix" ? await fix(values) : await audit(values);
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 declare function runAudit(cfg: Config): Promise<AuditResult>;
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 (const c of cfg.cases) {
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 declare function complete(system: string, user: string, opts?: {
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
- }): Promise<string>;
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
- /** Minimal Anthropic client for the `fix` command's description rewrite. Uses Node's built-in
2
- * fetch (Node >=18) — zero deps. Needs ANTHROPIC_API_KEY (only `fix` uses this, not the audit). */
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
- "content-type": "application/json",
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
- .filter((b) => b.type === "text")
30
- .map((b) => b.text ?? "")
31
- .join("")
32
- .trim();
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
  }
@@ -21,4 +21,5 @@ export declare function measure(adapter: RuntimeAdapter, prompt: string, expecte
21
21
  threshold: number;
22
22
  conf: number;
23
23
  model?: string;
24
+ onProbe?: (validN: number, maxK: number) => void;
24
25
  }): Promise<RunStats>;
@@ -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 declare function renderTable(a: AuditResult): string;
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
- `exit ${a.exitCode} | cost $${a.totalCost.toFixed(4)}`);
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.1.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" },