skill-probe 0.1.0 → 0.3.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
@@ -11,7 +11,7 @@ Complements static linters like `skill-audit` (security/quality) — this is **b
11
11
 
12
12
  ## Why
13
13
 
14
- - Skill activation is a coin flip: independent studies measured 55–87% trigger rates.
14
+ - Skill activation is stochastic: the same prompt can produce different skill-routing outcomes.
15
15
  - A single run lies (we measured the same prompt at 0/5 one batch, 2/3 the next).
16
16
  - skill-probe runs each prompt **k times**, reports a **Wilson 95% CI**, stops early when the
17
17
  result is statistically decided, and flags **trigger-theft** (a sibling stealing a trigger).
@@ -22,11 +22,27 @@ Complements static linters like `skill-audit` (security/quality) — this is **b
22
22
  npm i -g skill-probe # or: npx skill-probe
23
23
  ```
24
24
 
25
+ ## Generate a config (`skill-probe gen`)
26
+
27
+ Don't want to hand-write the cases? Draft them from your skills, then review:
28
+
29
+ ```bash
30
+ ANTHROPIC_API_KEY=sk-ant-... skill-probe gen --cwd . > probe.config.json
31
+ ```
32
+ It reads each `.claude/skills/*/SKILL.md`, and an LLM drafts realistic should-fire prompts per
33
+ skill, cross-skill **near-misses** (to surface mis-routing), and off-topic **decoys**. Hallucinated
34
+ skill names are dropped automatically. Flags: `--per-skill <n>` (default 3), `--decoys <n>`
35
+ (default 2), `--model <id>`.
36
+
37
+ > **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). `gen`
39
+ > needs `ANTHROPIC_API_KEY`.
40
+
25
41
  ## Use
26
42
 
27
43
  ```bash
28
44
  # 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
45
+ skill-probe --config my.config.json --k 10 --threshold 0.7 --json
30
46
 
31
47
  # or, from a clone of this repo, try the bundled example (needs `claude` installed + auth):
32
48
  skill-probe --config examples/audit.config.json
@@ -48,12 +64,15 @@ Output:
48
64
  ```
49
65
  skill-probe — runtime: claude-code model: (runtime default) threshold: 70%
50
66
 
51
- PASS reliability 100% [72%, 100%] k=10 expect=commit-writer
67
+ PASS expect=commit-writer | write a commit message
68
+ reliability 100% [72%, 100%] k=10
52
69
  outcomes: commit-writer×10
53
- FAIL reliability 20% [6%, 51%] k=10 expect=pr-describer
70
+ FAIL expect=pr-describer | write a pull request description
71
+ reliability 20% [6%, 51%] k=10
54
72
  outcomes: None×8, commit-writer×2
55
73
  ⚠ trigger-theft by: commit-writer
56
- PASS reliability 100% [72%, 100%] k=10 expect=(none)
74
+ PASS expect=(none) | what's the weather?
75
+ reliability 100% [72%, 100%] k=10
57
76
  outcomes: None×10
58
77
 
59
78
  Result: 2 pass / 1 fail / 0 inconclusive / 0 error | exit 1 | cost $0.18
@@ -65,6 +84,11 @@ Result: 2 pass / 1 fail / 0 inconclusive / 0 error | exit 1 | cost $0.18
65
84
  - Exit code: `0` all pass, `1` a behavioral fail / trigger-theft, `2` inconclusive or an
66
85
  infrastructure error (runtime down → never a silent pass).
67
86
 
87
+ **Output options:** a live `probing [2/3] …` progress line prints to stderr while it runs (use
88
+ `--quiet` to silence it). `--markdown` emits a table you can paste into a PR/README; `--json`
89
+ for machine output; `--no-cost` hides the cost line (handy on a Claude subscription, where the
90
+ dollar figure is just an estimate, not a charge).
91
+
68
92
  **Cross-runtime comparison must pin the model.** A skill can fire on one runtime and not another
69
93
  partly because of the *model*, not the runtime. Always set `model` (recorded in the report) so a
70
94
  Claude Code vs OpenCode comparison is fair — otherwise you're comparing two confounded variables.
@@ -95,6 +119,7 @@ effect is positive — otherwise reverts. When applied, the original is snapshot
95
119
  before: 0% [0%, 49%] after: 100% [51%, 100%] (4 paired runs)
96
120
  P(rewrite improved reliability) = 100% Δ = +80% [36%, 99%]
97
121
  ✅ APPLIED
122
+ original backed up to: /path/to/SKILL.md.bak.1719000000000
98
123
  ```
99
124
 
100
125
  `fix` needs `ANTHROPIC_API_KEY` (for the rewrite). It changes descriptions on statistical evidence,
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, 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 (needs ANTHROPIC_API_KEY):
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,63 @@ 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
+ function intArg(raw, def, min) {
78
+ if (raw === undefined)
79
+ return def;
80
+ const v = Math.floor(Number(raw));
81
+ return Number.isFinite(v) && v >= min ? v : def;
82
+ }
83
+ async function gen(values) {
84
+ const cwd = values["cwd"] ?? ".";
85
+ const skills = listSkills(resolve(cwd));
86
+ if (skills.length === 0) {
87
+ console.error(`no skills found under ${cwd}/.claude/skills/`);
88
+ return 2;
89
+ }
90
+ const model = values["model"];
91
+ const cases = await generateCases(skills, {
92
+ perSkill: intArg(values["per-skill"], 3, 1),
93
+ decoys: intArg(values["decoys"], 2, 0),
94
+ ...(model ? { model } : {}),
95
+ });
96
+ console.log(renderJson(genConfig(cases)));
97
+ console.error(`# drafted ${cases.length} cases for ${skills.length} skill(s) — REVIEW before ` +
98
+ `running (add the messy/oblique phrasings the generator misses).`);
99
+ return 0;
100
+ }
44
101
  async function fix(values) {
45
102
  const skill = values["skill"];
46
103
  if (!skill) {
@@ -51,7 +108,7 @@ async function fix(values) {
51
108
  runtime: values["runtime"], model: values["model"],
52
109
  k: values["k"], conf: values["conf"],
53
110
  });
54
- const applyBar = values["apply-threshold"] ? Number(values["apply-threshold"]) : 0.9;
111
+ const applyBar = parseProbability(values["apply-threshold"], "--apply-threshold", 0.9);
55
112
  const result = await runFix(cfg, { skill, k: cfg.k, applyBar, ...(cfg.model ? { model: cfg.model } : {}) });
56
113
  console.log(values["json"] ? renderJson(result) : renderFix(result));
57
114
  return result.applied ? 0 : 1;
@@ -68,16 +125,32 @@ async function main() {
68
125
  conf: { type: "string" },
69
126
  skill: { type: "string" },
70
127
  "apply-threshold": { type: "string" },
128
+ cwd: { type: "string" },
129
+ "per-skill": { type: "string" },
130
+ decoys: { type: "string" },
71
131
  json: { type: "boolean", default: false },
132
+ markdown: { type: "boolean", default: false },
133
+ "no-cost": { type: "boolean", default: false },
134
+ quiet: { type: "boolean", default: false },
72
135
  help: { type: "boolean", short: "h", default: false },
73
136
  },
74
137
  });
75
- if (values.help || !values.config) {
138
+ if (values.help) {
76
139
  console.log(HELP);
77
- process.exit(values.help ? 0 : 2);
140
+ process.exit(0);
78
141
  }
79
142
  const cmd = positionals[0];
80
- const code = cmd === "fix" ? await fix(values) : await audit(values);
143
+ let code;
144
+ if (cmd === "gen") {
145
+ code = await gen(values);
146
+ }
147
+ else {
148
+ if (!values.config) {
149
+ console.log(HELP);
150
+ process.exit(2);
151
+ }
152
+ code = cmd === "fix" ? await fix(values) : await audit(values);
153
+ }
81
154
  process.exit(code);
82
155
  }
83
156
  main().catch((e) => {
package/dist/config.d.ts CHANGED
@@ -7,6 +7,8 @@ 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;
10
12
  /** CLI flag overrides (already string-typed from argv); numerics are coerced then validated. */
11
13
  export interface Overrides {
12
14
  runtime?: string | undefined;
package/dist/config.js CHANGED
@@ -65,6 +65,16 @@ 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
+ }
68
78
  export function loadConfig(configPath, overrides = {}) {
69
79
  let fileRaw;
70
80
  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
+ }
@@ -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.3.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" },