inspector/scripts/skill-eval.mjs
cliffhall 02f094f001 Review round 1: gate the hand-off on connecting, not on tier depth
Copilot, both findings accepted.

1. The trigger was over-broad. "a smoke" and "everything from web
   integration rightwards" routed work to `test-servers` that needs no
   fixture at all: `smoke:launcher` only checks `--help`, `smoke:web`
   asserts the SPA is served, `smoke:web:browser` stops at first paint,
   and Storybook play functions render from fixture props. The condition
   is now "does this exercise MCP behaviour over a transport?", stated
   as such, with both sides named — the smokes that connect
   (`smoke:cli`, `smoke:web:app`, `smoke:web:elicit`, `smoke:web:tabs`)
   and `pack:verify` on one side, the boot-only ones and Storybook on
   the other. Depth in the tier list is explicitly not the boundary.

2. The threshold rationale contradicted the new result. It said an 0.8
   bar would mark a hand-off red "regardless of how strongly the first
   skill points at the second" — which the 100% measurement refutes.
   Both copies (docs and `skill-eval.mjs`) now say what 0.5 actually
   buys: a floor on useful reliability for a noisier second-hop load, so
   the column still separates an adequate pointer from a broken one. The
   value is unchanged.

Re-measured after the narrowing, since a tighter trigger could have
weakened the pointer: 100% / 100% on the hand-off and 63/63 first-move
at 100%, full suite at RUNS=5. Recorded figure updated from the
intermediate 100% / 80%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQVdemwgUhh45yzeaRSLhW
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 16:17:31 -04:00

696 lines
28 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
//
// Two kinds of case, measured and reported separately (#2204). A `expect` case
// is a FIRST-MOVE measurement: one turn, does the model reach for the skill
// before anything else. A `chain` case is a HAND-OFF measurement: many turns,
// does loading skill A actually lead the model to load skill B. The two numbers
// are not comparable — a hand-off is a second-hop load that only happens once
// the run has established it needs one — so they never share a column.
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 {
isChainCase,
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);
// A hand-off is a harder thing to hit than a first move, and what counts as
// acceptable is a separate judgement rather than one inherited from a number
// tuned for the other measurement. 0.5 is the weakest claim worth asserting —
// the pointer is taken more often than not — and it is a floor on useful
// reliability for a SECOND-HOP load, not a claim that 0.8 is unreachable. A
// well-shaped pointer does clear 0.8: the committed `testing` -> `test-servers`
// cases measured 33% (RUNS=3) when `testing` merely classified which work
// belonged to `test-servers`, and 100%/80% (RUNS=5) once #2247 reshaped that
// into an imperative step. What 0.5 buys is a column that still separates an
// adequate pointer from a broken one — a hand-off is a noisier measurement than
// a first move, so a bar set where a STRONG pointer sits would mark both red.
// A reshaped pointer therefore raises the ceiling those cases reach, not the
// floor a *new* hand-off case should be judged against.
//
// It is compared STRICTLY, unlike the first-move threshold. "More often than
// not" is `> 0.5`, and an inclusive compare passes exactly half the samples
// whenever RUNS is even — 2/4 would report a pass the stated criterion does not
// license (Copilot). A consequence worth knowing: a strict bound of 1.0 can
// never be met, so it is rejected below rather than silently failing every case.
const CHAIN_THRESHOLD = Number(process.env.CHAIN_THRESHOLD ?? 0.5);
const RUNS = Number(process.env.RUNS ?? 3);
const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4);
/**
* Turns a hand-off case gets.
*
* `--max-turns 1` is what makes a first-move case a first-move case, so a
* chained case needs a budget wide enough for the run to establish that it
* needs the second skill. #2204 measured the `testing` -> `test-servers`
* hand-off going 9-12 tool calls without reaching it; a budget under that
* cannot distinguish "the hand-off does not fire" from "the run was cut short".
*/
const CHAIN_MAX_TURNS = Number(process.env.CHAIN_MAX_TURNS ?? 14);
/** 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.
*
* Collection is two passes. A hand-off case names other skills, and its links
* are checked against the repo's model-invoked set — which is only complete
* once every directory has been parsed. Validating inside the first pass would
* make the check depend on directory order: `test-servers` sorts before
* `testing`, so a chain through `testing` would be rejected as unknown purely
* because of where the alphabet put it.
*
* @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();
const files = [];
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}`,
);
}
files.push({ dir, parsed });
}
for (const { dir, parsed } of files) {
const invalid = validateEvalCases(dir, parsed, ours);
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.
*
* The invocations come back as an ORDERED array, repeats included, rather than
* a set. A hand-off case asserts that one skill was loaded *after* another, so
* occurrence order is the observation — and collapsing repeats would make a
* run that loaded B, then A, then B again indistinguishable from one that never
* reached B from A (#2204).
*
* Each entry also carries the **assistant event** it came from, and that
* boundary is what makes the order mean something. The model may emit several
* `tool_use` blocks in one message, and it cannot see the first skill's body
* until the message after — so two `Skill` calls in the SAME event are
* concurrent guesses, not a hand-off, however they happen to be ordered inside
* the array. Flattening the stream and reading position alone would score that
* as `A` leading to `B` (Copilot); `chainHit` requires a later turn instead.
*
* @param {string} text One or more newline-delimited JSON events. A trailing
* partial line is ignored, so this can be fed incrementally.
* @param {number} [turnOffset] Assistant events already seen, so a stream fed
* in chunks keeps one monotonic turn count rather than restarting per chunk.
* @returns {{ invoked: {payload: string, turn: number}[], rest: string,
* result: string | null, nextTurn: number }}
*/
export function collectSkillInvocations(text, turnOffset = 0) {
const lines = text.split("\n");
const rest = lines.pop() ?? "";
const invoked = [];
let result = null;
let turn = turnOffset;
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;
// One assistant event is one turn: everything inside it was decided at
// once, before any of its results came back.
turn++;
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.push({ payload: JSON.stringify(block.input ?? {}), turn });
}
}
return { invoked, rest, result, nextTurn: turn };
}
/**
* The skill names one recorded invocation asked for.
*
* @param {{payload: string} | string} entry
* @returns {string[]}
*/
function entryNames(entry) {
return invokedSkillNames(typeof entry === "string" ? entry : entry.payload);
}
/**
* 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).
*
* Turn boundaries are irrelevant here — a first-move case asks only whether a
* skill fired at all — so this reads the names and ignores the rest.
*
* @param {string | null} expect Skill name, or null for a negative case.
* @param {Iterable<{payload: string} | 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(entryNames);
if (expect === null) {
return ours === null ? names.length === 0 : !names.some((n) => ours.has(n));
}
return names.includes(expect);
}
/**
* Whether one sample satisfies a hand-off case.
*
* The chain has to appear as an ordered SUBSEQUENCE of what fired, not as a
* prefix and not as a contiguous run. Two reasons, both of which a stricter
* match gets wrong: the model is free to load an unrelated skill in between,
* and it may well load something before the chain's first link — neither
* changes the fact that A led to B, which is the only claim the case makes.
*
* Every link after the first must land in a **strictly later assistant turn**
* than the one before. Position in the stream is not causation: the model can
* emit several `tool_use` blocks in one message, and it has not seen the first
* skill's body when it does, so two `Skill` calls in the same turn are parallel
* guesses that a flat index would happily score as a hand-off (Copilot). This
* is the whole difference between "B was loaded after A" and "A led to B".
*
* The scan stays greedy, which is still correct under that constraint: taking
* the EARLIEST occurrence of a link can only leave more room for the rest, so
* no later starting point could succeed where the greedy one fails.
*
* Nothing is asserted about foreign skills here, unlike a negative case. A
* hand-off case names exactly what it wants and a contributor's own
* `~/.claude/skills` entry firing alongside it says nothing either way.
*
* @param {string[]} chain Ordered skill names, ending with the owning skill.
* @param {Iterable<{payload: string, turn: number}>} invoked
* @returns {boolean}
*/
export function chainHit(chain, invoked) {
let want = 0;
let prevTurn = -Infinity;
for (const entry of invoked) {
if (!entryNames(entry).includes(chain[want])) continue;
// A link in the same turn as the previous one cannot have been caused by
// it — the model had not seen that skill's body yet.
if (want > 0 && !(entry.turn > prevTurn)) continue;
prevTurn = entry.turn;
want++;
if (want === chain.length) return true;
}
return false;
}
/**
* Score one sample against whichever kind of case it belongs to.
*
* @param {{ expect?: string | null, chain?: string[] }} c
* @param {Iterable<string>} invoked
* @param {Set<string> | null} ours
*/
export function caseHit(c, invoked, ours) {
return isChainCase(c)
? chainHit(c.chain, invoked)
: sampleHit(c.expect, invoked, ours);
}
/**
* The only tools an eval run needs: read the repo, and load a skill.
*
* Enumerating what is AVAILABLE rather than only what is denied is the load-
* bearing half. A deny list cannot bound a 14-turn run, because it only names
* the tools known when it was written: this checkout configures an HTTP
* `mcp-docs` server in `.mcp.json`, and a contributor's own MCP servers and
* plugins add more tools that no list here has ever seen (Copilot). Naming the
* four the harness actually needs closes that by construction.
*
* The list goes to `--tools`, which selects from the built-in set, AND to
* `--allowedTools`, which pre-approves. The distinction matters and cost us a
* round: `--allowedTools` grants permission, it does not filter availability,
* so a tool a user's or a plugin's settings already permit would still have
* been reachable across those 14 turns (Copilot). `--tools` is the restriction;
* `--allowedTools` keeps the four from needing a prompt no headless run can
* answer.
*/
const ALLOWED_TOOLS = ["Read", "Glob", "Grep", "Skill"];
/**
* Tools no eval run may use, first-move or hand-off.
*
* Kept alongside the allow list rather than replaced by it: a deny is
* unconditional, while an allow list governs which tools are pre-approved, so
* the two together are stricter than either. A skill may inject `!`-prefixed
* shell commands on load, and those run BEFORE its content reaches the model —
* so this is what keeps a measurement from having side effects. It matters
* more for a hand-off case than a first-move one: `--max-turns 1` was doing
* much of the containment by itself, and a 14-turn budget removes that
* (#2204). Hence the agentic and network tools too — `Task` would spawn a
* subagent whose own tool policy neither flag reaches.
*
* MCP servers are dropped outright with `--strict-mcp-config` (and no
* `--mcp-config`) rather than named here, since their tool names are not
* knowable from this file. What remains outside all three mechanisms is a
* contributor's own plugin tools; `--bare` would remove those and skills with
* them, which would measure nothing.
*
* The cost is stated rather than hidden: denying `Bash` also changes the path
* a run can take toward the second skill, since investigating a repo by hand
* often starts there. `Read`/`Glob`/`Grep` remain, which is enough to reach a
* hand-off, but a chained rate is a measurement under this policy and not a
* prediction of an unrestricted session.
*/
const DISALLOWED_TOOLS = [
"Bash",
"Write",
"Edit",
"NotebookEdit",
"Task",
"Agent",
"SlashCommand",
"WebFetch",
"WebSearch",
"KillShell",
];
/**
* 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, maxTurns?: number }} [opts]
* @returns {Promise<{payload: string, turn: number}[]>} Skill invocations, in
* the order they fired, each tagged with the assistant turn it came from.
*/
export function runPrompt(
prompt,
{
spawnFn = spawn,
cwd = ROOT,
platform = process.platform,
maxTurns = 1,
} = {},
) {
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",
String(maxTurns),
// Keep the run read-only, across every turn it is given: what the
// harness needs, minus what it must never do, minus every MCP server
// this checkout or the contributor happens to configure.
"--tools",
ALLOWED_TOOLS.join(","),
"--allowedTools",
ALLOWED_TOOLS.join(","),
"--disallowedTools",
DISALLOWED_TOOLS.join(","),
"--strict-mcp-config",
],
{ cwd, stdio: ["pipe", "pipe", "inherit"] },
platform,
);
const p = spawnFn(command, args, options);
let buf = "";
const invoked = [];
let result = null;
// Carried across chunks so the turn count is monotonic over the whole
// stream rather than restarting at each read.
let turnOffset = 0;
p.stdout.on("data", (chunk) => {
const parsed = collectSkillInvocations(
buf + chunk.toString(),
turnOffset,
);
buf = parsed.rest;
turnOffset = parsed.nextTurn;
for (const entry of parsed.invoked) invoked.push(entry);
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;
}
/**
* Whether a measured rate clears its bar.
*
* The comparison differs by case kind, and the difference is the point. A
* first-move threshold is a floor to reach (`>=` 0.8 means four of five). A
* chain threshold states "the pointer is taken more often than not", which is
* strictly `> 0.5` — an inclusive compare would pass 2/4 whenever RUNS is even
* and report a result the stated criterion does not license (Copilot).
*
* @param {number} rate
* @param {number} threshold
* @param {boolean} strict
*/
export function passesThreshold(rate, threshold, strict) {
return strict ? rate > threshold : rate >= threshold;
}
/**
* Render the whole report, and say how many cases fell short.
*
* Extracted and exported so the SEPARATION itself is testable. The acceptance
* criterion of #2204 is that a hand-off rate is never folded into the
* first-move headline, and until this was a function that claim had no
* automated coverage at all: the tests exercised the scorers and the turn
* budget while the reporting — the thing that could silently merge the two
* measurements — lived inside `main` where nothing could reach it (Copilot).
*
* @param {object[]} cases
* @param {{c: object, invoked: Iterable<string>}[]} results One per sample.
* @param {Set<string> | null} ours
* @param {{threshold: number, chainThreshold: number, chainMaxTurns: number}} opts
* @returns {{ lines: string[], failed: number }}
*/
export function formatReport(cases, results, ours, opts) {
const lines = [];
let failed = 0;
const group = (members, heading, threshold, strict) => {
if (members.length === 0) return 0;
lines.push("", heading);
let short = 0;
for (const c of members) {
const mine = results.filter((r) => r.c === c);
const passes = mine.filter((r) => caseHit(c, r.invoked, ours)).length;
const rate = mine.length === 0 ? 0 : passes / mine.length;
const ok = mine.length > 0 && passesThreshold(rate, threshold, strict);
if (!ok) short++;
const label = isChainCase(c)
? c.chain.join(" → ")
: (c.expect ?? "(no skill)");
lines.push(
`${ok ? "PASS" : "FAIL"} ${(rate * 100).toFixed(0).padStart(3)}% ${label.padEnd(26)} ${c.prompt}`,
);
}
return short;
};
const direct = cases.filter((c) => !isChainCase(c));
const chained = cases.filter(isChainCase);
const directShort = group(
direct,
"First move (1 turn)",
opts.threshold,
false,
);
const chainedShort = group(
chained,
`Hand-off (${opts.chainMaxTurns} turns)`,
opts.chainThreshold,
true,
);
failed = directShort + chainedShort;
// Two numbers, never one. A hand-off is a second-hop load over many turns and
// a first-move rate is the model's opening move; summing them would produce a
// figure that describes neither, and a handful of hand-off cases would
// quietly move a headline everyone reads as trigger reliability (#2204).
lines.push("");
if (direct.length > 0) {
lines.push(
`${direct.length - directShort}/${direct.length} first-move cases at or above ${opts.threshold * 100}%.`,
);
}
lines.push(
chained.length === 0
? "No hand-off cases in this selection."
: `${chained.length - chainedShort}/${chained.length} hand-off cases above ${opts.chainThreshold * 100}%.`,
);
return { lines, failed };
}
async function main() {
// The chain bar is strict, so 1.0 cannot be cleared by any run and would fail
// every hand-off case while looking like a trigger problem. NaN and negative
// values are rejected for the same reason from the other side: `Number("abc")`
// is NaN, which fails every comparison and prints an `above NaN%` summary,
// and a negative bar passes every chain unconditionally — both turn an
// advertised knob into a measurement that quietly means nothing (Copilot).
if (
!Number.isFinite(CHAIN_THRESHOLD) ||
CHAIN_THRESHOLD < 0 ||
CHAIN_THRESHOLD >= 1
) {
console.error(
`skills:eval — CHAIN_THRESHOLD must be a number in [0, 1) (got ${process.env.CHAIN_THRESHOLD ?? CHAIN_THRESHOLD}); it is a strict lower bound.`,
);
process.exit(1);
}
if (!Number.isFinite(THRESHOLD) || THRESHOLD < 0 || THRESHOLD > 1) {
// The first-move bar is inclusive, so 1.0 is meetable and allowed; the
// non-finite and negative cases fail the same way as above.
console.error(
`skills:eval — THRESHOLD must be a number in [0, 1] (got ${process.env.THRESHOLD ?? THRESHOLD}).`,
);
process.exit(1);
}
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, {
maxTurns: isChainCase(c) ? CHAIN_MAX_TURNS : 1,
}),
}));
const { lines, failed } = formatReport(cases, results, ours, {
threshold: THRESHOLD,
chainThreshold: CHAIN_THRESHOLD,
chainMaxTurns: CHAIN_MAX_TURNS,
});
for (const line of lines) console.log(line);
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);
});
}