Files
cliffhallandClaude Opus 5.5 a9cf19e162 feat: coverage and lockstep guards, root validate, per-package CI legs
- verify:format-coverage and verify:typecheck-coverage ported from the
  Inspector, with the Inspector's tsc-program and resolve-node-bin libs
  (+ tests), adapted to npm workspaces via a new lib/workspaces.mjs that
  reads the root workspaces patterns from disk and checks the root
  validate still runs `npm run validate --workspaces`.
- verify:dep-lockstep: every shared toolchain devDependency (typescript,
  vitest, @vitest/coverage-v8, prettier, @types/node) is declared with one
  range everywhere, or once at the root.
- Root validate = validate:guards (root format/lint, the guards,
  test:scripts) + npm run validate --workspaces.
- typescript.yml: each matrix leg runs only its own package's validate;
  the root-guards job runs validate:guards.
- Removes #4863's temporary verify:format-coverage wiring allowance, and
  fixes the four root-lint findings in the skills scripts.

Refs #4864

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-29 00:10:03 -04:00

999 lines
40 KiB
JavaScript

#!/usr/bin/env node
// Trigger eval for the model-invoked skills in `.claude/skills` (inspector#2163).
//
// Ported from the MCP Inspector (#4863); `inspector#N` references are that
// repo's issues and PRs, a bare `#N` is this repo's.
//
// `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 -- pr-flow # one skill's cases
// npm run skills:eval -- pr-flow issue-create # several skills' cases
// RUNS=5 THRESHOLD=0.8 npm run skills:eval
// AGENT=copilot npm run skills:eval # the same cases, driven through GitHub Copilot
//
// Two agents, measured separately (inspector#2397). Some maintainers work with
// the GitHub Copilot CLI, which discovers project skills from `.claude/skills/`
// as well as `.github/skills/` (verified in the Inspector on 1.0.85: `copilot
// skill list` shows all ten of its skills). Discovery is not triggering, though, so the same
// committed cases run through `copilot` when `AGENT=copilot`. One invocation
// measures ONE agent and says which in every heading: a Copilot rate and a
// Claude rate come from different models behind different harnesses, so they
// are never folded into one number, for the same reason first-move and
// hand-off cases are not.
//
// Two kinds of case, measured and reported separately (inspector#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 { cliSpawnArgs, probeCliVersion } 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. One
// well-shaped pointer has cleared 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 inspector#2247
// reshaped that into an imperative step. That is ONE pointer over ONE edit that
// moved wording and placement together, so read it as an existence proof that
// the ceiling is above 0.8 — not as a general property of "well-shaped"
// pointers, and not as grounds for raising this floor (Copilot, inspector#2264). (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. inspector#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);
/** The agent CLIs this eval can drive. `claude` is the default. */
export const AGENTS = ["claude", "copilot"];
const AGENT = process.env.AGENT ?? "claude";
/** 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}`,
{ cause: e },
);
}
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 (inspector#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 };
}
/**
* Extract the skills the Copilot CLI's `skill` tool was asked for, from a chunk
* of `copilot --output-format json` output (inspector#2397).
*
* The same contract as `collectSkillInvocations`, over a different event shape.
* Copilot emits one `assistant.message` per model call, carrying every tool
* request that call made in `data.toolRequests` — so one such event is one
* turn, and two `skill` requests inside it are concurrent guesses rather than a
* hand-off, exactly as for Claude. The terminal event is `{type: "result",
* exitCode}`; it is reported as `success` for exit 0 and `exit_<code>`
* otherwise, so `runRejection` classifies both CLIs with one table.
*
* A request is counted whether or not the tool then succeeded. That matches
* the Claude side, which scores the `tool_use` block, and it is what a trigger
* eval asks: did the model reach for the skill. (Copilot refuses a request
* for a `disable-model-invocation: true` skill with "Skill not found", and
* no case here names such a skill.)
*
* @param {string} text Newline-delimited JSON events; a trailing partial line
* is held back.
* @param {number} [turnOffset]
* @returns {{ invoked: {payload: string, turn: number}[], rest: string,
* result: string | null, nextTurn: number }}
*/
export function collectCopilotSkillInvocations(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 {
continue;
}
if (evt?.type === "result") {
result = evt.exitCode === 0 ? "success" : `exit_${evt.exitCode}`;
}
if (evt?.type !== "assistant.message") continue;
turn++;
for (const req of evt.data?.toolRequests ?? []) {
if (req?.name !== "skill") continue;
invoked.push({ payload: JSON.stringify(req.arguments ?? {}), 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",
// Copilot has no `--max-turns`; `runPrompt` stops it at the budget itself and
// records this. It is the same observation as `error_max_turns` (inspector#2397).
"turn_budget",
]);
/**
* 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
* (inspector#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",
];
/**
* The Copilot CLI's equivalents of the three lists above (inspector#2397).
*
* Its permission model has the same split Claude's does, under other names:
* `--available-tools` decides what the model can SEE ("disables all other
* tools"), while `--allow-tool` / `--deny-tool` only decide approval and
* "do not expose tools that were filtered out" — so availability is the
* restriction here too, and the deny patterns are the unconditional second
* layer. `view`/`glob`/`grep` are Copilot's read tools and `skill` is its
* skill loader, confirmed from a live run's `toolRequests`.
*
* Copilot's deny list takes permission KINDS rather than tool names: `shell`
* (every shell command), `write` (every file-modifying tool) and `url` (every
* URL the shell or web-fetch tools would reach). `--disable-builtin-mcps`
* drops the one MCP server it ships (`github-mcp-server`); it does not read
* this checkout's `.mcp.json`, and any server a contributor configured is
* invisible past `--available-tools` anyway.
*/
const COPILOT_AVAILABLE_TOOLS = ["view", "glob", "grep", "skill"];
const COPILOT_DENIED_KINDS = ["shell", "write", "url"];
/**
* The command line for one headless run of `agent`.
*
* @param {string} agent One of `AGENTS`.
* @param {number} maxTurns
* @returns {string[]}
*/
export function agentArgs(agent, maxTurns) {
if (agent === "copilot") {
return [
// No `-p`: with none, the CLI reads the prompt from piped stdin, which
// keeps it out of argv for the same reasons as the Claude run below.
"--output-format",
"json",
"--available-tools",
COPILOT_AVAILABLE_TOOLS.join(","),
"--allow-tool",
COPILOT_AVAILABLE_TOOLS.join(","),
...COPILOT_DENIED_KINDS.flatMap((kind) => ["--deny-tool", kind]),
"--disable-builtin-mcps",
"--disallow-temp-dir",
"--no-ask-user",
// A measurement should run the CLI version it reports, not one it
// downloaded partway through the suite.
"--no-auto-update",
];
}
if (agent !== "claude") throw new Error(`unknown agent \`${agent}\``);
return [
"-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",
];
}
/**
* Stop a child and everything it started.
*
* `copilot` is a Node wrapper around a native binary, and SIGTERM to the
* wrapper does NOT reach the binary: measured on 1.0.85, the native process was
* still running (and still spending its model call) ten seconds later. So on
* POSIX a Copilot run is spawned as the leader of its own process group and the
* whole group is signalled (Copilot). On Windows the CLI runs under `cmd.exe`,
* where `taskkill /T` takes the tree.
*
* @param {{ pid?: number, kill: (signal: string) => unknown }} child
* @param {string} platform
* @param {{ spawnFn?: typeof spawn, killProcess?: typeof process.kill }} [io]
*/
export function killTree(
child,
platform,
{ spawnFn = spawn, killProcess = process.kill } = {},
) {
if (child.pid === undefined) return;
if (platform === "win32") {
spawnFn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
stdio: "ignore",
});
return;
}
try {
killProcess(-child.pid, "SIGTERM");
} catch {
// The group is already gone — the run finished as the budget was reached.
}
}
/**
* Copilot runs still in flight, so an interrupted eval can stop them.
*
* A child in its own process group no longer receives the terminal's Ctrl-C,
* which is the price of being able to signal the group — so without this, an
* interrupted suite would leave every in-flight session running unattended.
*/
const liveCopilotRuns = new Set();
/**
* Stop every Copilot run still in flight.
*
* Every way the eval can end early has to come through here: an interrupt, and
* just as much one sample rejecting — `pool` rejects on the first failure and
* `main().catch` exits while the other detached groups are still running, and
* a process exit does not reach them (Copilot).
*
* @param {Set<{child: object, platform: string, killFn: Function}>} [runs]
* @returns {number} How many runs were signalled.
*/
export function stopLiveCopilotRuns(runs = liveCopilotRuns) {
let n = 0;
for (const run of runs) {
run.killFn(run.child, run.platform);
n++;
}
runs.clear();
return n;
}
/**
* 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,
agent = "claude",
killFn = killTree,
} = {},
) {
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 } = cliSpawnArgs(
agent,
agentArgs(agent, maxTurns),
{
cwd,
stdio: ["pipe", "pipe", "inherit"],
// Its own process group, so `killTree` can reach the native binary the
// wrapper starts. Windows has no groups; `taskkill /T` covers it.
...(agent === "copilot" && platform !== "win32"
? { detached: true }
: {}),
},
platform,
);
const p = spawnFn(command, args, options);
const run = { child: p, platform, killFn };
if (agent === "copilot") liveCopilotRuns.add(run);
const collect =
agent === "copilot"
? collectCopilotSkillInvocations
: collectSkillInvocations;
// Copilot has no `--max-turns`, so the budget is enforced from outside: once
// the stream has shown `maxTurns` model calls, the run has made every move
// it is being scored on, and the rest would only spend metered calls.
let stopped = false;
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) => {
if (stopped) return;
const parsed = collect(buf + chunk.toString(), turnOffset);
buf = parsed.rest;
turnOffset = parsed.nextTurn;
for (const entry of parsed.invoked) {
// A pipe does not preserve event boundaries, so one chunk can carry a
// model call past the budget — even the whole rest of the run, result
// included. Scoring is bounded by turn number rather than by when the
// stop happened to land (Copilot). Claude's own `--max-turns` already
// bounds its stream.
if (agent === "copilot" && entry.turn > maxTurns) continue;
invoked.push(entry);
}
if (parsed.result !== null) result = parsed.result;
if (agent === "copilot" && turnOffset >= maxTurns && result === null) {
stopped = true;
result = "turn_budget";
killFn(p, platform);
}
});
p.on("error", reject);
p.on("close", (code) => {
liveCopilotRuns.delete(run);
const rejection = runRejection({ result, code });
if (rejection !== null) {
// An unauthenticated `copilot` answers `--version` fine, then prints
// its login hint to the stdout this run captures and exits with no
// events — so the one actionable fact would otherwise be swallowed.
const hint =
agent === "copilot" && result === null
? " — if it is not signed in, run `copilot` and `/login`, or export COPILOT_GITHUB_TOKEN"
: "";
reject(
new Error(`\`${agent}\` ${rejection}${hint} for prompt: ${prompt}`),
);
return;
}
resolve(invoked);
});
p.stdin?.end(prompt);
});
}
/**
* Read `copilot --version` (`GitHub Copilot CLI 1.0.85.`) down to its version.
*
* @param {string} text
* @returns {string | null}
*/
export function parseCopilotVersion(text) {
return /^GitHub Copilot CLI (\d+\.\d+\.\d+)/m.exec(text)?.[1] ?? null;
}
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 an env-supplied count is usable as a run, turn or worker count.
*
* @param {number} value
*/
export function isPositiveInteger(value) {
return Number.isInteger(value) && value > 0;
}
/**
* 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 inspector#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,
* agent?: string}} opts
* @returns {{ lines: string[], failed: number }}
*/
export function formatReport(cases, results, ours, opts) {
const lines = [];
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);
// Every heading names the agent, so a Copilot report pasted next to a Claude
// one cannot be read as the same measurement (inspector#2397).
const agent = opts.agent ?? "claude";
const directShort = group(
direct,
`First move (1 turn) — ${agent}`,
opts.threshold,
false,
);
const chainedShort = group(
chained,
`Hand-off (${opts.chainMaxTurns} turns) — ${agent}`,
opts.chainThreshold,
true,
);
const 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 (inspector#2204).
lines.push("");
if (direct.length > 0) {
lines.push(
`${direct.length - directShort}/${direct.length} ${agent} first-move cases at or above ${opts.threshold * 100}%.`,
);
}
lines.push(
chained.length === 0
? `No ${agent} hand-off cases in this selection.`
: `${chained.length - chainedShort}/${chained.length} ${agent} 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);
}
// The turn budget is also Copilot's external stop condition, so a non-numeric
// value would silently remove the cap on metered calls (`turnOffset >= NaN`
// is never true); RUNS and CONCURRENCY fail the same quiet way (Copilot,
// #4895).
for (const [name, value] of [
["CHAIN_MAX_TURNS", CHAIN_MAX_TURNS],
["RUNS", RUNS],
["CONCURRENCY", CONCURRENCY],
]) {
if (!isPositiveInteger(value)) {
console.error(
`skills:eval — ${name} must be a positive integer (got ${process.env[name] ?? value}).`,
);
process.exit(1);
}
}
if (!AGENTS.includes(AGENT)) {
console.error(
`skills:eval — AGENT must be one of ${AGENTS.join(", ")} (got \`${AGENT}\`).`,
);
process.exit(1);
}
const version = probeCliVersion(
AGENT,
AGENT === "claude" ? parseClaudeVersion : parseCopilotVersion,
);
if (version === null) {
console.error(
AGENT === "claude"
? "skills:eval — no usable `claude` CLI on PATH. This eval needs one."
: "skills:eval — no usable `copilot` CLI on PATH. Install it with " +
"`npm install -g @github/copilot` and sign in (`copilot` then " +
"`/login`, or export COPILOT_GITHUB_TOKEN), then re-run.",
);
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);
}
for (const signal of ["SIGINT", "SIGTERM"]) {
process.once(signal, () => {
stopLiveCopilotRuns();
process.exit(130);
});
}
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,
agent: AGENT,
}),
}));
const { lines, failed } = formatReport(cases, results, ours, {
threshold: THRESHOLD,
chainThreshold: CHAIN_THRESHOLD,
chainMaxTurns: CHAIN_MAX_TURNS,
agent: AGENT,
});
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) => {
stopLiveCopilotRuns();
console.error(e.message ?? e);
process.exit(1);
});
}