@icntswm/skillcheck 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,673 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { parseArgs } from "node:util";
7
+ import { ClaudeAdapter, DEFAULT_DIRECTIVE } from "./agents/claude.js";
8
+ import { BATCH_SCHEMA, buildBatchPrompt, parseBatchAnswer } from "./batch.js";
9
+ import { Budget } from "./budget.js";
10
+ import { BUILTIN_SKILLS, ConfigError, findDefaultCasesFile, knownSkillNames, loadSuite, unknownNames, } from "./cases.js";
11
+ import { confusion } from "./confusion.js";
12
+ import { loadSkillDocs } from "./describe.js";
13
+ import { aggregate, judge } from "./judge.js";
14
+ import { toJunit } from "./junit.js";
15
+ import { lint, LINT_DEFAULTS } from "./lint.js";
16
+ import { runPool } from "./pool.js";
17
+ import { oneLine, Reporter } from "./report.js";
18
+ import { buildReport } from "./results.js";
19
+ import { readVersion } from "./version.js";
20
+ const USAGE = `skillcheck — regression tests for agent skill routing
21
+
22
+ Usage:
23
+ skillcheck run [file] [options] run cases against an agent
24
+ skillcheck check [file] [options] validate cases without calling the model
25
+ skillcheck lint [file] [options] static checks on descriptions and cases
26
+ skillcheck list [options] print the skills and commands the agent can load
27
+ skillcheck init [file] [options] write a starter cases file with those names
28
+
29
+ run options:
30
+ -a, --agent <name> agent to route with (default: suite or claude)
31
+ -m, --model <name> model passed to the agent
32
+ -j, --jobs <n> concurrent runs (default: 4)
33
+ --only <1,2|id> run only these cases
34
+ --repeat <n> runs per case (default: suite or 1)
35
+ --threshold <x> share of runs a case must pass, 0 < x <= 1 (default: suite or 1)
36
+ --timeout <sec> per-run timeout (default: 180)
37
+ --directive <file> replace the stop directive sent with every query
38
+ --no-early-stop let the agent finish its turn instead of killing it
39
+ --batch one model call per chunk of cases instead of one per case
40
+ --batch-size <n> cases per batch call (default: 25, requires --batch)
41
+ --budget <usd> stop starting new runs once the spend estimate reaches usd
42
+ --json <path> machine-readable report to path ("-" writes it to stdout
43
+ and the terminal report to stderr)
44
+ --junit <path> JUnit XML report (GitLab/GitHub test reporters) to path
45
+ run/check options:
46
+ --skill <a,b> keep only cases that mention these skills
47
+ check options:
48
+ --no-name-check skip matching names against installed skills
49
+ lint options:
50
+ --top <n> flag an expected skill ranked below n (default: 5)
51
+ --overlap <x> description similarity treated as overlap, 0 < x <= 1 (default: 0.3)
52
+ --strict exit 1 when lint finds short, similar or far
53
+ list/init options:
54
+ -a, --agent <name> agent to ask (default: claude)
55
+ --timeout <sec> per-call timeout (same default as run)
56
+ --config-dir <dir> claude config dir: run/check/lint/list/init use it instead of
57
+ CLAUDE_CONFIG_DIR; run, list and init also set it for the agent
58
+ init options:
59
+ --force overwrite an existing file
60
+ common:
61
+ -h, --help show this help
62
+ --version show version
63
+
64
+ lint reads skill descriptions from disk and makes no model calls.
65
+ list and init call claude once but kill it right after its init event, so
66
+ nothing reaches the model and nothing is billed, even when not logged in.
67
+
68
+ Exit codes: 0 all passed, 1 some case failed or was skipped, 2 config or environment error.
69
+ `;
70
+ const OPTIONS = {
71
+ help: { type: "boolean", short: "h", default: false },
72
+ version: { type: "boolean", default: false },
73
+ agent: { type: "string" },
74
+ model: { type: "string" },
75
+ jobs: { type: "string", short: "j" },
76
+ only: { type: "string" },
77
+ repeat: { type: "string" },
78
+ threshold: { type: "string" },
79
+ timeout: { type: "string" },
80
+ directive: { type: "string" },
81
+ "no-early-stop": { type: "boolean", default: false },
82
+ "no-name-check": { type: "boolean", default: false },
83
+ batch: { type: "boolean", default: false },
84
+ "batch-size": { type: "string" },
85
+ skill: { type: "string" },
86
+ budget: { type: "string" },
87
+ json: { type: "string" },
88
+ junit: { type: "string" },
89
+ top: { type: "string" },
90
+ overlap: { type: "string" },
91
+ strict: { type: "boolean", default: false },
92
+ "config-dir": { type: "string" },
93
+ force: { type: "boolean", default: false },
94
+ };
95
+ /** Config/environment problems: message to stderr, exit 2. */
96
+ class UsageError extends Error {
97
+ }
98
+ function parseFlags(values, cwd) {
99
+ if (values["batch-size"] !== undefined && !values.batch)
100
+ throw new UsageError("--batch-size requires --batch");
101
+ if (values.batch && (values.directive !== undefined || values["no-early-stop"])) {
102
+ throw new UsageError("--directive and --no-early-stop do not apply to --batch");
103
+ }
104
+ return {
105
+ help: values.help,
106
+ version: values.version,
107
+ agent: values.agent,
108
+ model: values.model,
109
+ jobs: intFlag(values.jobs, "jobs", 1) ?? 4,
110
+ only: values.only,
111
+ repeat: intFlag(values.repeat, "repeat", 1),
112
+ threshold: thresholdFlag(values.threshold),
113
+ timeoutSec: intFlag(values.timeout, "timeout", 1) ?? 180,
114
+ directiveFile: values.directive,
115
+ earlyStop: !values["no-early-stop"],
116
+ nameCheck: !values["no-name-check"],
117
+ batch: values.batch,
118
+ batchSize: intFlag(values["batch-size"], "batch-size", 1) ?? 25,
119
+ skill: values.skill,
120
+ budget: numberFlag(values.budget),
121
+ json: values.json,
122
+ junit: values.junit,
123
+ top: intFlag(values.top, "top", 1) ?? 5,
124
+ overlap: ratioFlag(values.overlap) ?? 0.3,
125
+ strict: values.strict,
126
+ configDir: values["config-dir"] !== undefined ? resolveConfigDir(values["config-dir"], cwd) : undefined,
127
+ force: values.force,
128
+ };
129
+ }
130
+ /** --config-dir: absolute, must be an existing directory. */
131
+ function resolveConfigDir(raw, cwd) {
132
+ const abs = path.resolve(cwd, raw);
133
+ let isDir = false;
134
+ try {
135
+ isDir = fs.statSync(abs).isDirectory();
136
+ }
137
+ catch {
138
+ // missing => not a directory
139
+ }
140
+ if (!isDir)
141
+ throw new UsageError(`--config-dir is not a directory: ${abs}`);
142
+ return abs;
143
+ }
144
+ function intFlag(raw, name, min) {
145
+ if (raw === undefined)
146
+ return undefined;
147
+ const n = Number(raw);
148
+ if (!Number.isInteger(n) || n < min)
149
+ throw new UsageError(`--${name} must be an integer >= ${min}`);
150
+ return n;
151
+ }
152
+ function numberFlag(raw) {
153
+ if (raw === undefined)
154
+ return undefined;
155
+ const n = Number(raw);
156
+ if (!Number.isFinite(n) || n <= 0)
157
+ throw new UsageError("--budget must be a number > 0");
158
+ return n;
159
+ }
160
+ function thresholdFlag(raw) {
161
+ if (raw === undefined)
162
+ return undefined;
163
+ const n = Number(raw);
164
+ if (!(n > 0 && n <= 1))
165
+ throw new UsageError("--threshold must be a number with 0 < x <= 1");
166
+ return n;
167
+ }
168
+ function ratioFlag(raw) {
169
+ if (raw === undefined)
170
+ return undefined;
171
+ const n = Number(raw);
172
+ if (!(n > 0 && n <= 1))
173
+ throw new UsageError("--overlap must be a number with 0 < x <= 1");
174
+ return n;
175
+ }
176
+ export async function main(argv, io = { stdout: process.stdout, stderr: process.stderr, cwd: process.cwd() }, deps = {}) {
177
+ let positionals;
178
+ let values;
179
+ try {
180
+ const parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
181
+ positionals = parsed.positionals;
182
+ values = parsed.values;
183
+ }
184
+ catch (e) {
185
+ io.stderr.write(`skillcheck: ${e.message}\n`);
186
+ return 2;
187
+ }
188
+ try {
189
+ if (values.help) {
190
+ io.stdout.write(USAGE);
191
+ return 0;
192
+ }
193
+ if (values.version) {
194
+ io.stdout.write(`${readVersion()}\n`);
195
+ return 0;
196
+ }
197
+ const command = positionals[0];
198
+ if (!command) {
199
+ io.stderr.write(USAGE);
200
+ return 2;
201
+ }
202
+ if (command !== "run" && command !== "check" && command !== "lint" && command !== "list" && command !== "init") {
203
+ io.stderr.write(`skillcheck: unknown command "${command}"\n\n${USAGE}`);
204
+ return 2;
205
+ }
206
+ const flags = parseFlags(values, io.cwd);
207
+ if (command === "lint")
208
+ return await lintCommand(positionals[1], flags, io);
209
+ if (command === "list")
210
+ return await listCommand(flags, io, deps);
211
+ if (command === "init")
212
+ return await initCommand(positionals[1], flags, io, deps);
213
+ const file = casesFile(positionals[1], io);
214
+ if (command === "run")
215
+ return await runCommand(file, flags, io, deps);
216
+ return await checkCommand(file, flags, io);
217
+ }
218
+ catch (e) {
219
+ if (e instanceof UsageError) {
220
+ io.stderr.write(`skillcheck: ${e.message}\n`);
221
+ return 2;
222
+ }
223
+ if (e instanceof ConfigError) {
224
+ printConfigError(io.stderr, "cases file", e);
225
+ return 2;
226
+ }
227
+ io.stderr.write(`skillcheck: ${e.message}\n`);
228
+ return 2;
229
+ }
230
+ }
231
+ function casesFile(positional, io) {
232
+ if (positional)
233
+ return positional;
234
+ const found = findDefaultCasesFile(io.cwd);
235
+ if (!found)
236
+ throw new UsageError("no cases file: pass a path or create skillcheck.yaml");
237
+ return found;
238
+ }
239
+ async function loadCases(file, io) {
240
+ try {
241
+ return await loadSuite(file);
242
+ }
243
+ catch (e) {
244
+ if (e instanceof ConfigError) {
245
+ printConfigError(io.stderr, file, e);
246
+ return null;
247
+ }
248
+ throw e;
249
+ }
250
+ }
251
+ function printConfigError(stderr, file, err) {
252
+ const prefix = `skillcheck: ${file}: `;
253
+ if (err.errors.length <= 3) {
254
+ stderr.write(prefix + err.errors.join("; ") + "\n");
255
+ }
256
+ else {
257
+ stderr.write(prefix + "\n");
258
+ for (const message of err.errors)
259
+ stderr.write(` ${message}\n`);
260
+ }
261
+ }
262
+ async function checkCommand(file, flags, io) {
263
+ const suite = await loadCases(file, io);
264
+ if (!suite)
265
+ return 2;
266
+ const cases = applySkillFilter(suite.cases, flags.skill);
267
+ if (flags.nameCheck) {
268
+ const errors = unknownNames({ ...suite, cases }, knownSkillNames({ cwd: io.cwd, configDir: flags.configDir }));
269
+ if (errors.length > 0) {
270
+ printConfigError(io.stderr, file, new ConfigError(errors));
271
+ return 2;
272
+ }
273
+ }
274
+ io.stdout.write(`${file}: ${cases.length} cases, ok\n`);
275
+ return 0;
276
+ }
277
+ async function lintCommand(positional, flags, io) {
278
+ let suite;
279
+ const file = positional ?? findDefaultCasesFile(io.cwd);
280
+ if (file) {
281
+ suite = await loadCases(file, io);
282
+ if (!suite)
283
+ return 2; // loadCases already printed the errors
284
+ }
285
+ else {
286
+ suite = null;
287
+ }
288
+ const docs = loadSkillDocs({ cwd: io.cwd, configDir: flags.configDir });
289
+ const report = lint(docs, suite, { top: flags.top, overlap: flags.overlap, minLength: LINT_DEFAULTS.minLength });
290
+ printLint(io.stdout, report, docs, suite);
291
+ if (flags.strict && (report.short.length || report.similar.length || report.far.length))
292
+ return 1;
293
+ return 0;
294
+ }
295
+ async function listCommand(flags, io, deps) {
296
+ const adapter = pickAdapter(flags.agent ?? "claude", io, deps);
297
+ if (!adapter)
298
+ return 2;
299
+ if (!adapter.listSkills) {
300
+ io.stderr.write(`skillcheck: agent ${adapter.name} has no list mode\n`);
301
+ return 2;
302
+ }
303
+ const list = await adapter.listSkills({ configDir: flags.configDir, timeoutMs: flags.timeoutSec * 1000 });
304
+ if (list.error) {
305
+ io.stderr.write(`skillcheck: ${list.error}\n`);
306
+ return 2;
307
+ }
308
+ io.stdout.write(`skills (${list.skills.length}):\n`);
309
+ for (const name of list.skills)
310
+ io.stdout.write(` ${name}\n`);
311
+ io.stdout.write(`other slash commands (${list.slashCommands.length}):\n`);
312
+ for (const name of list.slashCommands)
313
+ io.stdout.write(` ${name}\n`);
314
+ return 0;
315
+ }
316
+ const INIT_LIST_WIDTH = 76; // plus the "# " prefix the line stays within 78 columns
317
+ /** The names block of the init template: a header line plus comma-wrapped "# …" lines. */
318
+ function nameListBlock(header, names) {
319
+ if (names.length === 0)
320
+ return [header, "# (none)"];
321
+ const wrapped = wrapNames(names, INIT_LIST_WIDTH).split("\n").map((line) => `# ${line}`);
322
+ return [header, ...wrapped];
323
+ }
324
+ function initTemplate(agent, skills, slashCommands) {
325
+ // the sample case should name something the user plausibly owns, not a built-in
326
+ const alpha = skills.find((s) => !BUILTIN_SKILLS.includes(s)) ?? "my-skill";
327
+ const lines = [
328
+ "# skillcheck cases: each query is sent to the agent with a stop directive,",
329
+ "# and the skills it loads are checked against the expectations below.",
330
+ "# Fields per case: query (required), expect, expect_any, forbid, first, none,",
331
+ "# note, id, repeat, threshold, agents. Docs: https://github.com/icntswm/skillcheck",
332
+ "#",
333
+ "# Cheapest first: `skillcheck lint` (no model calls), `skillcheck run --batch`",
334
+ "# (one call per 25 cases), `skillcheck run` (one call per case and repeat).",
335
+ "#",
336
+ ...nameListBlock(`# Skills available to ${agent}:`, skills),
337
+ ];
338
+ if (slashCommands.length > 0)
339
+ lines.push(...nameListBlock("# Other slash commands:", slashCommands));
340
+ lines.push(`agent: ${agent}`, "# model: sonnet", "repeat: 1", "threshold: 1.0", "cases:", ` - query: "replace with a request that should load ${alpha}"`, ` expect: [${alpha}]`, ' - query: "what is 2 + 2?"', " none: true");
341
+ return lines.join("\n") + "\n";
342
+ }
343
+ async function initCommand(positional, flags, io, deps) {
344
+ const file = positional ?? path.join(io.cwd, "skillcheck.yaml");
345
+ if (fs.existsSync(file) && !flags.force) {
346
+ throw new UsageError(`${file} exists, use --force to overwrite`);
347
+ }
348
+ const adapter = pickAdapter(flags.agent ?? "claude", io, deps);
349
+ if (!adapter)
350
+ return 2;
351
+ let skills = [];
352
+ let slashCommands = [];
353
+ const list = adapter.listSkills
354
+ ? await adapter.listSkills({ configDir: flags.configDir, timeoutMs: flags.timeoutSec * 1000 })
355
+ : null;
356
+ if (list && !list.error) {
357
+ skills = list.skills;
358
+ slashCommands = list.slashCommands;
359
+ }
360
+ else {
361
+ // a missing or failing agent must not block writing the starter file
362
+ const why = list?.error ?? `agent ${adapter.name} has no list mode`;
363
+ io.stderr.write(`note: could not ask the agent (${why}); listed skills found on disk\n`);
364
+ skills = [...knownSkillNames({ cwd: io.cwd, configDir: flags.configDir })].sort();
365
+ }
366
+ try {
367
+ fs.writeFileSync(file, initTemplate(flags.agent ?? "claude", skills, slashCommands));
368
+ }
369
+ catch (e) {
370
+ throw new UsageError(`cannot write ${file}: ${e.message}`);
371
+ }
372
+ io.stdout.write(`wrote ${file} (${skills.length} skills listed)\n`);
373
+ return 0;
374
+ }
375
+ const UNCOVERED_WIDTH = 78;
376
+ function printLint(out, report, docs, suite) {
377
+ const casesPart = suite ? `${report.cases} cases` : "no cases file";
378
+ out.write(`lint: ${report.docs} skills and commands with descriptions, ${casesPart}\n`);
379
+ if (report.short.length) {
380
+ out.write(`short descriptions (${report.short.length}):\n`);
381
+ for (const s of report.short)
382
+ out.write(` ${s.name} ${s.kind}, ${s.length} chars\n`);
383
+ }
384
+ if (report.similar.length) {
385
+ out.write(`similar descriptions (${report.similar.length}):\n`);
386
+ for (const p of report.similar)
387
+ out.write(` ${p.a} ↔ ${p.b} ${p.score.toFixed(2)}\n`);
388
+ }
389
+ if (report.far.length) {
390
+ out.write(`expected skill far from the query (${report.far.length}):\n`);
391
+ for (const f of report.far) {
392
+ const label = `#${f.id ?? f.index}`;
393
+ out.write(` ${label} ${oneLine(f.query)} → ${f.expected} ranked ${f.rank} (top: ${f.top.join(", ")})\n`);
394
+ }
395
+ }
396
+ if (report.uncovered.length) {
397
+ const nSkills = docs.filter((d) => d.kind === "skill").length;
398
+ out.write(`uncovered skills (${report.uncovered.length} of ${nSkills}):\n`);
399
+ out.write(`${wrapNames(report.uncovered, UNCOVERED_WIDTH)}\n`);
400
+ }
401
+ const clean = !report.short.length && !report.similar.length && !report.far.length && !report.uncovered.length;
402
+ if (clean)
403
+ out.write("no problems found\n");
404
+ }
405
+ /** Comma-separated names, line-wrapped to width with a two-space indent;
406
+ * every line but the last ends with a comma so the list reads as one. */
407
+ function wrapNames(names, width) {
408
+ const lines = [];
409
+ let line = " ";
410
+ for (const name of names) {
411
+ const piece = line === " " ? name : `, ${name}`;
412
+ if (line !== " " && line.length + piece.length > width) {
413
+ lines.push(`${line},`);
414
+ line = ` ${name}`;
415
+ }
416
+ else {
417
+ line += piece;
418
+ }
419
+ }
420
+ if (line !== " ")
421
+ lines.push(line);
422
+ return lines.join("\n");
423
+ }
424
+ /** Only claude exists at this stage; an injected adapter is accepted by name. */
425
+ function pickAdapter(agent, io, deps) {
426
+ if (agent === "claude")
427
+ return deps.adapter ?? new ClaudeAdapter();
428
+ if (deps.adapter && deps.adapter.name === agent)
429
+ return deps.adapter;
430
+ io.stderr.write(`skillcheck: unknown agent "${agent}" (available: ${deps.adapter ? `claude, ${deps.adapter.name}` : "claude"})\n`);
431
+ return null;
432
+ }
433
+ /** Errors that every other run would repeat: stop instead of failing each case. */
434
+ const FATAL_ERROR = /Not logged in|Failed to authenticate|invalid x-api-key|OAuth access token|API Error: 401/i;
435
+ /** Remembers the first fatal run error; the pool stops starting new runs after it. */
436
+ class FatalStop {
437
+ message = null;
438
+ note(error) {
439
+ if (this.message === null && error !== null && FATAL_ERROR.test(error))
440
+ this.message = error;
441
+ }
442
+ canStart(budget) {
443
+ return this.message === null && (!budget || !budget.exceeded);
444
+ }
445
+ }
446
+ async function runCommand(file, flags, io, deps) {
447
+ const suite = await loadCases(file, io);
448
+ if (!suite)
449
+ return 2;
450
+ const agent = flags.agent ?? suite.agent;
451
+ const adapter = pickAdapter(agent, io, deps);
452
+ if (!adapter)
453
+ return 2;
454
+ const model = flags.model ?? suite.model;
455
+ const repeat = flags.repeat ?? suite.repeat;
456
+ const threshold = flags.threshold ?? suite.threshold;
457
+ const directive = await readDirective(flags.directiveFile);
458
+ if (flags.batch && !adapter.runBatch)
459
+ throw new UsageError(`agent ${agent} has no batch mode`);
460
+ const byOnly = selectCases(suite, agent, flags.only);
461
+ if (byOnly.length === 0) {
462
+ throw new UsageError(`no cases to run (${flags.only ? `--only ${flags.only} matches nothing` : `no cases for agent "${agent}"`})`);
463
+ }
464
+ const selected = applySkillFilter(byOnly, flags.skill);
465
+ const items = selected.map((c, i) => {
466
+ const n = c.repeat ?? repeat;
467
+ return { c, jobIndex: i, threshold: c.threshold ?? threshold, runs: Array.from({ length: n }), done: 0 };
468
+ });
469
+ const budget = flags.budget !== undefined ? new Budget(flags.budget) : null;
470
+ // "--json -" owns stdout, so the human report has to go to stderr
471
+ const reporter = new Reporter(flags.json === "-" ? io.stderr : io.stdout);
472
+ const startedAtMs = Date.now();
473
+ const fatal = new FatalStop();
474
+ const outcome = flags.batch
475
+ ? await runBatched(adapter.runBatch.bind(adapter), items, flags, model, repeat, reporter, budget, fatal)
476
+ : await runIndividually(adapter, items, flags, model, directive, repeat, reporter, budget, fatal);
477
+ if (fatal.message !== null) {
478
+ // an environment problem, not a routing result: no summary, no reports
479
+ io.stderr.write(`skillcheck: stopped, the agent cannot run: ${fatal.message}\n`);
480
+ return 2;
481
+ }
482
+ // budget-skipped cases print after the pool settles, in file order
483
+ const skipped = items.filter((item) => outcome.results[item.jobIndex] === undefined);
484
+ for (const item of skipped)
485
+ reporter.caseSkipped(item.c);
486
+ const notStartedRuns = skipped.reduce((n, item) => n + item.runs.length - item.done, 0);
487
+ const ordered = outcome.results.filter((r) => r !== undefined);
488
+ const expected = expectedNames(selected);
489
+ const unavailable = outcome.sawAvailability ? expected.filter((name) => !outcome.available.has(name)) : [];
490
+ const pairs = confusion(ordered);
491
+ reporter.summary(ordered, {
492
+ unavailable,
493
+ confusion: pairs,
494
+ skipped: skipped.length,
495
+ budget: skipped.length > 0 && budget ? { limitUsd: flags.budget, spent: budget.spent, notStartedRuns } : undefined,
496
+ });
497
+ if (flags.batch) {
498
+ // cases that only errored have no answer to confirm
499
+ const failed = ordered
500
+ .filter((r) => r.runs.some((v) => !v.ok && v.error === null))
501
+ .map((r) => String(r.case.id ?? r.case.index));
502
+ const hint = failed.length > 0 ? ` (--only ${failed.join(",")})` : "";
503
+ reporter.note(`batch mode: answers are the model's stated choice, not an actual Skill call — confirm failures with a normal run${hint}`);
504
+ }
505
+ const report = buildReport({
506
+ file,
507
+ agent,
508
+ model: model ?? null,
509
+ startedAtMs,
510
+ durationMs: Date.now() - startedAtMs,
511
+ cases: items.map((item) => ({ c: item.c, threshold: item.threshold, result: outcome.results[item.jobIndex] ?? null })),
512
+ unavailable,
513
+ confusion: pairs,
514
+ budgetUsd: flags.budget ?? null,
515
+ budgetReached: skipped.length > 0,
516
+ });
517
+ if (flags.json !== undefined) {
518
+ const payload = JSON.stringify(report, null, 2) + "\n";
519
+ if (flags.json === "-")
520
+ io.stdout.write(payload);
521
+ else
522
+ writeReportFile(flags.json, payload, "--json");
523
+ }
524
+ if (flags.junit !== undefined)
525
+ writeReportFile(flags.junit, toJunit(report), "--junit");
526
+ return ordered.some((r) => !r.ok) || skipped.length > 0 ? 1 : 0;
527
+ }
528
+ function writeReportFile(path, text, flag) {
529
+ try {
530
+ fs.writeFileSync(path, text);
531
+ }
532
+ catch (e) {
533
+ throw new UsageError(`cannot write ${flag} ${path}: ${e.message}`);
534
+ }
535
+ }
536
+ /** One agent call per case: the normal mode. */
537
+ async function runIndividually(adapter, items, flags, model, directive, repeat, reporter, budget, fatal) {
538
+ const totalRuns = items.reduce((n, item) => n + item.runs.length, 0);
539
+ reporter.header(items.length, repeat, 1, totalRuns);
540
+ const jobs = items.flatMap((item, jobIndex) => item.runs.map((_, runIndex) => ({ item, runIndex, jobIndex })));
541
+ // keep cases in file order even though runs of different cases interleave
542
+ const results = new Array(items.length);
543
+ const available = new Set();
544
+ let sawAvailability = false;
545
+ await runPool(jobs, flags.jobs, async (job) => {
546
+ const r = await adapter.run({
547
+ query: job.item.c.query,
548
+ directive,
549
+ model,
550
+ timeoutMs: flags.timeoutSec * 1000,
551
+ earlyStop: flags.earlyStop,
552
+ configDir: flags.configDir,
553
+ });
554
+ fatal.note(r.error);
555
+ if (r.availableSkills) {
556
+ sawAvailability = true;
557
+ for (const name of r.availableSkills)
558
+ available.add(name);
559
+ }
560
+ const verdict = judge(job.item.c, r);
561
+ budget?.add(verdict.costUsd);
562
+ job.item.runs[job.runIndex] = verdict;
563
+ job.item.done++;
564
+ if (job.item.done === job.item.runs.length) {
565
+ const res = aggregate(job.item.c, job.item.runs, job.item.threshold);
566
+ results[job.item.jobIndex] = res;
567
+ reporter.caseDone(res);
568
+ }
569
+ return verdict;
570
+ }, { shouldStart: () => fatal.canStart(budget) });
571
+ return { results, available, sawAvailability };
572
+ }
573
+ /** One agent call per chunk of cases; the model states its choice per request
574
+ * instead of loading skills. Verdicts flow through the same judge. */
575
+ async function runBatched(runBatch, items, flags, model, repeat, reporter, budget, fatal) {
576
+ const chunks = [];
577
+ for (let i = 0; i < items.length; i += flags.batchSize)
578
+ chunks.push(items.slice(i, i + flags.batchSize));
579
+ const rounds = Math.max(...items.map((item) => item.runs.length));
580
+ const jobs = [];
581
+ for (let round = 0; round < rounds; round++) {
582
+ for (const chunk of chunks)
583
+ jobs.push({ chunk, round });
584
+ }
585
+ reporter.batchHeader(items.length, repeat, chunks.length * rounds);
586
+ const results = new Array(items.length);
587
+ await runPool(jobs, flags.jobs, async (job) => {
588
+ // a case with a smaller repeat only takes its first rounds
589
+ const active = job.chunk.filter((item) => job.round < item.runs.length);
590
+ if (active.length === 0)
591
+ return;
592
+ const prompt = buildBatchPrompt(active.map((item, i) => ({ n: i + 1, query: item.c.query })));
593
+ const br = await runBatch({ prompt, schema: BATCH_SCHEMA, model, timeoutMs: flags.timeoutSec * 1000, configDir: flags.configDir });
594
+ const parsed = parseBatchAnswer(br.structured, br.text);
595
+ const chunkError = br.error ?? parsed.error; // a chunk error is every case's error
596
+ fatal.note(br.error);
597
+ active.forEach((item, i) => {
598
+ const answer = parsed.answers.get(i + 1);
599
+ const r = {
600
+ loaded: answer ?? [],
601
+ text: "",
602
+ costUsd: br.costUsd === null ? null : br.costUsd / active.length,
603
+ availableSkills: null,
604
+ error: chunkError ?? (answer === undefined ? "no answer for this case in the batch" : null),
605
+ stoppedEarly: false,
606
+ durationMs: br.durationMs,
607
+ };
608
+ const verdict = judge(item.c, r);
609
+ budget?.add(verdict.costUsd);
610
+ item.runs[job.round] = verdict;
611
+ item.done++;
612
+ if (item.done === item.runs.length) {
613
+ const res = aggregate(item.c, item.runs, item.threshold);
614
+ results[item.jobIndex] = res;
615
+ reporter.caseDone(res);
616
+ }
617
+ });
618
+ }, { shouldStart: () => fatal.canStart(budget) });
619
+ return { results, available: new Set(), sawAvailability: false };
620
+ }
621
+ function selectCases(suite, agent, only) {
622
+ const forAgent = suite.cases.filter((c) => !c.agents || c.agents.includes(agent));
623
+ if (!only)
624
+ return forAgent;
625
+ const tokens = only.split(",").map((t) => t.trim()).filter((t) => t !== "");
626
+ return forAgent.filter((c) => tokens.includes(String(c.index)) || (c.id !== undefined && tokens.includes(c.id)));
627
+ }
628
+ /** Cases mentioning any of the named skills in expect, expect_any, forbid or first. */
629
+ function applySkillFilter(cases, skill) {
630
+ if (skill === undefined)
631
+ return cases;
632
+ const names = new Set(skill.split(",").map((t) => t.trim()).filter((t) => t !== ""));
633
+ const kept = cases.filter((c) => [...c.expect, ...c.expect_any, ...c.forbid, ...(c.first ? [c.first] : [])].some((n) => names.has(n)));
634
+ if (kept.length === 0)
635
+ throw new UsageError(`no cases match --skill ${skill}`);
636
+ return kept;
637
+ }
638
+ function expectedNames(cases) {
639
+ const names = [];
640
+ for (const c of cases) {
641
+ for (const name of [...c.expect, ...c.expect_any, ...(c.first ? [c.first] : [])]) {
642
+ if (!names.includes(name))
643
+ names.push(name);
644
+ }
645
+ }
646
+ return names;
647
+ }
648
+ async function readDirective(file) {
649
+ if (!file)
650
+ return DEFAULT_DIRECTIVE;
651
+ try {
652
+ return (await readFile(file, "utf8")).trim();
653
+ }
654
+ catch (e) {
655
+ throw new UsageError(`cannot read directive ${file}: ${e.message}`);
656
+ }
657
+ }
658
+ function invokedDirectly() {
659
+ const entry = process.argv[1];
660
+ if (!entry)
661
+ return false;
662
+ try {
663
+ return import.meta.url === pathToFileURL(fs.realpathSync(entry)).href;
664
+ }
665
+ catch {
666
+ return false;
667
+ }
668
+ }
669
+ if (invokedDirectly()) {
670
+ main(process.argv.slice(2)).then((code) => {
671
+ process.exitCode = code;
672
+ });
673
+ }