inspector/scripts/skill-eval.mjs
cliffhall a344225e23 docs: accept several skill names in skills:eval and re-aim overlapping cases
Adds a multi-name filter to `npm run skills:eval` so a set of related
skills can be iterated together, and makes an unknown name a hard error
rather than an empty run — a typo would otherwise report a green 0/0
that reads exactly like a clean pass of the skill that was meant.

Re-aims two `local-dev` cases and one `test-servers` case that partially
overlapped `AGENTS.md`. Partial overlap oscillates across runs rather
than sitting at zero, so it reads as eval noise instead of a defect in
the case; `docs/skill-authoring.md` now records that failure mode and
the observed scores.

Widens the `test-servers` description to the "Use when …" shape and
points the `testing` skill at it, since anything needing a real server
to run against is a procedure `testing` does not carry. That growth
moves the skill listing from 2,921 to 3,144 of 4,000 characters, so the
figure recorded in AGENTS.md is updated to match.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-01 00:18:43 -04:00

374 lines
14 KiB
JavaScript
Executable file

#!/usr/bin/env node
// Trigger eval for the model-invoked skills in `.claude/skills` (#2163).
//
// `verify:skills` asserts a skill is well-formed and that its cases exist. This
// runs them: each prompt is executed headless in a FRESH session and we assert
// on whether the Skill tool fired with the expected skill.
//
// Seeing a skill fire once tells us Claude found it, not that it finds it
// reliably — the measurement is a hit rate over repeated fresh sessions. It
// needs negative cases too: a skill that fires on everything is a context
// regression, and it is the failure nobody notices by hand. `verify:skills`
// requires both.
//
// NOT part of `validate`, `local:gate`, or CI, and that is a decision rather
// than an omission. A trigger eval cannot be a gate: it spends metered model
// calls on every push, the measurement IS a hit rate over samples so it is
// non-deterministic by construction, and it goes red on a rate limit or an
// expired token — failures unrelated to the diff, whose first consequence is
// that people stop trusting the gate. Run it when adding a skill or editing a
// model-invoked skill's description.
//
// The cost of that split, stated plainly: neither `verify:skills` nor the
// authoritative `claude plugin validate` CI step can detect a well-formed skill
// whose description simply never matches anything. Both check structure.
// Closing that would need a deterministic trigger oracle, which does not exist.
//
// Usage:
// npm run skills:eval # every model-invoked skill's cases
// npm run skills:eval -- testing # one skill's cases
// npm run skills:eval -- testing test-servers # several skills' cases
// RUNS=5 THRESHOLD=0.8 npm run skills:eval
import { spawn } from "node:child_process";
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { claudeSpawnArgs, probeClaudeVersion } from "./lib/claude-cli.mjs";
import {
parseClaudeVersion,
parseSkill,
validateEvalCases,
} from "./lib/skill-manifest.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SKILLS_DIR = path.join(ROOT, ".claude", "skills");
const THRESHOLD = Number(process.env.THRESHOLD ?? 0.8);
const RUNS = Number(process.env.RUNS ?? 3);
const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4);
/** Collect the committed cases for every model-invoked skill (optionally one). */
/**
* Collect the committed cases, and the set of skill names that are OURS.
*
* `only` narrows which cases run, but it must NOT narrow `ours`: a negative
* case means "no skill of this repo fired", so excluding the repo's other
* model-invoked skills from that set would let `skills:eval -- testing` score a
* `local-dev` invocation as foreign and pass a negative case it should fail
* (Copilot). Every model-invoked skill is inspected; `only` is applied when
* enqueueing.
*
* A name that matches no model-invoked skill is a hard error rather than an
* empty run: a typo would otherwise enqueue nothing and the eval would report a
* green 0/0, which reads exactly like a clean pass of the skill you meant.
*
* @param {string | string[] | undefined} only One or more skill names.
* @param {string} [skillsDir]
* @returns {{ cases: object[], ours: Set<string> }}
*/
export function collectCases(only, skillsDir = SKILLS_DIR) {
const wanted =
only === undefined || only === null
? null
: new Set(Array.isArray(only) ? only : [only]);
const cases = [];
const ours = new Set();
for (const dir of readdirSync(skillsDir).sort()) {
const skillFile = path.join(skillsDir, dir, "SKILL.md");
if (
!statSync(path.join(skillsDir, dir)).isDirectory() ||
!existsSync(skillFile)
)
continue;
const skill = parseSkill(dir, readFileSync(skillFile, "utf8"));
// Skipping a BROKEN skill here would let `skills:eval` run the remaining
// cases and exit 0 while omitting the very skill that was just broken —
// reporting a green measurement of a set that quietly shrank (Copilot).
// Only a well-formed, deliberately name-only skill is skipped.
if (skill.errors.length > 0) {
throw new Error(
`${dir}/SKILL.md does not parse (${skill.errors[0]}). Run \`npm run verify:skills\`.`,
);
}
if (!skill.modelInvoked) continue;
ours.add(dir);
const evalsFile = path.join(skillsDir, dir, "evals", "evals.json");
if (!existsSync(evalsFile)) {
throw new Error(
`${dir} is model-invoked but has no evals/evals.json. Run \`npm run verify:skills\`.`,
);
}
let parsed;
try {
parsed = JSON.parse(readFileSync(evalsFile, "utf8"));
} catch (e) {
throw new Error(
`${dir}/evals/evals.json is not valid JSON — ${e.message}`,
);
}
const invalid = validateEvalCases(dir, parsed);
if (invalid.length > 0) {
throw new Error(`${dir}/evals/evals.json: ${invalid.join("; ")}`);
}
if (wanted && !wanted.has(dir)) continue;
for (const c of parsed) cases.push({ ...c, from: dir });
}
if (wanted) {
const unknown = [...wanted].filter((n) => !ours.has(n));
if (unknown.length > 0) {
throw new Error(
`no model-invoked skill named ${unknown.map((n) => `\`${n}\``).join(", ")} — ` +
`known: ${[...ours].sort().join(", ")}`,
);
}
}
return { cases, ours };
}
/**
* Extract the payloads the `Skill` tool was invoked with from a chunk of
* `--output-format stream-json` output.
*
* Pure and separately tested: everything below spawns a real CLI, so the
* parsing and the process-outcome handling are unreachable from the happy path
* of an eval run and would otherwise only ever be exercised by the thing they
* are supposed to measure.
*
* @param {string} text One or more newline-delimited JSON events. A trailing
* partial line is ignored, so this can be fed incrementally.
* @returns {{ invoked: Set<string>, rest: string, result: string | null }}
*/
export function collectSkillInvocations(text) {
const lines = text.split("\n");
const rest = lines.pop() ?? "";
const invoked = new Set();
let result = null;
for (const line of lines) {
if (!line.trim()) continue;
let evt;
try {
evt = JSON.parse(line);
} catch {
// A malformed line is noise from the CLI, not an observation.
continue;
}
if (evt?.type === "result") result = evt.subtype ?? null;
if (evt?.type !== "assistant") continue;
for (const block of evt.message?.content ?? []) {
if (block?.type !== "tool_use" || block.name !== "Skill") continue;
// Don't assume the input field's name — match on the whole payload.
invoked.add(JSON.stringify(block.input ?? {}));
}
}
return { invoked, rest, result };
}
/**
* Terminal `result` subtypes that mean the session ran to a real conclusion.
*
* `error_max_turns` is a SUCCESSFUL observation here, not a failure: with
* `--max-turns 1`, a run in which the model invokes a skill necessarily hits
* the limit and the CLI exits 1 — so treating a nonzero exit as failure would
* reject exactly the runs the eval is trying to count. Verified against the
* CLI: a firing prompt ends `{subtype: "error_max_turns", num_turns: 2}`.
*/
const CONCLUSIVE_RESULTS = new Set(["success", "error_max_turns"]);
/**
* Whether a finished run produced a usable observation.
*
* The failure that matters is the opposite one: an auth error, a rate limit or
* a missing CLI observes NOTHING, and counting that as "no skill invoked"
* passes every negative case and reads as a trigger miss on every positive one,
* so a run that never happened comes back as a plausible hit rate (Copilot).
* Classifying on the terminal event rather than the exit code separates the two.
*
* @param {{ result: string | null, code: number | null }} outcome
* @returns {string | null} A reason to reject, or null if the run is usable.
*/
export function runRejection({ result, code }) {
if (result === null) {
return `produced no terminal \`result\` event (exit ${code})`;
}
if (!CONCLUSIVE_RESULTS.has(result)) {
return `ended \`${result}\` (exit ${code})`;
}
return null;
}
/**
* The skill names a `Skill` tool_use payload actually asked for.
*
* Structural rather than substring: matching `"testing"` against the raw JSON
* counts `{"skill":"not-testing"}` as a hit and inflates the measured rate
* (Copilot). The field name is still not assumed — every string value in the
* payload is a candidate, compared by equality.
*
* @param {string} payload JSON text as recorded by `collectSkillInvocations`.
* @returns {string[]}
*/
export function invokedSkillNames(payload) {
let parsed;
try {
parsed = JSON.parse(payload);
} catch {
return [];
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
return [];
return Object.values(parsed).filter((v) => typeof v === "string");
}
/**
* Whether one sample satisfies a case.
*
* A negative case asserts that no skill **of this repo's** fired. Asserting
* that nothing fired at all would fail for a contributor who happens to have an
* unrelated skill in `~/.claude/skills`, or when a bundled skill matches — a
* false failure about someone else's environment rather than about these
* skills (Copilot).
*
* @param {string | null} expect Skill name, or null for a negative case.
* @param {Set<string>} invoked
* @param {Set<string> | null} [ours] Repo skill names. Null counts any skill.
*/
export function sampleHit(expect, invoked, ours = null) {
const names = [...invoked].flatMap(invokedSkillNames);
if (expect === null) {
return ours === null ? names.length === 0 : !names.some((n) => ours.has(n));
}
return names.includes(expect);
}
/**
* Drive one fresh session and return the payloads the `Skill` tool was called
* with.
*
* `spawnFn` is injectable so the stream handling and — more importantly — the
* exit handling are testable. Without a seam here, changing the nonzero branch
* back to resolving an empty set would leave every test green while the eval
* silently reported a plausible hit rate for runs that never happened (Copilot).
*
* @param {string} prompt
* @param {{ spawnFn?: typeof spawn, cwd?: string }} [opts]
* @returns {Promise<Set<string>>}
*/
export function runPrompt(
prompt,
{ spawnFn = spawn, cwd = ROOT, platform = process.platform } = {},
) {
return new Promise((resolve, reject) => {
// The prompt goes in on STDIN, not in argv. `claude -p` with piped stdin
// reads the prompt from it (verified), and that removes two problems at
// once: on Windows the CLI is a `.cmd` shim, so it can only be started
// through a shell, and `cmd.exe` would re-parse any prompt containing a
// metacharacter as syntax (Copilot). It also keeps the prompt out of the
// process table.
const { command, args, options } = claudeSpawnArgs(
[
"-p",
"--output-format",
"stream-json",
"--verbose",
"--max-turns",
"1",
// Keep the run read-only. A skill may inject `!`-prefixed shell commands
// on load, and those run BEFORE its content reaches the model.
"--disallowedTools",
"Bash,Write,Edit,NotebookEdit",
],
{ cwd, stdio: ["pipe", "pipe", "inherit"] },
platform,
);
const p = spawnFn(command, args, options);
let buf = "";
const invoked = new Set();
let result = null;
p.stdout.on("data", (chunk) => {
const parsed = collectSkillInvocations(buf + chunk.toString());
buf = parsed.rest;
for (const payload of parsed.invoked) invoked.add(payload);
if (parsed.result !== null) result = parsed.result;
});
p.on("error", reject);
p.on("close", (code) => {
const rejection = runRejection({ result, code });
if (rejection !== null) {
reject(new Error(`\`claude -p\` ${rejection} for prompt: ${prompt}`));
return;
}
resolve(invoked);
});
p.stdin?.end(prompt);
});
}
async function pool(items, n, fn) {
const out = new Array(items.length);
let i = 0;
await Promise.all(
Array.from({ length: Math.min(n, items.length) }, async () => {
while (i < items.length) {
const idx = i++;
out[idx] = await fn(items[idx]);
}
}),
);
return out;
}
async function main() {
if (probeClaudeVersion(parseClaudeVersion) === null) {
console.error(
"skills:eval — no usable `claude` CLI on PATH. This eval needs one.",
);
process.exit(1);
}
const { cases, ours } = collectCases(
process.argv.length > 2 ? process.argv.slice(2) : undefined,
);
if (cases.length === 0) {
console.error("skills:eval — no cases found.");
process.exit(1);
}
const jobs = cases.flatMap((c) => Array.from({ length: RUNS }, () => c));
const results = await pool(jobs, CONCURRENCY, async (c) => ({
c,
invoked: await runPrompt(c.prompt),
}));
let failed = 0;
for (const c of cases) {
const mine = results.filter((r) => r.c === c);
const passes = mine.filter((r) =>
sampleHit(c.expect, r.invoked, ours),
).length;
const rate = passes / mine.length;
const ok = rate >= THRESHOLD;
if (!ok) failed++;
const label = c.expect ?? "(no skill)";
console.log(
`${ok ? "PASS" : "FAIL"} ${(rate * 100).toFixed(0).padStart(3)}% ${label.padEnd(20)} ${c.prompt}`,
);
}
console.log(
`\n${cases.length - failed}/${cases.length} cases at or above ${THRESHOLD * 100}%.`,
);
process.exit(failed > 0 ? 1 : 0);
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main().catch((e) => {
console.error(e.message ?? e);
process.exit(1);
});
}