#!/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. 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 #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, #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. #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 }} */ 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 | 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} invoked * @param {Set | 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}[]} results One per sample. * @param {Set | 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); }); }