inspector/scripts/skill-eval.mjs
cliffhall a51e62dfa1 chore: address Copilot review round 1
- skill-manifest: the closing `---` fence must occupy a whole line. `---oops`
  was accepted as a terminator, silently truncating the frontmatter and passing
  validation — the exact failure the guard exists to catch. Regression cases
  added (prefix, trailing whitespace, EOF, CRLF).
- skill-eval: reject a nonzero `claude -p` exit instead of resolving it as an
  empty observation. An auth error or rate limit was scoring as "no skill
  invoked", which passes every negative case and reads as a trigger miss on
  every positive one.
- skill-eval: extract `collectSkillInvocations` / `sampleHit` behind a `main()`
  guard and cover them in `skill-eval.test.mjs` — the stream parsing and the
  process-outcome handling are unreachable from an eval run's happy path.
- docs/test-servers: annotate the era on every catalogue row. Six legacy rows
  carried no annotation under a "unless noted, Modern" default, so the doc sent
  readers to the wrong era for the feature they were trying to see.
- docs/quality-gate: `validate` runs four guards, not three; add the
  `verify:skills` row and the two new `test:scripts` suites; fix the `#publishing`
  fragment left dangling by the split.
- issue-create / pr-flow: delegate the card steps to `/board-ops` rather than
  restating its option IDs, which are explicitly unstable.
- issue-triage: the audit counted presence, not cardinality — an issue carrying
  both `v1` and `v2` passed a check that claims "exactly one". Same for the five
  type labels. Also scope the two milestone checks to board #28.
- AGENTS.md: codify the v1 milestone exception. Every milestone is a v2 release
  bucket, so a v1 issue has none to take and the Incoming-milestone invariant
  cannot apply to board #11.
- clients/launcher/README: its pointer promised an explanation of why the cli/tui
  manifests ship, which the extracted guide does not make.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 00:52:45 -04:00

211 lines
7 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: it needs the `claude` CLI and
// real model calls, so it is metered and non-deterministic. Run it when adding
// a skill or editing a model-invoked skill's description.
//
// Usage:
// npm run skills:eval # every model-invoked skill's cases
// npm run skills:eval -- testing # one skill's cases
// RUNS=5 THRESHOLD=0.8 npm run skills:eval
import { spawn, spawnSync } from "node:child_process";
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseSkill } 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). */
function collectCases(only) {
const cases = [];
for (const dir of readdirSync(SKILLS_DIR).sort()) {
if (only && dir !== only) continue;
const skillFile = path.join(SKILLS_DIR, dir, "SKILL.md");
if (
!statSync(path.join(SKILLS_DIR, dir)).isDirectory() ||
!existsSync(skillFile)
)
continue;
const skill = parseSkill(dir, readFileSync(skillFile, "utf8"));
if (skill.errors.length > 0 || !skill.modelInvoked) continue;
const evalsFile = path.join(SKILLS_DIR, dir, "evals", "evals.json");
if (!existsSync(evalsFile)) continue;
for (const c of JSON.parse(readFileSync(evalsFile, "utf8")))
cases.push({ ...c, from: dir });
}
return cases;
}
/**
* 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 }}
*/
export function collectSkillInvocations(text) {
const lines = text.split("\n");
const rest = lines.pop() ?? "";
const invoked = new Set();
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 !== "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 };
}
/**
* Whether one sample satisfies a case.
*
* @param {string | null} expect Skill name, or null for a negative case.
* @param {Set<string>} invoked
*/
export function sampleHit(expect, invoked) {
if (expect === null) return invoked.size === 0;
return [...invoked].some((payload) => payload.includes(expect));
}
/** Returns the payloads the Skill tool was called with in one fresh session. */
function runOnce(prompt) {
return new Promise((resolve, reject) => {
const p = spawn(
"claude",
[
"-p",
prompt,
"--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: ROOT, stdio: ["ignore", "pipe", "inherit"] },
);
let buf = "";
const invoked = new Set();
p.stdout.on("data", (chunk) => {
const parsed = collectSkillInvocations(buf + chunk.toString());
buf = parsed.rest;
for (const payload of parsed.invoked) invoked.add(payload);
});
p.on("error", reject);
p.on("close", (code) => {
// A CLI that failed to run observed NOTHING. Resolving it as an empty set
// would silently pass every negative case and read as a trigger miss on
// every positive one, so an auth error or a rate limit would come back as
// a plausible-looking hit rate (Copilot).
if (code !== 0) {
reject(new Error(`\`claude -p\` exited ${code} for prompt: ${prompt}`));
return;
}
resolve(invoked);
});
});
}
const hit = (invoked, skill) =>
[...invoked].some((payload) => payload.includes(skill));
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 (spawnSync("claude", ["--version"], { stdio: "ignore" }).error) {
console.error(
"skills:eval — the `claude` CLI is not on PATH. This eval needs it.",
);
process.exit(1);
}
const cases = collectCases(process.argv[2]);
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 runOnce(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)).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);
});
}