skill-probe 0.8.0 → 0.10.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/dist/doctor.js CHANGED
@@ -4,15 +4,17 @@
4
4
  *
5
5
  * Exit: 0 = healthy, 1 = warnings only, 2 = at least one hard failure. */
6
6
  import { spawnSync } from "node:child_process";
7
- import { existsSync, readdirSync, readFileSync } from "node:fs";
7
+ import { existsSync } from "node:fs";
8
8
  import { join, resolve } from "node:path";
9
+ import { probeSettings } from "./types.js";
9
10
  import { loadConfig } from "./config.js";
10
- import { wilsonInterval } from "./stats.js";
11
- import { kToCertify } from "./report.js";
11
+ import { kToCertify } from "./stats.js";
12
12
  import { getAdapter } from "./adapters/index.js";
13
- const NAME_RE = /^name:\s*(.*)$/m;
14
- const DESC_RE = /^description:\s*(.*)$/m;
13
+ import { readSkillFile, skillFolders, skillsRoot } from "./skills.js";
14
+ import { loadProblems } from "./orchestrator.js";
15
+ import { withSandbox } from "./sandbox.js";
15
16
  const MIN_NODE = { major: 22, minor: 18 }; // package.json engines: ">=22.18"
17
+ const HARMLESS_PROMPT = "Reply with exactly: ok";
16
18
  function realCliInstalled(cmd) {
17
19
  const r = spawnSync(cmd, ["--version"], { stdio: "ignore", timeout: 15_000 });
18
20
  // ENOENT = not on PATH. Any other result (even a non-zero exit) means the binary exists.
@@ -51,40 +53,35 @@ export async function runDoctor(opts, deps = {}) {
51
53
  const skillsCwd = cfg ? cfg.cwd : resolve(opts.cwd);
52
54
  const runtime = cfg?.runtime ?? opts.runtime ?? "claude-code";
53
55
  // 3-6. Skills directory + each SKILL.md
54
- const skillNames = new Set();
55
- const skillsRoot = join(skillsCwd, ".claude", "skills");
56
- if (!existsSync(skillsRoot)) {
56
+ const library = [];
57
+ const root = skillsRoot(skillsCwd);
58
+ if (!existsSync(root)) {
57
59
  add("fail", `no .claude/skills/ directory under ${skillsCwd}`);
58
60
  }
59
61
  else {
60
- const dirs = readdirSync(skillsRoot, { withFileTypes: true }).filter((d) => d.isDirectory());
62
+ const dirs = skillFolders(skillsCwd);
61
63
  if (dirs.length === 0) {
62
64
  add("fail", `.claude/skills/ exists but contains no skill folders`);
63
65
  }
64
- let valid = 0;
65
- for (const d of dirs) {
66
- const md = join(skillsRoot, d.name, "SKILL.md");
66
+ for (const name of dirs) {
67
+ const md = join(root, name, "SKILL.md");
67
68
  if (!existsSync(md)) {
68
- add("fail", `skill "${d.name}" missing SKILL.md`);
69
+ add("fail", `skill "${name}" missing SKILL.md`);
69
70
  continue;
70
71
  }
71
- const text = readFileSync(md, "utf8");
72
- const name = NAME_RE.exec(text)?.[1]?.trim() ?? "";
73
- const desc = DESC_RE.exec(text)?.[1]?.trim() ?? "";
74
- if (!name)
75
- add("fail", `skill "${d.name}" missing name:`);
76
- else if (name !== d.name)
77
- add("warn", `skill "${d.name}" has name: "${name}" (doesn't match folder name)`);
78
- if (!desc)
79
- add("fail", `skill "${d.name}" missing description:`);
80
- else if (desc === "|" || desc === ">")
81
- add("warn", `skill "${d.name}" uses a multi-line description block — a single-line description triggers more reliably`);
82
- skillNames.add(d.name);
83
- valid++;
72
+ const s = readSkillFile(md, name);
73
+ if (!s.frontmatterName)
74
+ add("fail", `skill "${name}" missing name: (in a --- frontmatter block)`);
75
+ else if (s.frontmatterName !== name)
76
+ add("warn", `skill "${name}" has name: "${s.frontmatterName}" (doesn't match folder name)`);
77
+ if (!s.description)
78
+ add("fail", `skill "${name}" missing description: (in a --- frontmatter block)`);
79
+ library.push(s);
84
80
  }
85
- if (valid > 0)
86
- add("pass", `found .claude/skills/ with ${valid} skill${valid === 1 ? "" : "s"}`);
81
+ if (library.length > 0)
82
+ add("pass", `found .claude/skills/ with ${library.length} skill${library.length === 1 ? "" : "s"}`);
87
83
  }
84
+ const skillNames = new Set(library.map((s) => s.name));
88
85
  // 7-9. Config-dependent checks
89
86
  if (cfg) {
90
87
  const missing = cfg.cases
@@ -98,13 +95,18 @@ export async function runDoctor(opts, deps = {}) {
98
95
  add("warn", `config has only decoy cases — no skill is being verified to fire`);
99
96
  }
100
97
  // a strict threshold at a small k can be mathematically impossible to certify
101
- if (wilsonInterval(cfg.k, cfg.k, cfg.conf).lo <= cfg.threshold) {
98
+ const need = kToCertify(cfg.threshold, cfg.conf);
99
+ if (need > cfg.k) {
102
100
  add("warn", `threshold ${Math.round(cfg.threshold * 100)}% with k=${cfg.k} cannot certify a pass ` +
103
- `even on a perfect run — need ~k=${kToCertify(cfg.threshold, cfg.conf)} (or lower the threshold)`);
101
+ `even on a perfect run — need k≥${need} (or lower the threshold)`);
102
+ }
103
+ if (cfg.userConfig) {
104
+ add("warn", `userConfig is on: probes load your personal runtime config, so results won't reproduce in CI`);
104
105
  }
105
106
  }
106
107
  // 10-11. Runtime + a harmless live probe (verifies the CLI is installed AND authed)
107
- add("info", `runtime: ${runtime} model: ${cfg?.model ?? opts.model ?? "(runtime default)"}`);
108
+ const model = cfg?.model ?? opts.model;
109
+ add("info", `runtime: ${runtime} model: ${model ?? "(runtime default)"}`);
108
110
  const known = runtime === "claude-code" || runtime === "opencode";
109
111
  const bin = runtime === "claude-code" ? "claude" : runtime === "opencode" ? "opencode" : null;
110
112
  let cliOk = false;
@@ -122,14 +124,46 @@ export async function runDoctor(opts, deps = {}) {
122
124
  add("info", `probe skipped (--skip-probe)`);
123
125
  }
124
126
  else if (known && cliOk) {
125
- const r = await probe(runtime, "Reply with exactly: ok", {
126
- cwd: skillsCwd, timeoutMs: 60_000,
127
- ...(cfg?.model ?? opts.model ? { model: (cfg?.model ?? opts.model) } : {}),
128
- });
129
- if (r.status === "ok")
130
- add("pass", `${runtime} probe succeeded (CLI is authenticated)`);
131
- else
127
+ const settings = {
128
+ ...(cfg ? probeSettings(cfg) : {}),
129
+ ...(model ? { model } : {}),
130
+ ...(opts.userConfig ? { userConfig: true } : {}),
131
+ timeoutMs: 60_000,
132
+ };
133
+ const r = await probe(runtime, HARMLESS_PROMPT, { cwd: skillsCwd, ...settings });
134
+ if (r.status !== "ok") {
132
135
  add("fail", `${runtime} probe failed — check auth/login: ${r.error ?? "unknown error"}`);
136
+ }
137
+ else {
138
+ add("pass", `${runtime} probe succeeded (CLI is authenticated)` +
139
+ (r.env?.runtimeVersion ? ` — ${bin} ${r.env.runtimeVersion}` : "") +
140
+ (r.env?.model && !model ? `, default model ${r.env.model}` : ""));
141
+ // what the runtime ACTUALLY loads (only runtimes that report it)
142
+ if (r.env?.loadedSkills && library.length) {
143
+ const { missing } = loadProblems(r.env, library.map((s) => s.name), library);
144
+ if (missing.length) {
145
+ add("fail", `the runtime did not load: ${missing.join(", ")} — it rejected or ignored these ` +
146
+ `SKILL.md files, so they can never fire`);
147
+ }
148
+ else {
149
+ add("pass", `the runtime loads all ${library.length} library skill${library.length === 1 ? "" : "s"}`);
150
+ }
151
+ // built-ins load in every probe; a library skill sharing a built-in's name is ambiguous
152
+ const empty = await withSandbox(skillsCwd, [], (sb) => probe(runtime, HARMLESS_PROMPT, { cwd: sb.cwd, ...settings }));
153
+ const builtins = empty.status === "ok" ? empty.env?.loadedSkills ?? [] : [];
154
+ const clash = library.filter((s) => builtins.includes(s.name) ||
155
+ (s.frontmatterName !== undefined && builtins.includes(s.frontmatterName)));
156
+ for (const s of clash) {
157
+ add("warn", `skill "${s.name}" has the same name as a ${runtime} built-in skill — the two compete ` +
158
+ `for the same trigger; consider renaming it`);
159
+ }
160
+ if (builtins.length) {
161
+ add("info", `${builtins.length} ${runtime} built-in skill(s) load alongside your library in every probe ` +
162
+ `(${builtins.slice(0, 8).join(", ")}${builtins.length > 8 ? ", …" : ""}) — they are part of the real ` +
163
+ `environment and can steal triggers`);
164
+ }
165
+ }
166
+ }
133
167
  }
134
168
  const hasFail = checks.some((c) => c.status === "fail");
135
169
  const hasWarn = checks.some((c) => c.status === "warn");
package/dist/eval.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /** The audit: run every case co-loaded, compute reliability (with CI), detect trigger-theft,
2
2
  * and keep infrastructure failure distinct from behavioral failure (finding #1). */
3
- import type { Config } from "./types.ts";
3
+ import { type Config } from "./types.ts";
4
4
  import { reliability } from "./stats.ts";
5
- import { type RunStats } from "./orchestrator.ts";
5
+ import { type Budget, type RunStats } from "./orchestrator.ts";
6
+ import { type RunManifest } from "./manifest.ts";
6
7
  /** Four honest states. `error` = the runtime failed enough that the measurement is untrustworthy. */
7
8
  export type Verdict = "pass" | "fail" | "inconclusive" | "error";
8
9
  export interface CaseResult {
@@ -10,10 +11,14 @@ export interface CaseResult {
10
11
  expected: string | null;
11
12
  stats: RunStats;
12
13
  reliability: ReturnType<typeof reliability>;
13
- /** non-expected, non-None skills that fired = interference / trigger-theft */
14
+ /** non-expected, non-None skills that fired = interference / trigger-theft. A warning unless
15
+ * failOnTheft is set: one stray firing out of k is not statistical evidence of a problem. */
14
16
  theft: string[];
15
17
  decision: "above" | "below" | "undecided";
16
18
  verdict: Verdict;
19
+ /** the runtime reported its loaded skills and the expected skill wasn't among them — it can
20
+ * never fire (usually a SKILL.md the runtime rejected) */
21
+ notLoaded?: boolean;
17
22
  }
18
23
  export interface AuditResult {
19
24
  runtime: string;
@@ -27,8 +32,12 @@ export interface AuditResult {
27
32
  inconclusive: number;
28
33
  error: number;
29
34
  };
30
- /** 0 = all pass; 1 = a behavioral failure/theft; 2 = inconclusive or infrastructure error */
35
+ /** 0 = all pass; 1 = a behavioral failure; 2 = inconclusive or infrastructure error */
31
36
  exitCode: 0 | 1 | 2;
37
+ /** who/what/when of this run — makes reports citable and baselines comparable */
38
+ manifest: RunManifest;
39
+ /** run-level problems worth reading before the verdicts (unloaded skills, personal config…) */
40
+ warnings: string[];
32
41
  }
33
42
  export interface ProgressEvent {
34
43
  caseIndex: number;
@@ -37,4 +46,10 @@ export interface ProgressEvent {
37
46
  validN: number;
38
47
  maxK: number;
39
48
  }
40
- export declare function runAudit(cfg: Config, onProgress?: (e: ProgressEvent) => void): Promise<AuditResult>;
49
+ export interface RunOpts {
50
+ onProgress?: (e: ProgressEvent) => void;
51
+ /** cases probed in parallel (default 1) */
52
+ concurrency?: number;
53
+ budget?: Budget;
54
+ }
55
+ export declare function runAudit(cfg: Config, optsOrProgress?: RunOpts | ((e: ProgressEvent) => void)): Promise<AuditResult>;
package/dist/eval.js CHANGED
@@ -1,12 +1,17 @@
1
- import { reliability, sequentialDecision } from "./stats.js";
2
- import { measure } from "./orchestrator.js";
1
+ /** The audit: run every case co-loaded, compute reliability (with CI), detect trigger-theft,
2
+ * and keep infrastructure failure distinct from behavioral failure (finding #1). */
3
+ import { probeSettings } from "./types.js";
4
+ import { reliability, planDecision, sequentialPlan } from "./stats.js";
5
+ import { measure, mapPool, infraUntrustworthy, loadProblems, extraSkills, } from "./orchestrator.js";
3
6
  import { getAdapter } from "./adapters/index.js";
4
- function verdictFor(stats, decision, theft) {
7
+ import { buildManifest, buildEnvironment, capabilityWarnings } from "./manifest.js";
8
+ import { listSkills } from "./skills.js";
9
+ function verdictFor(stats, decision, theft, failOnTheft) {
5
10
  // `error` means INFRASTRUCTURE failures dominate — untrustworthy. It is NOT about small k:
6
11
  // 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))
12
+ if (infraUntrustworthy(stats))
8
13
  return "error";
9
- if (theft.length > 0)
14
+ if (failOnTheft && theft.length > 0)
10
15
  return "fail";
11
16
  if (decision === "above")
12
17
  return "pass";
@@ -14,34 +19,48 @@ function verdictFor(stats, decision, theft) {
14
19
  return "fail";
15
20
  return "inconclusive"; // includes "k too small to decide" — report says how many runs are needed
16
21
  }
17
- export async function runAudit(cfg, onProgress) {
22
+ /** The library at cfg.cwd, or null when there's no readable skills dir (load checks are skipped). */
23
+ function tryListSkills(cwd) {
24
+ try {
25
+ return listSkills(cwd);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export async function runAudit(cfg, optsOrProgress = {}) {
32
+ const opts = typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress;
18
33
  const adapter = getAdapter(cfg.runtime);
19
- const cases = [];
20
- let totalCost = 0;
21
- for (let ci = 0; ci < cfg.cases.length; ci++) {
22
- const c = cfg.cases[ci];
34
+ const library = tryListSkills(cfg.cwd);
35
+ const plan = sequentialPlan(cfg.k, cfg.threshold, cfg.conf);
36
+ const settings = probeSettings(cfg);
37
+ const onProgress = opts.onProgress;
38
+ const cases = await mapPool(cfg.cases, opts.concurrency ?? 1, async (c, ci) => {
23
39
  const stats = await measure(adapter, c.prompt, c.expected, {
24
- cwd: cfg.cwd, maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf,
25
- ...(cfg.model ? { model: cfg.model } : {}),
40
+ cwd: cfg.cwd, maxK: cfg.k, threshold: cfg.threshold, conf: cfg.conf, ...settings,
41
+ ...(opts.budget ? { budget: opts.budget } : {}),
26
42
  ...(onProgress ? {
27
43
  onProbe: (validN, maxK) => onProgress({
28
44
  caseIndex: ci + 1, caseTotal: cfg.cases.length, prompt: c.prompt, validN, maxK,
29
45
  }),
30
46
  } : {}),
31
47
  });
32
- totalCost += stats.totalCost;
33
48
  const rel = reliability(stats.hits, stats.n, cfg.conf);
34
49
  const want = c.expected ?? "None";
35
50
  const theft = Object.keys(stats.dist).filter((s) => s !== want && s !== "None");
36
- const decision = sequentialDecision(stats.hits, stats.n, cfg.threshold, cfg.conf);
37
- cases.push({
51
+ const decision = planDecision(plan, stats.hits, stats.n);
52
+ const notLoaded = library !== null && c.expected !== null &&
53
+ loadProblems(stats.env, [c.expected], library.filter((s) => s.name === c.expected)).missing.length > 0;
54
+ return {
38
55
  prompt: c.prompt, expected: c.expected, stats, reliability: rel,
39
- theft, decision, verdict: verdictFor(stats, decision, theft),
40
- });
41
- }
42
- // NOTE: Benjamini-Hochberg multiplicity control over per-case exact-test p-values lands in M2,
43
- // once each case carries a real p-value rather than a CI decision. The functions are tested
44
- // in stats.ts but are NOT yet wired into this audit path.
56
+ theft, decision, verdict: verdictFor(stats, decision, theft, Boolean(cfg.failOnTheft)),
57
+ ...(notLoaded ? { notLoaded } : {}),
58
+ };
59
+ });
60
+ // NOTE: per-case verdicts here are sequential tests vs the threshold, each with its own exact
61
+ // error bound — not a family of p-values to BH-correct. Where families of Fisher tests exist
62
+ // (context, ablation, baseline comparison) the correction is applied.
63
+ const totalCost = cases.reduce((s, c) => s + c.stats.totalCost, 0);
45
64
  const counts = {
46
65
  pass: cases.filter((c) => c.verdict === "pass").length,
47
66
  fail: cases.filter((c) => c.verdict === "fail").length,
@@ -49,8 +68,24 @@ export async function runAudit(cfg, onProgress) {
49
68
  error: cases.filter((c) => c.verdict === "error").length,
50
69
  };
51
70
  const exitCode = counts.fail > 0 ? 1 : (counts.error > 0 || counts.inconclusive > 0) ? 2 : 0;
71
+ const env = cases.find((c) => c.stats.env)?.stats.env;
72
+ const warnings = capabilityWarnings(cfg, adapter);
73
+ const unloaded = [...new Set(cases.filter((c) => c.notLoaded).map((c) => c.expected))];
74
+ if (unloaded.length) {
75
+ warnings.push(`the runtime did not load: ${unloaded.join(", ")} — these can never fire ` +
76
+ `(check each SKILL.md's frontmatter; run \`skill-probe doctor\`)`);
77
+ }
78
+ if (cfg.userConfig) {
79
+ warnings.push(`probes loaded your personal runtime config (--user-config): results reflect this ` +
80
+ `machine and won't reproduce in CI`);
81
+ }
82
+ if (cases.some((c) => c.stats.budgetExhausted)) {
83
+ warnings.push(`--max-cost budget reached — cases cut short are inconclusive (error if they never started), never a pass`);
84
+ }
52
85
  return {
53
86
  runtime: cfg.runtime, model: cfg.model ?? "(runtime default)",
54
87
  threshold: cfg.threshold, cases, totalCost, counts, exitCode,
88
+ manifest: buildManifest("audit", cfg, buildEnvironment(cfg, env, library ? extraSkills(env, library) : undefined, adapter)),
89
+ warnings,
55
90
  };
56
91
  }
package/dist/fix.d.ts CHANGED
@@ -1,9 +1,6 @@
1
- import type { Config } from "./types.ts";
1
+ import { type Config } from "./types.ts";
2
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;
3
+ import { type Budget } from "./orchestrator.ts";
7
4
  export interface Sibling {
8
5
  name: string;
9
6
  desc: string;
@@ -22,14 +19,20 @@ export interface FixResult {
22
19
  errors: number;
23
20
  applied: boolean;
24
21
  applyBar: number;
25
- /** path of the kept backup (only when applied); null when reverted (no change made) */
22
+ /** path of the kept backup (only when applied); null when nothing was written */
26
23
  backupPath: string | null;
24
+ /** why the rewrite wasn't tested or applied, beyond the statistics */
25
+ note?: string;
26
+ totalCost: number;
27
27
  }
28
28
  export interface FixOpts {
29
29
  skill: string;
30
30
  k: number;
31
31
  applyBar: number;
32
32
  model?: string;
33
+ /** paired runs in flight at once (each pair is 2 probes; default 1) */
34
+ concurrency?: number;
35
+ budget?: Budget;
33
36
  }
34
37
  export interface FixDeps {
35
38
  rewrite?: (skill: string, cur: string, prompts: string[], siblings: Sibling[], model?: string) => Promise<string>;
package/dist/fix.js CHANGED
@@ -1,31 +1,21 @@
1
1
  /** `skill-probe fix` — rewrite a failing skill's description, then PROVE the lift is real with
2
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.
3
+ * - PAIRED before/after: two identical sandboxes of the library, one with the old description and
4
+ * one with the new; each pair of probes runs at the same moment, so session/model drift hits
5
+ * both arms equally.
5
6
  * - 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";
7
+ * - The real SKILL.md is never touched during the experiment (an interrupted run can't leave it
8
+ * half-edited). The new description is written once, at the end, only if P(improvement) >= bar
9
+ * AND the effect is positive AND the file hasn't changed on disk since the run started — with
10
+ * the exact original snapshotted to a timestamped backup first. */
11
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, rmSync } from "node:fs";
12
+ import { probeSettings } from "./types.js";
10
13
  import { getAdapter } from "./adapters/index.js";
11
14
  import { probImprovement, reliability } from "./stats.js";
12
15
  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
- }
16
+ import { mapPool } from "./orchestrator.js";
17
+ import { listSkills, parseFrontmatter, replaceDescription, normalizeDescription, skillFile } from "./skills.js";
18
+ import { buildSandbox } from "./sandbox.js";
29
19
  /** Unique per-run snapshot path, never clobbering a prior run's backup. */
30
20
  function backupPathFor(file) {
31
21
  const base = `${file}.bak.${Date.now()}`;
@@ -49,63 +39,85 @@ export async function defaultRewrite(skill, cur, prompts, siblings, model) {
49
39
  }
50
40
  export async function runFix(cfg, opts, deps = {}) {
51
41
  const adapter = getAdapter(cfg.runtime);
52
- const file = skillFile(cfg, opts.skill);
42
+ const file = skillFile(cfg.cwd, opts.skill);
53
43
  if (!existsSync(file))
54
44
  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")) }));
45
+ const library = listSkills(cfg.cwd);
46
+ const siblings = library
47
+ .filter((s) => s.name !== opts.skill)
48
+ .map((s) => ({ name: s.name, desc: s.description }));
59
49
  const prompts = cfg.cases.filter((c) => c.expected === opts.skill).map((c) => c.prompt);
60
50
  if (prompts.length === 0)
61
51
  throw new Error(`no cases in the config expect skill '${opts.skill}'`);
62
- const oldDesc = readDesc(file);
52
+ const original = readFileSync(file, "utf8");
53
+ const fm = parseFrontmatter(original);
54
+ if (!fm.ok)
55
+ throw new Error(`${file} has no \`---\` frontmatter block — can't rewrite its description`);
56
+ const oldDesc = normalizeDescription(fm.fields["description"] ?? "");
57
+ replaceDescription(original, oldDesc || "x"); // throws now (before paying for a rewrite) if it can't be rewritten
63
58
  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 } : {}) });
59
+ const newDesc = normalizeDescription(await rewrite(opts.skill, oldDesc, prompts, siblings, opts.model));
60
+ const base = {
61
+ skill: opts.skill, oldDesc, newDesc, applyBar: opts.applyBar,
62
+ before: reliability(0, 0), after: reliability(0, 0),
63
+ pImprove: 0, deltaMean: 0, deltaCi: [0, 0],
64
+ n: 0, errors: 0, applied: false, backupPath: null, totalCost: 0,
71
65
  };
72
- let beforeHits = 0, afterHits = 0, n = 0, errors = 0;
66
+ if (!newDesc)
67
+ return { ...base, note: "the rewrite came back empty — nothing to test" };
68
+ if (newDesc === oldDesc)
69
+ return { ...base, note: "the rewrite is identical to the current description — nothing to test" };
70
+ const names = library.map((s) => s.name);
71
+ const beforeBox = buildSandbox(cfg.cwd, names); // holds the exact original SKILL.md
72
+ const afterBox = buildSandbox(cfg.cwd, names);
73
+ let beforeHits = 0, afterHits = 0, n = 0, errors = 0, totalCost = 0;
73
74
  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++;
75
+ const afterFile = skillFile(afterBox.cwd, opts.skill);
76
+ rmSync(afterFile, { force: true }); // a fresh file — never write through a link into the real library
77
+ writeFileSync(afterFile, replaceDescription(original, newDesc));
78
+ const settings = probeSettings(cfg);
79
+ const jobs = Array.from({ length: opts.k }, () => prompts).flat();
80
+ await mapPool(jobs, opts.concurrency ?? 1, async (p) => {
81
+ if (opts.budget?.exhausted) {
82
+ errors++;
83
+ return;
87
84
  }
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
- };
85
+ const [rb, ra] = await Promise.all([
86
+ adapter.probe(p, { cwd: beforeBox.cwd, ...settings }),
87
+ adapter.probe(p, { cwd: afterBox.cwd, ...settings }),
88
+ ]);
89
+ totalCost += rb.cost + ra.cost;
90
+ opts.budget?.add(rb.cost + ra.cost);
91
+ if (rb.status === "error" || ra.status === "error") {
92
+ errors++;
93
+ return;
94
+ } // drop incomplete pair
95
+ if (rb.skillFired === opts.skill)
96
+ beforeHits++;
97
+ if (ra.skillFired === opts.skill)
98
+ afterHits++;
99
+ n++;
100
+ });
105
101
  }
106
- catch (e) {
107
- copyFileSync(backupPath, file); // restore exact pre-fix file on any error
108
- unlinkSync(backupPath);
109
- throw e;
102
+ finally {
103
+ beforeBox.cleanup();
104
+ afterBox.cleanup();
110
105
  }
106
+ const imp = probImprovement(beforeHits, n, afterHits, n);
107
+ const result = {
108
+ ...base,
109
+ before: reliability(beforeHits, n), after: reliability(afterHits, n),
110
+ pImprove: imp.pImprove, deltaMean: imp.deltaMean, deltaCi: imp.deltaCi,
111
+ n, errors, totalCost,
112
+ };
113
+ if (!(n > 0 && imp.pImprove >= opts.applyBar && imp.deltaMean > 0))
114
+ return result;
115
+ // commit: only if the file is exactly what we measured against (someone may have edited it)
116
+ if (readFileSync(file, "utf8") !== original) {
117
+ return { ...result, note: `${file} changed on disk during the run — not overwriting it; re-run fix` };
118
+ }
119
+ const backupPath = backupPathFor(file);
120
+ copyFileSync(file, backupPath);
121
+ writeFileSync(file, replaceDescription(original, newDesc));
122
+ return { ...result, applied: true, backupPath };
111
123
  }
package/dist/gen.d.ts CHANGED
@@ -1,9 +1,10 @@
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). */
1
5
  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[];
6
+ import type { SkillInfo } from "./skills.ts";
7
+ export { listSkills, type SkillInfo } from "./skills.ts";
7
8
  export interface GenOpts {
8
9
  perSkill: number;
9
10
  decoys: number;
package/dist/gen.js CHANGED
@@ -1,25 +1,5 @@
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
1
  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
- }
2
+ export { listSkills } from "./skills.js";
23
3
  export async function defaultGenerate(skills, opts) {
24
4
  const list = skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
25
5
  const sys = "You build a TEST SUITE that audits whether an AI coding agent loads the correct "
package/dist/llm.d.ts CHANGED
@@ -13,3 +13,5 @@ export declare function interpretClaudeJson(code: number | null, out: string, er
13
13
  text?: string;
14
14
  error?: string;
15
15
  };
16
+ /** CLI arguments for one completion. Exported for testing. */
17
+ export declare function claudeCliArgs(system: string, user: string, opts: CompleteOpts): string[];