Copilot, all three accepted.
1. Connecting still is not the condition. A few integration tests
deliberately hand-roll a JSON-RPC server because the composable
fixture *cannot* produce what they assert on —
`inspectorClient-malformed-list.test.ts` says so in its own header:
"the SDK's own server cannot emit these shapes: they are exactly what
its types forbid". Same for `listSalvage-era.test.ts`. Real
transport, real client, zero `test-servers/` dependency.
Round 4 already stated the right condition at the top — "does this
test depend on a fixture from `test-servers/`" — and then three
downstream sites went on saying "connects". Those are now scoped to
fixture use, and the hand-rolled exception is stated where the
trigger is defined rather than left to be discovered, with both files
named. Connecting is kept as a hint, since it usually does imply a
fixture, with the instruction to check whether one can express the
case first.
2. "description-strength measurement" was the wrong surface, and this
PR is what made it wrong: a chained prompt is deliberately written so
the target's *description* cannot fire on it, so the number measures
the pointer in the first skill's body and nothing else. Left as-is it
would send the next person tuning a red hand-off at the description.
3. "an 0.8 bar" -> "a 0.8 bar".
Re-measured: 100% / 100% hand-off, 63/63 first-move at 100%, RUNS=5.
Staged by explicit path, not `git add -A` — see ec6895a5.
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>
700 lines
28 KiB
JavaScript
Executable file
700 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 clears 0.8 outright: the committed `testing` ->
|
|
// `test-servers` cases measured 33% (RUNS=3) when `testing` merely classified
|
|
// which work belonged to `test-servers`, and 100%/100% (RUNS=5) once #2247
|
|
// reshaped that into an imperative step. (An intermediate build of that change
|
|
// measured 100%/80%. That is NOT an example of clearing a 0.8 bar — this
|
|
// threshold is compared strictly, so 80% would fail one — but it is worth
|
|
// knowing as the residual noise still present at RUNS=5.) 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);
|
|
});
|
|
}
|