@leostera/evalkit 0.8.0 → 0.9.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 (34) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/README.md +2 -2
  3. package/dist/cli.mjs +386 -365
  4. package/dist/dashboard/assets/index-By1SJ1on.js +66 -0
  5. package/dist/dashboard/assets/index-geXMXCx9.css +2 -0
  6. package/dist/dashboard/index.html +2 -2
  7. package/dist/{files-C-U0-PWW.mjs → files-DkDQs2JL.mjs} +3 -0
  8. package/dist/{index-db-vbqEGFaU.mjs → index-db-BbVblaHz.mjs} +333 -51
  9. package/dist/index.d.mts +220 -178
  10. package/dist/index.d.mts.map +1 -1
  11. package/dist/index.mjs +2 -2
  12. package/dist/runner.d.mts +226 -168
  13. package/dist/runner.d.mts.map +1 -1
  14. package/dist/runner.mjs +2 -2
  15. package/dist/share-worker.d.mts +10 -3
  16. package/dist/share-worker.d.mts.map +1 -1
  17. package/dist/share-worker.mjs +6 -3
  18. package/dist/share.d.mts +49 -7
  19. package/dist/share.d.mts.map +1 -1
  20. package/dist/share.mjs +4 -4
  21. package/dist/skills/evalkit/SKILL.md +3 -3
  22. package/dist/skills/evalkit-agents/SKILL.md +5 -3
  23. package/dist/skills/evalkit-datasets/SKILL.md +5 -5
  24. package/dist/skills/evalkit-evals/SKILL.md +5 -5
  25. package/dist/skills/evalkit-judges/SKILL.md +5 -3
  26. package/dist/skills/evalkit-matrices/SKILL.md +2 -2
  27. package/dist/skills/evalkit-results/SKILL.md +6 -6
  28. package/dist/{src-CZnDXP1K.mjs → src-Bn6fnZnF.mjs} +8 -3
  29. package/dist/{src-LeGSyDD2.mjs → src-BrfIXfO2.mjs} +72 -54
  30. package/dist/{src-mm8L_IxM.mjs → src-CcdLZqFk.mjs} +422 -230
  31. package/dist/upgrade-60SIRius.mjs +110 -0
  32. package/package.json +1 -1
  33. package/dist/dashboard/assets/index-C12IeG2y.css +0 -2
  34. package/dist/dashboard/assets/index-xlVsW6ZR.js +0 -37
package/dist/cli.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/bin/sh
2
2
  ':' //; exec "${EVALKIT_RUNTIME:-$(command -v bun || command -v node)}" "$0" "$@"
3
- import { B as defineEvalMatrix, V as canonicalParameters, a as authoringId, c as defineSuite, i as agentRuntimes, m as registerEvals, p as recordError } from "./src-LeGSyDD2.mjs";
4
- import { a as readRunSummary, d as trialWorkspacePath, f as staticDatasetFile, i as readRunManifest, l as readTrialSummary, m as globMatcher, n as runMatrix, o as readTrialEvents, p as staticDatasetFiles, r as ownerIsGone, s as readTrialManifest, t as runEval, u as localReportStore } from "./src-mm8L_IxM.mjs";
5
- import { c as dashboardIndex, d as encodeBundle, o as repoKey } from "./index-db-vbqEGFaU.mjs";
6
- import { t as createDirectoryShareApp } from "./src-CZnDXP1K.mjs";
3
+ import { H as canonicalParameters, V as defineEvalMatrix, a as authoringId, c as defineSuite, i as agentRuntimes, m as registerEvals, p as recordError } from "./src-BrfIXfO2.mjs";
4
+ import { a as readRunEvents, c as readRunSummary, d as staticDatasetFile, f as staticDatasetFiles, i as ownerIsGone, l as localReportStore, n as upgradeReports, o as readRunManifest, p as globMatcher, r as runMatrix, t as runEval, u as runWorkspacePath } from "./src-CcdLZqFk.mjs";
5
+ import { f as encodeBundle, i as normalizeBasePath, l as dashboardIndex, s as repoKey } from "./index-db-BbVblaHz.mjs";
6
+ import { t as createDirectoryShareApp } from "./src-Bn6fnZnF.mjs";
7
7
  import { Effect, LogLevel, Logger } from "effect";
8
- import { cp, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
8
+ import { cp, lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
9
9
  import path, { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
10
10
  import fs, { existsSync } from "node:fs";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -166,64 +166,6 @@ function shortSource(source) {
166
166
  //#endregion
167
167
  //#region ../cli/src/dashboard-reports.ts
168
168
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
169
- function runMetrics(trials) {
170
- const byName = /* @__PURE__ */ new Map();
171
- for (const trial of trials) for (const result of trial?.scoring?.metrics ?? []) {
172
- const entry = byName.get(result.name) ?? {
173
- name: result.name,
174
- ...result.unit ? { unit: result.unit } : {},
175
- ...result.better ? { better: result.better } : {},
176
- ...result.builtIn ? { builtIn: true } : {},
177
- count: 0,
178
- sum: 0
179
- };
180
- if (result.value !== void 0) {
181
- entry.count += 1;
182
- entry.sum += result.value;
183
- }
184
- byName.set(result.name, entry);
185
- }
186
- return [...byName.values()].map(({ sum, ...metric }) => ({
187
- ...metric,
188
- ...metric.count ? { mean: sum / metric.count } : {}
189
- }));
190
- }
191
- function runScorers(trials) {
192
- const byKey = /* @__PURE__ */ new Map();
193
- for (const trial of trials) for (const score of trial?.scoring?.results ?? []) {
194
- const kind = score.kind;
195
- const key = `${kind}\0${score.name}`;
196
- const entry = byKey.get(key) ?? {
197
- name: score.name,
198
- kind,
199
- count: 0,
200
- skipped: 0,
201
- sum: 0
202
- };
203
- if (score.skipped) entry.skipped += 1;
204
- else if (score.value !== void 0) {
205
- entry.count += 1;
206
- entry.sum += score.value;
207
- }
208
- byKey.set(key, entry);
209
- }
210
- return [...byKey.values()].map(({ sum, ...scorer }) => ({
211
- ...scorer,
212
- ...scorer.count ? { mean: sum / scorer.count } : {}
213
- }));
214
- }
215
- function runDatasets(manifests) {
216
- const byId = /* @__PURE__ */ new Map();
217
- for (const manifest of manifests) for (const dataset of manifest?.datasets ?? []) {
218
- const digests = byId.get(dataset.id) ?? /* @__PURE__ */ new Set();
219
- digests.add(dataset.digest);
220
- byId.set(dataset.id, digests);
221
- }
222
- return [...byId].map(([id, digests]) => ({
223
- id,
224
- digests: [...digests].sort()
225
- }));
226
- }
227
169
  /** Written into a run directory by `evalkit tidy`; deleting it restores the run. */
228
170
  const ARCHIVE_MARKER = "archived.json";
229
171
  function isMissing(error) {
@@ -240,23 +182,19 @@ async function ifPresent(operation) {
240
182
  function assertUuid(id, label) {
241
183
  if (!UUID_PATTERN.test(id)) throw new Error(`Invalid ${label}`);
242
184
  }
243
- function trialPath(root, runId, trialId) {
185
+ function runPath(root, runId) {
244
186
  assertUuid(runId, "run ID");
245
- assertUuid(trialId, "trial ID");
246
- return resolve(root, runId, "trials", trialId);
247
- }
248
- function runStatus(summary) {
249
- if (summary.status === "running") return "running";
250
- if (summary.status === "cancelled") return "cancelled";
251
- if (summary.status !== "completed") return "errored";
252
- return summary.failed === 0 && summary.passed === summary.trialCount ? "passed" : "failed";
253
- }
254
- function trialStatus(summary) {
255
- if (summary.status === "running") return "running";
256
- if (summary.status === "cancelled") return "cancelled";
257
- if (summary.status !== "completed") return "errored";
258
- return summary.scoring?.passed === true ? "passed" : "failed";
187
+ return resolve(root, runId);
259
188
  }
189
+ /**
190
+ * Headers for files an agent, a dataset or an adapter produced: untrusted
191
+ * content on the dashboard's origin, so never rendered or sniffed as HTML.
192
+ */
193
+ const UNTRUSTED_FILE = {
194
+ "content-type": "application/octet-stream",
195
+ "x-content-type-options": "nosniff",
196
+ "content-security-policy": "sandbox"
197
+ };
260
198
  function fileContentType(file) {
261
199
  return {
262
200
  ".css": "text/css",
@@ -265,15 +203,17 @@ function fileContentType(file) {
265
203
  }[extname(file)] ?? "application/octet-stream";
266
204
  }
267
205
  /**
268
- * Read saved local reports. A missing summary means "running"; malformed reports are errors.
269
- * Runs written by an older EvalKit (manifest `schemaVersion` < 2) are skipped and counted.
206
+ * Read saved local reports. A missing summary means "running"; malformed
207
+ * reports are errors. Runs from an older report format (before schema
208
+ * version 6) are skipped and counted, for `evalkit upgrade` to convert.
270
209
  */
271
- function createDashboardReportReader(root, registry) {
210
+ function createDashboardReportReader(root) {
272
211
  const legacyRuns = /* @__PURE__ */ new Set();
273
212
  async function listRuns() {
274
213
  const entries = await ifPresent(() => readdir(root, { withFileTypes: true }));
275
214
  if (!entries) return [];
276
215
  const ids = entries.filter((entry) => entry.isDirectory() && UUID_PATTERN.test(entry.name)).map((entry) => entry.name);
216
+ legacyRuns.clear();
277
217
  return (await Promise.all(ids.map(readRun))).filter((run) => run !== void 0);
278
218
  }
279
219
  /** True for a run from a report format this version cannot read; never for corrupt JSON. */
@@ -286,7 +226,7 @@ function createDashboardReportReader(root, registry) {
286
226
  } catch {
287
227
  return false;
288
228
  }
289
- return typeof version === "number" && version < 2;
229
+ return typeof version === "number" && version < 6;
290
230
  }
291
231
  async function readRun(id) {
292
232
  if (await isLegacyRun(id)) {
@@ -296,15 +236,9 @@ function createDashboardReportReader(root, registry) {
296
236
  const manifest = await ifPresent(() => readRunManifest(root, id));
297
237
  if (!manifest) return void 0;
298
238
  const summary = await ifPresent(() => readRunSummary(root, id));
299
- const trialIds = await ifPresent(() => readdir(resolve(root, id, "trials")));
300
- const trials = await Promise.all((trialIds ?? []).map((trialId) => ifPresent(() => readTrialSummary(root, id, trialId))));
301
- const trialManifests = await Promise.all((trialIds ?? []).map((trialId) => ifPresent(() => readTrialManifest(root, id, trialId))));
302
- const scores = trials.flatMap((trial) => trial?.scoring?.overall === void 0 ? [] : [trial.scoring.overall]);
303
- const scorers = runScorers(trials);
304
- const metrics = runMetrics(trials);
305
- const datasets = runDatasets(trialManifests);
306
239
  const archived = await ifPresent(() => stat(resolve(root, id, ARCHIVE_MARKER)));
307
240
  const interrupted = !summary && ownerIsGone(manifest.process);
241
+ const scoring = summary?.scoring;
308
242
  return {
309
243
  id,
310
244
  ...archived ? { archived: true } : {},
@@ -321,76 +255,76 @@ function createDashboardReportReader(root, registry) {
321
255
  manifest.aut.id,
322
256
  manifest.aut.version
323
257
  ].filter(Boolean).join(" / ") } : {},
324
- status: summary ? runStatus(summary) : interrupted ? "cancelled" : "running",
258
+ status: summary ? summary.status : interrupted ? "cancelled" : "running",
325
259
  ...interrupted ? { interrupted: true } : {},
326
260
  startedAt: manifest.startedAt,
327
261
  ...summary?.endedAt ? { completedAt: summary.endedAt } : {},
328
262
  ...summary?.durationMs !== void 0 ? { durationMs: summary.durationMs } : {},
329
- completedTrials: summary?.trialCount ?? trials.filter(Boolean).length,
330
- requestedTrials: summary?.trialCount ?? registry.get(manifest.evalId)?.policy?.trials ?? 1,
331
- ...scores.length ? { score: scores.reduce((sum, score) => sum + score, 0) / scores.length } : {},
332
- ...scorers.length ? { scorers } : {},
333
- ...metrics.length ? { metrics } : {},
334
- ...datasets.length ? { datasets } : {}
335
- };
336
- }
337
- async function listTrials(runId) {
338
- assertUuid(runId, "run ID");
339
- const ids = await ifPresent(() => readdir(resolve(root, runId, "trials")));
340
- if (!ids) return [];
341
- const runManifest = await ifPresent(() => readRunManifest(root, runId));
342
- const ownerGone = ownerIsGone(runManifest?.process);
343
- return (await Promise.all(ids.map(async (id) => {
344
- const manifest = await readTrialManifest(root, runId, id);
345
- const summary = await ifPresent(() => readTrialSummary(root, runId, id));
346
- return {
263
+ ...manifest.batch ? { batch: manifest.batch } : {},
264
+ ...scoring?.overall === void 0 ? {} : { score: scoring.overall },
265
+ ...scoring?.results.length ? { scorers: scoring.results.map((result) => ({
266
+ name: result.name,
267
+ kind: result.kind,
268
+ ...result.skipped ? { skipped: true } : {},
269
+ ...result.value === void 0 || result.skipped ? {} : { value: result.value }
270
+ })) } : {},
271
+ ...scoring?.metrics?.length ? { metrics: scoring.metrics.map((metric) => ({
272
+ name: metric.name,
273
+ ...metric.unit ? { unit: metric.unit } : {},
274
+ ...metric.better ? { better: metric.better } : {},
275
+ ...metric.builtIn ? { builtIn: true } : {},
276
+ ...metric.value === void 0 ? {} : { value: metric.value }
277
+ })) } : {},
278
+ ...manifest.datasets?.length ? { datasets: manifest.datasets.map(({ id, digest }) => ({
347
279
  id,
348
- index: manifest.trialIndex,
349
- status: summary ? trialStatus(summary) : ownerGone ? "cancelled" : "running",
350
- startedAt: manifest.startedAt,
351
- ...summary?.endedAt ? { completedAt: summary.endedAt } : {},
352
- ...summary?.durationMs !== void 0 ? { durationMs: summary.durationMs } : {},
353
- ...summary?.scoring?.overall === void 0 ? {} : { score: summary.scoring.overall },
354
- scores: summary?.scoring?.results ?? [],
355
- ...summary?.scoring?.metrics ? { metrics: summary.scoring.metrics } : {},
356
- checkpoints: summary?.scoring?.checkpoints ?? [],
357
- ...summary?.scoring?.skippedScorers ? { skippedScorers: summary.scoring.skippedScorers } : {}
358
- };
359
- }))).sort((left, right) => left.index - right.index);
280
+ digest
281
+ })) } : {}
282
+ };
360
283
  }
361
- async function trialDetail(runId, trialId) {
362
- trialPath(root, runId, trialId);
284
+ async function runDetail(runId) {
285
+ runPath(root, runId);
286
+ const manifest = await readRunManifest(root, runId);
287
+ const summary = await ifPresent(() => readRunSummary(root, runId));
288
+ const scoring = summary?.scoring;
363
289
  return {
364
- manifest: await readTrialManifest(root, runId, trialId),
365
- summary: await ifPresent(() => readTrialSummary(root, runId, trialId)) ?? { status: "running" }
290
+ scores: scoring?.results ?? [],
291
+ ...scoring?.metrics ? { metrics: scoring.metrics } : {},
292
+ checkpoints: scoring?.checkpoints ?? [],
293
+ ...scoring?.skippedScorers ? { skippedScorers: scoring.skippedScorers } : {},
294
+ ...summary?.attachments?.length ? { attachments: summary.attachments } : {},
295
+ manifest,
296
+ summary: summary ?? { status: "running" }
366
297
  };
367
298
  }
368
- async function listArtifacts(runId, trialId) {
369
- return walkArtifacts(resolve(trialPath(root, runId, trialId), "artifacts"));
299
+ async function listArtifacts(runId) {
300
+ return walkArtifacts(resolve(runPath(root, runId), "artifacts"));
370
301
  }
371
- async function listCandidateWorkspace(runId, trialId) {
372
- return walkArtifacts(resolve(trialPath(root, runId, trialId), "artifacts", "candidate"));
302
+ async function listCandidateWorkspace(runId) {
303
+ return walkArtifacts(resolve(runPath(root, runId), "artifacts", "candidate"));
373
304
  }
374
- async function candidateFile(runId, trialId, relativePath) {
375
- const candidateRoot = resolve(trialPath(root, runId, trialId), "artifacts", "candidate");
376
- const file = resolve(candidateRoot, relativePath);
377
- if (file !== candidateRoot && !file.startsWith(`${candidateRoot}${sep}`)) throw new Error("Invalid workspace path");
378
- if (!(await stat(file)).isFile()) throw new Error("Workspace path is not a file");
379
- return new Response(await readFile(file), { headers: { "content-type": fileContentType(file) } });
305
+ /** A file below one of a run's artifact trees; never outside it. */
306
+ async function artifactFile(runId, tree, relativePath) {
307
+ const treeRoot = resolve(runPath(root, runId), "artifacts", tree);
308
+ const file = resolve(treeRoot, relativePath);
309
+ if (file === treeRoot || !file.startsWith(`${treeRoot}${sep}`)) throw new Error(`Invalid ${tree === "candidate" ? "workspace" : "attachment"} path`);
310
+ if (!(await lstat(file)).isFile()) throw new Error(`${tree === "candidate" ? "Workspace path" : "Attachment"} is not a file`);
311
+ return new Response(await readFile(file), { headers: UNTRUSTED_FILE });
380
312
  }
381
- async function trajectory(runId, trialId) {
382
- trialPath(root, runId, trialId);
383
- return await ifPresent(() => readTrialEvents(root, runId, trialId)) ?? [];
313
+ const candidateFile = (runId, path) => artifactFile(runId, "candidate", path);
314
+ const attachmentFile = (runId, name) => artifactFile(runId, "attachments", name);
315
+ async function trajectory(runId) {
316
+ runPath(root, runId);
317
+ return await ifPresent(() => readRunEvents(root, runId)) ?? [];
384
318
  }
385
319
  return {
386
320
  listRuns,
387
- /** Run IDs skipped by the last `listRuns()` because an older EvalKit wrote them. */
321
+ /** Run IDs skipped by the last `listRuns()`: an older EvalKit wrote them; `evalkit upgrade` converts them. */
388
322
  legacyRunIds: () => [...legacyRuns],
389
- listTrials,
390
- trialDetail,
323
+ runDetail,
391
324
  listArtifacts,
392
325
  listCandidateWorkspace,
393
326
  candidateFile,
327
+ attachmentFile,
394
328
  trajectory
395
329
  };
396
330
  }
@@ -461,40 +395,32 @@ async function buildDashboard(options, step = quietStep) {
461
395
  }
462
396
  return files;
463
397
  }, (files) => count(files, "dataset file"));
464
- const reports = createDashboardReportReader(reportRoot, registry);
398
+ const reports = createDashboardReportReader(reportRoot);
465
399
  const snapshot = await step("Run reports", async (progress) => {
466
400
  const all = await reports.listRuns();
467
401
  const runs = all.filter((run) => !run.archived);
468
402
  await write("runs.json", { runs });
469
- let trials = 0;
470
403
  let files = 0;
471
- const runTrials = [];
472
404
  for (const [index, run] of runs.entries()) {
473
405
  progress(`run ${index + 1}/${runs.length} · ${run.id.slice(0, 8)}`);
474
406
  const written = await snapshotRun(reports, run.id, directorySink(resolve(data, "runs", run.id)));
475
- trials += written.trialIds.length;
476
407
  files += written.files;
477
- runTrials.push({
478
- id: run.id,
479
- trials: written.trialIds
480
- });
481
408
  }
482
409
  return {
483
410
  runs: runs.length,
484
- trials,
485
411
  files,
486
412
  skipped: reports.legacyRunIds().length,
487
413
  archived: all.length - runs.length,
488
- runTrials
414
+ runIds: runs.map((run) => run.id)
489
415
  };
490
- }, ({ runs, trials, files, skipped, archived }) => `${count(runs, "run")} · ${count(trials, "trial")} · ${count(files, "candidate file")}` + (archived ? ` · ${archived} archived excluded` : "") + (skipped ? ` · ${skipped} older-format skipped` : ""));
416
+ }, ({ runs, files, skipped, archived }) => `${count(runs, "run")} · ${count(files, "candidate file")}` + (archived ? ` · ${archived} archived excluded` : "") + (skipped ? ` · ${skipped} older-format skipped (run \`evalkit upgrade\`)` : ""));
491
417
  await step("Route pages", async () => {
492
418
  const catalog = registry.catalog();
493
419
  const routes = staticRoutes({
494
420
  scenarios: catalog.map((entry) => entry.id),
495
421
  agents: catalog.flatMap((entry) => entry.agent.id ? [entry.agent.id] : []),
496
422
  datasets: catalog.flatMap((entry) => entry.datasets.map((dataset) => dataset.id)),
497
- runs: snapshot.runTrials
423
+ runs: snapshot.runIds
498
424
  });
499
425
  for (const route of routes) {
500
426
  const file = resolve(outDir, `${route}.html`);
@@ -504,7 +430,7 @@ async function buildDashboard(options, step = quietStep) {
504
430
  await writeFile(resolve(outDir, "404.html"), dashboardIndex(template, { base: "/" }));
505
431
  return routes.length;
506
432
  }, (pages) => `${count(pages, "page")} with clean /path URLs`);
507
- const { runTrials: _runs, ...summary } = snapshot;
433
+ const { runIds: _runs, ...summary } = snapshot;
508
434
  return {
509
435
  outDir,
510
436
  ...summary
@@ -522,7 +448,6 @@ function staticRoutes(ids) {
522
448
  return [
523
449
  "scoreboard",
524
450
  "runs",
525
- "trials",
526
451
  "workspaces",
527
452
  "library/agents",
528
453
  "library/judges",
@@ -531,7 +456,11 @@ function staticRoutes(ids) {
531
456
  ...under("library/scenarios", ids.scenarios),
532
457
  ...under("library/agents", ids.agents),
533
458
  ...under("library/datasets", ids.datasets),
534
- ...ids.runs.filter((run) => safe(run.id)).flatMap((run) => [`runs/${run.id}`, ...run.trials.filter(safe).flatMap((trial) => [`runs/${run.id}/trials/${trial}`, `runs/${run.id}/trials/${trial}/workspace`])])
459
+ ...ids.runs.filter(safe).flatMap((id) => [
460
+ `runs/${id}`,
461
+ `runs/${id}/workspace`,
462
+ `runs/${id}/attachments`
463
+ ])
535
464
  ];
536
465
  }
537
466
  /** `runs/abc.html` is served at `<mount>/runs/abc`; its base is one level up. */
@@ -564,33 +493,33 @@ function directorySink(root) {
564
493
  }
565
494
  /**
566
495
  * One run's snapshot, shared by `evalkit build` (files on disk) and
567
- * `evalkit share` (an upload bundle): `trials.json`, then per trial
568
- * `detail.json`, `events.json`, `artifacts.json`, `workspace.json`, and the
569
- * candidate files under `workspace/`. Evaluator workspaces are never read.
496
+ * `evalkit share` (an upload bundle): `detail.json`, `events.json`,
497
+ * `artifacts.json` and `workspace.json`, the candidate files under
498
+ * `workspace/`, and attachments under `attachments/` (both left out with
499
+ * `workspaceFiles: false`; they stay listed). Evaluator workspaces are never
500
+ * read.
570
501
  */
571
502
  async function snapshotRun(reports, runId, sink, options = {}) {
572
- const trials = await reports.listTrials(runId);
573
- await sink.json("trials.json", { trials });
503
+ const detail = await reports.runDetail(runId);
504
+ await sink.json("detail.json", detail);
505
+ await sink.json("events.json", { events: await reports.trajectory(runId) });
506
+ await sink.json("artifacts.json", { artifacts: await reports.listArtifacts(runId) });
507
+ const workspace = await reports.listCandidateWorkspace(runId);
508
+ await sink.json("workspace.json", workspace);
509
+ if (options.workspaceFiles === false) return { files: 0 };
574
510
  let files = 0;
575
- for (const trial of trials) {
576
- const base = `trials/${trial.id}`;
577
- await sink.json(`${base}/detail.json`, await reports.trialDetail(runId, trial.id));
578
- await sink.json(`${base}/events.json`, { events: await reports.trajectory(runId, trial.id) });
579
- await sink.json(`${base}/artifacts.json`, { artifacts: await reports.listArtifacts(runId, trial.id) });
580
- const workspace = await reports.listCandidateWorkspace(runId, trial.id);
581
- await sink.json(`${base}/workspace.json`, workspace);
582
- if (options.workspaceFiles === false) continue;
583
- for (const entry of workspace) {
584
- if (entry.kind !== "file") continue;
585
- const response = await reports.candidateFile(runId, trial.id, entry.path);
586
- await sink.bytes(`${base}/workspace/${entry.path}`, new Uint8Array(await response.arrayBuffer()));
587
- files += 1;
588
- }
511
+ for (const attachment of detail.attachments ?? []) {
512
+ const response = await reports.attachmentFile(runId, attachment.name);
513
+ await sink.bytes(`attachments/${attachment.name}`, new Uint8Array(await response.arrayBuffer()));
514
+ files += 1;
589
515
  }
590
- return {
591
- trialIds: trials.map((trial) => trial.id),
592
- files
593
- };
516
+ for (const entry of workspace) {
517
+ if (entry.kind !== "file") continue;
518
+ const response = await reports.candidateFile(runId, entry.path);
519
+ await sink.bytes(`workspace/${entry.path}`, new Uint8Array(await response.arrayBuffer()));
520
+ files += 1;
521
+ }
522
+ return { files };
594
523
  }
595
524
  /** Total size and file count of a directory, for the build summary. */
596
525
  async function directorySize(root) {
@@ -639,7 +568,7 @@ function listMatrixCells(matrix, input) {
639
568
  if (![
640
569
  "eval",
641
570
  "agent",
642
- "trials",
571
+ "repeat",
643
572
  ...[...Object.keys(matrix.cases?.[0] ?? {}), ...Object.keys(matrix.parameters)].map((key) => `axis:${key}`)
644
573
  ].includes(input.sort)) throw new Error(`Unknown matrix sort: ${input.sort}`);
645
574
  const selection = input.evalIds.length ? { evals: input.evalIds } : {};
@@ -663,7 +592,7 @@ function listMatrixCells(matrix, input) {
663
592
  }
664
593
  if (input.sort === "eval") return cell.eval.name ?? cell.eval.id;
665
594
  if (input.sort === "agent") return cell.eval.agent?.identity?.name ?? cell.eval.agent?.identity?.kind ?? "";
666
- return cell.eval.policy?.trials ?? 1;
595
+ return cell.eval.repeat ?? 1;
667
596
  }
668
597
  cells.sort((left, right) => {
669
598
  const a = value(left);
@@ -894,7 +823,12 @@ async function discover(root, config) {
894
823
  async function isDirectory(path) {
895
824
  return stat(path).then((info) => info.isDirectory(), () => false);
896
825
  }
897
- async function loadProject(cwd, configPath) {
826
+ /**
827
+ * The project's config and root, without importing any scenario: enough for
828
+ * commands that only touch results (`evalkit upgrade`), and that must work
829
+ * while scenarios from an older EvalKit still fail to load.
830
+ */
831
+ async function loadConfig(cwd, configPath) {
898
832
  const candidates = configPath ? [resolve(cwd, configPath)] : [
899
833
  "evalkit.config.ts",
900
834
  "evalkit.config.js",
@@ -908,7 +842,16 @@ async function loadProject(cwd, configPath) {
908
842
  const root = file ? dirname(file) : resolve(cwd);
909
843
  const config = file ? (await importProjectModule(file)).default : {};
910
844
  if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("Config must default-export defineConfig({...})");
845
+ if (config.execution && "trials" in config.execution) throw new Error("execution.trials was renamed to execution.repeat in EvalKit 0.9");
911
846
  for (const [key, value] of Object.entries(config.execution ?? {})) if (value !== void 0 && (!Number.isSafeInteger(value) || value < 1)) throw new Error(`execution.${key} must be a positive integer`);
847
+ return {
848
+ root,
849
+ ...file ? { file } : {},
850
+ config
851
+ };
852
+ }
853
+ async function loadProject(cwd, configPath) {
854
+ const { root, file, config } = await loadConfig(cwd, configPath);
912
855
  let registry = config.registry;
913
856
  if (!file && !configPath && await exists$1(resolve(root, "src/registry.ts"))) registry = (await importProjectModule(resolve(root, "src/registry.ts"))).default;
914
857
  let loadErrors = [];
@@ -996,25 +939,23 @@ function cellLabel(name, parameters) {
996
939
  const shown = Object.entries(parameters).map(([key, value]) => `${key}=${typeof value === "string" ? value : JSON.stringify(value)}`).join(" ");
997
940
  return shown ? `${bold(name)} ${dim(shown)}` : bold(name);
998
941
  }
999
- function passed(result) {
1000
- return result.status === "completed" && (result.aggregateScoring?.passRate === 1 || Boolean(result.scoring?.passed));
1001
- }
1002
942
  /** Live log for runs launched from the `evalkit dev` dashboard. */
1003
943
  const runLog = {
1004
944
  started(name, parameters = {}, suiteId) {
1005
945
  const suite = suiteId ? dim(` (suite ${suiteId})`) : "";
1006
946
  console.log(`${clock()} ${cyan("▶ run ")} ${cellLabel(name, parameters)}${suite}`);
1007
947
  },
1008
- finished(name, parameters, result, ms) {
1009
- const ok = passed(result);
1010
- const trials = result.trials?.length ?? 1;
1011
- const passedTrials = result.aggregateScoring?.passed ?? (ok ? trials : 0);
1012
- const status = result.status === "completed" ? ok ? green("✓ pass") : red("✗ fail") : red(`✗ ${result.status}`);
1013
- console.log(`${clock()} ${status} ${cellLabel(name, parameters)} ${dim(`${passedTrials}/${trials} trials · ${duration(ms)}`)}`);
1014
- console.log(` ${dim(`report ${result.reportLocation}`)}`);
948
+ /** One line per run; a batch's runs say which one they are. */
949
+ finished(name, parameters, runs, ms) {
950
+ for (const result of runs) {
951
+ const status = result.status === "passed" ? green("✓ pass") : result.status === "failed" ? red("✗ fail") : red(`✗ ${result.status}`);
952
+ const batch = result.batch ? ` #${result.batch.index + 1}/${result.batch.size}` : "";
953
+ console.log(`${clock()} ${status} ${cellLabel(name, parameters)}${dim(`${batch} · ${duration(result.durationMs ?? ms)}`)}`);
954
+ console.log(` ${dim(`report ${result.reportLocation}`)}`);
955
+ }
1015
956
  },
1016
- cell(cell, result, ms) {
1017
- runLog.finished(cell.eval.name ?? authoringId(cell.eval), cell.parameters, result, ms);
957
+ cell(cell, runs, ms) {
958
+ runLog.finished(cell.eval.name ?? authoringId(cell.eval), cell.parameters, runs, ms);
1018
959
  },
1019
960
  failed(name, error) {
1020
961
  console.log(`${clock()} ${red("✗ error")} ${bold(name)} ${error instanceof Error ? error.message : String(error)}`);
@@ -1066,9 +1007,9 @@ async function confirm(question) {
1066
1007
  }
1067
1008
  //#endregion
1068
1009
  //#region ../cli/src/run-reporter.ts
1069
- function scenarioOutcome(result) {
1010
+ function runOutcome(result) {
1070
1011
  if (result.status === "cancelled") return "cancelled";
1071
- return result.status === "completed" && (result.trials ? result.trials.length > 0 && result.trials.every((trial) => trial.status === "completed" && trial.scoring?.passed) : Boolean(result.scoring?.passed)) ? "passed" : "failed";
1012
+ return result.status === "passed" ? "passed" : "failed";
1072
1013
  }
1073
1014
  function passAtFor(definition, name) {
1074
1015
  const rule = [...definition.transcript, ...definition.scoring].find((step) => step.kind === "predicate" && step.name === name);
@@ -1087,51 +1028,48 @@ function checkpointReason(definition, checkpoint) {
1087
1028
  const passAt = passAtFor(definition, checkpoint.name);
1088
1029
  return `step ${checkpoint.step} ${checkpoint.name} ${checkpoint.value === void 0 ? checkpoint.status : value(checkpoint)}${passAt === void 0 ? "" : ` (passes at ${passAt})`}`;
1089
1030
  }
1090
- /** What went wrong in a scenario, deduplicated across its trials. */
1091
- function reasonsFor(definition, trials) {
1031
+ /** What went wrong in a run. */
1032
+ function reasonsFor(definition, run) {
1092
1033
  const reasons = /* @__PURE__ */ new Set();
1093
1034
  const skipped = /* @__PURE__ */ new Set();
1094
- for (const trial of trials) {
1095
- if (trial.status === "failed" && trial.error) reasons.add(`error: ${trial.error.message}`);
1096
- for (const checkpoint of trial.scoring?.checkpoints ?? []) if (checkpoint.status === "failed" || checkpoint.status === "error") reasons.add(checkpointReason(definition, checkpoint));
1097
- for (const score of trial.scoring?.results ?? []) if (score.skipped) skipped.add(score.name);
1098
- else if (!score.passed) reasons.add(scoreReason(definition, score));
1099
- for (const name of trial.scoring?.skippedScorers ?? []) if (trial.status !== "cancelled") reasons.add(`${name} not run`);
1100
- }
1035
+ if (run.status === "errored" && run.error) reasons.add(`error: ${run.error.message}`);
1036
+ for (const checkpoint of run.scoring?.checkpoints ?? []) if (checkpoint.status === "failed" || checkpoint.status === "error") reasons.add(checkpointReason(definition, checkpoint));
1037
+ for (const score of run.scoring?.results ?? []) if (score.skipped) skipped.add(score.name);
1038
+ else if (!score.passed) reasons.add(scoreReason(definition, score));
1039
+ for (const name of run.scoring?.skippedScorers ?? []) if (run.status !== "cancelled") reasons.add(`${name} not run`);
1101
1040
  return {
1102
1041
  reasons: [...reasons],
1103
1042
  skipped: [...skipped]
1104
1043
  };
1105
1044
  }
1106
- function scenarioResult(cell, result, suiteId) {
1045
+ function runReport(cell, result, suiteId) {
1107
1046
  const evalId = authoringId(cell.eval);
1108
- const trials = result.trials ?? [result];
1109
1047
  return {
1110
1048
  label: suiteId ? `${suiteId}/${evalId}` : evalId,
1111
1049
  evalId,
1112
1050
  ...suiteId ? { suiteId } : {},
1113
1051
  parameters: cell.parameters,
1114
- outcome: scenarioOutcome(result),
1052
+ outcome: runOutcome(result),
1115
1053
  ...result.durationMs !== void 0 ? { durationMs: result.durationMs } : {},
1116
1054
  result,
1117
- ...reasonsFor(cell.eval, trials)
1055
+ ...reasonsFor(cell.eval, result)
1118
1056
  };
1119
1057
  }
1120
1058
  function parameterLabel(parameters) {
1121
1059
  return Object.entries(parameters).map(([key, item]) => `${key}=${typeof item === "string" ? item : JSON.stringify(item)}`).join(" ");
1122
1060
  }
1123
- /** One printed line for a finished scenario; failing reasons on the same line. */
1124
- function scenarioLine(scenario) {
1061
+ /** One printed line for a finished run; failing reasons on the same line. */
1062
+ function runLine(scenario) {
1125
1063
  const mark = scenario.outcome === "passed" ? green("✓") : scenario.outcome === "cancelled" ? yellow("◌") : red("✗");
1126
1064
  const parameters = parameterLabel(scenario.parameters);
1127
- const trials = scenario.result.trials;
1128
- const trialCount = trials && trials.length > 1 ? dim(` ${trials.filter((trial) => trial.status === "completed" && trial.scoring?.passed).length}/${trials.length} trials`) : "";
1065
+ const batch = scenario.result.batch;
1066
+ const position = batch ? dim(` #${batch.index + 1}/${batch.size}`) : "";
1129
1067
  const details = [
1130
1068
  ...scenario.outcome === "failed" ? scenario.reasons : [],
1131
1069
  ...scenario.outcome === "cancelled" ? ["cancelled"] : [],
1132
1070
  ...scenario.skipped.map((name) => `${name} skipped`)
1133
1071
  ];
1134
- return ` ${mark} ${bold(scenario.label)}${parameters ? ` ${dim(parameters)}` : ""}${trialCount}${details.length ? ` ${dim(details.join(" · "))}` : ""}`;
1072
+ return ` ${mark} ${bold(scenario.label)}${parameters ? ` ${dim(parameters)}` : ""}${position}${details.length ? ` ${dim(details.join(" · "))}` : ""}`;
1135
1073
  }
1136
1074
  /**
1137
1075
  * Prints run progress. On a terminal, failures print as they happen and a live
@@ -1177,29 +1115,29 @@ function createRunReporter(options) {
1177
1115
  timer.unref?.();
1178
1116
  }
1179
1117
  },
1180
- onEvent(trial, event) {
1181
- if (event.source === "runner" && event.kind === "trial-started") {
1182
- running.set(trial.trialId, {
1183
- label: options.labelFor(trial),
1118
+ onEvent(run, event) {
1119
+ if (event.source === "runner" && event.kind === "run-started") {
1120
+ running.set(run.runId, {
1121
+ label: options.labelFor(run),
1184
1122
  startedAt: now()
1185
1123
  });
1186
1124
  return;
1187
1125
  }
1188
- const activity = running.get(trial.trialId);
1126
+ const activity = running.get(run.runId);
1189
1127
  if (!activity) return;
1190
- if (event.source === "runner" && (event.kind === "trial-completed" || event.kind === "error")) running.delete(trial.trialId);
1128
+ if (event.source === "runner" && (event.kind === "run-completed" || event.kind === "error")) running.delete(run.runId);
1191
1129
  else if (event.source === "aut" && event.kind === "tool-call") activity.doing = `tool ${event.name}`;
1192
1130
  else if (event.source === "runner" && event.kind === "scorer-started") activity.doing = `scoring ${event.scorer}`;
1193
1131
  else if (event.source === "runner" && event.kind === "checkpoint-started") activity.doing = `check ${event.name}`;
1194
1132
  },
1195
- onScenario(scenario) {
1133
+ onRun(scenario) {
1196
1134
  scenarios.push(scenario);
1197
- for (const trial of scenario.result.trials ?? [scenario.result]) running.delete(trial.trialId);
1198
- if (!options.interactive || options.verbose || scenario.outcome !== "passed") print(scenarioLine(scenario));
1135
+ running.delete(scenario.result.runId);
1136
+ if (!options.interactive || options.verbose || scenario.outcome !== "passed") print(runLine(scenario));
1199
1137
  },
1200
1138
  /** Called on the first Ctrl+C. */
1201
1139
  cancelling() {
1202
- print(`\n ${yellow("◌")} Cancelling: finishing running trials. Press Ctrl+C again to exit now.`);
1140
+ print(`\n ${yellow("◌")} Cancelling: finishing running runs. Press Ctrl+C again to exit now.`);
1203
1141
  },
1204
1142
  finish(durationMs) {
1205
1143
  if (timer) clearInterval(timer);
@@ -1208,11 +1146,12 @@ function createRunReporter(options) {
1208
1146
  passed: scenarios.filter((s) => s.outcome === "passed").length,
1209
1147
  failed: scenarios.filter((s) => s.outcome === "failed").length,
1210
1148
  cancelled: scenarios.filter((s) => s.outcome === "cancelled").length,
1211
- scenarios: scenarios.length,
1149
+ runs: scenarios.length,
1150
+ scenarios: new Set(scenarios.map((s) => `${s.label} ${parameterLabel(s.parameters)}`)).size,
1212
1151
  durationMs
1213
1152
  };
1214
1153
  },
1215
- scenarios: () => scenarios
1154
+ runs: () => scenarios
1216
1155
  };
1217
1156
  }
1218
1157
  function totalsLine(totals) {
@@ -1221,7 +1160,8 @@ function totalsLine(totals) {
1221
1160
  totals.failed ? red(`${totals.failed} failed`) : "",
1222
1161
  totals.cancelled ? yellow(`${totals.cancelled} cancelled`) : ""
1223
1162
  ].filter(Boolean);
1224
- return ` ${bold(parts.join(" · "))} ${dim(`· ${plural(totals.scenarios, "scenario")} in ${duration(totals.durationMs)}`)}`;
1163
+ const coverage = totals.runs === totals.scenarios ? plural(totals.runs, "run") : `${plural(totals.runs, "run")} of ${plural(totals.scenarios, "scenario")}`;
1164
+ return ` ${bold(parts.join(" · "))} ${dim(`· ${coverage} in ${duration(totals.durationMs)}`)}`;
1225
1165
  }
1226
1166
  //#endregion
1227
1167
  //#region ../cli/src/junit.ts
@@ -1231,8 +1171,8 @@ function escape(text) {
1231
1171
  const seconds = (ms) => ((ms ?? 0) / 1e3).toFixed(3);
1232
1172
  /**
1233
1173
  * A JUnit XML report: one test suite per EvalKit suite (`default` for
1234
- * scenarios without one) and one test case per scenario, per matrix cell
1235
- * when it has parameters.
1174
+ * scenarios without one) and one test case per run, named after its scenario
1175
+ * and parameters, and its place in a batch (` #2`) when repeated.
1236
1176
  */
1237
1177
  function junitReport(scenarios) {
1238
1178
  const bySuite = /* @__PURE__ */ new Map();
@@ -1246,7 +1186,8 @@ function junitReport(scenarios) {
1246
1186
  const time = cases.reduce((sum, c) => sum + (c.durationMs ?? 0), 0);
1247
1187
  const testcases = cases.map((scenario) => {
1248
1188
  const parameters = parameterLabel(scenario.parameters);
1249
- const attributes = `name="${escape(parameters ? `${scenario.evalId} [${parameters}]` : scenario.evalId)}" classname="${escape(suite)}" time="${seconds(scenario.durationMs)}"`;
1189
+ const batch = scenario.result.batch;
1190
+ const attributes = `name="${escape((parameters ? `${scenario.evalId} [${parameters}]` : scenario.evalId) + (batch ? ` #${batch.index + 1}` : ""))}" classname="${escape(suite)}" time="${seconds(scenario.durationMs)}"`;
1250
1191
  if (scenario.outcome === "failed") return ` <testcase ${attributes}>\n <failure message="${escape(scenario.reasons.join("; ") || "failed")}">${escape(scenario.reasons.join("\n"))}</failure>\n </testcase>`;
1251
1192
  if (scenario.outcome === "cancelled") return ` <testcase ${attributes}>\n <skipped message="cancelled"/>\n </testcase>`;
1252
1193
  if (allSkipped(scenario)) return ` <testcase ${attributes}>\n <skipped message="${escape(`all scorers skipped: ${scenario.skipped.join(", ")}`)}"/>\n </testcase>`;
@@ -1256,15 +1197,15 @@ function junitReport(scenarios) {
1256
1197
  });
1257
1198
  return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites name="evalkit" tests="${scenarios.length}" failures="${scenarios.filter((c) => c.outcome === "failed").length}">\n${suites.join("\n")}\n</testsuites>\n`;
1258
1199
  }
1259
- /** True when every score of every trial was skip()ped. */
1200
+ /** True when every score was skip()ped. */
1260
1201
  function allSkipped(scenario) {
1261
- const results = (scenario.result.trials ?? [scenario.result]).flatMap((trial) => trial.scoring?.results ?? []);
1202
+ const results = scenario.result.scoring?.results ?? [];
1262
1203
  return results.length > 0 && results.every((score) => score.skipped);
1263
1204
  }
1264
1205
  //#endregion
1265
1206
  //#region ../cli/src/version.ts
1266
1207
  /** The CLI version; kept in sync with package.json when releasing. */
1267
- const VERSION = "0.8.0";
1208
+ const VERSION = "0.9.0";
1268
1209
  //#endregion
1269
1210
  //#region ../cli/src/run-command.ts
1270
1211
  /** Validates `run` flags into a selection, parameter overrides and limits. */
@@ -1291,28 +1232,27 @@ function normalizeRunOptions(values, positionals) {
1291
1232
  const index = value.indexOf("=");
1292
1233
  if (index < 1) throw new Error("--select requires axis=value");
1293
1234
  const axis = value.slice(0, index);
1294
- let choice;
1235
+ const raw = value.slice(index + 1);
1236
+ let choices;
1295
1237
  try {
1296
- choice = JSON.parse(value.slice(index + 1));
1238
+ choices = [JSON.parse(raw)];
1297
1239
  } catch {
1298
- choice = value.slice(index + 1);
1240
+ choices = raw.split(",").filter(Boolean);
1299
1241
  }
1300
- (selection[axis] ??= []).push(choice);
1242
+ (selection[axis] ??= []).push(...choices);
1301
1243
  }
1302
1244
  for (const value of values.param ?? []) {
1303
1245
  const index = value.indexOf("=");
1304
1246
  if (index < 1) throw new Error("--param requires name=JSON");
1305
1247
  parameters[value.slice(0, index)] = JSON.parse(value.slice(index + 1));
1306
1248
  }
1307
- if (values.suite && values.matrix) throw new Error("--suite and --matrix cannot be used together");
1308
- if ((values.suite || values.matrix) && positionals.length) throw new Error("Use --eval to select evals with --suite or --matrix");
1309
1249
  return {
1310
1250
  values,
1311
1251
  positionals,
1312
1252
  parameters,
1313
1253
  selection,
1314
1254
  concurrency: integer("concurrency"),
1315
- trials: integer("trials")
1255
+ repeat: integer("repeat")
1316
1256
  };
1317
1257
  }
1318
1258
  /** Load errors that affect what was asked for, so the run must not pretend it covered them. */
@@ -1333,35 +1273,32 @@ async function runProjectCommand(cwd, parsed) {
1333
1273
  const project = await loadProject(cwd, values.config);
1334
1274
  process.chdir(project.root);
1335
1275
  const registry = project.registry;
1336
- const requestedEvals = values.eval?.flatMap((value) => value.split(",")) ?? [];
1276
+ const requestedEvals = [...values.eval ?? [], ...positionals].flatMap((value) => value.split(",").filter(Boolean));
1337
1277
  let matrix;
1338
1278
  let suiteId;
1339
1279
  const quiet = Boolean(values.json || values["dry-run"]);
1340
1280
  if (!quiet) banner("run", VERSION);
1281
+ const suite = values.suite ? registry.suites.find((item) => item.id === values.suite) : void 0;
1341
1282
  if (values.suite) {
1342
- const suite = registry.suites.find((item) => item.id === values.suite);
1343
1283
  const broken = project.loadErrors.filter((error) => error.suiteId === values.suite);
1344
1284
  if (!suite && !broken.length) throw new Error(`Unknown suite: ${values.suite}`);
1345
1285
  suiteId = values.suite;
1346
- matrix = defineEvalMatrix({
1347
- id: "default",
1348
- evals: suite?.evals ?? [],
1349
- parameters: project.config.matrix?.parameters ?? {},
1350
- defaults: project.config.matrix?.defaults,
1351
- cases: project.config.matrix?.cases,
1352
- exclude: project.config.matrix?.exclude
1353
- });
1354
- } else if (values.matrix) {
1355
- matrix = registry.matrices.find((item) => item.id === values.matrix);
1356
- if (!matrix) throw new Error(`Unknown matrix: ${values.matrix}`);
1357
- } else {
1358
- requestedEvals.push(...positionals.flatMap((value) => value.split(",")));
1359
- matrix = project.config.matrix ? registry.matrices.at(-1) : defineEvalMatrix({
1360
- id: "default",
1361
- evals: registry.evals,
1362
- parameters: {}
1363
- });
1364
1286
  }
1287
+ const configured = values.matrix ? registry.matrices.find((item) => item.id === values.matrix) : project.config.matrix ? registry.matrices.at(-1) : void 0;
1288
+ if (values.matrix && !configured) throw new Error(`Unknown matrix: ${values.matrix}`);
1289
+ if (configured && suite) {
1290
+ const inSuite = new Set(suite.evals.map(authoringId));
1291
+ const both = configured.evals.map(authoringId).filter((id) => inSuite.has(id));
1292
+ if (!both.length) throw new Error(`Matrix ${authoringId(configured)} has no scenarios from suite ${suite.id}`);
1293
+ const outside = requestedEvals.filter((id) => !both.includes(id));
1294
+ if (outside.length) throw new Error(`Not in both matrix ${authoringId(configured)} and suite ${suite.id}: ${outside.join(", ")}`);
1295
+ if (!requestedEvals.length) requestedEvals.push(...both);
1296
+ }
1297
+ matrix = configured ?? defineEvalMatrix({
1298
+ id: "default",
1299
+ evals: values.suite ? suite?.evals ?? [] : registry.evals,
1300
+ parameters: {}
1301
+ });
1365
1302
  const broken = brokenSelection(project.loadErrors, {
1366
1303
  ...suiteId ? { suiteId } : {},
1367
1304
  evalIds: requestedEvals,
@@ -1385,14 +1322,17 @@ async function runProjectCommand(cwd, parsed) {
1385
1322
  else throw new Error(`Unknown matrix axis: ${axis}`);
1386
1323
  const cells = matrix.count(selection);
1387
1324
  if (!cells) throw new Error("Selection contains no eligible cells; check cases and exclusions");
1388
- const trials = parsed.trials ?? project.config.execution?.trials;
1325
+ const repeat = parsed.repeat ?? project.config.execution?.repeat;
1326
+ let runs = 0;
1327
+ for (const cell of matrix.cells(selection)) runs += repeat ?? cell.eval.repeat ?? 1;
1389
1328
  const plan = {
1390
1329
  matrix: authoringId(matrix),
1391
1330
  evals: requestedEvals.length ? requestedEvals : matrix.evals.map((definition) => definition.id),
1392
1331
  cells,
1332
+ runs,
1393
1333
  parameters: selection.parameters,
1394
1334
  overrides: selection.overrides,
1395
- trials: trials ?? "eval policy"
1335
+ repeat: repeat ?? "scenario"
1396
1336
  };
1397
1337
  if (values["dry-run"]) {
1398
1338
  console.log(JSON.stringify(plan, null, 2));
@@ -1409,14 +1349,15 @@ async function runProjectCommand(cwd, parsed) {
1409
1349
  ] });
1410
1350
  const suiteOf = new Map(registry.suites.flatMap((suite) => suite.evals.map((evaluation) => [authoringId(evaluation), suite.id])));
1411
1351
  const suiteFor = (evalId) => suiteId ?? suiteOf.get(evalId);
1412
- if (!values.json) console.log(` Running ${plural(cells, "scenario")}${sweeps ? ` from matrix ${plan.matrix}` : ""}${suiteId ? ` from suite ${suiteId}` : ""}` + (source ? ` at ${shortSource(source)}${source.ref ? ` (${source.ref})` : ""}` : " (untracked: not in a git repository)") + "\n");
1352
+ if (!values.json) console.log(` Running ${runs === cells ? plural(cells, "scenario") : `${plural(runs, "run")} of ${plural(cells, "scenario")}`}${sweeps ? ` from matrix ${plan.matrix}` : ""}${suiteId ? ` from suite ${suiteId}` : ""}` + (source ? ` at ${shortSource(source)}${source.ref ? ` (${source.ref})` : ""}` : " (untracked: not in a git repository)") + "\n");
1413
1353
  const reporter = createRunReporter({
1414
1354
  interactive: interactive && !values.json,
1415
1355
  verbose: Boolean(values.verbose),
1416
- total: cells,
1417
- labelFor: (trial) => {
1418
- const suite = suiteFor(trial.evalId);
1419
- return suite ? `${suite}/${trial.evalId}` : trial.evalId;
1356
+ total: runs,
1357
+ labelFor: (run) => {
1358
+ const suite = suiteFor(run.evalId);
1359
+ const label = suite ? `${suite}/${run.evalId}` : run.evalId;
1360
+ return run.batch ? `${label} #${run.batch.index + 1}/${run.batch.size}` : label;
1420
1361
  },
1421
1362
  write: values.json ? () => {} : void 0
1422
1363
  });
@@ -1433,7 +1374,7 @@ async function runProjectCommand(cwd, parsed) {
1433
1374
  summary = await Effect.runPromise(runnerLogs(runMatrix(matrix, {
1434
1375
  selection,
1435
1376
  concurrency: parsed.concurrency ?? project.config.execution?.concurrency ?? 4,
1436
- trials,
1377
+ ...repeat ? { repeat } : {},
1437
1378
  ...suiteId ? { suiteId } : {},
1438
1379
  suiteFor: (cell) => suiteFor(authoringId(cell.eval)),
1439
1380
  ...values.local ? { runtime: "local" } : {},
@@ -1443,20 +1384,23 @@ async function runProjectCommand(cwd, parsed) {
1443
1384
  workspaceRoot: sandboxDir,
1444
1385
  keepWorkspaces: project.config.keepSandboxes,
1445
1386
  artifactExclude: project.config.artifacts?.exclude,
1387
+ attachmentLimits: project.config.artifacts?.attachments,
1446
1388
  signal: controller.signal,
1447
- onEvent: (trial, event) => reporter.onEvent(trial, event),
1448
- onResult: (cell, result) => {
1449
- const scenario = scenarioResult(cell, result, suiteFor(authoringId(cell.eval)));
1450
- if (values.json) console.log(JSON.stringify({
1451
- cell: {
1452
- key: cell.key,
1453
- eval: authoringId(cell.eval),
1454
- parameters: cell.parameters
1455
- },
1456
- outcome: scenario.outcome,
1457
- result
1458
- }));
1459
- reporter.onScenario(scenario);
1389
+ onEvent: (run, event) => reporter.onEvent(run, event),
1390
+ onResult: (cell, results) => {
1391
+ for (const result of results) {
1392
+ const report = runReport(cell, result, suiteFor(authoringId(cell.eval)));
1393
+ if (values.json) console.log(JSON.stringify({
1394
+ cell: {
1395
+ key: cell.key,
1396
+ eval: authoringId(cell.eval),
1397
+ parameters: cell.parameters
1398
+ },
1399
+ outcome: report.outcome,
1400
+ result
1401
+ }));
1402
+ reporter.onRun(report);
1403
+ }
1460
1404
  }
1461
1405
  })));
1462
1406
  } finally {
@@ -1466,7 +1410,7 @@ async function runProjectCommand(cwd, parsed) {
1466
1410
  if (values.junit) {
1467
1411
  const file = resolve(cwd, values.junit);
1468
1412
  await mkdir(dirname(file), { recursive: true });
1469
- await writeFile(file, junitReport(reporter.scenarios()));
1413
+ await writeFile(file, junitReport(reporter.runs()));
1470
1414
  }
1471
1415
  if (values.json) console.log(JSON.stringify({ summary: totals }));
1472
1416
  else {
@@ -1498,7 +1442,7 @@ function projectEvalIds(registry) {
1498
1442
  */
1499
1443
  async function tidyReports(options) {
1500
1444
  const known = projectEvalIds(options.registry);
1501
- const reports = createDashboardReportReader(options.reportRoot, options.registry);
1445
+ const reports = createDashboardReportReader(options.reportRoot);
1502
1446
  const result = {
1503
1447
  archived: [],
1504
1448
  restored: [],
@@ -1529,19 +1473,19 @@ async function tidyReports(options) {
1529
1473
  return result;
1530
1474
  }
1531
1475
  /**
1532
- * Remove the trial workspaces `keep` would not have kept: those of passed
1533
- * trials by default. Unfinished trials are never touched, so a run in
1534
- * progress keeps its workspaces; workspaces without a report are left alone.
1476
+ * Remove the run workspaces `keep` would not have kept: those of passed runs
1477
+ * by default. Unfinished runs are never touched, so a run in progress keeps
1478
+ * its workspace; workspaces without a report are left alone.
1535
1479
  */
1536
1480
  async function tidySandboxes(options) {
1537
1481
  const keep = options.keep ?? "failed";
1538
1482
  if (keep === "all") return { removed: 0 };
1539
- const reports = createDashboardReportReader(options.reportRoot, options.registry);
1483
+ const reports = createDashboardReportReader(options.reportRoot);
1540
1484
  let removed = 0;
1541
- for (const run of await reports.listRuns()) for (const trial of await reports.listTrials(run.id)) {
1542
- if (trial.status === "running") continue;
1543
- if (keep === "failed" && trial.status !== "passed") continue;
1544
- const workspace = trialWorkspacePath(options.sandboxRoot, trial.id);
1485
+ for (const run of await reports.listRuns()) {
1486
+ if (run.status === "running") continue;
1487
+ if (keep === "failed" && run.status !== "passed") continue;
1488
+ const workspace = runWorkspacePath(options.sandboxRoot, run.id);
1545
1489
  if (!await stat(workspace).catch(() => void 0)) continue;
1546
1490
  await rm(workspace, {
1547
1491
  recursive: true,
@@ -1601,11 +1545,22 @@ function noRunsMessage(selection, head, options) {
1601
1545
  if (selection.running) return `No finished runs for ${at}: ${plural(selection.running, "run")} still running. Share again when they finish.`;
1602
1546
  return `No runs for ${at}. Run \`evalkit run\` first${options.all ? "" : ", or pass --all to share runs from other commits"}.`;
1603
1547
  }
1604
- /** Where to look after sharing: the commit, or the repository if several commits were shared. */
1605
- function shareTarget(serverUrl, head, runs) {
1606
- const commits = new Set(runs.map((run) => run.source.commit));
1607
- const commit = commits.size === 1 ? `/-/${[...commits][0]}` : "";
1608
- return new URL(`/${repoKey(head)}${commit}`, serverUrl).href;
1548
+ /**
1549
+ * A share server API URL. The server may live below a path
1550
+ * (`https://example.com/evals`), so paths resolve below `share.url`.
1551
+ */
1552
+ function shareApiUrl(serverUrl, path) {
1553
+ return new URL(path, serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`);
1554
+ }
1555
+ /**
1556
+ * Where to look after sharing: the commit, or the repository if several
1557
+ * commits were shared. Both come from a run URL the server returned
1558
+ * (`<repo pages>/-/<sha>/runs/<id>`), since only the server knows its layout.
1559
+ */
1560
+ function shareTarget(serverUrl, runUrl, runs) {
1561
+ const commitUrl = runUrl?.replace(/\/runs\/[^/]+\/?$/, "");
1562
+ if (!commitUrl || commitUrl === runUrl) return serverUrl;
1563
+ return new Set(runs.map((run) => run.source.commit)).size === 1 ? commitUrl : commitUrl.replace(/\/-\/[0-9a-f]{40}$/, "") || serverUrl;
1609
1564
  }
1610
1565
  /** Build one run's upload bundle: run.json, catalog.json, and its snapshot. */
1611
1566
  async function runBundle(options) {
@@ -1613,7 +1568,10 @@ async function runBundle(options) {
1613
1568
  const encode = (value) => new TextEncoder().encode(`${JSON.stringify(value)}\n`);
1614
1569
  files.push({
1615
1570
  path: "run.json",
1616
- data: encode(options.run)
1571
+ data: encode({
1572
+ snapshotVersion: 2,
1573
+ ...options.run
1574
+ })
1617
1575
  });
1618
1576
  const saved = await readFile(resolve(options.reportRoot, options.run.id, "catalog.json")).catch(() => void 0);
1619
1577
  files.push({
@@ -1658,7 +1616,7 @@ async function resolveShareHeaders(headers) {
1658
1616
  }
1659
1617
  /** Check that the URL is an EvalKit share server and the headers get through. */
1660
1618
  async function checkShareServer(serverUrl, headers) {
1661
- const response = await fetch(new URL("/api/v1/repos", serverUrl), {
1619
+ const response = await fetch(shareApiUrl(serverUrl, "api/v1/repos"), {
1662
1620
  headers,
1663
1621
  redirect: "manual"
1664
1622
  });
@@ -1681,7 +1639,7 @@ async function uploadBundle(serverUrl, bundle, options = {}) {
1681
1639
  throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`Upload failed: ${String(lastError)}`);
1682
1640
  }
1683
1641
  async function postBundle(serverUrl, bundle, headers) {
1684
- const response = await fetch(new URL("/api/v1/uploads", serverUrl), {
1642
+ const response = await fetch(shareApiUrl(serverUrl, "api/v1/uploads"), {
1685
1643
  method: "POST",
1686
1644
  headers: {
1687
1645
  ...headers,
@@ -1716,7 +1674,7 @@ async function expectShareServer(response, serverUrl) {
1716
1674
  if (denied || response.ok && !type.includes("json")) throw new ShareServerError(`${new URL(serverUrl).host} did not answer as an EvalKit share server (${response.status}${type ? `, ${type.split(";")[0]}` : ""}). ` + (denied ? "Check share.headers." : "Check share.url."));
1717
1675
  }
1718
1676
  async function shareRuns(options) {
1719
- const reports = createDashboardReportReader(options.reportRoot, options.registry);
1677
+ const reports = createDashboardReportReader(options.reportRoot);
1720
1678
  return options.step("Uploading", async (progress) => {
1721
1679
  const result = {
1722
1680
  created: 0,
@@ -1737,6 +1695,7 @@ async function shareRuns(options) {
1737
1695
  const uploaded = await uploadBundle(options.serverUrl, bundle, { headers: options.headers });
1738
1696
  result.bytes += bundle.byteLength;
1739
1697
  result[uploaded.status] += 1;
1698
+ result.url ??= uploaded.url;
1740
1699
  } catch (error) {
1741
1700
  if (error instanceof ShareServerError) throw error;
1742
1701
  result.failed.push({
@@ -1789,7 +1748,7 @@ export default defineConfig({
1789
1748
  "datasets/greeting/greeting.txt": "Hello\n",
1790
1749
  "datasets/greeting.dataset.ts": `import { defineDataset } from '@leostera/evalkit';
1791
1750
 
1792
- // Files the agent can read: copied into each trial's workspace under greeting/.
1751
+ // Files the agent can read: copied into each run's workspace under greeting/.
1793
1752
  // Paths are relative to the project root.
1794
1753
  export const greeting = defineDataset({
1795
1754
  id: 'greeting',
@@ -1865,14 +1824,14 @@ bun run evalkit build # write a read-only, shareable s
1865
1824
  Coding agents can use the EvalKit skills that ship with the package, in
1866
1825
  \`node_modules/@leostera/evalkit/dist/skills/\` (start with \`evalkit/SKILL.md\`).
1867
1826
 
1868
- EvalKit installs from npm. Reports, trial workspaces, and dashboard snapshots stay local
1827
+ EvalKit installs from npm. Reports, run workspaces, and dashboard snapshots stay local
1869
1828
  and are ignored by Git. A snapshot in \`_evalkit-dashboard/\` contains report messages,
1870
1829
  scores, evidence, and candidate files (never evaluator workspaces): review it before sharing.
1871
1830
  `;
1872
1831
  }
1873
1832
  //#endregion
1874
1833
  //#region ../cli/src/new-project.ts
1875
- const npmDependency = "^0.8.0";
1834
+ const npmDependency = "^0.9.0";
1876
1835
  const ignored = [
1877
1836
  "node_modules/",
1878
1837
  "_evalkit-results/",
@@ -1996,24 +1955,59 @@ const FINISHED = /* @__PURE__ */ new Set([
1996
1955
  function unitKey(run) {
1997
1956
  return `${run.evalId}\0${canonicalParameters(run.parameters ?? {})}`;
1998
1957
  }
1999
- /** The run that represents each unit: the latest finished one, else the latest at all. */
1958
+ /**
1959
+ * What represents each unit: the latest finished run's batch (its finished
1960
+ * runs), or that run alone; else the latest run at all.
1961
+ */
2000
1962
  function latestByUnit(runs) {
2001
- const byUnit = /* @__PURE__ */ new Map();
2002
- let ignored = 0;
2003
- const ordered = [...runs].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
1963
+ const latest = /* @__PURE__ */ new Map();
1964
+ const ordered = [...runs].filter((run) => run.status !== "running").sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2004
1965
  for (const run of ordered) {
2005
- if (run.status === "running") continue;
2006
1966
  const key = unitKey(run);
2007
- const current = byUnit.get(key);
2008
- if (!current) byUnit.set(key, run);
2009
- else {
2010
- ignored += 1;
2011
- if (!FINISHED.has(current.status) && FINISHED.has(run.status)) byUnit.set(key, run);
2012
- }
1967
+ const current = latest.get(key);
1968
+ if (!current || !FINISHED.has(current.status) && FINISHED.has(run.status)) latest.set(key, run);
1969
+ }
1970
+ const byUnit = /* @__PURE__ */ new Map();
1971
+ let used = 0;
1972
+ for (const [key, run] of latest) {
1973
+ const members = run.batch ? ordered.filter((other) => other.batch?.id === run.batch.id && FINISHED.has(other.status)) : [run];
1974
+ used += members.length;
1975
+ byUnit.set(key, side(members));
2013
1976
  }
2014
1977
  return {
2015
1978
  byUnit,
2016
- ignored
1979
+ ignored: ordered.length - used
1980
+ };
1981
+ }
1982
+ function side(runs) {
1983
+ const scored = runs.filter((run) => run.status !== "errored");
1984
+ const passed = scored.filter((run) => run.status === "passed").length;
1985
+ const values = /* @__PURE__ */ new Map();
1986
+ for (const run of runs) for (const scorer of run.scorers ?? []) {
1987
+ const key = `${scorer.kind}\0${scorer.name}`;
1988
+ const entry = values.get(key) ?? {
1989
+ scorer,
1990
+ sum: 0,
1991
+ n: 0
1992
+ };
1993
+ if (scorer.value !== void 0 && run.status !== "errored") {
1994
+ entry.sum += scorer.value;
1995
+ entry.n += 1;
1996
+ }
1997
+ values.set(key, entry);
1998
+ }
1999
+ const first = runs[0];
2000
+ return {
2001
+ ...first,
2002
+ status: !scored.length ? first.status : passed === runs.length ? "passed" : "failed",
2003
+ scorers: [...values.values()].map(({ scorer, sum, n }) => ({
2004
+ name: scorer.name,
2005
+ kind: scorer.kind,
2006
+ ...n ? { value: sum / n } : {}
2007
+ })),
2008
+ passed,
2009
+ total: runs.length,
2010
+ runs: [...runs]
2017
2011
  };
2018
2012
  }
2019
2013
  function scorerSignature(run) {
@@ -2030,8 +2024,9 @@ function classify(base, head) {
2030
2024
  change: "not-comparable",
2031
2025
  reason: "different scorers"
2032
2026
  };
2033
- if (base.status === "passed" && head.status === "failed") return { change: "regressed" };
2034
- if (base.status === "failed" && head.status === "passed") return { change: "fixed" };
2027
+ const rate = (side) => side.passed / side.total;
2028
+ if (rate(head) < rate(base)) return { change: "regressed" };
2029
+ if (rate(head) > rate(base)) return { change: "fixed" };
2035
2030
  return { change: "unchanged" };
2036
2031
  }
2037
2032
  function label(run, suite) {
@@ -2056,8 +2051,8 @@ function suiteScorers(units) {
2056
2051
  name: scorer.name,
2057
2052
  kind: scorer.kind
2058
2053
  });
2059
- if (scorer.mean === void 0) continue;
2060
- sides[side].set(key, [...sides[side].get(key) ?? [], scorer.mean]);
2054
+ if (scorer.value === void 0) continue;
2055
+ sides[side].set(key, [...sides[side].get(key) ?? [], scorer.value]);
2061
2056
  }
2062
2057
  }
2063
2058
  for (const unit of units) {
@@ -2111,7 +2106,11 @@ function compareRuns(baseRuns, headRuns) {
2111
2106
  const changedDatasets = /* @__PURE__ */ new Set();
2112
2107
  for (const unit of units) {
2113
2108
  if (!unit.base || !unit.head) continue;
2114
- const digests = (run) => new Map((run.datasets ?? []).map((d) => [d.id, d.digests.join(",")]));
2109
+ const digests = (side) => {
2110
+ const byId = /* @__PURE__ */ new Map();
2111
+ for (const run of side.runs) for (const dataset of run.datasets ?? []) byId.set(dataset.id, (byId.get(dataset.id) ?? /* @__PURE__ */ new Set()).add(dataset.digest));
2112
+ return new Map([...byId].map(([id, set]) => [id, [...set].sort().join(",")]));
2113
+ };
2115
2114
  const before = digests(unit.base);
2116
2115
  const after = digests(unit.head);
2117
2116
  for (const id of /* @__PURE__ */ new Set([...before.keys(), ...after.keys()])) if (before.get(id) !== after.get(id)) changedDatasets.add(id);
@@ -2120,10 +2119,10 @@ function compareRuns(baseRuns, headRuns) {
2120
2119
  for (const unit of units) bySuite.set(unit.suite, [...bySuite.get(unit.suite) ?? [], unit]);
2121
2120
  const suites = [...bySuite].map(([suite, members]) => {
2122
2121
  const side = (pick) => {
2123
- const runs = members.flatMap((unit) => unit[pick] ? [unit[pick]] : []);
2122
+ const sides = members.flatMap((unit) => unit[pick] ? [unit[pick]] : []);
2124
2123
  return {
2125
- passed: runs.filter((run) => run.status === "passed").length,
2126
- total: runs.length
2124
+ passed: sides.reduce((sum, entry) => sum + entry.passed, 0),
2125
+ total: sides.reduce((sum, entry) => sum + entry.total, 0)
2127
2126
  };
2128
2127
  };
2129
2128
  return {
@@ -2232,8 +2231,8 @@ function headRuns(runs, head, options) {
2232
2231
  return runs.filter((run) => run.source !== void 0 && run.source.commit === head.commit && repoKey(run.source) === repoKey(head) && (!run.source.dirty || options.allowDirty) && !run.archived && run.status !== "running");
2233
2232
  }
2234
2233
  /** Base runs from a results directory, e.g. an artifact from the base pipeline. Dirty runs are never a base. */
2235
- async function baseRunsFromDirectory(directory, registry) {
2236
- return (await createDashboardReportReader(directory, registry).listRuns()).filter((run) => !run.source?.dirty && !run.archived);
2234
+ async function baseRunsFromDirectory(directory) {
2235
+ return (await createDashboardReportReader(directory).listRuns()).filter((run) => !run.source?.dirty && !run.archived);
2237
2236
  }
2238
2237
  /** A full SHA, a unique prefix of at least 7 characters, or a ref's latest shared commit. */
2239
2238
  function resolveSharedRef(commits, ref) {
@@ -2260,7 +2259,7 @@ async function getJson(url, serverUrl, headers) {
2260
2259
  /** Base runs shared for `ref` of this repository; `undefined` when the base was never shared. */
2261
2260
  async function baseRunsFromServer(options) {
2262
2261
  const repo = repoKey(options.head);
2263
- const api = (path) => new URL(`/api/v1/repos/${repo}/-/${path}`, options.serverUrl);
2262
+ const api = (path) => shareApiUrl(options.serverUrl, `api/v1/repos/${repo}/-/${path}`);
2264
2263
  const listed = await getJson(api("commits"), options.serverUrl, options.headers);
2265
2264
  const commits = Array.isArray(listed?.commits) ? listed.commits : [];
2266
2265
  const commit = resolveSharedRef(commits, options.ref);
@@ -5257,11 +5256,12 @@ function createProgram(actions) {
5257
5256
  showGlobalOptions: false
5258
5257
  }).addHelpText("after", `\nRun from your eval project's directory. Docs: https://evalkit.leostera.dev/docs/\n`);
5259
5258
  program.command("new").description("create an eval project (never overwrites files)").argument("<directory>", "where to create it; use . for the current directory").addHelpText("after", examples(["bunx @leostera/evalkit new ./agent-evals", "evalkit new ."])).action((directory) => actions.create(directory));
5260
- program.command("run").description("run scenarios and save the results in _evalkit-results/").argument("[eval-ids...]", "scenarios to run (default: all)").addOption(configOption()).option("-e, --eval <id>", "run this scenario; repeatable, or comma-separated", collect).option("-s, --suite <id>", "run every scenario in this suite").option("-m, --matrix <id>", "run this matrix").option("--model <value>", "--select model=<value> for each comma-separated value", collect).option("--mode <value>", "--select mode=<value> for each comma-separated value", collect).option("--select <axis=value>", "select a value of any matrix axis; repeatable", collect).option("--param <name=json>", "set a parameter for every scenario; repeatable", collect).option("-n, --trials <n>", "trials per scenario, overriding config and policy").option("-j, --concurrency <n>", "matrix cells to run at once (default: 4)").option("--max-tokens <n>", "pass maxTokens to the agent in context.parameters").option("--turn-budget <n>", "pass turnBudget to the agent in context.parameters").option("--chat-timeout-ms <n>", "pass chatTimeoutMs to the agent in context.parameters").option("--local", "require the agent's local runtime").option("--all", "allow more matrix cells than execution.maxCells").option("--dry-run", "print the plan as JSON; run nothing").option("--json", "print one JSON result per matrix cell").option("--junit <file>", "write a JUnit XML report").option("--skip-broken", "run healthy scenarios when some files fail to load, then exit 1").option("-v, --verbose", "print every scenario, not only failures").addOption(logLevelOption()).addHelpText("after", examples([
5259
+ program.command("run").description("run scenarios and save the results in _evalkit-results/").argument("[eval-ids...]", "scenarios to run (default: all)").addOption(configOption()).option("-e, --eval <id>", "run this scenario; repeatable, or comma-separated", collect).option("-s, --suite <id>", "run the scenarios in this suite; with -m, only those in both").option("-m, --matrix <id>", "run this matrix").option("--model <value>", "--select model=<value> for each comma-separated value", collect).option("--mode <value>", "--select mode=<value> for each comma-separated value", collect).option("-a, --select <axis=value>", "keep only these values of a matrix axis (a for axis); repeatable, comma-separated", collect).option("--param <name=json>", "set a parameter for every scenario; repeatable", collect).option("-n, --repeat <n>", "runs of each scenario, as one batch; overrides config and scenario").option("-j, --concurrency <n>", "matrix cells to run at once (default: 4)").option("--max-tokens <n>", "pass maxTokens to the agent in context.parameters").option("--turn-budget <n>", "pass turnBudget to the agent in context.parameters").option("--chat-timeout-ms <n>", "pass chatTimeoutMs to the agent in context.parameters").option("--local", "require the agent's local runtime").option("--all", "allow more matrix cells than execution.maxCells").option("--dry-run", "print the plan as JSON; run nothing").option("--json", "print one JSON result per matrix cell").option("--junit <file>", "write a JUnit XML report").option("--skip-broken", "run healthy scenarios when some files fail to load, then exit 1").option("-v, --verbose", "print every scenario, not only failures").addOption(logLevelOption()).addHelpText("after", examples([
5261
5260
  "evalkit run",
5262
5261
  "evalkit run greeting -n 5",
5263
5262
  "evalkit run -s billing --junit _evalkit-results/junit.xml",
5264
- "evalkit run -m models --model model-a,model-b --dry-run"
5263
+ "evalkit run -m models --model model-a,model-b --dry-run",
5264
+ "evalkit run -m models -s testing -a model=luna -a toolchain=riot"
5265
5265
  ])).action((evalIds, options) => {
5266
5266
  setRunnerLogLevel(options.logLevel);
5267
5267
  return actions.run(runInput(evalIds, options));
@@ -5275,11 +5275,12 @@ function createProgram(actions) {
5275
5275
  return actions.build(options);
5276
5276
  });
5277
5277
  program.command("tidy").description("archive runs of scenarios no longer in the project, and remove sandboxes keepSandboxes would not keep").addOption(configOption()).action((options) => actions.tidy(options));
5278
+ program.command("upgrade").description("convert results saved by an older EvalKit to the current format, in place").addOption(configOption()).option("--dry-run", "show what would change without changing anything").action((options) => actions.upgrade(options));
5278
5279
  program.command("share").description("upload this commit's results to a share server").addOption(configOption()).option("--server <url>", "share server to use instead of share.url in the config").option("--all", "also upload runs from other commits").option("--allow-dirty", "include runs made with uncommitted changes").option("--no-workspace", "leave out candidate file contents").addHelpText("after", examples(["evalkit share", "evalkit share --server https://evals.example.com"])).action((options) => actions.share({
5279
5280
  ...options,
5280
5281
  all: Boolean(options.all),
5281
5282
  allowDirty: Boolean(options.allowDirty)
5282
- })).command("serve").description("run a share server locally, with no authentication").option("-d, --dir <dir>", "where to store uploads", "_evalkit-share").addOption(portOption(4318)).action((options) => actions.shareServe(options));
5283
+ })).command("serve").description("run a share server locally, with no authentication").option("-d, --dir <dir>", "where to store uploads", "_evalkit-share").addOption(portOption(4318)).option("--base-path <path>", "serve everything below this path, e.g. /evals").option("--repo <key>", "show and accept only this repository, e.g. github.com/acme/agents").action((options) => actions.shareServe(options));
5283
5284
  program.command("compare").description("compare this commit's results with a base commit's").addOption(configOption()).option("-b, --base <ref>", "base branch, tag or commit, from the share server").option("--base-dir <dir>", "base results from a directory instead").option("--server <url>", "share server to use instead of share.url in the config").option("--fail-on <condition>", "exit 1 on regression, or score-drop=<amount>; repeatable", collect).option("--markdown <file>", "write the comparison as Markdown").option("--json", "print the comparison as JSON").option("--require-base", "exit 1 when the base has no results").option("--allow-dirty", "include runs made with uncommitted changes").addHelpText("after", examples(["evalkit compare -b main", "evalkit compare -b main --fail-on regression --markdown compare.md"])).action((options) => actions.compare({
5284
5285
  ...options,
5285
5286
  failOn: options.failOn ?? [],
@@ -5303,7 +5304,7 @@ function runInput(evalIds, flags) {
5303
5304
  "chat-timeout-ms": flags.chatTimeoutMs,
5304
5305
  "turn-budget": flags.turnBudget,
5305
5306
  concurrency: flags.concurrency,
5306
- trials: flags.trials,
5307
+ repeat: flags.repeat,
5307
5308
  json: flags.json,
5308
5309
  "dry-run": flags.dryRun,
5309
5310
  all: flags.all,
@@ -5343,6 +5344,7 @@ async function runEvals(options) {
5343
5344
  workspaceRoot: sandboxRoot,
5344
5345
  keepWorkspaces: project?.config?.keepSandboxes,
5345
5346
  artifactExclude: project?.config?.artifacts?.exclude,
5347
+ attachmentLimits: project?.config?.artifacts?.attachments,
5346
5348
  suiteId: options.suiteId,
5347
5349
  runtime: runtimeFor(evaluation),
5348
5350
  concurrency: options.concurrency,
@@ -5362,14 +5364,14 @@ async function runProvenance(registry) {
5362
5364
  catalog: runCatalog(registry, projectRoot)
5363
5365
  };
5364
5366
  }
5365
- function registerMatrixRoutes(app, registry, dashboardMatrix, trials) {
5367
+ function registerMatrixRoutes(app, registry, dashboardMatrix, repeat) {
5366
5368
  let matrixRunning = false;
5367
5369
  app.get("/v1/matrix", (context) => context.json({ matrix: dashboardMatrix ? {
5368
5370
  id: dashboardMatrix.id,
5369
5371
  parameters: dashboardMatrix.parameters,
5370
5372
  ...dashboardMatrix.cases?.length || dashboardMatrix.exclude?.length ? { dimensions: [...Object.keys(dashboardMatrix.cases?.[0] ?? {}), ...Object.keys(dashboardMatrix.parameters)] } : {},
5371
5373
  ...dashboardMatrix.cases?.length || dashboardMatrix.exclude?.length ? { constrained: true } : {},
5372
- ...trials ? { trials } : {}
5374
+ ...repeat ? { repeat } : {}
5373
5375
  } : null }));
5374
5376
  app.get("/v1/matrix/cells", (context) => {
5375
5377
  if (!dashboardMatrix) return context.text("Project has no matrix", 404);
@@ -5409,12 +5411,13 @@ function registerMatrixRoutes(app, registry, dashboardMatrix, trials) {
5409
5411
  runProvenance(registry).then((provenance) => Effect.runPromise(runnerLogs(runMatrix(dashboardMatrix, {
5410
5412
  selection,
5411
5413
  concurrency: 1,
5412
- trials,
5414
+ ...repeat ? { repeat } : {},
5413
5415
  ...suiteId ? { suiteId } : {},
5414
5416
  report: localReportStore(reportRoot),
5415
5417
  workspaceRoot: sandboxRoot,
5416
5418
  keepWorkspaces: project?.config?.keepSandboxes,
5417
5419
  artifactExclude: project?.config?.artifacts?.exclude,
5420
+ attachmentLimits: project?.config?.artifacts?.attachments,
5418
5421
  ...provenance,
5419
5422
  onResult: (cell, result) => runLog.cell(cell, result, performance.now() - started)
5420
5423
  })))).catch((error) => runLog.failed(name, error)).finally(() => {
@@ -5465,7 +5468,7 @@ function registerReportRoutes(app, registry, reports) {
5465
5468
  if (!dataset) return context.text("Unknown dataset", 404);
5466
5469
  const file = context.req.param("path");
5467
5470
  try {
5468
- return new Response(await staticDatasetFile(dataset, projectRoot, file), { headers: { "content-type": fileContentType(file) } });
5471
+ return new Response(await staticDatasetFile(dataset, projectRoot, file), { headers: UNTRUSTED_FILE });
5469
5472
  } catch (error) {
5470
5473
  return context.text(error instanceof Error ? error.message : "Unable to read dataset file", 404);
5471
5474
  }
@@ -5477,44 +5480,44 @@ function registerReportRoutes(app, registry, reports) {
5477
5480
  app.get("/v1/evals", (context) => context.json({ evals: registry.metadata() }));
5478
5481
  app.get("/v1/suites", (context) => context.json({ suites: registry.suiteMetadata() }));
5479
5482
  app.get("/v1/runs", async (context) => context.json({ runs: await reports.listRuns() }));
5480
- app.get("/v1/runs/:runId/trials", async (context) => {
5483
+ app.get("/v1/runs/:runId", async (context) => {
5481
5484
  try {
5482
- return context.json({ trials: await reports.listTrials(context.req.param("runId")) });
5485
+ return context.json(await reports.runDetail(context.req.param("runId")));
5483
5486
  } catch (error) {
5484
- return context.text(error instanceof Error ? error.message : "Bad request", 400);
5487
+ return context.text(error instanceof Error ? error.message : "Not found", 404);
5485
5488
  }
5486
5489
  });
5487
- app.get("/v1/runs/:runId/trials/:trialId", async (context) => {
5490
+ app.get("/v1/runs/:runId/workspace", async (context) => {
5488
5491
  try {
5489
- return context.json(await reports.trialDetail(context.req.param("runId"), context.req.param("trialId")));
5492
+ return context.json(await reports.listCandidateWorkspace(context.req.param("runId")));
5490
5493
  } catch (error) {
5491
- return context.text(error instanceof Error ? error.message : "Not found", 404);
5494
+ return context.text(error instanceof Error ? error.message : "Unable to list workspace", 400);
5492
5495
  }
5493
5496
  });
5494
- app.get("/v1/runs/:runId/trials/:trialId/workspace", async (context) => {
5497
+ app.get("/v1/runs/:runId/workspace/:path{.+}", async (context) => {
5495
5498
  try {
5496
- return context.json(await reports.listCandidateWorkspace(context.req.param("runId"), context.req.param("trialId")));
5499
+ return await reports.candidateFile(context.req.param("runId"), context.req.param("path"));
5497
5500
  } catch (error) {
5498
- return context.text(error instanceof Error ? error.message : "Unable to list workspace", 400);
5501
+ return context.text(error instanceof Error ? error.message : "Unable to read workspace file", 400);
5499
5502
  }
5500
5503
  });
5501
- app.get("/v1/runs/:runId/trials/:trialId/workspace/:path{.+}", async (context) => {
5504
+ app.get("/v1/runs/:runId/attachments/:name{.+}", async (context) => {
5502
5505
  try {
5503
- return await reports.candidateFile(context.req.param("runId"), context.req.param("trialId"), context.req.param("path"));
5506
+ return await reports.attachmentFile(context.req.param("runId"), context.req.param("name"));
5504
5507
  } catch (error) {
5505
- return context.text(error instanceof Error ? error.message : "Unable to read workspace file", 400);
5508
+ return context.text(error instanceof Error ? error.message : "Unable to read attachment", 400);
5506
5509
  }
5507
5510
  });
5508
- app.get("/v1/runs/:runId/trials/:trialId/artifacts", async (context) => {
5511
+ app.get("/v1/runs/:runId/artifacts", async (context) => {
5509
5512
  try {
5510
- return context.json({ artifacts: await reports.listArtifacts(context.req.param("runId"), context.req.param("trialId")) });
5513
+ return context.json({ artifacts: await reports.listArtifacts(context.req.param("runId")) });
5511
5514
  } catch (error) {
5512
5515
  return context.text(error instanceof Error ? error.message : "Not found", 404);
5513
5516
  }
5514
5517
  });
5515
- app.get("/v1/runs/:runId/trials/:trialId/events", async (context) => {
5518
+ app.get("/v1/runs/:runId/events", async (context) => {
5516
5519
  try {
5517
- return context.json({ events: await reports.trajectory(context.req.param("runId"), context.req.param("trialId")) });
5520
+ return context.json({ events: await reports.trajectory(context.req.param("runId")) });
5518
5521
  } catch (error) {
5519
5522
  return context.text(error instanceof Error ? error.message : "Not found", 404);
5520
5523
  }
@@ -5568,11 +5571,11 @@ async function serveDashboard({ port, open }) {
5568
5571
  const dashboardRoot = dashboardAssets();
5569
5572
  const app = new Hono();
5570
5573
  const dashboardMatrix = project?.config.matrix ? registry.matrices.at(-1) : void 0;
5571
- registerMatrixRoutes(app, registry, dashboardMatrix, project?.config.execution?.trials);
5572
- registerReportRoutes(app, registry, createDashboardReportReader(reportRoot, registry));
5574
+ registerMatrixRoutes(app, registry, dashboardMatrix, project?.config.execution?.repeat);
5575
+ registerReportRoutes(app, registry, createDashboardReportReader(reportRoot));
5573
5576
  registerDashboardAssets(app, dashboardRoot);
5574
5577
  const url = `http://localhost:${(await listen(app.fetch, port, 4317)).port}`;
5575
- const reader = createDashboardReportReader(reportRoot, registry);
5578
+ const reader = createDashboardReportReader(reportRoot);
5576
5579
  const runs = (await reader.listRuns()).length;
5577
5580
  const legacy = reader.legacyRunIds().length;
5578
5581
  const matrixCells = dashboardMatrix?.count({ parameters: {} });
@@ -5581,7 +5584,7 @@ async function serveDashboard({ port, open }) {
5581
5584
  printLoadErrors(project?.loadErrors ?? []);
5582
5585
  row("Local:", cyan(url));
5583
5586
  row("Project:", `${plural(registry.evals.length, "eval")} · ${plural(registry.suites.length, "suite")}` + (dashboardMatrix ? ` · matrix ${authoringId(dashboardMatrix)} (${plural(matrixCells, "cell")})` : ""));
5584
- row("Reports:", `${relative(process.cwd(), reportRoot) || "."} ${dim(`(${plural(runs, "run")}${legacy ? `, ${legacy} from an older EvalKit hidden` : ""})`)}`);
5587
+ row("Reports:", `${relative(process.cwd(), reportRoot) || "."} ${dim(`(${plural(runs, "run")}${legacy ? `, ${legacy} from an older EvalKit hidden; run \`evalkit upgrade\`` : ""})`)}`);
5585
5588
  console.log(`\n ${dim("Runs started from the dashboard are logged below. Press Ctrl+C to stop.")}\n`);
5586
5589
  if (open) openInBrowser(url);
5587
5590
  }
@@ -5639,7 +5642,7 @@ async function loadProjectStep(configPath) {
5639
5642
  */
5640
5643
  async function shouldRunEvals(run) {
5641
5644
  if (run !== void 0) return run;
5642
- const runs = (await createDashboardReportReader(reportRoot, project.registry).listRuns()).filter((entry) => !entry.archived).length;
5645
+ const runs = (await createDashboardReportReader(reportRoot).listRuns()).filter((entry) => !entry.archived).length;
5643
5646
  if (!runs) {
5644
5647
  console.log(` ${cyan("◇")} No results yet, so running the evals to have data to build.`);
5645
5648
  return true;
@@ -5672,7 +5675,7 @@ async function tidyAction(options) {
5672
5675
  }), (value) => {
5673
5676
  const keep = project.config.keepSandboxes ?? "failed";
5674
5677
  if (keep === "all") return "all kept (keepSandboxes: 'all')";
5675
- return `${plural(value.removed, "workspace")} of ${keep === "none" ? "finished" : "passed"} trials removed`;
5678
+ return `${plural(value.removed, "workspace")} of ${keep === "none" ? "finished" : "passed"} runs removed`;
5676
5679
  });
5677
5680
  const byEval = (entries) => [...entries.reduce((groups, entry) => groups.set(entry.evalId, (groups.get(entry.evalId) ?? 0) + 1), /* @__PURE__ */ new Map()).entries()].map(([id, total]) => ` ${bold(id)} ${dim(`(${plural(total, "run")})`)}`).join("\n");
5678
5681
  console.log("");
@@ -5683,16 +5686,34 @@ async function tidyAction(options) {
5683
5686
  if (totalArchived) console.log(`\n ${dim(`Archived runs (${totalArchived}) are hidden in \`evalkit dev\` (filter "Show archived")`)}\n ${dim("and left out of `evalkit build`. No reports were deleted.")}`);
5684
5687
  console.log("");
5685
5688
  }
5689
+ async function upgradeAction(options) {
5690
+ banner("upgrade", VERSION);
5691
+ const { root, config } = await loadConfig(projectRoot, options.config);
5692
+ reportRoot = resolve(root, config.reportDir ?? "_evalkit-results");
5693
+ const report = await step(options.dryRun ? "Checking results" : "Upgrading results", () => upgradeReports(reportRoot, { dryRun: options.dryRun }), (value) => `${plural(value.upgraded, "run")} from an older EvalKit ${options.dryRun ? "found" : "upgraded"}`);
5694
+ console.log("");
5695
+ if (!report.upgraded) console.log(` ${cyan("✓")} Every run is already in the current format.`);
5696
+ else {
5697
+ const into = `${plural(report.upgraded, "run")} from older EvalKit versions into ${plural(report.runs, "run")}`;
5698
+ console.log(options.dryRun ? ` ${cyan("◆")} Would upgrade ${into}. Nothing was changed.` : ` ${cyan("✓")} Upgraded ${into}.`);
5699
+ if (report.batches) console.log(` ${dim(`${plural(report.batches, "run")} had more than one trial; each trial is now a run in a batch.`)}`);
5700
+ }
5701
+ if (report.running.length) console.log(` ${yellow("◆")} Left ${plural(report.running.length, "run")} still running alone; upgrade again when they finish.`);
5702
+ console.log("");
5703
+ }
5686
5704
  async function shareServeAction(options) {
5687
5705
  const root = resolve(process.cwd(), options.dir);
5688
- const server = await listen((await createDirectoryShareApp({
5706
+ const url = `http://localhost:${(await listen((await createDirectoryShareApp({
5689
5707
  dir: root,
5690
- dashboardRoot: dashboardAssets()
5691
- })).fetch, options.port, 4318);
5708
+ dashboardRoot: dashboardAssets(),
5709
+ ...options.basePath ? { basePath: options.basePath } : {},
5710
+ ...options.repo ? { repo: options.repo } : {}
5711
+ })).fetch, options.port, 4318)).port}${normalizeBasePath(options.basePath)}`;
5692
5712
  banner("share serve", VERSION);
5693
- console.log(` ${cyan("➜")} ${bold("Local:".padEnd(9))}${cyan(`http://localhost:${server.port}`)}`);
5713
+ console.log(` ${cyan("➜")} ${bold("Local:".padEnd(9))}${cyan(url)}`);
5714
+ if (options.repo) console.log(` ${cyan("➜")} ${bold("Repo:".padEnd(9))}${options.repo}`);
5694
5715
  console.log(` ${cyan("➜")} ${bold("Storage:".padEnd(9))}${relative(process.cwd(), root) || "."}`);
5695
- console.log(`\n ${dim(`Upload with share.url = "http://localhost:${server.port}". No authentication: keep it private.`)}\n`);
5716
+ console.log(`\n ${dim(`Upload with share.url = "${url}". No authentication: keep it private.`)}\n`);
5696
5717
  }
5697
5718
  async function shareAction(options) {
5698
5719
  const { all, allowDirty } = options;
@@ -5708,7 +5729,7 @@ async function shareAction(options) {
5708
5729
  ] }), (source) => source ? `${repoKey(source)} @ ${shortSource(source)}${source.ref ? ` (${source.ref})` : ""}` : "not a git repository");
5709
5730
  if (!head) throw new Error("evalkit share needs to know the commit: run it inside a git repository or set EVALKIT_SOURCE_REPO and EVALKIT_SOURCE_COMMIT. `evalkit build` works without one.");
5710
5731
  const registry = project.registry;
5711
- const selection = await step("Runs", async () => selectRuns(await createDashboardReportReader(reportRoot, registry).listRuns(), head, {
5732
+ const selection = await step("Runs", async () => selectRuns(await createDashboardReportReader(reportRoot).listRuns(), head, {
5712
5733
  all,
5713
5734
  allowDirty
5714
5735
  }), (value) => selectionSummary(value, { all }));
@@ -5730,7 +5751,7 @@ async function shareAction(options) {
5730
5751
  step
5731
5752
  });
5732
5753
  for (const failure of result.failed) console.log(` ${yellow("✗")} ${failure.runId.slice(0, 8)} ${dim(failure.error)}`);
5733
- const target = shareTarget(serverUrl, head, selection.selected);
5754
+ const target = shareTarget(serverUrl, result.url, selection.selected);
5734
5755
  console.log(`\n ${bold(result.failed.length ? "Shared with errors" : "Shared")} in ${duration(performance.now() - started)}\n`);
5735
5756
  console.log(` ${cyan("➜")} ${cyan(target)}\n`);
5736
5757
  if (result.failed.length) process.exitCode = 1;
@@ -5741,17 +5762,16 @@ async function compareAction(options) {
5741
5762
  const say = options.json ? () => {} : (line = "") => console.log(line);
5742
5763
  if (!options.json) banner("compare", VERSION);
5743
5764
  await useProject(options.config);
5744
- const registry = project.registry;
5745
5765
  const head = await detectSource(projectRoot, { outputDirs: [
5746
5766
  reportRoot,
5747
5767
  sandboxRoot,
5748
5768
  resolve(projectRoot, "_evalkit-dashboard")
5749
5769
  ] });
5750
5770
  if (!head) throw new Error("evalkit compare needs to know the commit: run it inside a git repository or set EVALKIT_SOURCE_REPO and EVALKIT_SOURCE_COMMIT.");
5751
- const headSide = headRuns(await createDashboardReportReader(reportRoot, registry).listRuns(), head, { allowDirty: options.allowDirty });
5771
+ const headSide = headRuns(await createDashboardReportReader(reportRoot).listRuns(), head, { allowDirty: options.allowDirty });
5752
5772
  let base;
5753
5773
  if (options.baseDir) {
5754
- const runs = await baseRunsFromDirectory(resolve(process.cwd(), options.baseDir), registry);
5774
+ const runs = await baseRunsFromDirectory(resolve(process.cwd(), options.baseDir));
5755
5775
  base = runs.length ? {
5756
5776
  label: options.baseDir,
5757
5777
  runs
@@ -5825,6 +5845,7 @@ try {
5825
5845
  dev: devAction,
5826
5846
  build: buildAction,
5827
5847
  tidy: tidyAction,
5848
+ upgrade: upgradeAction,
5828
5849
  share: shareAction,
5829
5850
  shareServe: shareServeAction,
5830
5851
  compare: compareAction