webmcp-gauge 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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/action.yml +162 -0
  4. package/bin/webmcp-gauge.mjs +544 -0
  5. package/bin/webmcp-gauge.test.mjs +354 -0
  6. package/browser/launch.mjs +188 -0
  7. package/browser/serve.mjs +78 -0
  8. package/browser/session.mjs +210 -0
  9. package/browser/webmcp.mjs +432 -0
  10. package/browser/webmcp.test.mjs +299 -0
  11. package/core/args.mjs +93 -0
  12. package/core/args.test.mjs +85 -0
  13. package/core/capture-seam.test.mjs +86 -0
  14. package/core/cohort.mjs +432 -0
  15. package/core/cohort.test.mjs +370 -0
  16. package/core/gallery.mjs +145 -0
  17. package/core/gallery.test.mjs +128 -0
  18. package/core/gate.mjs +164 -0
  19. package/core/gate.test.mjs +213 -0
  20. package/core/lint.mjs +381 -0
  21. package/core/lint.test.mjs +346 -0
  22. package/core/orchestrate.mjs +128 -0
  23. package/core/orchestrate.test.mjs +191 -0
  24. package/core/stats.mjs +172 -0
  25. package/core/stats.test.mjs +156 -0
  26. package/core/sweep.mjs +274 -0
  27. package/core/sweep.test.mjs +162 -0
  28. package/core/taxonomy.mjs +175 -0
  29. package/core/taxonomy.test.mjs +198 -0
  30. package/core/trial.mjs +248 -0
  31. package/core/visibility.mjs +163 -0
  32. package/core/visibility.test.mjs +164 -0
  33. package/docs/concept.md +468 -0
  34. package/docs/explainer.md +161 -0
  35. package/docs/getting-started.md +331 -0
  36. package/fixtures/README.md +42 -0
  37. package/fixtures/airlock.utterances.json +284 -0
  38. package/fixtures/broken/compose.mjs +52 -0
  39. package/fixtures/broken/compose.test.mjs +270 -0
  40. package/fixtures/broken/sample-expenses.csv +966 -0
  41. package/fixtures/broken/tools.json +1311 -0
  42. package/fixtures/broken/twin.html +482 -0
  43. package/fixtures/broken/widget.html +62 -0
  44. package/fixtures/gallery/gallery.html +56 -0
  45. package/judges/openai-compatible.mjs +145 -0
  46. package/package.json +53 -0
  47. package/report/badge.mjs +110 -0
  48. package/report/badge.test.mjs +97 -0
  49. package/report/emit.mjs +282 -0
  50. package/report/published-runs.test.mjs +77 -0
  51. package/report/scorecard.mjs +157 -0
  52. package/report/scorecard.test.mjs +130 -0
@@ -0,0 +1,544 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { launchSession } from '../browser/launch.mjs';
6
+ import { startFixtureServer } from '../browser/serve.mjs';
7
+ import { openSession } from '../browser/session.mjs';
8
+ import { captureManifest } from '../browser/webmcp.mjs';
9
+ import { parseOptions } from '../core/args.mjs';
10
+ import { EXIT, gateRun, parseFailUnder } from '../core/gate.mjs';
11
+ import { lintManifest, lintToText } from '../core/lint.mjs';
12
+ import { runSessions } from '../core/orchestrate.mjs';
13
+ import { buildPlan, readCheckpoint, readFailures, runSessionSweep, trialKey } from '../core/sweep.mjs';
14
+ import { runTrial } from '../core/trial.mjs';
15
+ import { createJudge } from '../judges/openai-compatible.mjs';
16
+ import { buildReport, toMarkdown } from '../report/emit.mjs';
17
+ import { buildBadge, renderBadgeSvg } from '../report/badge.mjs';
18
+
19
+ const { name, version } = JSON.parse(
20
+ await readFile(new URL('../package.json', import.meta.url), 'utf8')
21
+ );
22
+
23
+ const usage = `${name} ${version}
24
+
25
+ Usage: webmcp-gauge <command> [options]
26
+
27
+ Commands:
28
+ trial run one trial: one utterance, one expected tool, one outcome
29
+ run run S isolated sessions and emit a stamped report
30
+ session run one session (used by run; each session gets its own process)
31
+ lint static checks on a page's tool manifest: no judge, no API key
32
+
33
+ Shared options (each accepts --name value or --name=value; an unknown option is an
34
+ error, never a silently ignored default):
35
+ --fixture <path> utterance set (default fixtures/airlock.utterances.json)
36
+ --url <url> subject page (default the fixture's subject url). With
37
+ --serve, a path relative to the served directory
38
+ --serve <dir> serve <dir> on 127.0.0.1 and resolve --url against it, so a
39
+ measurement can run against a fixture page in this repo
40
+ --judge <model> judge model id (env WEBMCP_GAUGE_JUDGE_MODEL)
41
+ --base-url <url> judge endpoint (env WEBMCP_GAUGE_JUDGE_BASE_URL)
42
+ --port <n> attach to an existing Chrome instead of launching one
43
+
44
+ lint options (no judge required):
45
+ --manifest <path> lint a manifest JSON file instead of a live page. Reads
46
+ {tools:[...]} or a bare array; the only way to lint a name a
47
+ browser refuses to register
48
+ --variant <name> for a multi-variant fixture file, lint variants.<name>
49
+ --json emit the finding list as JSON
50
+ --fail-on <level> error (default) or warning
51
+ --min-description <n> description floor in characters (default 60)
52
+ --max-properties <n> schema property ceiling (default 6)
53
+ --budget-warn <n> tool count that warns about budget headroom (default 64)
54
+
55
+ trial options:
56
+ --tool <name> expected tool
57
+ --utterance <id> utterance id, e.g. sum_by_category-05
58
+
59
+ run / session options:
60
+ --sessions <n> isolated sessions, each its own process, browser and cold
61
+ profile (default 3). Between-session sigma needs >= 2
62
+ --repeats <n> repeats inside one session (default 1). Within-session
63
+ sigma needs >= 2, and it is a floor, not a stability claim
64
+ --concurrency <n> parallel tabs inside a session (default 1)
65
+ --gap <seconds> wait between sessions (default 0)
66
+ --tools <a,b> restrict to these tools
67
+ --no-controls skip the negative controls
68
+ --headful show the browser instead of --headless=new
69
+ --out <dir> report directory (default artifacts/)
70
+ --resume reuse the JSONL checkpoint in the report directory
71
+ --subject <name> name the subject in the report, when it is not the fixture's
72
+ own subject — a report that mislabels what it measured is
73
+ worse than one with no label
74
+ --fail-under <rate> exit 1 when any tool's invocation rate is below this rate,
75
+ e.g. 0.9. Compared against the point rate; the interval is
76
+ reported beside it
77
+ --badge-label <text> label for badge.json / badge.svg, both written on every run
78
+ (default "webmcp invocation"). An incomplete run's badge
79
+ says "incomplete" rather than a rate, and never a colour
80
+ that could be read as a pass
81
+
82
+ Exit codes (a gate is only useful if 1 means one thing):
83
+ 0 every planned trial was measured, and nothing fell below --fail-under; for
84
+ lint, no finding at or above --fail-on
85
+ 1 a tool's invocation rate is below --fail-under — the page regressed; for lint,
86
+ the manifest carries findings at that level
87
+ 2 the command cannot answer: planned trials have no measurement (re-run with
88
+ --resume), a manifest never settled, or the arguments were unusable. Never a
89
+ threshold breach
90
+
91
+ The judge must not be the model that authored the utterance set; the set records
92
+ which one that was. See docs/getting-started.md and .env.example.`;
93
+
94
+ /**
95
+ * Every option this CLI accepts, declared so the parser can refuse the rest.
96
+ *
97
+ * Before 2026-09-03 an unknown option was silently kept and `--fail-under=0.9`
98
+ * parsed as a *switch* named `fail-under=0.9`, so the threshold was never read and
99
+ * a CI job written that way was never gated. Both forms work now, and a typo is an
100
+ * error rather than a default. `session` is internal: `run` spawns `session` with
101
+ * it (see core/orchestrate.mjs).
102
+ */
103
+ const CLI_OPTIONS = {
104
+ values: [
105
+ 'fixture', 'url', 'serve', 'judge', 'base-url', 'port',
106
+ 'manifest', 'variant', 'fail-on', 'min-description', 'max-properties', 'budget-warn',
107
+ 'tool', 'utterance',
108
+ 'sessions', 'repeats', 'concurrency', 'gap', 'tools', 'out', 'subject', 'fail-under', 'badge-label',
109
+ 'session',
110
+ ],
111
+ switches: ['json', 'no-controls', 'headful', 'resume'],
112
+ maxPositional: 1,
113
+ };
114
+
115
+ /**
116
+ * Usage errors exit 2, the same code as an unmeasurable run: in both cases the
117
+ * command produced no number, which is the distinction a CI job needs. Exit 1 is
118
+ * reserved for a measured rate below the threshold.
119
+ */
120
+ const fail = (message) => {
121
+ console.error(`webmcp-gauge: ${message}`);
122
+ process.exit(EXIT.incomplete);
123
+ };
124
+
125
+ /**
126
+ * A crash is "could not measure", not "the page is bad".
127
+ *
128
+ * This file is a module with top-level await, so anything that throws becomes an
129
+ * unhandled rejection and Node exits **1** — the code reserved for a measured rate
130
+ * below the threshold. CI on 2026-09-02 proved what that costs: Chrome failed to
131
+ * start on the runner, the CLI exited 1, and the Action reported it as *"the
132
+ * manifest has findings"*. A broken environment was presented as a bad page, which
133
+ * is the exact conflation the split exit codes exist to prevent.
134
+ */
135
+ const cannotMeasure = (error) => {
136
+ console.error(`webmcp-gauge: could not measure — ${error?.stack ?? error}`);
137
+ process.exit(EXIT.incomplete);
138
+ };
139
+ process.on('uncaughtException', cannotMeasure);
140
+ process.on('unhandledRejection', cannotMeasure);
141
+
142
+ const command = process.argv[2];
143
+
144
+ if (command === undefined || command === '--help' || command === '-h') {
145
+ console.log(usage);
146
+ process.exit(EXIT.pass);
147
+ }
148
+
149
+ if (command === '--version' || command === '-v') {
150
+ console.log(version);
151
+ process.exit(EXIT.pass);
152
+ }
153
+
154
+ if (!['trial', 'run', 'session', 'lint'].includes(command)) {
155
+ console.error(`webmcp-gauge: no such command '${command}'\n`);
156
+ console.error(usage);
157
+ process.exit(EXIT.incomplete);
158
+ }
159
+
160
+ const { options: flags, positional, error: optionsError } = parseOptions(process.argv.slice(3), CLI_OPTIONS);
161
+ if (optionsError) fail(`${optionsError}. Run 'webmcp-gauge --help' for the full list.`);
162
+
163
+ const needsJudge = command !== 'lint';
164
+ const serveDir = typeof flags.serve === 'string' ? flags.serve : null;
165
+
166
+ const fixturePath = new URL(
167
+ typeof flags.fixture === 'string' ? flags.fixture : '../fixtures/airlock.utterances.json',
168
+ import.meta.url
169
+ );
170
+ const fixture = JSON.parse(await readFile(fixturePath, 'utf8'));
171
+ const url = (typeof flags.url === 'string' && flags.url) || positional[0] || fixture.subject?.url;
172
+ if (!url && !(command === 'lint' && typeof flags.manifest === 'string')) {
173
+ fail('no url given and the fixture names no subject url');
174
+ }
175
+
176
+ const judgeModel =
177
+ (typeof flags.judge === 'string' && flags.judge) || process.env.WEBMCP_GAUGE_JUDGE_MODEL;
178
+ const judgeBaseUrl =
179
+ (typeof flags['base-url'] === 'string' && flags['base-url']) ||
180
+ process.env.WEBMCP_GAUGE_JUDGE_BASE_URL;
181
+ // L0 is the free on-ramp: it reads a manifest and calls no model, so demanding a
182
+ // judge for it would put an API key in front of the cheapest useful answer.
183
+ if (needsJudge && (!judgeModel || !judgeBaseUrl)) {
184
+ fail('pass --judge and --base-url, or set WEBMCP_GAUGE_JUDGE_MODEL and WEBMCP_GAUGE_JUDGE_BASE_URL');
185
+ }
186
+
187
+ if (needsJudge && fixture.authoring?.modelId && judgeModel === fixture.authoring.modelId) {
188
+ fail(
189
+ `judge '${judgeModel}' authored this utterance set, so it cannot judge it: the metric would measure self-consistency`
190
+ );
191
+ }
192
+
193
+ const explicitPort = typeof flags.port === 'string' ? flags.port : process.env.CDP_PORT;
194
+ const outDir = typeof flags.out === 'string' ? flags.out : 'artifacts';
195
+ // A repo measuring more than one page needs more than one badge label, so this is
196
+ // a flag rather than a constant. The default names the metric, not the subject.
197
+ const badgeLabel = typeof flags['badge-label'] === 'string' ? flags['badge-label'] : 'webmcp invocation';
198
+ const checkpointPath = `${outDir}/sweep.jsonl`;
199
+ const tools =
200
+ typeof flags.tools === 'string' ? flags.tools.split(',').map((part) => part.trim()) : null;
201
+ const includeControls = flags['no-controls'] !== true;
202
+ const repeatsPerSession = Number(flags.repeats ?? 1);
203
+ const concurrency = Number(flags.concurrency ?? 1);
204
+
205
+ /**
206
+ * With --serve, --url is a path inside the served directory. The server is started
207
+ * per process, not per run: concurrent sessions must not share one, for the same
208
+ * reason each session starts its own browser.
209
+ */
210
+ const openTarget = async () => {
211
+ const server = serveDir ? await startFixtureServer({ root: serveDir }) : null;
212
+ return {
213
+ url: server ? server.urlFor(url) : url,
214
+ close: () => (server ? server.close() : Promise.resolve()),
215
+ };
216
+ };
217
+
218
+ if (command === 'lint') {
219
+ const failOn = flags['fail-on'] === 'warning' ? 'warning' : 'error';
220
+ if (typeof flags['fail-on'] === 'string' && !['error', 'warning'].includes(flags['fail-on'])) {
221
+ fail(`--fail-on takes 'error' or 'warning', not '${flags['fail-on']}'`);
222
+ }
223
+
224
+ const numeric = (flag) => {
225
+ if (flags[flag] === undefined) return undefined;
226
+ const value = Number(flags[flag]);
227
+ if (!Number.isFinite(value) || value < 0) fail(`--${flag} takes a non-negative number`);
228
+ return value;
229
+ };
230
+ const options = {
231
+ minDescriptionChars: numeric('min-description'),
232
+ maxProperties: numeric('max-properties'),
233
+ budgetWarnAt: numeric('budget-warn'),
234
+ };
235
+ for (const key of Object.keys(options)) if (options[key] === undefined) delete options[key];
236
+
237
+ let manifest;
238
+ let subject;
239
+
240
+ if (typeof flags.manifest === 'string') {
241
+ // A name a browser refuses to register cannot appear in a live manifest -
242
+ // Chrome 152 throws "Invalid tool name" for a name with a space - so the static
243
+ // path is the only way to lint what the page actually declares.
244
+ const parsed = JSON.parse(await readFile(flags.manifest, 'utf8'));
245
+ const variant = typeof flags.variant === 'string' ? flags.variant : null;
246
+ const list = Array.isArray(parsed)
247
+ ? parsed
248
+ : variant
249
+ ? parsed.variants?.[variant]
250
+ : parsed.tools;
251
+ if (!Array.isArray(list)) {
252
+ fail(
253
+ variant
254
+ ? `${flags.manifest} has no variants.${variant} array`
255
+ : `${flags.manifest} has no tools array (pass --variant <name> for a multi-variant fixture)`
256
+ );
257
+ }
258
+ manifest = { present: true, settled: true, tools: list };
259
+ subject = `${flags.manifest}${variant ? ` (${variant})` : ''}`;
260
+ } else {
261
+ const target = await openTarget();
262
+ const browser = explicitPort
263
+ ? null
264
+ : await launchSession({ headless: flags.headful !== true, profileDir: `${outDir}/lint-profile` });
265
+ const tab = await openSession({ port: explicitPort ?? browser.port });
266
+ try {
267
+ await tab.navigate(target.url);
268
+ manifest = await captureManifest(tab);
269
+ subject = target.url;
270
+ } finally {
271
+ await tab.close();
272
+ if (browser) await browser.close();
273
+ await target.close();
274
+ }
275
+ }
276
+
277
+ const result = lintManifest({ manifest, options });
278
+ console.log(flags.json === true ? JSON.stringify({ subject, ...result }, null, 2) : lintToText(result, { subject }));
279
+
280
+ // A manifest that is absent, unsettled or empty is not a clean page: there was
281
+ // nothing to lint, which is exit 2 rather than a pass.
282
+ if (manifest.present !== true) {
283
+ console.error('lint: no WebMCP surface on this page, so nothing was linted');
284
+ process.exitCode = EXIT.incomplete;
285
+ } else if (manifest.settled === false) {
286
+ console.error('lint: the tool set never stopped changing, so this manifest is a partial read');
287
+ process.exitCode = EXIT.incomplete;
288
+ } else if (result.manifest.toolCount === 0) {
289
+ console.error('lint: the page registered no tools, so nothing was linted');
290
+ process.exitCode = EXIT.incomplete;
291
+ } else {
292
+ const blocking =
293
+ failOn === 'warning' ? result.counts.error + result.counts.warning : result.counts.error;
294
+ if (blocking > 0) {
295
+ console.error(
296
+ `lint: ${blocking} finding${blocking === 1 ? '' : 's'} at or above ${failOn}. This is what the manifest says, not a measured invocation rate.`
297
+ );
298
+ }
299
+ process.exitCode = blocking > 0 ? EXIT.breach : EXIT.pass;
300
+ }
301
+ } else if (command === 'trial') {
302
+ const judge = createJudge({ baseUrl: judgeBaseUrl, model: judgeModel });
303
+ const utteranceId = typeof flags.utterance === 'string' ? flags.utterance : null;
304
+ const toolName =
305
+ typeof flags.tool === 'string' ? flags.tool : utteranceId?.replace(/-\d+$/, '') ?? null;
306
+ if (!toolName) fail('pass --tool <name> or --utterance <id>');
307
+
308
+ const toolBlock = fixture.tools.find((tool) => tool.name === toolName);
309
+ if (!toolBlock) fail(`fixture has no block for tool '${toolName}'`);
310
+
311
+ const utterance = utteranceId
312
+ ? toolBlock.utterances.find((candidate) => candidate.id === utteranceId)
313
+ : toolBlock.utterances[0];
314
+ if (!utterance) fail(`fixture has no utterance '${utteranceId}'`);
315
+
316
+ const target = await openTarget();
317
+ const browser = explicitPort ? null : await launchSession({ headless: flags.headful !== true });
318
+ const tab = await openSession({ port: explicitPort ?? browser.port });
319
+ try {
320
+ const record = await runTrial({
321
+ session: tab,
322
+ judge,
323
+ url: target.url,
324
+ toolName,
325
+ utterance,
326
+ expectation: utterance,
327
+ setup: toolBlock.setup ?? null,
328
+ fixtureVersion: fixture.version,
329
+ });
330
+ console.log(JSON.stringify(record, null, 2));
331
+ // Same three-way split as `run`: an outcome of null is a trial that produced no
332
+ // measurement, which is not the page failing and must not read as one.
333
+ if (record.outcome === null) {
334
+ console.error(
335
+ `trial: no measurement — ${record.harnessFailure?.kind ?? 'unknown'}: ${record.harnessFailure?.detail ?? 'no reason given'}`
336
+ );
337
+ process.exitCode = EXIT.incomplete;
338
+ } else {
339
+ process.exitCode = record.outcome === 'ok' ? EXIT.pass : EXIT.breach;
340
+ }
341
+ } catch (error) {
342
+ console.error(`trial: threw before producing a measurement — ${error.message ?? error}`);
343
+ process.exitCode = EXIT.incomplete;
344
+ } finally {
345
+ await tab.close();
346
+ if (browser) await browser.close();
347
+ await target.close();
348
+ }
349
+ } else if (command === 'session') {
350
+ const session = Number(flags.session ?? 1);
351
+ const judge = createJudge({ baseUrl: judgeBaseUrl, model: judgeModel });
352
+ const target = await openTarget();
353
+
354
+ // Each session owns its browser: a cold profile, its own port, its own process
355
+ // tree. Attaching to a shared instance is still allowed with --port, and the
356
+ // report records which of the two it was.
357
+ const browser = explicitPort
358
+ ? null
359
+ : await launchSession({
360
+ headless: flags.headful !== true,
361
+ profileDir: `${outDir}/sessions/session-${session}`,
362
+ });
363
+
364
+ const sessionMeta = browser
365
+ ? {
366
+ isolated: true,
367
+ browserPid: browser.pid,
368
+ port: browser.port,
369
+ build: browser.build,
370
+ headless: browser.headless,
371
+ profileDir: browser.profileDir,
372
+ startedAt: browser.startedAt,
373
+ ...(serveDir ? { servedFrom: serveDir, servedUrl: target.url } : {}),
374
+ }
375
+ : { isolated: false, port: String(explicitPort), note: 'attached to a pre-existing Chrome' };
376
+
377
+ try {
378
+ const result = await runSessionSweep({
379
+ fixture,
380
+ judge,
381
+ url: target.url,
382
+ session,
383
+ sessionMeta,
384
+ repeatsPerSession,
385
+ tools,
386
+ includeControls,
387
+ concurrency,
388
+ port: explicitPort ?? browser.port,
389
+ checkpointPath,
390
+ failureLogPath: `${outDir}/harness-failures.jsonl`,
391
+ onProgress: ({ completed, total, item, failures }) => {
392
+ process.stderr.write(
393
+ `\rsession ${session}: ${completed}/${total} · ${item.utterance.id} · ${failures} harness failures `
394
+ );
395
+ },
396
+ });
397
+ process.stderr.write('\n');
398
+ console.error(
399
+ `session ${session}: ${result.written.length} trials recorded, ${result.failures.length} harness failures, ${(result.elapsedMs / 1000).toFixed(0)}s`
400
+ );
401
+ // A session that could not measure part of its plan exits 2, so a hand-run
402
+ // session and the orchestrator agree on what an incomplete measurement is.
403
+ // The parent does not depend on this: it recomputes coverage from the plan.
404
+ process.exitCode = result.failures.length > 0 ? EXIT.incomplete : EXIT.pass;
405
+ } finally {
406
+ if (browser) await browser.close();
407
+ await target.close();
408
+ }
409
+ } else {
410
+ const sessions = Number(flags.sessions ?? 3);
411
+ if (!Number.isInteger(sessions) || sessions < 1) fail('--sessions must be a positive integer');
412
+ if (!Number.isInteger(repeatsPerSession) || repeatsPerSession < 1) {
413
+ fail('--repeats must be a positive integer');
414
+ }
415
+
416
+ let failUnder = null;
417
+ try {
418
+ failUnder = parseFailUnder(flags['fail-under']);
419
+ } catch (error) {
420
+ fail(error.message);
421
+ }
422
+
423
+ if (flags.resume !== true) {
424
+ const existing = await readCheckpoint(checkpointPath);
425
+ if (existing.records.length > 0) {
426
+ fail(
427
+ `${checkpointPath} already holds ${existing.records.length} trials. Pass --resume to continue it, or --out <dir> to start a fresh one.`
428
+ );
429
+ }
430
+ }
431
+
432
+ const childArgs = [
433
+ '--fixture',
434
+ typeof flags.fixture === 'string' ? flags.fixture : '../fixtures/airlock.utterances.json',
435
+ '--url',
436
+ url,
437
+ '--judge',
438
+ judgeModel,
439
+ '--base-url',
440
+ judgeBaseUrl,
441
+ '--out',
442
+ outDir,
443
+ '--repeats',
444
+ String(repeatsPerSession),
445
+ '--concurrency',
446
+ String(concurrency),
447
+ '--resume',
448
+ ...(tools ? ['--tools', tools.join(',')] : []),
449
+ ...(includeControls ? [] : ['--no-controls']),
450
+ ...(flags.headful === true ? ['--headful'] : []),
451
+ ...(serveDir ? ['--serve', serveDir] : []),
452
+ ...(explicitPort ? ['--port', String(explicitPort)] : []),
453
+ ];
454
+
455
+ const startedAt = new Date().toISOString();
456
+ const startedMs = Date.now();
457
+
458
+ const sessionResults = await runSessions({
459
+ sessions,
460
+ binPath: fileURLToPath(import.meta.url),
461
+ args: childArgs,
462
+ gapSeconds: Number(flags.gap ?? 0),
463
+ // What a working session touches. The watchdog kills a session that stops
464
+ // writing to both, which is the one stall a per-trial deadline cannot see.
465
+ progressPaths: [checkpointPath, `${outDir}/harness-failures.jsonl`],
466
+ onSessionStart: ({ session }) => console.error(`\n=== session ${session} of ${sessions} ===`),
467
+ onSessionEnd: ({ session, stalled, error }) => {
468
+ if (stalled) console.error(`=== session ${session} STALLED: ${error} ===`);
469
+ },
470
+ });
471
+
472
+ const { records } = await readCheckpoint(checkpointPath);
473
+ const loggedFailures = await readFailures(`${outDir}/harness-failures.jsonl`);
474
+
475
+ // A failure whose trial later succeeded on --resume is history, not a hole. The
476
+ // report must not list it as a gap in the current measurement, or a resumed run
477
+ // looks permanently incomplete; it is counted as recovered instead.
478
+ const measured = new Set(
479
+ records.map((record) => trialKey(record.session ?? 1, record.repeat, record.utteranceId))
480
+ );
481
+ const harnessFailures = loggedFailures.filter(
482
+ (failure) => !measured.has(trialKey(failure.session ?? 1, failure.repeat, failure.utteranceId))
483
+ );
484
+ const recoveredFailures = loggedFailures.length - harnessFailures.length;
485
+
486
+ // Completeness is derived from the plan, not from the failure log: a session
487
+ // killed mid-plan logs nothing, and a run that silently measured 900 of 960
488
+ // trials must not be allowed to exit 0 on a rate over the wrong denominator.
489
+ const plan = buildPlan({ fixture, repeatsPerSession, tools, includeControls });
490
+ const expectedKeys = [];
491
+ for (let session = 1; session <= sessions; session += 1) {
492
+ for (const item of plan) expectedKeys.push(trialKey(session, item.repeat, item.utterance.id));
493
+ }
494
+ const missing = expectedKeys.filter((key) => !measured.has(key));
495
+ const coverage = {
496
+ expectedTrials: expectedKeys.length,
497
+ measuredTrials: records.length,
498
+ missingTrials: missing.length,
499
+ missing: missing.slice(0, 10),
500
+ };
501
+
502
+ const report = buildReport({
503
+ fixture,
504
+ records,
505
+ harnessFailures,
506
+ recoveredFailures,
507
+ coverage,
508
+ judge: { model: judgeModel, baseUrl: judgeBaseUrl, requested: judgeModel },
509
+ settings: {
510
+ url,
511
+ subjectName: typeof flags.subject === 'string' ? flags.subject : null,
512
+ servedFrom: serveDir,
513
+ sessions,
514
+ repeatsPerSession,
515
+ concurrency,
516
+ tools,
517
+ includeControls,
518
+ gapSeconds: Number(flags.gap ?? 0),
519
+ isolatedSessions: !explicitPort,
520
+ },
521
+ sessionResults,
522
+ timing: { startedAt, finishedAt: new Date().toISOString(), elapsedMs: Date.now() - startedMs },
523
+ });
524
+
525
+ const gate = gateRun({ report, failUnder });
526
+ const gated = { ...report, gate };
527
+
528
+ await mkdir(dirname(`${outDir}/report.json`), { recursive: true });
529
+ await writeFile(`${outDir}/report.json`, `${JSON.stringify(gated, null, 2)}\n`, 'utf8');
530
+ await writeFile(`${outDir}/report.md`, toMarkdown(gated), 'utf8');
531
+
532
+ // A badge is written for every run, including the ones that cannot report a
533
+ // rate — an incomplete run gets a badge that says "incomplete", because the
534
+ // alternative is a stale badge from the last run that could report one.
535
+ const badge = buildBadge(gated, { label: badgeLabel });
536
+ await writeFile(`${outDir}/badge.json`, `${JSON.stringify(badge, null, 2)}\n`, 'utf8');
537
+ await writeFile(`${outDir}/badge.svg`, renderBadgeSvg(badge), 'utf8');
538
+
539
+ console.log(toMarkdown(gated));
540
+ console.error(`report.json, report.md, badge.json and badge.svg written to ${outDir}/ · checkpoint ${checkpointPath}`);
541
+ console.error(`badge: ${badge.label} — ${badge.message}`);
542
+ console.error(`gate: ${gate.summary}`);
543
+ process.exitCode = gate.code;
544
+ }