skill-probe 0.1.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/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/adapters/claudeCode.d.ts +10 -0
- package/dist/adapters/claudeCode.js +84 -0
- package/dist/adapters/index.d.ts +4 -0
- package/dist/adapters/index.js +13 -0
- package/dist/adapters/openCode.d.ts +15 -0
- package/dist/adapters/openCode.js +109 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +89 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +88 -0
- package/dist/eval.d.ts +33 -0
- package/dist/eval.js +50 -0
- package/dist/fix.d.ts +37 -0
- package/dist/fix.js +111 -0
- package/dist/llm.d.ts +4 -0
- package/dist/llm.js +33 -0
- package/dist/orchestrator.d.ts +24 -0
- package/dist/orchestrator.js +29 -0
- package/dist/report.d.ts +5 -0
- package/dist/report.js +74 -0
- package/dist/stats.d.ts +34 -0
- package/dist/stats.js +154 -0
- package/dist/types.d.ts +45 -0
- package/dist/types.js +2 -0
- package/examples/audit.config.json +14 -0
- package/examples/fix.config.json +9 -0
- package/examples/sample-skills/.claude/skills/commit-writer/SKILL.md +7 -0
- package/examples/sample-skills/.claude/skills/pr-describer/SKILL.md +7 -0
- package/package.json +23 -0
package/dist/eval.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { reliability, sequentialDecision } from "./stats.js";
|
|
2
|
+
import { measure } from "./orchestrator.js";
|
|
3
|
+
import { getAdapter } from "./adapters/index.js";
|
|
4
|
+
function verdictFor(stats, decision, theft) {
|
|
5
|
+
// `error` means INFRASTRUCTURE failures dominate — untrustworthy. It is NOT about small k:
|
|
6
|
+
// a small-but-all-valid run is `inconclusive` (wide CI), which the report explains.
|
|
7
|
+
if (stats.n === 0 || (stats.errors > 0 && stats.errors >= stats.n))
|
|
8
|
+
return "error";
|
|
9
|
+
if (theft.length > 0)
|
|
10
|
+
return "fail";
|
|
11
|
+
if (decision === "above")
|
|
12
|
+
return "pass";
|
|
13
|
+
if (decision === "below")
|
|
14
|
+
return "fail";
|
|
15
|
+
return "inconclusive"; // includes "k too small to decide" — report says how many runs are needed
|
|
16
|
+
}
|
|
17
|
+
export async function runAudit(cfg) {
|
|
18
|
+
const adapter = getAdapter(cfg.runtime);
|
|
19
|
+
const cases = [];
|
|
20
|
+
let totalCost = 0;
|
|
21
|
+
for (const c of cfg.cases) {
|
|
22
|
+
const stats = await measure(adapter, c.prompt, c.expected, {
|
|
23
|
+
cwd: cfg.cwd, maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf,
|
|
24
|
+
...(cfg.model ? { model: cfg.model } : {}),
|
|
25
|
+
});
|
|
26
|
+
totalCost += stats.totalCost;
|
|
27
|
+
const rel = reliability(stats.hits, stats.n, cfg.conf);
|
|
28
|
+
const want = c.expected ?? "None";
|
|
29
|
+
const theft = Object.keys(stats.dist).filter((s) => s !== want && s !== "None");
|
|
30
|
+
const decision = sequentialDecision(stats.hits, stats.n, cfg.threshold, cfg.conf);
|
|
31
|
+
cases.push({
|
|
32
|
+
prompt: c.prompt, expected: c.expected, stats, reliability: rel,
|
|
33
|
+
theft, decision, verdict: verdictFor(stats, decision, theft),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// NOTE: Benjamini-Hochberg multiplicity control over per-case exact-test p-values lands in M2,
|
|
37
|
+
// once each case carries a real p-value rather than a CI decision. The functions are tested
|
|
38
|
+
// in stats.ts but are NOT yet wired into this audit path.
|
|
39
|
+
const counts = {
|
|
40
|
+
pass: cases.filter((c) => c.verdict === "pass").length,
|
|
41
|
+
fail: cases.filter((c) => c.verdict === "fail").length,
|
|
42
|
+
inconclusive: cases.filter((c) => c.verdict === "inconclusive").length,
|
|
43
|
+
error: cases.filter((c) => c.verdict === "error").length,
|
|
44
|
+
};
|
|
45
|
+
const exitCode = counts.fail > 0 ? 1 : (counts.error > 0 || counts.inconclusive > 0) ? 2 : 0;
|
|
46
|
+
return {
|
|
47
|
+
runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
|
|
48
|
+
threshold: cfg.threshold, cases, totalCost, counts, exitCode,
|
|
49
|
+
};
|
|
50
|
+
}
|
package/dist/fix.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Config } from "./types.ts";
|
|
2
|
+
import { reliability } from "./stats.ts";
|
|
3
|
+
export declare function readDesc(file: string): string;
|
|
4
|
+
/** Replace only the single-line description; backups are handled explicitly in runFix (not here,
|
|
5
|
+
* because the interleaved loop calls this many times and must not snapshot intermediate states). */
|
|
6
|
+
export declare function writeDesc(file: string, desc: string): void;
|
|
7
|
+
export interface Sibling {
|
|
8
|
+
name: string;
|
|
9
|
+
desc: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function defaultRewrite(skill: string, cur: string, prompts: string[], siblings: Sibling[], model?: string): Promise<string>;
|
|
12
|
+
export interface FixResult {
|
|
13
|
+
skill: string;
|
|
14
|
+
oldDesc: string;
|
|
15
|
+
newDesc: string;
|
|
16
|
+
before: ReturnType<typeof reliability>;
|
|
17
|
+
after: ReturnType<typeof reliability>;
|
|
18
|
+
pImprove: number;
|
|
19
|
+
deltaMean: number;
|
|
20
|
+
deltaCi: [number, number];
|
|
21
|
+
n: number;
|
|
22
|
+
errors: number;
|
|
23
|
+
applied: boolean;
|
|
24
|
+
applyBar: number;
|
|
25
|
+
/** path of the kept backup (only when applied); null when reverted (no change made) */
|
|
26
|
+
backupPath: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface FixOpts {
|
|
29
|
+
skill: string;
|
|
30
|
+
k: number;
|
|
31
|
+
applyBar: number;
|
|
32
|
+
model?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface FixDeps {
|
|
35
|
+
rewrite?: (skill: string, cur: string, prompts: string[], siblings: Sibling[], model?: string) => Promise<string>;
|
|
36
|
+
}
|
|
37
|
+
export declare function runFix(cfg: Config, opts: FixOpts, deps?: FixDeps): Promise<FixResult>;
|
package/dist/fix.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** `skill-probe fix` — rewrite a failing skill's description, then PROVE the lift is real with
|
|
2
|
+
* the Bayesian Beta-Binomial (not "it looks better"). Upgrades on the Python prototype:
|
|
3
|
+
* - INTERLEAVED before/after (old desc -> probe -> new desc -> probe, paired) controls for
|
|
4
|
+
* session drift at the same probe cost as block-wise.
|
|
5
|
+
* - COLLISION-AWARE rewrite: the LLM is told sibling descriptions so it won't steal triggers.
|
|
6
|
+
* - Applies the new description only if P(improvement) >= bar AND the effect is positive;
|
|
7
|
+
* otherwise reverts. Original is always backed up to SKILL.md.bak. */
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, copyFileSync, unlinkSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { getAdapter } from "./adapters/index.js";
|
|
11
|
+
import { probImprovement, reliability } from "./stats.js";
|
|
12
|
+
import { complete } from "./llm.js";
|
|
13
|
+
const DESC_RE = /^description:\s*(.*)$/m;
|
|
14
|
+
function skillFile(cfg, name) {
|
|
15
|
+
return join(cfg.cwd, ".claude", "skills", name, "SKILL.md");
|
|
16
|
+
}
|
|
17
|
+
export function readDesc(file) {
|
|
18
|
+
const m = DESC_RE.exec(readFileSync(file, "utf8"));
|
|
19
|
+
if (!m)
|
|
20
|
+
throw new Error(`no single-line 'description:' found in ${file} (multi-line block scalars not yet supported)`);
|
|
21
|
+
return m[1].trim();
|
|
22
|
+
}
|
|
23
|
+
/** Replace only the single-line description; backups are handled explicitly in runFix (not here,
|
|
24
|
+
* because the interleaved loop calls this many times and must not snapshot intermediate states). */
|
|
25
|
+
export function writeDesc(file, desc) {
|
|
26
|
+
const txt = readFileSync(file, "utf8");
|
|
27
|
+
writeFileSync(file, txt.replace(DESC_RE, `description: ${desc}`));
|
|
28
|
+
}
|
|
29
|
+
/** Unique per-run snapshot path, never clobbering a prior run's backup. */
|
|
30
|
+
function backupPathFor(file) {
|
|
31
|
+
const base = `${file}.bak.${Date.now()}`;
|
|
32
|
+
let p = base, i = 1;
|
|
33
|
+
while (existsSync(p))
|
|
34
|
+
p = `${base}-${i++}`;
|
|
35
|
+
return p;
|
|
36
|
+
}
|
|
37
|
+
export async function defaultRewrite(skill, cur, prompts, siblings, model) {
|
|
38
|
+
const sib = siblings.length ? siblings.map((s) => `- ${s.name}: "${s.desc}"`).join("\n") : "(none)";
|
|
39
|
+
const sys = "You optimize Agent Skill trigger descriptions. The description is the routing signal a "
|
|
40
|
+
+ "model reads to decide whether to load the skill. Return ONLY the new one-line description — no "
|
|
41
|
+
+ "quotes, no preamble, no trailing notes.";
|
|
42
|
+
const user = `Skill \`${skill}\` current description:\n${cur}\n\n`
|
|
43
|
+
+ `It FAILS to reliably trigger on these user phrasings:\n${prompts.map((p) => `- "${p}"`).join("\n")}\n\n`
|
|
44
|
+
+ `Rewrite the description so it reliably triggers on those AND similar phrasings, front-loading `
|
|
45
|
+
+ `trigger keywords. CRITICAL: do NOT overlap with these sibling skills — you must not start `
|
|
46
|
+
+ `stealing their triggers:\n${sib}\n\nKeep it ONE sentence.`;
|
|
47
|
+
const out = await complete(sys, user, model ? { model, maxTokens: 300 } : { maxTokens: 300 });
|
|
48
|
+
return out.replace(/^["']+|["']+$/g, "").trim();
|
|
49
|
+
}
|
|
50
|
+
export async function runFix(cfg, opts, deps = {}) {
|
|
51
|
+
const adapter = getAdapter(cfg.runtime);
|
|
52
|
+
const file = skillFile(cfg, opts.skill);
|
|
53
|
+
if (!existsSync(file))
|
|
54
|
+
throw new Error(`skill not found: ${file}`);
|
|
55
|
+
const root = join(cfg.cwd, ".claude", "skills");
|
|
56
|
+
const siblings = readdirSync(root)
|
|
57
|
+
.filter((n) => n !== opts.skill && existsSync(join(root, n, "SKILL.md")))
|
|
58
|
+
.map((n) => ({ name: n, desc: readDesc(join(root, n, "SKILL.md")) }));
|
|
59
|
+
const prompts = cfg.cases.filter((c) => c.expected === opts.skill).map((c) => c.prompt);
|
|
60
|
+
if (prompts.length === 0)
|
|
61
|
+
throw new Error(`no cases in the config expect skill '${opts.skill}'`);
|
|
62
|
+
const oldDesc = readDesc(file);
|
|
63
|
+
const rewrite = deps.rewrite ?? defaultRewrite;
|
|
64
|
+
const newDesc = await rewrite(opts.skill, oldDesc, prompts, siblings, opts.model);
|
|
65
|
+
// Snapshot the EXACT pre-fix file once, to a unique path (never clobber a prior run's backup).
|
|
66
|
+
const backupPath = backupPathFor(file);
|
|
67
|
+
copyFileSync(file, backupPath);
|
|
68
|
+
const probeWith = async (desc, prompt) => {
|
|
69
|
+
writeDesc(file, desc);
|
|
70
|
+
return adapter.probe(prompt, { cwd: cfg.cwd, ...(cfg.model ? { model: cfg.model } : {}) });
|
|
71
|
+
};
|
|
72
|
+
let beforeHits = 0, afterHits = 0, n = 0, errors = 0;
|
|
73
|
+
try {
|
|
74
|
+
for (let i = 0; i < opts.k; i++) {
|
|
75
|
+
for (const p of prompts) {
|
|
76
|
+
const rb = await probeWith(oldDesc, p);
|
|
77
|
+
const ra = await probeWith(newDesc, p);
|
|
78
|
+
if (rb.status === "error" || ra.status === "error") {
|
|
79
|
+
errors++;
|
|
80
|
+
continue;
|
|
81
|
+
} // drop incomplete pair
|
|
82
|
+
if (rb.skillFired === opts.skill)
|
|
83
|
+
beforeHits++;
|
|
84
|
+
if (ra.skillFired === opts.skill)
|
|
85
|
+
afterHits++;
|
|
86
|
+
n++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const imp = probImprovement(beforeHits, n, afterHits, n);
|
|
90
|
+
const applied = n > 0 && imp.pImprove >= opts.applyBar && imp.deltaMean > 0;
|
|
91
|
+
if (applied) {
|
|
92
|
+
writeDesc(file, newDesc); // commit; keep the timestamped backup
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
copyFileSync(backupPath, file); // restore exact original
|
|
96
|
+
unlinkSync(backupPath); // no change made -> don't leave a redundant backup
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
skill: opts.skill, oldDesc, newDesc,
|
|
100
|
+
before: reliability(beforeHits, n), after: reliability(afterHits, n),
|
|
101
|
+
pImprove: imp.pImprove, deltaMean: imp.deltaMean, deltaCi: imp.deltaCi,
|
|
102
|
+
n, errors, applied, applyBar: opts.applyBar,
|
|
103
|
+
backupPath: applied ? backupPath : null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
copyFileSync(backupPath, file); // restore exact pre-fix file on any error
|
|
108
|
+
unlinkSync(backupPath);
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
}
|
package/dist/llm.d.ts
ADDED
package/dist/llm.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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). */
|
|
3
|
+
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
|
|
4
|
+
export async function complete(system, user, opts = {}) {
|
|
5
|
+
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
|
+
const model = opts.model ?? process.env["SKILL_PROBE_FIX_MODEL"] ?? "claude-sonnet-4-6";
|
|
10
|
+
const res = await fetch(ANTHROPIC_URL, {
|
|
11
|
+
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
|
+
});
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
throw new Error(`Anthropic API ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
26
|
+
}
|
|
27
|
+
const body = (await res.json());
|
|
28
|
+
return (body.content ?? [])
|
|
29
|
+
.filter((b) => b.type === "text")
|
|
30
|
+
.map((b) => b.text ?? "")
|
|
31
|
+
.join("")
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Runs one prompt up to k VALID times with sequential early-stopping vs the threshold.
|
|
2
|
+
* Infra errors (status="error") are retried up to a budget and never counted as a behavioral
|
|
3
|
+
* outcome (finding #1) — they're tracked separately so the audit can flag untrustworthy cases. */
|
|
4
|
+
import type { RuntimeAdapter } from "./types.ts";
|
|
5
|
+
export interface RunStats {
|
|
6
|
+
/** valid probes whose outcome matched the expected behavior */
|
|
7
|
+
hits: number;
|
|
8
|
+
/** number of VALID probes (excludes infra errors) */
|
|
9
|
+
n: number;
|
|
10
|
+
/** infrastructure failures (timeout/auth/crash/empty) */
|
|
11
|
+
errors: number;
|
|
12
|
+
/** last infra-error reason (for the report) */
|
|
13
|
+
lastError?: string;
|
|
14
|
+
/** distribution of outcomes over valid probes: skill name (or "None") -> count */
|
|
15
|
+
dist: Record<string, number>;
|
|
16
|
+
totalCost: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function measure(adapter: RuntimeAdapter, prompt: string, expected: string | null, opts: {
|
|
19
|
+
cwd: string;
|
|
20
|
+
maxK: number;
|
|
21
|
+
threshold: number;
|
|
22
|
+
conf: number;
|
|
23
|
+
model?: string;
|
|
24
|
+
}): Promise<RunStats>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { sequentialDecision } from "./stats.js";
|
|
2
|
+
export async function measure(adapter, prompt, expected, opts) {
|
|
3
|
+
const want = expected ?? "None";
|
|
4
|
+
const dist = {};
|
|
5
|
+
let hits = 0, n = 0, errors = 0, totalCost = 0;
|
|
6
|
+
let lastError;
|
|
7
|
+
// allow up to 50% extra attempts to absorb transient infra errors without inflating k
|
|
8
|
+
const maxAttempts = opts.maxK + Math.ceil(opts.maxK / 2);
|
|
9
|
+
for (let attempt = 0; attempt < maxAttempts && n < opts.maxK; attempt++) {
|
|
10
|
+
const r = await adapter.probe(prompt, { cwd: opts.cwd, ...(opts.model ? { model: opts.model } : {}) });
|
|
11
|
+
totalCost += r.cost;
|
|
12
|
+
if (r.status === "error") {
|
|
13
|
+
errors++;
|
|
14
|
+
lastError = r.error;
|
|
15
|
+
continue; // do NOT record as a behavioral outcome
|
|
16
|
+
}
|
|
17
|
+
const key = r.skillFired ?? "None";
|
|
18
|
+
dist[key] = (dist[key] ?? 0) + 1;
|
|
19
|
+
if (key === want)
|
|
20
|
+
hits++;
|
|
21
|
+
n++;
|
|
22
|
+
if (n >= 3) {
|
|
23
|
+
const d = sequentialDecision(hits, n, opts.threshold, opts.conf);
|
|
24
|
+
if (d !== "undecided")
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { hits, n, errors, dist, totalCost, ...(lastError ? { lastError } : {}) };
|
|
29
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AuditResult } from "./eval.ts";
|
|
2
|
+
import type { FixResult } from "./fix.ts";
|
|
3
|
+
export declare function renderTable(a: AuditResult): string;
|
|
4
|
+
export declare function renderJson(a: AuditResult | unknown): string;
|
|
5
|
+
export declare function renderFix(f: FixResult): string;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Human table + machine JSON rendering. Every number ships with its CI and k; infrastructure
|
|
2
|
+
* errors are shown distinctly from behavioral results. */
|
|
3
|
+
import { wilsonInterval } from "./stats.js";
|
|
4
|
+
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
5
|
+
const LABEL = {
|
|
6
|
+
pass: "PASS ", fail: "FAIL ",
|
|
7
|
+
inconclusive: "INCONCLUSIVE", error: "ERROR ",
|
|
8
|
+
};
|
|
9
|
+
/** Smallest k at which an all-success run could certify reliability >= threshold. */
|
|
10
|
+
function kToCertify(threshold, conf) {
|
|
11
|
+
for (let k = 1; k <= 500; k++) {
|
|
12
|
+
if (wilsonInterval(k, k, conf).lo > threshold)
|
|
13
|
+
return k;
|
|
14
|
+
}
|
|
15
|
+
return 500;
|
|
16
|
+
}
|
|
17
|
+
export function renderTable(a) {
|
|
18
|
+
const out = [];
|
|
19
|
+
out.push(`skill-probe — runtime: ${a.runtime} model: ${a.model} threshold: ${pct(a.threshold)}`);
|
|
20
|
+
out.push("");
|
|
21
|
+
for (const c of a.cases) {
|
|
22
|
+
const r = c.reliability;
|
|
23
|
+
out.push(` ${LABEL[c.verdict]} expect=${c.expected ?? "(none)"} | ${c.prompt}`);
|
|
24
|
+
if (c.verdict === "error") {
|
|
25
|
+
out.push(` ⛔ infrastructure error — result NOT trustworthy ` +
|
|
26
|
+
`(${c.stats.errors} failed, ${c.stats.n} valid)`);
|
|
27
|
+
if (c.stats.lastError)
|
|
28
|
+
out.push(` reason: ${c.stats.lastError}`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
out.push(` reliability ${pct(r.pHat)} [${pct(r.ciLow)}, ${pct(r.ciHigh)}] k=${r.k}` +
|
|
32
|
+
(c.stats.errors ? ` (+${c.stats.errors} infra errors ignored)` : ""));
|
|
33
|
+
const dist = Object.entries(c.stats.dist)
|
|
34
|
+
.sort((p, q) => q[1] - p[1]).map(([s, n]) => `${s}×${n}`).join(", ");
|
|
35
|
+
out.push(` outcomes: ${dist}`);
|
|
36
|
+
if (c.theft.length)
|
|
37
|
+
out.push(` ⚠ trigger-theft by: ${c.theft.join(", ")}`);
|
|
38
|
+
if (c.verdict === "inconclusive") {
|
|
39
|
+
out.push(` ↳ CI straddles threshold — need ~k=${kToCertify(a.threshold, r.conf)} ` +
|
|
40
|
+
`all-success runs to certify ≥${pct(a.threshold)} (currently k=${r.k})`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
out.push("");
|
|
44
|
+
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)}`);
|
|
47
|
+
return out.join("\n");
|
|
48
|
+
}
|
|
49
|
+
export function renderJson(a) {
|
|
50
|
+
return JSON.stringify(a, null, 2);
|
|
51
|
+
}
|
|
52
|
+
export function renderFix(f) {
|
|
53
|
+
const out = [];
|
|
54
|
+
out.push(`skill-probe fix — skill: ${f.skill}`);
|
|
55
|
+
out.push("");
|
|
56
|
+
out.push(` old: ${f.oldDesc}`);
|
|
57
|
+
out.push(` new: ${f.newDesc}`);
|
|
58
|
+
out.push("");
|
|
59
|
+
out.push(` before: ${pct(f.before.pHat)} [${pct(f.before.ciLow)}, ${pct(f.before.ciHigh)}] ` +
|
|
60
|
+
`after: ${pct(f.after.pHat)} [${pct(f.after.ciLow)}, ${pct(f.after.ciHigh)}] ` +
|
|
61
|
+
`(${f.n} paired runs${f.errors ? `, ${f.errors} dropped to infra errors` : ""})`);
|
|
62
|
+
out.push(` P(rewrite improved reliability) = ${pct(f.pImprove)} ` +
|
|
63
|
+
`Δ = ${f.deltaMean >= 0 ? "+" : ""}${pct(f.deltaMean)} [${pct(f.deltaCi[0])}, ${pct(f.deltaCi[1])}]`);
|
|
64
|
+
out.push("");
|
|
65
|
+
if (f.applied) {
|
|
66
|
+
out.push(` ✅ APPLIED — P(improvement) ${pct(f.pImprove)} ≥ bar ${pct(f.applyBar)} and Δ>0.`);
|
|
67
|
+
out.push(` original backed up to: ${f.backupPath}`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
out.push(` ↩ REVERTED — not enough evidence the rewrite helps ` +
|
|
71
|
+
`(need P(improvement) ≥ ${pct(f.applyBar)} and Δ>0). Description unchanged, no backup kept.`);
|
|
72
|
+
}
|
|
73
|
+
return out.join("\n");
|
|
74
|
+
}
|
package/dist/stats.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statistical core (TS port of stats_ref/stats.py — the validated reference impl).
|
|
3
|
+
* Skill activation is Bernoulli with unknown p; we estimate from k runs and never
|
|
4
|
+
* report a bare point. See ../../STATS.md.
|
|
5
|
+
*/
|
|
6
|
+
export interface Interval {
|
|
7
|
+
lo: number;
|
|
8
|
+
hi: number;
|
|
9
|
+
center: number;
|
|
10
|
+
}
|
|
11
|
+
export type Decision = "above" | "below" | "undecided";
|
|
12
|
+
/** Wilson score interval — well-behaved at p≈0/1 and small n (unlike Wald). */
|
|
13
|
+
export declare function wilsonInterval(x: number, n: number, conf?: number): Interval;
|
|
14
|
+
export interface Reliability {
|
|
15
|
+
pHat: number;
|
|
16
|
+
ciLow: number;
|
|
17
|
+
ciHigh: number;
|
|
18
|
+
k: number;
|
|
19
|
+
conf: number;
|
|
20
|
+
}
|
|
21
|
+
export declare function reliability(x: number, n: number, conf?: number): Reliability;
|
|
22
|
+
/** Sequential stop rule: is p reliably above/below threshold yet, or keep sampling? */
|
|
23
|
+
export declare function sequentialDecision(x: number, n: number, threshold: number, conf?: number): Decision;
|
|
24
|
+
/** Two-sided Fisher's exact test on [[a,b],[c,d]] — exact, for small samples. */
|
|
25
|
+
export declare function fisherExact2x2(a: number, b: number, c: number, d: number): number;
|
|
26
|
+
export interface ImprovementResult {
|
|
27
|
+
pImprove: number;
|
|
28
|
+
deltaMean: number;
|
|
29
|
+
deltaCi: [number, number];
|
|
30
|
+
}
|
|
31
|
+
/** Bayesian Beta-Binomial: P(p_after > p_before) + delta credible interval. */
|
|
32
|
+
export declare function probImprovement(xBefore: number, nBefore: number, xAfter: number, nAfter: number, prior?: number, samples?: number, seed?: number): ImprovementResult;
|
|
33
|
+
/** Benjamini-Hochberg FDR. Returns booleans (true = flag as real). */
|
|
34
|
+
export declare function benjaminiHochberg(pvals: number[], q?: number): boolean[];
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statistical core (TS port of stats_ref/stats.py — the validated reference impl).
|
|
3
|
+
* Skill activation is Bernoulli with unknown p; we estimate from k runs and never
|
|
4
|
+
* report a bare point. See ../../STATS.md.
|
|
5
|
+
*/
|
|
6
|
+
const Z = {
|
|
7
|
+
"0.9": 1.6448536269514722,
|
|
8
|
+
"0.95": 1.959963984540054,
|
|
9
|
+
"0.99": 2.5758293035489004,
|
|
10
|
+
};
|
|
11
|
+
/** Wilson score interval — well-behaved at p≈0/1 and small n (unlike Wald). */
|
|
12
|
+
export function wilsonInterval(x, n, conf = 0.95) {
|
|
13
|
+
if (n === 0)
|
|
14
|
+
return { lo: 0, hi: 1, center: 0.5 };
|
|
15
|
+
const z = Z[String(conf)];
|
|
16
|
+
if (z === undefined) {
|
|
17
|
+
throw new Error(`unsupported confidence ${conf}; use 0.9, 0.95, or 0.99`);
|
|
18
|
+
}
|
|
19
|
+
const p = x / n;
|
|
20
|
+
const denom = 1 + (z * z) / n;
|
|
21
|
+
const center = (p + (z * z) / (2 * n)) / denom;
|
|
22
|
+
const half = (z / denom) * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n));
|
|
23
|
+
return { lo: Math.max(0, center - half), hi: Math.min(1, center + half), center };
|
|
24
|
+
}
|
|
25
|
+
export function reliability(x, n, conf = 0.95) {
|
|
26
|
+
const { lo, hi } = wilsonInterval(x, n, conf);
|
|
27
|
+
return { pHat: n ? x / n : 0, ciLow: lo, ciHigh: hi, k: n, conf };
|
|
28
|
+
}
|
|
29
|
+
/** Sequential stop rule: is p reliably above/below threshold yet, or keep sampling? */
|
|
30
|
+
export function sequentialDecision(x, n, threshold, conf = 0.95) {
|
|
31
|
+
const { lo, hi } = wilsonInterval(x, n, conf);
|
|
32
|
+
if (lo > threshold)
|
|
33
|
+
return "above";
|
|
34
|
+
if (hi < threshold)
|
|
35
|
+
return "below";
|
|
36
|
+
return "undecided";
|
|
37
|
+
}
|
|
38
|
+
// --- lgamma (Lanczos) ---
|
|
39
|
+
const LANCZOS = [
|
|
40
|
+
0.99999999999980993, 676.5203681218851, -1259.1392167224028,
|
|
41
|
+
771.32342877765313, -176.61502916214059, 12.507343278686905,
|
|
42
|
+
-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7,
|
|
43
|
+
];
|
|
44
|
+
function lgamma(z) {
|
|
45
|
+
if (z < 0.5) {
|
|
46
|
+
return Math.log(Math.PI / Math.sin(Math.PI * z)) - lgamma(1 - z);
|
|
47
|
+
}
|
|
48
|
+
z -= 1;
|
|
49
|
+
let a = LANCZOS[0];
|
|
50
|
+
const t = z + 7.5;
|
|
51
|
+
for (let i = 1; i < 9; i++)
|
|
52
|
+
a += LANCZOS[i] / (z + i);
|
|
53
|
+
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(a);
|
|
54
|
+
}
|
|
55
|
+
function hypergeomLogP(a, r1, c1, n) {
|
|
56
|
+
const b = r1 - a, c = c1 - a, d = n - r1 - c1 + a;
|
|
57
|
+
if (Math.min(a, b, c, d) < 0)
|
|
58
|
+
return -Infinity;
|
|
59
|
+
return (lgamma(r1 + 1) - lgamma(a + 1) - lgamma(b + 1) +
|
|
60
|
+
lgamma(n - r1 + 1) - lgamma(c + 1) - lgamma(d + 1) -
|
|
61
|
+
(lgamma(n + 1) - lgamma(c1 + 1) - lgamma(n - c1 + 1)));
|
|
62
|
+
}
|
|
63
|
+
/** Two-sided Fisher's exact test on [[a,b],[c,d]] — exact, for small samples. */
|
|
64
|
+
export function fisherExact2x2(a, b, c, d) {
|
|
65
|
+
const n = a + b + c + d, r1 = a + b, c1 = a + c;
|
|
66
|
+
const pObs = Math.exp(hypergeomLogP(a, r1, c1, n));
|
|
67
|
+
const aMin = Math.max(0, c1 - (n - r1)), aMax = Math.min(r1, c1);
|
|
68
|
+
let total = 0;
|
|
69
|
+
for (let ai = aMin; ai <= aMax; ai++) {
|
|
70
|
+
const pa = Math.exp(hypergeomLogP(ai, r1, c1, n));
|
|
71
|
+
if (pa <= pObs * (1 + 1e-9))
|
|
72
|
+
total += pa;
|
|
73
|
+
}
|
|
74
|
+
return Math.min(1, total);
|
|
75
|
+
}
|
|
76
|
+
// --- seeded RNG + Beta sampling for the Bayesian before/after ---
|
|
77
|
+
function mulberry32(seed) {
|
|
78
|
+
let a = seed >>> 0;
|
|
79
|
+
return () => {
|
|
80
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
81
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
82
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
83
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function gaussian(rng) {
|
|
87
|
+
let u = 0, v = 0;
|
|
88
|
+
while (u === 0)
|
|
89
|
+
u = rng();
|
|
90
|
+
while (v === 0)
|
|
91
|
+
v = rng();
|
|
92
|
+
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
|
93
|
+
}
|
|
94
|
+
function gammaSample(k, rng) {
|
|
95
|
+
if (k < 1)
|
|
96
|
+
return gammaSample(1 + k, rng) * Math.pow(rng(), 1 / k);
|
|
97
|
+
const d = k - 1 / 3, c = 1 / Math.sqrt(9 * d);
|
|
98
|
+
for (;;) {
|
|
99
|
+
let x, v;
|
|
100
|
+
do {
|
|
101
|
+
x = gaussian(rng);
|
|
102
|
+
v = 1 + c * x;
|
|
103
|
+
} while (v <= 0);
|
|
104
|
+
v = v * v * v;
|
|
105
|
+
const u = rng();
|
|
106
|
+
if (u < 1 - 0.0331 * x * x * x * x)
|
|
107
|
+
return d * v;
|
|
108
|
+
if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v)))
|
|
109
|
+
return d * v;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function betaSample(a, b, rng) {
|
|
113
|
+
const ga = gammaSample(a, rng), gb = gammaSample(b, rng);
|
|
114
|
+
return ga / (ga + gb);
|
|
115
|
+
}
|
|
116
|
+
/** Bayesian Beta-Binomial: P(p_after > p_before) + delta credible interval. */
|
|
117
|
+
export function probImprovement(xBefore, nBefore, xAfter, nAfter, prior = 0.5, samples = 40000, seed = 12345) {
|
|
118
|
+
const rng = mulberry32(seed);
|
|
119
|
+
const ab = prior + xBefore, bb = prior + (nBefore - xBefore);
|
|
120
|
+
const aa = prior + xAfter, ba = prior + (nAfter - xAfter);
|
|
121
|
+
let wins = 0, sum = 0;
|
|
122
|
+
const deltas = new Array(samples);
|
|
123
|
+
for (let i = 0; i < samples; i++) {
|
|
124
|
+
const pa = betaSample(aa, ba, rng), pb = betaSample(ab, bb, rng);
|
|
125
|
+
if (pa > pb)
|
|
126
|
+
wins++;
|
|
127
|
+
const d = pa - pb;
|
|
128
|
+
deltas[i] = d;
|
|
129
|
+
sum += d;
|
|
130
|
+
}
|
|
131
|
+
deltas.sort((p, q) => p - q);
|
|
132
|
+
return {
|
|
133
|
+
pImprove: wins / samples,
|
|
134
|
+
deltaMean: sum / samples,
|
|
135
|
+
deltaCi: [deltas[Math.floor(0.025 * samples)], deltas[Math.floor(0.975 * samples)]],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** Benjamini-Hochberg FDR. Returns booleans (true = flag as real). */
|
|
139
|
+
export function benjaminiHochberg(pvals, q = 0.05) {
|
|
140
|
+
const m = pvals.length;
|
|
141
|
+
if (m === 0)
|
|
142
|
+
return [];
|
|
143
|
+
const order = [...pvals.keys()].sort((i, j) => pvals[i] - pvals[j]);
|
|
144
|
+
let maxRank = 0;
|
|
145
|
+
order.forEach((i, idx) => {
|
|
146
|
+
const rank = idx + 1;
|
|
147
|
+
if (pvals[i] <= (rank / m) * q)
|
|
148
|
+
maxRank = rank;
|
|
149
|
+
});
|
|
150
|
+
const rejected = new Array(m).fill(false);
|
|
151
|
+
order.forEach((i, idx) => { if (idx + 1 <= maxRank)
|
|
152
|
+
rejected[i] = true; });
|
|
153
|
+
return rejected;
|
|
154
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Core types. The normalized ProbeResult is the seam every runtime adapter maps to. */
|
|
2
|
+
export interface ProbeResult {
|
|
3
|
+
/** "ok" = the runtime ran and we trust the reading; "error" = infrastructure failure
|
|
4
|
+
* (timeout, auth, crash, empty/unparseable output) — must NOT count as a behavioral outcome. */
|
|
5
|
+
status: "ok" | "error";
|
|
6
|
+
/** name of the skill that activated, or null if none fired. Meaningful only when status==="ok". */
|
|
7
|
+
skillFired: string | null;
|
|
8
|
+
/** tool names in invocation order (for execution-trajectory checks later) */
|
|
9
|
+
trajectory: string[];
|
|
10
|
+
/** USD cost of this single probe, if the runtime reports it */
|
|
11
|
+
cost: number;
|
|
12
|
+
/** failure reason when status==="error" */
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ProbeOpts {
|
|
16
|
+
cwd: string;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
/** model id to pin (runtime-specific format, e.g. "claude-sonnet-4-6" or
|
|
19
|
+
* "anthropic/claude-haiku-4-5"); omitted = the runtime's default. */
|
|
20
|
+
model?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface RuntimeAdapter {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
/** Run one prompt with the library at cwd co-loaded; report what fired. */
|
|
25
|
+
probe(prompt: string, opts: ProbeOpts): Promise<ProbeResult>;
|
|
26
|
+
}
|
|
27
|
+
export interface EvalCase {
|
|
28
|
+
prompt: string;
|
|
29
|
+
/** the skill that SHOULD fire, or null = a decoy that should fire nothing */
|
|
30
|
+
expected: string | null;
|
|
31
|
+
}
|
|
32
|
+
export interface Config {
|
|
33
|
+
runtime: string;
|
|
34
|
+
/** model id to pin for reproducible, fair cross-runtime comparison (optional) */
|
|
35
|
+
model?: string;
|
|
36
|
+
/** project dir that contains .claude/skills/ */
|
|
37
|
+
cwd: string;
|
|
38
|
+
/** max repeats per prompt (sequential stop may finish earlier) */
|
|
39
|
+
k: number;
|
|
40
|
+
/** minimum acceptable trigger reliability (0..1) */
|
|
41
|
+
threshold: number;
|
|
42
|
+
/** confidence level for intervals */
|
|
43
|
+
conf: number;
|
|
44
|
+
cases: EvalCase[];
|
|
45
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"runtime": "claude-code",
|
|
3
|
+
"cwd": "./sample-skills",
|
|
4
|
+
"k": 10,
|
|
5
|
+
"threshold": 0.7,
|
|
6
|
+
"conf": 0.95,
|
|
7
|
+
"cases": [
|
|
8
|
+
{ "prompt": "write a git commit message for my staged changes", "expected": "commit-writer" },
|
|
9
|
+
{ "prompt": "commit my changes with a clear message", "expected": "commit-writer" },
|
|
10
|
+
{ "prompt": "write a pull request description summarizing my commits", "expected": "pr-describer" },
|
|
11
|
+
{ "prompt": "draft a PR description for this branch", "expected": "pr-describer" },
|
|
12
|
+
{ "prompt": "what is the capital of France?", "expected": null }
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: commit-writer
|
|
3
|
+
description: Use this skill when the user wants to commit, write a commit message, or save their staged or current changes with a clear message to git history.
|
|
4
|
+
---
|
|
5
|
+
# Commit Writer
|
|
6
|
+
When asked to write a commit message, produce a concise conventional-commits style message.
|
|
7
|
+
Output ONLY the commit message, nothing else.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pr-describer
|
|
3
|
+
description: Use this skill when the user wants to draft, write, or generate a pull request (PR) description, summary, or overview for a branch, diff, or set of commits to prepare for code review.
|
|
4
|
+
---
|
|
5
|
+
# PR Describer
|
|
6
|
+
When asked to describe a pull request, produce a short PR title and bullet summary.
|
|
7
|
+
Output ONLY the PR description, nothing else.
|