- verify-skills: version-gate the optional `claude plugin validate`. Probing
only for the executable failed the MANDATORY gate for anyone on a CLI older
than 2.1.233, which has no such subcommand and exits nonzero on it. Skips
below the floor; a failure now means a supported validator rejected the skills.
- skill-eval: match the invoked skill structurally. A substring test counted
`{"skill":"not-testing"}` as a hit for `testing` and inflated the rate.
- skill-eval: `runOnce` → exported `runPrompt` with an injectable `spawnFn`, so
the nonzero-exit branch is actually pinned. Reverting it now fails a test
(verified by mutation) rather than leaving the suite green.
- board-ops: filter the item lookups by repository. Issue numbers are repo-local
and these are org boards — #11 really does carry a `servers` card — so an
unfiltered `select` can move or delete the wrong card, or pass two ids.
- issue-triage: the audit's `own()` filter dropped draft cards, which carry no
`.content.repository`, so the "non-Issue on a board" check reported 0 while
the invariant was being violated. Verified against a fixture.
- pr-flow: cut the branch from the base it will target. The blanket "cut from
v2/main" was wrong for v1, whose history is unrelated.
Signed-off-by: cliffhall <cliff@futurescale.com>
257 lines
8.8 KiB
JavaScript
Executable file
257 lines
8.8 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: `verify:skills` cannot detect a
|
|
// well-formed skill whose description simply never matches anything. Nothing in
|
|
// the gate can. 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
|
|
// 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 };
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @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) =>
|
|
invokedSkillNames(payload).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 } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const p = spawnFn(
|
|
"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, 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.
|
|
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 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)).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);
|
|
});
|
|
}
|