@leostera/evalkit 0.4.0 → 0.6.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 (45) hide show
  1. package/CHANGELOG.md +67 -1
  2. package/README.md +10 -8
  3. package/dist/cli.mjs +1287 -266
  4. package/dist/dashboard/assets/index-BK9x-zoi.css +2 -0
  5. package/dist/dashboard/assets/index-DzsDLGHx.js +32 -0
  6. package/dist/dashboard/index.html +2 -2
  7. package/dist/files-C-U0-PWW.mjs +0 -2
  8. package/dist/index-db-vbqEGFaU.mjs +0 -2
  9. package/dist/index.d.mts +1344 -2
  10. package/dist/index.d.mts.map +1 -1
  11. package/dist/index.mjs +2 -4
  12. package/dist/runner.d.mts +807 -11
  13. package/dist/runner.d.mts.map +1 -1
  14. package/dist/runner.mjs +2 -2
  15. package/dist/share-worker.d.mts +76 -1
  16. package/dist/share-worker.d.mts.map +1 -1
  17. package/dist/share-worker.mjs +0 -2
  18. package/dist/share.d.mts +149 -6
  19. package/dist/share.d.mts.map +1 -1
  20. package/dist/share.mjs +1 -1
  21. package/dist/skills/evalkit/SKILL.md +32 -0
  22. package/dist/skills/evalkit-agents/SKILL.md +16 -0
  23. package/dist/skills/evalkit-datasets/SKILL.md +17 -0
  24. package/dist/skills/evalkit-evals/SKILL.md +19 -0
  25. package/dist/skills/evalkit-judges/SKILL.md +17 -0
  26. package/dist/skills/evalkit-matrices/SKILL.md +14 -0
  27. package/dist/skills/evalkit-results/SKILL.md +14 -0
  28. package/dist/{src-DoPs9Yug.mjs → src-CVSCn3bZ.mjs} +445 -115
  29. package/dist/{src-DzZueFn3.mjs → src-CZnDXP1K.mjs} +46 -10
  30. package/dist/{src-ButXPY8P.mjs → src-D4tCwxNb.mjs} +418 -94
  31. package/package.json +3 -2
  32. package/dist/cli.mjs.map +0 -1
  33. package/dist/dashboard/assets/index-D9NtA46z.css +0 -2
  34. package/dist/dashboard/assets/index-DrBwLKJD.js +0 -30
  35. package/dist/files-C-U0-PWW.mjs.map +0 -1
  36. package/dist/index-U6TUTBf7.d.mts +0 -1019
  37. package/dist/index-U6TUTBf7.d.mts.map +0 -1
  38. package/dist/index-db-HdZXelOS.d.mts +0 -142
  39. package/dist/index-db-HdZXelOS.d.mts.map +0 -1
  40. package/dist/index-db-vbqEGFaU.mjs.map +0 -1
  41. package/dist/index.mjs.map +0 -1
  42. package/dist/share-worker.mjs.map +0 -1
  43. package/dist/src-ButXPY8P.mjs.map +0 -1
  44. package/dist/src-DoPs9Yug.mjs.map +0 -1
  45. package/dist/src-DzZueFn3.mjs.map +0 -1
package/dist/cli.mjs CHANGED
@@ -1,14 +1,15 @@
1
- #!/usr/bin/env bun
2
- import { I as defineEvalMatrix, L as canonicalParameters, m as registerEvals, n as authoringId } from "./src-ButXPY8P.mjs";
3
- import { a as readTrialEvents, c as readTrialSummary, i as readRunSummary, l as localReportStore, n as runMatrix, o as readTrialManifest, r as readRunManifest, t as runEval } from "./src-DoPs9Yug.mjs";
1
+ #!/bin/sh
2
+ ':' //; exec "${EVALKIT_RUNTIME:-$(command -v bun || command -v node)}" "$0" "$@"
3
+ import { L as defineEvalMatrix, R as canonicalParameters, d as recordError, f as registerEvals, i as authoringId, r as agentRuntimes, s as defineSuite } from "./src-D4tCwxNb.mjs";
4
+ import { a as readRunSummary, d as staticDatasetFile, f as staticDatasetFiles, i as readRunManifest, l as readTrialSummary, n as runMatrix, o as readTrialEvents, r as ownerIsGone, s as readTrialManifest, t as runEval, u as localReportStore } from "./src-CVSCn3bZ.mjs";
4
5
  import { c as dashboardIndex, d as encodeBundle, o as repoKey } from "./index-db-vbqEGFaU.mjs";
5
- import { t as createDirectoryShareApp } from "./src-DzZueFn3.mjs";
6
+ import { t as createDirectoryShareApp } from "./src-CZnDXP1K.mjs";
6
7
  import * as Schema from "effect/Schema";
7
8
  import { Effect, LogLevel, Logger, Option } from "effect";
9
+ import * as OS from "node:os";
8
10
  import { cp, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
9
11
  import * as Path from "node:path";
10
12
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
11
- import * as OS from "node:os";
12
13
  import * as Crypto from "node:crypto";
13
14
  import * as NFS from "node:fs";
14
15
  import { existsSync } from "node:fs";
@@ -67,9 +68,10 @@ import * as FiberSet from "effect/FiberSet";
67
68
  import "effect/Fiber";
68
69
  import "effect/Pool";
69
70
  import * as MutableRef from "effect/MutableRef";
71
+ import { createServer } from "node:http";
70
72
  import { parseArgs, promisify } from "node:util";
71
73
  import * as ChildProcess from "node:child_process";
72
- import { execFile } from "node:child_process";
74
+ import { execFile, spawn } from "node:child_process";
73
75
  import * as RcRef from "effect/RcRef";
74
76
  import * as readline from "node:readline";
75
77
  import { Hono } from "hono";
@@ -5792,7 +5794,7 @@ const merge = (map1, map2) => {
5792
5794
  }
5793
5795
  return map1;
5794
5796
  };
5795
- const escape = (string) => escapeSingleQuoted(string.replaceAll("\\", "\\\\")).replaceAll("[", "\\[").replaceAll("]", "\\]").replaceAll(":", "\\:").replaceAll("$", "\\$").replaceAll("`", "\\`").replaceAll("(", "\\(").replaceAll(")", "\\)");
5797
+ const escape$1 = (string) => escapeSingleQuoted(string.replaceAll("\\", "\\\\")).replaceAll("[", "\\[").replaceAll("]", "\\]").replaceAll(":", "\\:").replaceAll("$", "\\$").replaceAll("`", "\\`").replaceAll("(", "\\(").replaceAll(")", "\\)");
5796
5798
  const getShortDescription$1 = (self) => {
5797
5799
  switch (self._tag) {
5798
5800
  case "Empty":
@@ -5860,7 +5862,7 @@ const getZshCompletions$1 = (self, state = {
5860
5862
  const possibleValues = getZshCompletions$3(self.primitiveType);
5861
5863
  const multiple = state.multiple ? "*" : "";
5862
5864
  const conflicts = Arr.isNonEmptyReadonlyArray(state.conflicts) ? `(${Arr.join(state.conflicts, " ")})` : "";
5863
- return Arr.map(names, (name) => `${conflicts}${multiple}${name}[${escape(description)}]${possibleValues}`);
5865
+ return Arr.map(names, (name) => `${conflicts}${multiple}${name}[${escape$1(description)}]${possibleValues}`);
5864
5866
  }
5865
5867
  case "KeyValueMap": return getZshCompletions$1(self.argumentOption, {
5866
5868
  ...state,
@@ -8069,8 +8071,193 @@ const layerManager = /* @__PURE__ */ Layer.provide(layerManager$2, layerWorker);
8069
8071
  */
8070
8072
  const layer = /*#__PURE__*/ pipe(/*#__PURE__*/ Layer.mergeAll(layer$3, layer$5, layer$1, layerManager), /*#__PURE__*/ Layer.provideMerge(layer$7));
8071
8073
  //#endregion
8074
+ //#region ../cli/src/dataset-files.ts
8075
+ /** The project's datasets by ID; the first definition wins when IDs repeat. */
8076
+ function projectDatasets(registry) {
8077
+ const byId = /* @__PURE__ */ new Map();
8078
+ for (const evaluation of registry.evals) for (const dataset of evaluation.datasets ?? []) if (!byId.has(dataset.id)) byId.set(dataset.id, dataset);
8079
+ return byId;
8080
+ }
8081
+ async function datasetListing(dataset, root) {
8082
+ const files = await staticDatasetFiles(dataset, root);
8083
+ return files ? {
8084
+ kind: "static",
8085
+ mount: dataset.mount,
8086
+ files
8087
+ } : {
8088
+ kind: "computed",
8089
+ mount: dataset.mount
8090
+ };
8091
+ }
8092
+ //#endregion
8093
+ //#region ../cli/src/source.ts
8094
+ const exec = promisify(execFile);
8095
+ /**
8096
+ * Normalize a git remote to a stable repository key: lowercase host, no
8097
+ * credentials, port, scheme, `.git`, or trailing slash.
8098
+ *
8099
+ * git@GitLab.example.com:group/sub/repo.git → gitlab.example.com/group/sub/repo
8100
+ * https://user:token@github.com/o/r/ → github.com/o/r
8101
+ * /srv/repos/thing.git (no host) → local/thing
8102
+ */
8103
+ function normalizeRepoUrl(url) {
8104
+ const trimmed = url.trim().replace(/\/+$/, "").replace(/\.git$/, "");
8105
+ const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/)(.+)$/.exec(trimmed);
8106
+ if (scp && !/^[a-z]+:\/\//i.test(trimmed)) return `${scp[1].toLowerCase()}/${scp[2].replace(/^\/+/, "")}`;
8107
+ try {
8108
+ const parsed = new URL(trimmed);
8109
+ if (parsed.protocol !== "file:" && parsed.hostname) return `${parsed.hostname.toLowerCase()}${parsed.pathname.replace(/\/+$/, "")}`;
8110
+ } catch {}
8111
+ return `local/${basename(trimmed)}`;
8112
+ }
8113
+ async function git(cwd, ...args) {
8114
+ try {
8115
+ const { stdout } = await exec("git", [
8116
+ "-C",
8117
+ cwd,
8118
+ ...args
8119
+ ], { timeout: 1e4 });
8120
+ return stdout.trim() || void 0;
8121
+ } catch {
8122
+ return;
8123
+ }
8124
+ }
8125
+ const posix = (path) => path.split(sep).join("/");
8126
+ const projectPath = (root, projectRoot) => root ? posix(relative(root, projectRoot)) : "";
8127
+ function ciSource(env, projectRoot) {
8128
+ if (env.EVALKIT_SOURCE_REPO && env.EVALKIT_SOURCE_COMMIT) return {
8129
+ repo: normalizeRepoUrl(env.EVALKIT_SOURCE_REPO),
8130
+ project: env.EVALKIT_SOURCE_PROJECT ?? "",
8131
+ commit: env.EVALKIT_SOURCE_COMMIT,
8132
+ ...env.EVALKIT_SOURCE_REF ? { ref: env.EVALKIT_SOURCE_REF } : {},
8133
+ ...env.EVALKIT_SOURCE_COMMITTED_AT ? { committedAt: env.EVALKIT_SOURCE_COMMITTED_AT } : {}
8134
+ };
8135
+ if (env.GITLAB_CI === "true" && env.CI_PROJECT_URL && env.CI_COMMIT_SHA) return {
8136
+ repo: normalizeRepoUrl(env.CI_PROJECT_URL),
8137
+ project: projectPath(env.CI_PROJECT_DIR, projectRoot),
8138
+ commit: env.CI_COMMIT_SHA,
8139
+ ...env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ?? env.CI_COMMIT_REF_NAME ? { ref: env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ?? env.CI_COMMIT_REF_NAME } : {},
8140
+ ...env.CI_COMMIT_TIMESTAMP ? { committedAt: env.CI_COMMIT_TIMESTAMP } : {},
8141
+ ci: {
8142
+ provider: "gitlab",
8143
+ ...env.CI_PIPELINE_ID ? { pipeline: env.CI_PIPELINE_ID } : {},
8144
+ ...env.CI_JOB_ID ? { job: env.CI_JOB_ID } : {},
8145
+ ...env.CI_JOB_URL ? { url: env.CI_JOB_URL } : {}
8146
+ }
8147
+ };
8148
+ if (env.GITHUB_ACTIONS === "true" && env.GITHUB_REPOSITORY && env.GITHUB_SHA) {
8149
+ const server = env.GITHUB_SERVER_URL ?? "https://github.com";
8150
+ return {
8151
+ repo: normalizeRepoUrl(`${server}/${env.GITHUB_REPOSITORY}`),
8152
+ project: projectPath(env.GITHUB_WORKSPACE, projectRoot),
8153
+ commit: env.GITHUB_SHA,
8154
+ ...env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME ? { ref: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME } : {},
8155
+ ci: {
8156
+ provider: "github",
8157
+ ...env.GITHUB_RUN_ID ? { pipeline: env.GITHUB_RUN_ID } : {},
8158
+ ...env.GITHUB_JOB ? { job: env.GITHUB_JOB } : {},
8159
+ ...env.GITHUB_RUN_ID ? { url: `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` } : {}
8160
+ }
8161
+ };
8162
+ }
8163
+ }
8164
+ /**
8165
+ * Determine which repository and commit this project's eval code comes from.
8166
+ * Order: EVALKIT_SOURCE_* variables, GitLab/GitHub CI variables, then git.
8167
+ * Returns undefined outside git with no CI variables ("untracked" results).
8168
+ *
8169
+ * `outputDirs` (results, sandbox, snapshot) never make the tree dirty, even
8170
+ * when a project forgot to ignore them.
8171
+ */
8172
+ async function detectSource(givenRoot, options = {}) {
8173
+ const env = options.env ?? process.env;
8174
+ const projectRoot = await realpath(givenRoot).catch(() => givenRoot);
8175
+ const top = await git(projectRoot, "rev-parse", "--show-toplevel");
8176
+ const excludes = top ? (options.outputDirs ?? []).map((dir) => join(projectRoot, relative(givenRoot, dir))).map((dir) => posix(relative(top, dir))).filter((dir) => dir && !dir.startsWith("..")).map((dir) => `:(exclude)${dir}`) : [];
8177
+ const dirty = top ? Boolean(await git(top, "status", "--porcelain", "--", ".", ...excludes)) : false;
8178
+ const fromCi = ciSource(env, projectRoot);
8179
+ if (fromCi) return {
8180
+ ...fromCi,
8181
+ ...top && !env.EVALKIT_SOURCE_PROJECT ? { project: projectPath(top, projectRoot) } : {},
8182
+ dirty
8183
+ };
8184
+ if (!top) return void 0;
8185
+ const commit = await git(top, "rev-parse", "HEAD");
8186
+ if (!commit) return void 0;
8187
+ const remote = await git(top, "config", "--get", "remote.origin.url") ?? (await git(top, "remote"))?.split("\n").map((name) => name.trim()).find(Boolean);
8188
+ const remoteUrl = remote && !remote.includes("/") && !remote.includes(":") ? await git(top, "config", "--get", `remote.${remote}.url`) : remote;
8189
+ const ref = await git(top, "symbolic-ref", "--short", "-q", "HEAD");
8190
+ const committedAt = await git(top, "show", "-s", "--format=%cI", "HEAD");
8191
+ return {
8192
+ repo: normalizeRepoUrl(remoteUrl ?? top),
8193
+ project: projectPath(top, projectRoot),
8194
+ commit,
8195
+ ...ref ? { ref } : {},
8196
+ ...committedAt ? { committedAt } : {},
8197
+ dirty
8198
+ };
8199
+ }
8200
+ /** What the dashboard's header shows: repository, commit, branch, dirty. */
8201
+ function projectInfo(source) {
8202
+ return {
8203
+ repo: source.repo,
8204
+ ...source.project ? { project: source.project } : {},
8205
+ commit: source.commit,
8206
+ ...source.ref ? { ref: source.ref } : {},
8207
+ dirty: source.dirty
8208
+ };
8209
+ }
8210
+ /** The project's evals and suites, saved with each run (`catalog.json`). */
8211
+ function runCatalog(registry, root) {
8212
+ return {
8213
+ catalog: registry.catalog({ root }),
8214
+ evals: registry.metadata(),
8215
+ suites: registry.suiteMetadata()
8216
+ };
8217
+ }
8218
+ /** `abc1234` or `abc1234+dirty`. */
8219
+ function shortSource(source) {
8220
+ return `${source.commit.slice(0, 7)}${source.dirty ? "+dirty" : ""}`;
8221
+ }
8222
+ //#endregion
8072
8223
  //#region ../cli/src/dashboard-reports.ts
8073
8224
  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;
8225
+ function runScorers(trials) {
8226
+ const byKey = /* @__PURE__ */ new Map();
8227
+ for (const trial of trials) for (const score of trial?.scoring?.results ?? []) {
8228
+ const kind = score.kind;
8229
+ const key = `${kind}\0${score.name}`;
8230
+ const entry = byKey.get(key) ?? {
8231
+ name: score.name,
8232
+ kind,
8233
+ count: 0,
8234
+ skipped: 0,
8235
+ sum: 0
8236
+ };
8237
+ if (score.skipped) entry.skipped += 1;
8238
+ else if (score.value !== void 0) {
8239
+ entry.count += 1;
8240
+ entry.sum += score.value;
8241
+ }
8242
+ byKey.set(key, entry);
8243
+ }
8244
+ return [...byKey.values()].map(({ sum, ...scorer }) => ({
8245
+ ...scorer,
8246
+ ...scorer.count ? { mean: sum / scorer.count } : {}
8247
+ }));
8248
+ }
8249
+ function runDatasets(manifests) {
8250
+ const byId = /* @__PURE__ */ new Map();
8251
+ for (const manifest of manifests) for (const dataset of manifest?.datasets ?? []) {
8252
+ const digests = byId.get(dataset.id) ?? /* @__PURE__ */ new Set();
8253
+ digests.add(dataset.digest);
8254
+ byId.set(dataset.id, digests);
8255
+ }
8256
+ return [...byId].map(([id, digests]) => ({
8257
+ id,
8258
+ digests: [...digests].sort()
8259
+ }));
8260
+ }
8074
8261
  /** Written into a run directory by `evalkit tidy`; deleting it restores the run. */
8075
8262
  const ARCHIVE_MARKER = "archived.json";
8076
8263
  function isMissing(error) {
@@ -8094,11 +8281,13 @@ function trialPath(root, runId, trialId) {
8094
8281
  }
8095
8282
  function runStatus(summary) {
8096
8283
  if (summary.status === "running") return "running";
8284
+ if (summary.status === "cancelled") return "cancelled";
8097
8285
  if (summary.status !== "completed") return "errored";
8098
8286
  return summary.failed === 0 && summary.passed === summary.trialCount ? "passed" : "failed";
8099
8287
  }
8100
8288
  function trialStatus(summary) {
8101
8289
  if (summary.status === "running") return "running";
8290
+ if (summary.status === "cancelled") return "cancelled";
8102
8291
  if (summary.status !== "completed") return "errored";
8103
8292
  return summary.scoring?.passed === true ? "passed" : "failed";
8104
8293
  }
@@ -8143,10 +8332,15 @@ function createDashboardReportReader(root, registry) {
8143
8332
  const summary = await ifPresent(() => readRunSummary(root, id));
8144
8333
  const trialIds = await ifPresent(() => readdir(resolve(root, id, "trials")));
8145
8334
  const trials = await Promise.all((trialIds ?? []).map((trialId) => ifPresent(() => readTrialSummary(root, id, trialId))));
8335
+ const trialManifests = await Promise.all((trialIds ?? []).map((trialId) => ifPresent(() => readTrialManifest(root, id, trialId))));
8146
8336
  const scores = trials.flatMap((trial) => trial?.scoring?.overall === void 0 ? [] : [trial.scoring.overall]);
8337
+ const scorers = runScorers(trials);
8338
+ const datasets = runDatasets(trialManifests);
8339
+ const archived = await ifPresent(() => stat(resolve(root, id, ARCHIVE_MARKER)));
8340
+ const interrupted = !summary && ownerIsGone(manifest.process);
8147
8341
  return {
8148
8342
  id,
8149
- ...await ifPresent(() => stat(resolve(root, id, "archived.json"))) ? { archived: true } : {},
8343
+ ...archived ? { archived: true } : {},
8150
8344
  ...manifest.source ? { source: manifest.source } : {},
8151
8345
  evalId: manifest.evalId,
8152
8346
  ...manifest.suiteId ? { suiteId: manifest.suiteId } : {},
@@ -8160,26 +8354,31 @@ function createDashboardReportReader(root, registry) {
8160
8354
  manifest.aut.id,
8161
8355
  manifest.aut.version
8162
8356
  ].filter(Boolean).join(" / ") } : {},
8163
- status: summary ? runStatus(summary) : "running",
8357
+ status: summary ? runStatus(summary) : interrupted ? "cancelled" : "running",
8358
+ ...interrupted ? { interrupted: true } : {},
8164
8359
  startedAt: manifest.startedAt,
8165
8360
  ...summary?.endedAt ? { completedAt: summary.endedAt } : {},
8166
8361
  ...summary?.durationMs !== void 0 ? { durationMs: summary.durationMs } : {},
8167
8362
  completedTrials: summary?.trialCount ?? trials.filter(Boolean).length,
8168
8363
  requestedTrials: summary?.trialCount ?? registry.get(manifest.evalId)?.policy?.trials ?? 1,
8169
- ...scores.length ? { score: scores.reduce((sum, score) => sum + score, 0) / scores.length } : {}
8364
+ ...scores.length ? { score: scores.reduce((sum, score) => sum + score, 0) / scores.length } : {},
8365
+ ...scorers.length ? { scorers } : {},
8366
+ ...datasets.length ? { datasets } : {}
8170
8367
  };
8171
8368
  }
8172
8369
  async function listTrials(runId) {
8173
8370
  assertUuid(runId, "run ID");
8174
8371
  const ids = await ifPresent(() => readdir(resolve(root, runId, "trials")));
8175
8372
  if (!ids) return [];
8373
+ const runManifest = await ifPresent(() => readRunManifest(root, runId));
8374
+ const ownerGone = ownerIsGone(runManifest?.process);
8176
8375
  return (await Promise.all(ids.map(async (id) => {
8177
8376
  const manifest = await readTrialManifest(root, runId, id);
8178
8377
  const summary = await ifPresent(() => readTrialSummary(root, runId, id));
8179
8378
  return {
8180
8379
  id,
8181
8380
  index: manifest.trialIndex,
8182
- status: summary ? trialStatus(summary) : "running",
8381
+ status: summary ? trialStatus(summary) : ownerGone ? "cancelled" : "running",
8183
8382
  startedAt: manifest.startedAt,
8184
8383
  ...summary?.endedAt ? { completedAt: summary.endedAt } : {},
8185
8384
  ...summary?.durationMs !== void 0 ? { durationMs: summary.durationMs } : {},
@@ -8208,7 +8407,7 @@ function createDashboardReportReader(root, registry) {
8208
8407
  const file = resolve(candidateRoot, relativePath);
8209
8408
  if (file !== candidateRoot && !file.startsWith(`${candidateRoot}${sep}`)) throw new Error("Invalid workspace path");
8210
8409
  if (!(await stat(file)).isFile()) throw new Error("Workspace path is not a file");
8211
- return new Response(await Bun.file(file).arrayBuffer(), { headers: { "content-type": fileContentType(file) } });
8410
+ return new Response(await readFile(file), { headers: { "content-type": fileContentType(file) } });
8212
8411
  }
8213
8412
  async function trajectory(runId, trialId) {
8214
8413
  trialPath(root, runId, trialId);
@@ -8241,7 +8440,7 @@ async function walkArtifacts(root) {
8241
8440
  } else result.push({
8242
8441
  path,
8243
8442
  kind: "file",
8244
- size: Bun.file(resolve(directory, entry.name)).size
8443
+ size: (await stat(resolve(directory, entry.name))).size
8245
8444
  });
8246
8445
  }
8247
8446
  }
@@ -8254,7 +8453,7 @@ async function walkArtifacts(root) {
8254
8453
  const MARKER = ".evalkit-dashboard";
8255
8454
  const quietStep = (_label, work) => work(() => {});
8256
8455
  async function buildDashboard(options, step = quietStep) {
8257
- const { registry, reportRoot, dashboardRoot, outDir } = options;
8456
+ const { registry, root, reportRoot, dashboardRoot, outDir } = options;
8258
8457
  const template = await readFile(resolve(dashboardRoot, "index.html"), "utf8");
8259
8458
  const data = resolve(outDir, "data");
8260
8459
  const write = async (path, value) => {
@@ -8269,7 +8468,8 @@ async function buildDashboard(options, step = quietStep) {
8269
8468
  await writeFile(resolve(outDir, "index.html"), dashboardIndex(template, { base: "./" }));
8270
8469
  }, () => "read-only UI with relative assets");
8271
8470
  await step("Eval catalog", async () => {
8272
- await write("catalog.json", { evals: registry.catalog() });
8471
+ await write("catalog.json", { evals: registry.catalog({ root }) });
8472
+ await write("project.json", { project: options.source ? projectInfo(options.source) : null });
8273
8473
  await write("evals.json", { evals: registry.metadata() });
8274
8474
  await write("suites.json", { suites: registry.suiteMetadata() });
8275
8475
  return {
@@ -8277,6 +8477,21 @@ async function buildDashboard(options, step = quietStep) {
8277
8477
  suites: registry.suites.length
8278
8478
  };
8279
8479
  }, ({ evals, suites }) => `${count(evals, "eval")} · ${count(suites, "suite")}`);
8480
+ await step("Datasets", async () => {
8481
+ let files = 0;
8482
+ for (const [id, dataset] of projectDatasets(registry)) {
8483
+ const listing = await datasetListing(dataset, root ?? process.cwd());
8484
+ await write(`datasets/${id}/files.json`, listing);
8485
+ if (listing.kind !== "static" || dataset.visibility === "evaluator") continue;
8486
+ for (const entry of listing.files) {
8487
+ const file = resolve(data, "datasets", id, "files", entry.path);
8488
+ await mkdir(dirname(file), { recursive: true });
8489
+ await writeFile(file, await staticDatasetFile(dataset, root ?? process.cwd(), entry.path));
8490
+ files++;
8491
+ }
8492
+ }
8493
+ return files;
8494
+ }, (files) => count(files, "dataset file"));
8280
8495
  const reports = createDashboardReportReader(reportRoot, registry);
8281
8496
  const snapshot = await step("Run reports", async (progress) => {
8282
8497
  const all = await reports.listRuns();
@@ -8284,13 +8499,16 @@ async function buildDashboard(options, step = quietStep) {
8284
8499
  await write("runs.json", { runs });
8285
8500
  let trials = 0;
8286
8501
  let files = 0;
8287
- const trialIds = [];
8502
+ const runTrials = [];
8288
8503
  for (const [index, run] of runs.entries()) {
8289
8504
  progress(`run ${index + 1}/${runs.length} · ${run.id.slice(0, 8)}`);
8290
8505
  const written = await snapshotRun(reports, run.id, directorySink(resolve(data, "runs", run.id)));
8291
8506
  trials += written.trialIds.length;
8292
8507
  files += written.files;
8293
- trialIds.push(...written.trialIds);
8508
+ runTrials.push({
8509
+ id: run.id,
8510
+ trials: written.trialIds
8511
+ });
8294
8512
  }
8295
8513
  return {
8296
8514
  runs: runs.length,
@@ -8298,16 +8516,16 @@ async function buildDashboard(options, step = quietStep) {
8298
8516
  files,
8299
8517
  skipped: reports.legacyRunIds().length,
8300
8518
  archived: all.length - runs.length,
8301
- runIds: runs.map((run) => run.id),
8302
- trialIds
8519
+ runTrials
8303
8520
  };
8304
8521
  }, ({ runs, trials, files, skipped, archived }) => `${count(runs, "run")} · ${count(trials, "trial")} · ${count(files, "candidate file")}` + (archived ? ` · ${archived} archived excluded` : "") + (skipped ? ` · ${skipped} older-format skipped` : ""));
8305
8522
  await step("Route pages", async () => {
8523
+ const catalog = registry.catalog();
8306
8524
  const routes = staticRoutes({
8307
- suites: registry.suites.map(authoringId),
8308
- evals: registry.catalog().map((entry) => entry.path),
8309
- runs: snapshot.runIds,
8310
- trials: snapshot.trialIds
8525
+ scenarios: catalog.map((entry) => entry.id),
8526
+ agents: catalog.flatMap((entry) => entry.agent.id ? [entry.agent.id] : []),
8527
+ datasets: catalog.flatMap((entry) => entry.datasets.map((dataset) => dataset.id)),
8528
+ runs: snapshot.runTrials
8311
8529
  });
8312
8530
  for (const route of routes) {
8313
8531
  const file = resolve(outDir, `${route}.html`);
@@ -8317,7 +8535,7 @@ async function buildDashboard(options, step = quietStep) {
8317
8535
  await writeFile(resolve(outDir, "404.html"), dashboardIndex(template, { base: "/" }));
8318
8536
  return routes.length;
8319
8537
  }, (pages) => `${count(pages, "page")} with clean /path URLs`);
8320
- const { runIds: _runs, trialIds: _trials, ...summary } = snapshot;
8538
+ const { runTrials: _runs, ...summary } = snapshot;
8321
8539
  return {
8322
8540
  outDir,
8323
8541
  ...summary
@@ -8330,18 +8548,21 @@ async function buildDashboard(options, step = quietStep) {
8330
8548
  * skipped; those pages still open by navigating inside the app.
8331
8549
  */
8332
8550
  function staticRoutes(ids) {
8333
- const safe = (id) => id.length > 0 && !/[/\\\0]/.test(id) && id !== "." && id !== "..";
8334
- const under = (prefix, values) => values.filter(safe).map((id) => `${prefix}/${id}`);
8551
+ const safe = (id) => id.length > 0 && !/[/\\\0#?%]/.test(id) && id !== "." && id !== "..";
8552
+ const under = (prefix, values = []) => [...new Set(values)].filter(safe).map((id) => `${prefix}/${id}`);
8335
8553
  return [
8336
- "suites",
8337
- "agents",
8338
- "fixtures",
8554
+ "scoreboard",
8339
8555
  "runs",
8340
- ...under("suites", ids.suites),
8341
- ...under("evals", ids.evals),
8342
- ...under("runs", ids.runs),
8343
- ...under("trials", ids.trials),
8344
- ...under("workspaces", ids.trials)
8556
+ "trials",
8557
+ "workspaces",
8558
+ "library/agents",
8559
+ "library/judges",
8560
+ "library/scenarios",
8561
+ "library/datasets",
8562
+ ...under("library/scenarios", ids.scenarios),
8563
+ ...under("library/agents", ids.agents),
8564
+ ...under("library/datasets", ids.datasets),
8565
+ ...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`])])
8345
8566
  ];
8346
8567
  }
8347
8568
  /** `runs/abc.html` is served at `<mount>/runs/abc`; its base is one level up. */
@@ -8516,6 +8737,124 @@ function selectDashboardCell(matrix, evalId, parameters) {
8516
8737
  return selection;
8517
8738
  }
8518
8739
  //#endregion
8740
+ //#region ../cli/src/runtime.ts
8741
+ /**
8742
+ * The few platform services the CLI needs, on Bun or Node. Everything else
8743
+ * uses `node:` APIs, which both runtimes provide.
8744
+ */
8745
+ const onBun = typeof globalThis.Bun !== "undefined";
8746
+ /** Node 22.18 is the first 22.x that runs the CLI's own code unflagged. */
8747
+ function assertSupportedRuntime(version = process.versions.node) {
8748
+ if (onBun) return;
8749
+ const [major = 0, minor = 0] = version.split(".").map(Number);
8750
+ if (major < 22 || major === 22 && minor < 18) throw new Error(`EvalKit needs Node 22.18 or later, or Bun; this is Node ${version}.`);
8751
+ }
8752
+ /** Serves a fetch handler on `port` (0 picks a free one). */
8753
+ async function serve(fetch, port) {
8754
+ if (onBun) {
8755
+ const server = globalThis.Bun.serve({
8756
+ port,
8757
+ fetch
8758
+ });
8759
+ return {
8760
+ port: server.port,
8761
+ stop: async () => server.stop(true)
8762
+ };
8763
+ }
8764
+ const server = createServer((incoming, outgoing) => {
8765
+ (async () => {
8766
+ try {
8767
+ const response = await fetch(toRequest(incoming, port));
8768
+ outgoing.writeHead(response.status, [...response.headers]);
8769
+ if (response.body) for await (const chunk of response.body) outgoing.write(chunk);
8770
+ outgoing.end();
8771
+ } catch (error) {
8772
+ outgoing.statusCode = 500;
8773
+ outgoing.end(error instanceof Error ? error.message : "Internal error");
8774
+ }
8775
+ })();
8776
+ });
8777
+ await new Promise((resolvePromise, reject) => {
8778
+ server.once("error", reject);
8779
+ server.listen(port, () => resolvePromise());
8780
+ });
8781
+ const address = server.address();
8782
+ return {
8783
+ port: typeof address === "object" && address ? address.port : port,
8784
+ stop: () => new Promise((resolvePromise) => server.close(() => resolvePromise()))
8785
+ };
8786
+ }
8787
+ function toRequest(incoming, port) {
8788
+ const host = incoming.headers.host ?? `localhost:${port}`;
8789
+ const url = new URL(incoming.url ?? "/", `http://${host}`);
8790
+ const headers = new Headers();
8791
+ for (const [name, value] of Object.entries(incoming.headers)) if (value !== void 0) for (const item of Array.isArray(value) ? value : [value]) headers.append(name, item);
8792
+ const method = incoming.method ?? "GET";
8793
+ return new Request(url, {
8794
+ method,
8795
+ headers,
8796
+ ...method === "GET" || method === "HEAD" ? {} : {
8797
+ body: incoming,
8798
+ duplex: "half"
8799
+ }
8800
+ });
8801
+ }
8802
+ const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
8803
+ /** Best effort; the printed URL stays authoritative. */
8804
+ function openInBrowser(url) {
8805
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
8806
+ try {
8807
+ const child = spawn(opener, [url], {
8808
+ stdio: "ignore",
8809
+ detached: true
8810
+ });
8811
+ child.on("error", () => {});
8812
+ child.unref();
8813
+ } catch {}
8814
+ }
8815
+ let jiti;
8816
+ /**
8817
+ * Imports a project's TypeScript or JavaScript module. Bun loads TypeScript
8818
+ * natively; on Node, jiti handles TypeScript, including `.js` specifiers that
8819
+ * point at `.ts` files.
8820
+ */
8821
+ async function importProjectModule(file) {
8822
+ if (onBun || /\.(m?js|cjs)$/.test(file)) return import(pathToFileURL(file).href);
8823
+ if (!jiti) {
8824
+ const { createJiti } = await import("jiti");
8825
+ jiti = createJiti(import.meta.url, { moduleCache: false });
8826
+ }
8827
+ return jiti.import(file);
8828
+ }
8829
+ /**
8830
+ * A glob matcher for discovery: `*` and `?` stay within a path segment, `**`
8831
+ * spans segments, and `{a,b}` is an alternation.
8832
+ */
8833
+ function globMatcher(pattern) {
8834
+ let source = "";
8835
+ for (let index = 0; index < pattern.length; index++) {
8836
+ const char = pattern[index];
8837
+ if (char === "*" && pattern[index + 1] === "*") {
8838
+ index++;
8839
+ if (pattern[index + 1] === "/") {
8840
+ index++;
8841
+ source += "(?:.*/)?";
8842
+ } else source += ".*";
8843
+ } else if (char === "*") source += "[^/]*";
8844
+ else if (char === "?") source += "[^/]";
8845
+ else if (char === "{") {
8846
+ const end = pattern.indexOf("}", index);
8847
+ if (end === -1) source += "\\{";
8848
+ else {
8849
+ source += `(?:${pattern.slice(index + 1, end).split(",").map((part) => part.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join("|")})`;
8850
+ index = end;
8851
+ }
8852
+ } else source += char.replace(/[.+^$()|[\]\\]/g, "\\$&");
8853
+ }
8854
+ const expression = new RegExp(`^${source}$`);
8855
+ return (path) => expression.test(path);
8856
+ }
8857
+ //#endregion
8519
8858
  //#region ../cli/src/project.ts
8520
8859
  const defaultInclude = ["**/*.eval.ts", "**/*.eval.js"];
8521
8860
  const defaultExclude = [
@@ -8538,10 +8877,16 @@ function evaluation(value, source) {
8538
8877
  if (typeof candidate.agent?.start !== "function" || !Array.isArray(candidate.transcript) || !Array.isArray(candidate.scoring)) throw new Error(`Invalid eval exported by ${source}`);
8539
8878
  return candidate;
8540
8879
  }
8880
+ /** The suite a discovered file belongs to: the first directory below the discovery root. */
8881
+ function directorySuite(relativePath) {
8882
+ const parts = relativePath.split("/");
8883
+ return parts.length > 1 ? parts[0] : void 0;
8884
+ }
8541
8885
  async function discover(root, config) {
8542
- const directory = resolve(root, config.testDir ?? "evals");
8543
- const includes = (config.include ?? defaultInclude).map((pattern) => new Bun.Glob(pattern));
8544
- const excludes = [...defaultExclude, ...config.exclude ?? []].map((pattern) => new Bun.Glob(pattern));
8886
+ const testDir = config.testDir ?? "scenarios";
8887
+ const directory = resolve(root, testDir);
8888
+ const includes = (config.include ?? defaultInclude).map((pattern) => ({ match: globMatcher(pattern) }));
8889
+ const excludes = [...defaultExclude, ...config.exclude ?? []].map((pattern) => ({ match: globMatcher(pattern) }));
8545
8890
  const paths = [];
8546
8891
  async function walk(dir) {
8547
8892
  const entries = await readdir(dir, { withFileTypes: true });
@@ -8557,16 +8902,42 @@ async function discover(root, config) {
8557
8902
  try {
8558
8903
  await walk(directory);
8559
8904
  } catch (error) {
8560
- if (error.code === "ENOENT") throw new Error(`Eval directory not found: ${directory}`);
8561
- throw error;
8905
+ if (error.code !== "ENOENT") throw error;
8906
+ const hint = config.testDir === void 0 && await isDirectory(resolve(root, "evals")) ? ` Scenarios now live in scenarios/: move evals/ there, or set testDir: 'evals' in evalkit.config.ts.` : "";
8907
+ throw new Error(`Eval directory not found: ${directory}.${hint}`);
8562
8908
  }
8563
- const evals = [];
8909
+ const directorySuites = config.discovery?.suites ?? true;
8910
+ const standalone = [];
8911
+ const bySuite = /* @__PURE__ */ new Map();
8912
+ const loadErrors = [];
8564
8913
  for (const file of paths.sort()) {
8565
- const module = await import(pathToFileURL(file).href);
8566
- for (const value of Array.isArray(module.default) ? module.default : [module.default]) evals.push(evaluation(value, file));
8914
+ const fromRoot = relative(root, file).split(sep).join("/");
8915
+ const suiteId = directorySuites ? directorySuite(relative(directory, file).split(sep).join("/")) : void 0;
8916
+ try {
8917
+ if (suiteId !== void 0 && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(suiteId)) throw new Error(`Directory ${testDir}/${suiteId} names a suite, so it must be lowercase kebab-case (or set discovery.suites: false)`);
8918
+ const exported = await (await importProjectModule(file)).default;
8919
+ const evals = (Array.isArray(exported) ? exported : [exported]).map((value) => evaluation(value, fromRoot));
8920
+ if (suiteId === void 0) standalone.push(...evals);
8921
+ else bySuite.set(suiteId, [...bySuite.get(suiteId) ?? [], ...evals]);
8922
+ } catch (error) {
8923
+ loadErrors.push({
8924
+ file: fromRoot,
8925
+ ...suiteId === void 0 ? {} : { suiteId },
8926
+ error: recordError(error)
8927
+ });
8928
+ }
8567
8929
  }
8568
- if (!evals.length) throw new Error(`No eval files found in ${directory}`);
8569
- return evals;
8930
+ if (!paths.length) throw new Error(`No eval files found in ${directory}`);
8931
+ return {
8932
+ registrations: [...standalone, ...[...bySuite].map(([id, evals]) => defineSuite({
8933
+ id,
8934
+ evals
8935
+ }))],
8936
+ loadErrors
8937
+ };
8938
+ }
8939
+ async function isDirectory(path) {
8940
+ return stat(path).then((info) => info.isDirectory(), () => false);
8570
8941
  }
8571
8942
  async function loadProject(cwd, configPath) {
8572
8943
  const candidates = configPath ? [resolve(cwd, configPath)] : [
@@ -8580,12 +8951,18 @@ async function loadProject(cwd, configPath) {
8580
8951
  if (present.length > 1) throw new Error("Multiple EvalKit configs found; select one with --config");
8581
8952
  const file = present[0];
8582
8953
  const root = file ? dirname(file) : resolve(cwd);
8583
- const config = file ? (await import(pathToFileURL(file).href)).default : {};
8954
+ const config = file ? (await importProjectModule(file)).default : {};
8584
8955
  if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("Config must default-export defineConfig({...})");
8585
8956
  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`);
8586
8957
  let registry = config.registry;
8587
- if (!file && !configPath && await exists$1(resolve(root, "src/registry.ts"))) registry = (await import(pathToFileURL(resolve(root, "src/registry.ts")).href)).default;
8588
- if (!registry) registry = registerEvals(config.evals ?? await discover(root, config));
8958
+ if (!file && !configPath && await exists$1(resolve(root, "src/registry.ts"))) registry = (await importProjectModule(resolve(root, "src/registry.ts"))).default;
8959
+ let loadErrors = [];
8960
+ if (!registry && config.evals) registry = registerEvals(config.evals);
8961
+ if (!registry) {
8962
+ const discovered = await discover(root, config);
8963
+ loadErrors = discovered.loadErrors;
8964
+ registry = registerEvals(discovered.registrations);
8965
+ }
8589
8966
  if (!registry?.evals || !registry.get) throw new Error("Invalid explicit eval registry");
8590
8967
  const ids = /* @__PURE__ */ new Set();
8591
8968
  for (const e of registry.evals) {
@@ -8608,7 +8985,8 @@ async function loadProject(cwd, configPath) {
8608
8985
  return {
8609
8986
  root,
8610
8987
  config,
8611
- registry
8988
+ registry,
8989
+ loadErrors
8612
8990
  };
8613
8991
  }
8614
8992
  //#endregion
@@ -8717,125 +9095,206 @@ async function confirm(question) {
8717
9095
  }
8718
9096
  }
8719
9097
  //#endregion
8720
- //#region ../cli/src/source.ts
8721
- const exec = promisify(execFile);
8722
- /**
8723
- * Normalize a git remote to a stable repository key: lowercase host, no
8724
- * credentials, port, scheme, `.git`, or trailing slash.
8725
- *
8726
- * git@GitLab.example.com:group/sub/repo.git → gitlab.example.com/group/sub/repo
8727
- * https://user:token@github.com/o/r/ → github.com/o/r
8728
- * /srv/repos/thing.git (no host) → local/thing
8729
- */
8730
- function normalizeRepoUrl(url) {
8731
- const trimmed = url.trim().replace(/\/+$/, "").replace(/\.git$/, "");
8732
- const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/)(.+)$/.exec(trimmed);
8733
- if (scp && !/^[a-z]+:\/\//i.test(trimmed)) return `${scp[1].toLowerCase()}/${scp[2].replace(/^\/+/, "")}`;
8734
- try {
8735
- const parsed = new URL(trimmed);
8736
- if (parsed.protocol !== "file:" && parsed.hostname) return `${parsed.hostname.toLowerCase()}${parsed.pathname.replace(/\/+$/, "")}`;
8737
- } catch {}
8738
- return `local/${basename(trimmed)}`;
8739
- }
8740
- async function git(cwd, ...args) {
8741
- try {
8742
- const { stdout } = await exec("git", [
8743
- "-C",
8744
- cwd,
8745
- ...args
8746
- ], { timeout: 1e4 });
8747
- return stdout.trim() || void 0;
8748
- } catch {
8749
- return;
9098
+ //#region ../cli/src/run-reporter.ts
9099
+ function scenarioOutcome(result) {
9100
+ if (result.status === "cancelled") return "cancelled";
9101
+ 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";
9102
+ }
9103
+ function passAtFor(definition, name) {
9104
+ const rule = [...definition.transcript, ...definition.scoring].find((step) => step.kind === "predicate" && step.name === name);
9105
+ return rule?.kind === "predicate" ? rule.passAt : void 0;
9106
+ }
9107
+ function value(score) {
9108
+ return score.value === void 0 ? "" : score.value.toFixed(1);
9109
+ }
9110
+ function scoreReason(definition, score) {
9111
+ if (score.error) return `${score.name} error: ${score.error.message}`;
9112
+ const passAt = passAtFor(definition, score.name);
9113
+ return `${score.name} ${value(score)}${passAt === void 0 ? "" : ` (passes at ${passAt})`}`;
9114
+ }
9115
+ function checkpointReason(definition, checkpoint) {
9116
+ if (checkpoint.status === "error") return `step ${checkpoint.step} ${checkpoint.name} error: ${checkpoint.error?.message ?? ""}`;
9117
+ const passAt = passAtFor(definition, checkpoint.name);
9118
+ return `step ${checkpoint.step} ${checkpoint.name} ${checkpoint.value === void 0 ? checkpoint.status : value(checkpoint)}${passAt === void 0 ? "" : ` (passes at ${passAt})`}`;
9119
+ }
9120
+ /** What went wrong in a scenario, deduplicated across its trials. */
9121
+ function reasonsFor(definition, trials) {
9122
+ const reasons = /* @__PURE__ */ new Set();
9123
+ const skipped = /* @__PURE__ */ new Set();
9124
+ for (const trial of trials) {
9125
+ if (trial.status === "failed" && trial.error) reasons.add(`error: ${trial.error.message}`);
9126
+ for (const checkpoint of trial.scoring?.checkpoints ?? []) if (checkpoint.status === "failed" || checkpoint.status === "error") reasons.add(checkpointReason(definition, checkpoint));
9127
+ for (const score of trial.scoring?.results ?? []) if (score.skipped) skipped.add(score.name);
9128
+ else if (!score.passed) reasons.add(scoreReason(definition, score));
9129
+ for (const name of trial.scoring?.skippedScorers ?? []) if (trial.status !== "cancelled") reasons.add(`${name} not run`);
8750
9130
  }
8751
- }
8752
- const posix = (path) => path.split(sep).join("/");
8753
- const projectPath = (root, projectRoot) => root ? posix(relative(root, projectRoot)) : "";
8754
- function ciSource(env, projectRoot) {
8755
- if (env.EVALKIT_SOURCE_REPO && env.EVALKIT_SOURCE_COMMIT) return {
8756
- repo: normalizeRepoUrl(env.EVALKIT_SOURCE_REPO),
8757
- project: env.EVALKIT_SOURCE_PROJECT ?? "",
8758
- commit: env.EVALKIT_SOURCE_COMMIT,
8759
- ...env.EVALKIT_SOURCE_REF ? { ref: env.EVALKIT_SOURCE_REF } : {},
8760
- ...env.EVALKIT_SOURCE_COMMITTED_AT ? { committedAt: env.EVALKIT_SOURCE_COMMITTED_AT } : {}
8761
- };
8762
- if (env.GITLAB_CI === "true" && env.CI_PROJECT_URL && env.CI_COMMIT_SHA) return {
8763
- repo: normalizeRepoUrl(env.CI_PROJECT_URL),
8764
- project: projectPath(env.CI_PROJECT_DIR, projectRoot),
8765
- commit: env.CI_COMMIT_SHA,
8766
- ...env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ?? env.CI_COMMIT_REF_NAME ? { ref: env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ?? env.CI_COMMIT_REF_NAME } : {},
8767
- ...env.CI_COMMIT_TIMESTAMP ? { committedAt: env.CI_COMMIT_TIMESTAMP } : {},
8768
- ci: {
8769
- provider: "gitlab",
8770
- ...env.CI_PIPELINE_ID ? { pipeline: env.CI_PIPELINE_ID } : {},
8771
- ...env.CI_JOB_ID ? { job: env.CI_JOB_ID } : {},
8772
- ...env.CI_JOB_URL ? { url: env.CI_JOB_URL } : {}
8773
- }
9131
+ return {
9132
+ reasons: [...reasons],
9133
+ skipped: [...skipped]
8774
9134
  };
8775
- if (env.GITHUB_ACTIONS === "true" && env.GITHUB_REPOSITORY && env.GITHUB_SHA) {
8776
- const server = env.GITHUB_SERVER_URL ?? "https://github.com";
8777
- return {
8778
- repo: normalizeRepoUrl(`${server}/${env.GITHUB_REPOSITORY}`),
8779
- project: projectPath(env.GITHUB_WORKSPACE, projectRoot),
8780
- commit: env.GITHUB_SHA,
8781
- ...env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME ? { ref: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME } : {},
8782
- ci: {
8783
- provider: "github",
8784
- ...env.GITHUB_RUN_ID ? { pipeline: env.GITHUB_RUN_ID } : {},
8785
- ...env.GITHUB_JOB ? { job: env.GITHUB_JOB } : {},
8786
- ...env.GITHUB_RUN_ID ? { url: `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` } : {}
8787
- }
8788
- };
8789
- }
8790
9135
  }
8791
- /**
8792
- * Determine which repository and commit this project's eval code comes from.
8793
- * Order: EVALKIT_SOURCE_* variables, GitLab/GitHub CI variables, then git.
8794
- * Returns undefined outside git with no CI variables ("untracked" results).
8795
- *
8796
- * `outputDirs` (results, sandbox, snapshot) never make the tree dirty, even
8797
- * when a project forgot to ignore them.
8798
- */
8799
- async function detectSource(givenRoot, options = {}) {
8800
- const env = options.env ?? process.env;
8801
- const projectRoot = await realpath(givenRoot).catch(() => givenRoot);
8802
- const top = await git(projectRoot, "rev-parse", "--show-toplevel");
8803
- const excludes = top ? (options.outputDirs ?? []).map((dir) => join(projectRoot, relative(givenRoot, dir))).map((dir) => posix(relative(top, dir))).filter((dir) => dir && !dir.startsWith("..")).map((dir) => `:(exclude)${dir}`) : [];
8804
- const dirty = top ? Boolean(await git(top, "status", "--porcelain", "--", ".", ...excludes)) : false;
8805
- const fromCi = ciSource(env, projectRoot);
8806
- if (fromCi) return {
8807
- ...fromCi,
8808
- ...top && !env.EVALKIT_SOURCE_PROJECT ? { project: projectPath(top, projectRoot) } : {},
8809
- dirty
8810
- };
8811
- if (!top) return void 0;
8812
- const commit = await git(top, "rev-parse", "HEAD");
8813
- if (!commit) return void 0;
8814
- const remote = await git(top, "config", "--get", "remote.origin.url") ?? (await git(top, "remote"))?.split("\n").map((name) => name.trim()).find(Boolean);
8815
- const remoteUrl = remote && !remote.includes("/") && !remote.includes(":") ? await git(top, "config", "--get", `remote.${remote}.url`) : remote;
8816
- const ref = await git(top, "symbolic-ref", "--short", "-q", "HEAD");
8817
- const committedAt = await git(top, "show", "-s", "--format=%cI", "HEAD");
9136
+ function scenarioResult(cell, result, suiteId) {
9137
+ const evalId = authoringId(cell.eval);
9138
+ const trials = result.trials ?? [result];
8818
9139
  return {
8819
- repo: normalizeRepoUrl(remoteUrl ?? top),
8820
- project: projectPath(top, projectRoot),
8821
- commit,
8822
- ...ref ? { ref } : {},
8823
- ...committedAt ? { committedAt } : {},
8824
- dirty
9140
+ label: suiteId ? `${suiteId}/${evalId}` : evalId,
9141
+ evalId,
9142
+ ...suiteId ? { suiteId } : {},
9143
+ parameters: cell.parameters,
9144
+ outcome: scenarioOutcome(result),
9145
+ ...result.durationMs !== void 0 ? { durationMs: result.durationMs } : {},
9146
+ result,
9147
+ ...reasonsFor(cell.eval, trials)
8825
9148
  };
8826
9149
  }
8827
- /** The project's evals and suites, saved with each run (`catalog.json`). */
8828
- function runCatalog(registry) {
9150
+ function parameterLabel(parameters) {
9151
+ return Object.entries(parameters).map(([key, item]) => `${key}=${typeof item === "string" ? item : JSON.stringify(item)}`).join(" ");
9152
+ }
9153
+ /** One printed line for a finished scenario; failing reasons on the same line. */
9154
+ function scenarioLine(scenario) {
9155
+ const mark = scenario.outcome === "passed" ? green("✓") : scenario.outcome === "cancelled" ? yellow("◌") : red("✗");
9156
+ const parameters = parameterLabel(scenario.parameters);
9157
+ const trials = scenario.result.trials;
9158
+ const trialCount = trials && trials.length > 1 ? dim(` ${trials.filter((trial) => trial.status === "completed" && trial.scoring?.passed).length}/${trials.length} trials`) : "";
9159
+ const details = [
9160
+ ...scenario.outcome === "failed" ? scenario.reasons : [],
9161
+ ...scenario.outcome === "cancelled" ? ["cancelled"] : [],
9162
+ ...scenario.skipped.map((name) => `${name} skipped`)
9163
+ ];
9164
+ return ` ${mark} ${bold(scenario.label)}${parameters ? ` ${dim(parameters)}` : ""}${trialCount}${details.length ? ` ${dim(details.join(" · "))}` : ""}`;
9165
+ }
9166
+ /**
9167
+ * Prints run progress. On a terminal, failures print as they happen and a live
9168
+ * line shows counts and what is running. Elsewhere (CI), every finished
9169
+ * scenario prints one plain line.
9170
+ */
9171
+ function createRunReporter(options) {
9172
+ const write = options.write ?? ((text) => process.stdout.write(text));
9173
+ const now = options.now ?? (() => performance.now());
9174
+ const running = /* @__PURE__ */ new Map();
9175
+ const scenarios = [];
9176
+ let liveShown = false;
9177
+ let timer;
9178
+ function clearLive() {
9179
+ if (liveShown) write("\r\x1B[2K");
9180
+ liveShown = false;
9181
+ }
9182
+ function liveLine() {
9183
+ const failed = scenarios.filter((s) => s.outcome === "failed").length;
9184
+ const active = [...running.values()].slice(0, 2).map((activity) => {
9185
+ const seconds = Math.round((now() - activity.startedAt) / 1e3);
9186
+ return `${activity.label} (${activity.doing ? `${activity.doing}, ` : ""}${seconds}s)`;
9187
+ });
9188
+ const more = running.size > 2 ? ", …" : "";
9189
+ return ` ${cyan("◇")} ${scenarios.length}/${options.total}${failed ? ` · ${red(`${failed} failed`)}` : ""}${active.length ? dim(` · running: ${active.join(", ")}${more}`) : ""}`;
9190
+ }
9191
+ function drawLive() {
9192
+ if (!options.interactive) return;
9193
+ const line = liveLine();
9194
+ const width = process.stdout.columns ?? 120;
9195
+ write(`\r\x1b[2K${line.length > width + 40 ? line.slice(0, width + 40) : line}`);
9196
+ liveShown = true;
9197
+ }
9198
+ function print(line) {
9199
+ clearLive();
9200
+ write(`${line}\n`);
9201
+ drawLive();
9202
+ }
8829
9203
  return {
8830
- catalog: registry.catalog(),
8831
- evals: registry.metadata(),
8832
- suites: registry.suiteMetadata()
9204
+ start() {
9205
+ if (options.interactive) {
9206
+ timer = setInterval(drawLive, 100);
9207
+ timer.unref?.();
9208
+ }
9209
+ },
9210
+ onEvent(trial, event) {
9211
+ if (event.source === "runner" && event.kind === "trial-started") {
9212
+ running.set(trial.trialId, {
9213
+ label: options.labelFor(trial),
9214
+ startedAt: now()
9215
+ });
9216
+ return;
9217
+ }
9218
+ const activity = running.get(trial.trialId);
9219
+ if (!activity) return;
9220
+ if (event.source === "runner" && (event.kind === "trial-completed" || event.kind === "error")) running.delete(trial.trialId);
9221
+ else if (event.source === "aut" && event.kind === "tool-call") activity.doing = `tool ${event.name}`;
9222
+ else if (event.source === "runner" && event.kind === "scorer-started") activity.doing = `scoring ${event.scorer}`;
9223
+ else if (event.source === "runner" && event.kind === "checkpoint-started") activity.doing = `check ${event.name}`;
9224
+ },
9225
+ onScenario(scenario) {
9226
+ scenarios.push(scenario);
9227
+ for (const trial of scenario.result.trials ?? [scenario.result]) running.delete(trial.trialId);
9228
+ if (!options.interactive || options.verbose || scenario.outcome !== "passed") print(scenarioLine(scenario));
9229
+ },
9230
+ /** Called on the first Ctrl+C. */
9231
+ cancelling() {
9232
+ print(`\n ${yellow("◌")} Cancelling: finishing running trials. Press Ctrl+C again to exit now.`);
9233
+ },
9234
+ finish(durationMs) {
9235
+ if (timer) clearInterval(timer);
9236
+ clearLive();
9237
+ return {
9238
+ passed: scenarios.filter((s) => s.outcome === "passed").length,
9239
+ failed: scenarios.filter((s) => s.outcome === "failed").length,
9240
+ cancelled: scenarios.filter((s) => s.outcome === "cancelled").length,
9241
+ scenarios: scenarios.length,
9242
+ durationMs
9243
+ };
9244
+ },
9245
+ scenarios: () => scenarios
8833
9246
  };
8834
9247
  }
8835
- /** `abc1234` or `abc1234+dirty`. */
8836
- function shortSource(source) {
8837
- return `${source.commit.slice(0, 7)}${source.dirty ? "+dirty" : ""}`;
9248
+ function totalsLine(totals) {
9249
+ const parts = [
9250
+ green(`${totals.passed} passed`),
9251
+ totals.failed ? red(`${totals.failed} failed`) : "",
9252
+ totals.cancelled ? yellow(`${totals.cancelled} cancelled`) : ""
9253
+ ].filter(Boolean);
9254
+ return ` ${bold(parts.join(" · "))} ${dim(`· ${plural(totals.scenarios, "scenario")} in ${duration(totals.durationMs)}`)}`;
9255
+ }
9256
+ //#endregion
9257
+ //#region ../cli/src/junit.ts
9258
+ function escape(text) {
9259
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "");
9260
+ }
9261
+ const seconds = (ms) => ((ms ?? 0) / 1e3).toFixed(3);
9262
+ /**
9263
+ * A JUnit XML report: one test suite per EvalKit suite (`default` for
9264
+ * scenarios without one) and one test case per scenario, per matrix cell
9265
+ * when it has parameters.
9266
+ */
9267
+ function junitReport(scenarios) {
9268
+ const bySuite = /* @__PURE__ */ new Map();
9269
+ for (const scenario of scenarios) {
9270
+ const suite = scenario.suiteId ?? "default";
9271
+ bySuite.set(suite, [...bySuite.get(suite) ?? [], scenario]);
9272
+ }
9273
+ const suites = [...bySuite].map(([suite, cases]) => {
9274
+ const failures = cases.filter((c) => c.outcome === "failed").length;
9275
+ const skipped = cases.filter((c) => c.outcome === "cancelled" || c.outcome === "passed" && c.skipped.length > 0 && allSkipped(c)).length;
9276
+ const time = cases.reduce((sum, c) => sum + (c.durationMs ?? 0), 0);
9277
+ const testcases = cases.map((scenario) => {
9278
+ const parameters = parameterLabel(scenario.parameters);
9279
+ const attributes = `name="${escape(parameters ? `${scenario.evalId} [${parameters}]` : scenario.evalId)}" classname="${escape(suite)}" time="${seconds(scenario.durationMs)}"`;
9280
+ if (scenario.outcome === "failed") return ` <testcase ${attributes}>\n <failure message="${escape(scenario.reasons.join("; ") || "failed")}">${escape(scenario.reasons.join("\n"))}</failure>\n </testcase>`;
9281
+ if (scenario.outcome === "cancelled") return ` <testcase ${attributes}>\n <skipped message="cancelled"/>\n </testcase>`;
9282
+ if (allSkipped(scenario)) return ` <testcase ${attributes}>\n <skipped message="${escape(`all scorers skipped: ${scenario.skipped.join(", ")}`)}"/>\n </testcase>`;
9283
+ return ` <testcase ${attributes}/>`;
9284
+ });
9285
+ return ` <testsuite name="${escape(suite)}" tests="${cases.length}" failures="${failures}" errors="0" skipped="${skipped}" time="${seconds(time)}">\n${testcases.join("\n")}\n </testsuite>`;
9286
+ });
9287
+ 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`;
8838
9288
  }
9289
+ /** True when every score of every trial was skip()ped. */
9290
+ function allSkipped(scenario) {
9291
+ const results = (scenario.result.trials ?? [scenario.result]).flatMap((trial) => trial.scoring?.results ?? []);
9292
+ return results.length > 0 && results.every((score) => score.skipped);
9293
+ }
9294
+ //#endregion
9295
+ //#region ../cli/src/version.ts
9296
+ /** The CLI version; kept in sync with package.json when releasing. */
9297
+ const VERSION = "0.6.0";
8839
9298
  //#endregion
8840
9299
  //#region ../cli/src/run-command.ts
8841
9300
  function parseRunArgs(args) {
@@ -8875,7 +9334,10 @@ function parseRunArgs(args) {
8875
9334
  json: { type: "boolean" },
8876
9335
  "dry-run": { type: "boolean" },
8877
9336
  all: { type: "boolean" },
8878
- local: { type: "boolean" }
9337
+ local: { type: "boolean" },
9338
+ junit: { type: "string" },
9339
+ verbose: { type: "boolean" },
9340
+ "skip-broken": { type: "boolean" }
8879
9341
  }
8880
9342
  });
8881
9343
  return normalizeRunOptions(values, positionals);
@@ -8928,21 +9390,37 @@ function normalizeRunOptions(values, positionals) {
8928
9390
  trials: integer("trials")
8929
9391
  };
8930
9392
  }
9393
+ /** Load errors that affect what was asked for, so the run must not pretend it covered them. */
9394
+ function brokenSelection(loadErrors, request) {
9395
+ if (!loadErrors.length) return [];
9396
+ if (request.suiteId) return loadErrors.filter((error) => error.suiteId === request.suiteId);
9397
+ if (request.evalIds.length) return request.evalIds.some((id) => !request.registry.get(id)) ? loadErrors : [];
9398
+ return loadErrors;
9399
+ }
9400
+ /** Load errors are warnings; `print` is stderr when stdout must stay machine-readable. */
9401
+ function printLoadErrors(loadErrors, print = console.log) {
9402
+ for (const { file, error } of loadErrors) print(` ${yellow("✗")} ${bold(file)} failed to load:\n ${dim(error.message)}`);
9403
+ if (loadErrors.length) print("");
9404
+ }
8931
9405
  async function runProjectCommand(args, cwd = process.cwd(), parsed = parseRunArgs(args)) {
8932
9406
  const { values, positionals, parameters } = parsed;
9407
+ const started = performance.now();
8933
9408
  const project = await loadProject(cwd, values.config);
8934
9409
  process.chdir(project.root);
8935
9410
  const registry = project.registry;
8936
9411
  const requestedEvals = values.eval?.flatMap((value) => value.split(",")) ?? [];
8937
9412
  let matrix;
8938
9413
  let suiteId;
9414
+ const quiet = Boolean(values.json || values["dry-run"]);
9415
+ if (!quiet) banner("run", VERSION);
8939
9416
  if (values.suite) {
8940
9417
  const suite = registry.suites.find((item) => item.id === values.suite);
8941
- if (!suite) throw new Error(`Unknown suite: ${values.suite}`);
8942
- suiteId = authoringId(suite);
9418
+ const broken = project.loadErrors.filter((error) => error.suiteId === values.suite);
9419
+ if (!suite && !broken.length) throw new Error(`Unknown suite: ${values.suite}`);
9420
+ suiteId = values.suite;
8943
9421
  matrix = defineEvalMatrix({
8944
9422
  id: "default",
8945
- evals: suite.evals,
9423
+ evals: suite?.evals ?? [],
8946
9424
  parameters: project.config.matrix?.parameters ?? {},
8947
9425
  defaults: project.config.matrix?.defaults,
8948
9426
  cases: project.config.matrix?.cases,
@@ -8959,6 +9437,19 @@ async function runProjectCommand(args, cwd = process.cwd(), parsed = parseRunArg
8959
9437
  parameters: {}
8960
9438
  });
8961
9439
  }
9440
+ const broken = brokenSelection(project.loadErrors, {
9441
+ ...suiteId ? { suiteId } : {},
9442
+ evalIds: requestedEvals,
9443
+ registry
9444
+ });
9445
+ printLoadErrors(project.loadErrors, quiet ? console.error : console.log);
9446
+ if (broken.length && !values["skip-broken"] && !values["dry-run"]) throw new Error(`${plural(broken.length, "eval file")} in this selection failed to load: ${broken.map((error) => error.file).join(", ")}. Fix ${broken.length === 1 ? "it" : "them"}, or pass --skip-broken to run the rest.`);
9447
+ const knownEvals = requestedEvals.filter((id) => registry.get(id));
9448
+ if (values["skip-broken"] && knownEvals.length < requestedEvals.length) {
9449
+ requestedEvals.length = 0;
9450
+ requestedEvals.push(...knownEvals);
9451
+ if (!requestedEvals.length) throw new Error("Every selected eval is in a file that failed to load.");
9452
+ }
8962
9453
  const selection = {
8963
9454
  parameters: {},
8964
9455
  overrides: { ...parameters },
@@ -8982,7 +9473,8 @@ async function runProjectCommand(args, cwd = process.cwd(), parsed = parseRunArg
8982
9473
  console.log(JSON.stringify(plan, null, 2));
8983
9474
  return;
8984
9475
  }
8985
- if (cells > (project.config.execution?.maxCells ?? 100) && !values.all) throw new Error(`Selected ${cells} cells. Narrow the selection, inspect --dry-run, or pass --all.`);
9476
+ const sweeps = Object.keys(matrix.parameters).length > 0 || Boolean(matrix.cases?.length);
9477
+ if (sweeps && cells > (project.config.execution?.maxCells ?? 100) && !values.all) throw new Error(`Selected ${cells} cells. Narrow the selection, inspect --dry-run, or pass --all.`);
8986
9478
  const reportDir = resolve(project.root, project.config.reportDir ?? "_evalkit-results");
8987
9479
  const sandboxDir = resolve(project.root, project.config.sandboxDir ?? "_evalkit-sandbox");
8988
9480
  const source = await detectSource(project.root, { outputDirs: [
@@ -8990,36 +9482,75 @@ async function runProjectCommand(args, cwd = process.cwd(), parsed = parseRunArg
8990
9482
  sandboxDir,
8991
9483
  resolve(project.root, "_evalkit-dashboard")
8992
9484
  ] });
8993
- if (!values.json) console.log(`Running ${cells} cell(s) from ${plan.matrix}` + (source ? ` at ${shortSource(source)}${source.ref ? ` (${source.ref})` : ""}` : " (untracked: not in a git repository)"));
8994
- if ((await Effect.runPromise(runnerLogs(runMatrix(matrix, {
8995
- selection,
8996
- concurrency: parsed.concurrency ?? project.config.execution?.concurrency ?? 4,
8997
- trials,
8998
- ...suiteId ? { suiteId } : {},
8999
- ...values.local ? { runtime: "local" } : {},
9000
- ...source ? { source } : {},
9001
- catalog: runCatalog(registry),
9002
- report: localReportStore(reportDir),
9003
- workspaceRoot: sandboxDir,
9004
- onResult: (cell, result) => {
9005
- if (values.json) console.log(JSON.stringify({
9006
- cell: {
9007
- key: cell.key,
9008
- eval: authoringId(cell.eval),
9009
- parameters: cell.parameters
9010
- },
9011
- result
9012
- }));
9013
- else {
9014
- const passed = result.status === "completed" && (result.aggregateScoring?.passRate === 1 || result.scoring?.passed);
9015
- console.log(`${passed ? "PASS" : "FAIL"} ${cell.eval.name ?? authoringId(cell.eval)} ${JSON.stringify(cell.parameters)}\n report: ${result.reportLocation}`);
9016
- for (const trial of result.trials ?? [result]) {
9017
- for (const checkpoint of trial.scoring?.checkpoints ?? []) console.log(` step ${checkpoint.step} ${checkpoint.kind} ${checkpoint.name}: ${checkpoint.status}${checkpoint.value === void 0 ? "" : ` (${checkpoint.value})`}`);
9018
- for (const score of trial.scoring?.results ?? []) console.log(` ${score.kind} ${score.name}: ${score.value ?? "error"} ${score.explanation ?? ""}`);
9019
- }
9485
+ const suiteOf = new Map(registry.suites.flatMap((suite) => suite.evals.map((evaluation) => [authoringId(evaluation), suite.id])));
9486
+ const suiteFor = (evalId) => suiteId ?? suiteOf.get(evalId);
9487
+ 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");
9488
+ const reporter = createRunReporter({
9489
+ interactive: interactive && !values.json,
9490
+ verbose: Boolean(values.verbose),
9491
+ total: cells,
9492
+ labelFor: (trial) => {
9493
+ const suite = suiteFor(trial.evalId);
9494
+ return suite ? `${suite}/${trial.evalId}` : trial.evalId;
9495
+ },
9496
+ write: values.json ? () => {} : void 0
9497
+ });
9498
+ const controller = new AbortController();
9499
+ const onInterrupt = () => {
9500
+ if (controller.signal.aborted) process.exit(130);
9501
+ controller.abort();
9502
+ if (!values.json) reporter.cancelling();
9503
+ };
9504
+ process.on("SIGINT", onInterrupt);
9505
+ reporter.start();
9506
+ let summary;
9507
+ try {
9508
+ summary = await Effect.runPromise(runnerLogs(runMatrix(matrix, {
9509
+ selection,
9510
+ concurrency: parsed.concurrency ?? project.config.execution?.concurrency ?? 4,
9511
+ trials,
9512
+ ...suiteId ? { suiteId } : {},
9513
+ suiteFor: (cell) => suiteFor(authoringId(cell.eval)),
9514
+ ...values.local ? { runtime: "local" } : {},
9515
+ ...source ? { source } : {},
9516
+ catalog: runCatalog(registry, project.root),
9517
+ report: localReportStore(reportDir),
9518
+ workspaceRoot: sandboxDir,
9519
+ signal: controller.signal,
9520
+ onEvent: (trial, event) => reporter.onEvent(trial, event),
9521
+ onResult: (cell, result) => {
9522
+ const scenario = scenarioResult(cell, result, suiteFor(authoringId(cell.eval)));
9523
+ if (values.json) console.log(JSON.stringify({
9524
+ cell: {
9525
+ key: cell.key,
9526
+ eval: authoringId(cell.eval),
9527
+ parameters: cell.parameters
9528
+ },
9529
+ outcome: scenario.outcome,
9530
+ result
9531
+ }));
9532
+ reporter.onScenario(scenario);
9020
9533
  }
9021
- }
9022
- })))).failed) process.exitCode = 1;
9534
+ })));
9535
+ } finally {
9536
+ process.off("SIGINT", onInterrupt);
9537
+ }
9538
+ const totals = reporter.finish(performance.now() - started);
9539
+ if (values.junit) {
9540
+ const file = resolve(cwd, values.junit);
9541
+ await mkdir(dirname(file), { recursive: true });
9542
+ await writeFile(file, junitReport(reporter.scenarios()));
9543
+ }
9544
+ if (values.json) console.log(JSON.stringify({ summary: totals }));
9545
+ else {
9546
+ console.log(`\n${totalsLine(totals)}`);
9547
+ if (values.junit) console.log(` ${dim(`JUnit report: ${values.junit}`)}`);
9548
+ console.log(` ${cyan("➜")} bun run evalkit dev ${dim("to inspect")}`);
9549
+ if (project.config.share) console.log(` ${cyan("➜")} bun run evalkit share ${dim("to publish")}`);
9550
+ console.log("");
9551
+ }
9552
+ if (controller.signal.aborted) process.exitCode = 130;
9553
+ else if (summary.failed || broken.length) process.exitCode = 1;
9023
9554
  }
9024
9555
  //#endregion
9025
9556
  //#region ../cli/src/tidy.ts
@@ -9083,7 +9614,8 @@ function selectRuns(runs, head, options) {
9083
9614
  otherCommits: 0,
9084
9615
  dirty: 0,
9085
9616
  archived: 0,
9086
- running: 0
9617
+ running: 0,
9618
+ cancelled: 0
9087
9619
  };
9088
9620
  for (const run of runs) {
9089
9621
  const { source } = run;
@@ -9092,6 +9624,7 @@ function selectRuns(runs, head, options) {
9092
9624
  else if (source.dirty && !options.allowDirty) result.dirty += 1;
9093
9625
  else if (run.archived) result.archived += 1;
9094
9626
  else if (run.status === "running") result.running += 1;
9627
+ else if (run.status === "cancelled") result.cancelled += 1;
9095
9628
  else result.selected.push({
9096
9629
  ...run,
9097
9630
  source
@@ -9105,6 +9638,7 @@ function selectionSummary(selection, options) {
9105
9638
  `${plural(selection.selected.length, "run")} to share`,
9106
9639
  selection.otherCommits && !options.all ? `${selection.otherCommits} from other commits` : "",
9107
9640
  selection.running ? `${selection.running} still running skipped` : "",
9641
+ selection.cancelled ? `${selection.cancelled} cancelled skipped` : "",
9108
9642
  selection.dirty ? `${selection.dirty} dirty skipped` : "",
9109
9643
  selection.untracked ? `${selection.untracked} untracked skipped` : "",
9110
9644
  selection.archived ? `${selection.archived} archived skipped` : ""
@@ -9152,29 +9686,61 @@ async function runBundle(options) {
9152
9686
  }, { workspaceFiles: options.workspaceFiles });
9153
9687
  return encodeBundle(files);
9154
9688
  }
9155
- /** The server refused the bundle (4xx): retrying the same bundle cannot help. */
9689
+ /** The server refused this bundle (4xx): retrying it cannot help. */
9156
9690
  var UploadRejected = class extends Error {};
9691
+ /**
9692
+ * The URL does not answer as an EvalKit share server (a redirect, an HTML page,
9693
+ * an auth proxy). Every upload would fail the same way, so sharing stops.
9694
+ */
9695
+ var ShareServerError = class extends Error {};
9696
+ /** `share.headers` from the config: called once, checked, never printed. */
9697
+ async function resolveShareHeaders(headers) {
9698
+ let value;
9699
+ try {
9700
+ value = typeof headers === "function" ? await headers() : headers ?? {};
9701
+ } catch (error) {
9702
+ throw new Error(`share.headers failed: ${error instanceof Error ? error.message : String(error)}`);
9703
+ }
9704
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("share.headers must be an object of header names to values");
9705
+ const entries = Object.entries(value);
9706
+ for (const [name, item] of entries) if (typeof item !== "string" || !item) throw new Error(`share.headers "${name}" must be a non-empty string`);
9707
+ return Object.fromEntries(entries);
9708
+ }
9709
+ /** Check that the URL is an EvalKit share server and the headers get through. */
9710
+ async function checkShareServer(serverUrl, headers) {
9711
+ const response = await fetch(new URL("/api/v1/repos", serverUrl), {
9712
+ headers,
9713
+ redirect: "manual"
9714
+ });
9715
+ await expectShareServer(response, serverUrl);
9716
+ await response.body?.cancel();
9717
+ }
9157
9718
  /** POST one bundle, retrying network errors and 5xx responses. */
9158
9719
  async function uploadBundle(serverUrl, bundle, options = {}) {
9159
9720
  const attempts = options.attempts ?? 3;
9160
9721
  let lastError;
9161
9722
  for (let attempt = 1; attempt <= attempts; attempt++) {
9162
9723
  try {
9163
- return await postBundle(serverUrl, bundle);
9724
+ return await postBundle(serverUrl, bundle, options.headers ?? {});
9164
9725
  } catch (error) {
9165
- if (error instanceof UploadRejected) throw error;
9726
+ if (error instanceof UploadRejected || error instanceof ShareServerError) throw error;
9166
9727
  lastError = error;
9167
9728
  }
9168
- if (attempt < attempts) await Bun.sleep((options.delayMs ?? 500) * 2 ** (attempt - 1));
9729
+ if (attempt < attempts) await sleep((options.delayMs ?? 500) * 2 ** (attempt - 1));
9169
9730
  }
9170
9731
  throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`Upload failed: ${String(lastError)}`);
9171
9732
  }
9172
- async function postBundle(serverUrl, bundle) {
9733
+ async function postBundle(serverUrl, bundle, headers) {
9173
9734
  const response = await fetch(new URL("/api/v1/uploads", serverUrl), {
9174
9735
  method: "POST",
9175
- headers: { "content-type": "application/octet-stream" },
9176
- body: bundle
9736
+ headers: {
9737
+ ...headers,
9738
+ "content-type": "application/octet-stream"
9739
+ },
9740
+ body: bundle,
9741
+ redirect: "manual"
9177
9742
  });
9743
+ await expectShareServer(response, serverUrl);
9178
9744
  if (response.ok) return parseUploadResult(await response.json());
9179
9745
  const reason = await response.text();
9180
9746
  if (response.status < 500) throw new UploadRejected(`Share server rejected the upload: ${reason}`);
@@ -9186,7 +9752,18 @@ function parseUploadResult(value) {
9186
9752
  status: result.status,
9187
9753
  url: result.url
9188
9754
  };
9189
- throw new UploadRejected(`Unexpected share server response: ${JSON.stringify(value)}`);
9755
+ throw new ShareServerError(`Unexpected share server response: ${JSON.stringify(value)}`);
9756
+ }
9757
+ /** Redirects and non-JSON answers come from something in front of the server. */
9758
+ async function expectShareServer(response, serverUrl) {
9759
+ if (response.status >= 300 && response.status < 400) {
9760
+ const location = response.headers.get("location");
9761
+ const target = location ? new URL(location, serverUrl).host : "elsewhere";
9762
+ throw new ShareServerError(`${new URL(serverUrl).host} redirected to ${target} instead of answering. If an auth proxy is in front of the share server, set share.headers in evalkit.config.ts.`);
9763
+ }
9764
+ const type = response.headers.get("content-type") ?? "";
9765
+ const denied = response.status === 401 || response.status === 403;
9766
+ 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."));
9190
9767
  }
9191
9768
  async function shareRuns(options) {
9192
9769
  const reports = createDashboardReportReader(options.reportRoot, options.registry);
@@ -9207,10 +9784,11 @@ async function shareRuns(options) {
9207
9784
  run,
9208
9785
  workspaceFiles: options.workspaceFiles
9209
9786
  });
9210
- const uploaded = await uploadBundle(options.serverUrl, bundle);
9787
+ const uploaded = await uploadBundle(options.serverUrl, bundle, { headers: options.headers });
9211
9788
  result.bytes += bundle.byteLength;
9212
9789
  result[uploaded.status] += 1;
9213
9790
  } catch (error) {
9791
+ if (error instanceof ShareServerError) throw error;
9214
9792
  result.failed.push({
9215
9793
  runId: run.id,
9216
9794
  error: error instanceof Error ? error.message : String(error)
@@ -9227,7 +9805,7 @@ function formatBytes(size) {
9227
9805
  }
9228
9806
  //#endregion
9229
9807
  //#region ../cli/src/new-project-template.ts
9230
- /** Provider-free example: one fixture, agent, scorer, and a two-cell matrix. */
9808
+ /** Provider-free example: one dataset, agent, scorer, and a two-cell matrix. */
9231
9809
  const starterFiles = {
9232
9810
  "tsconfig.json": `${JSON.stringify({
9233
9811
  compilerOptions: {
@@ -9240,15 +9818,17 @@ const starterFiles = {
9240
9818
  },
9241
9819
  include: [
9242
9820
  "agents/**/*.ts",
9243
- "evals/**/*.ts",
9821
+ "datasets/**/*.ts",
9244
9822
  "judges/**/*.ts",
9823
+ "scenarios/**/*.ts",
9245
9824
  "evalkit.config.ts"
9246
9825
  ]
9247
9826
  }, null, 2)}\n`,
9248
9827
  "evalkit.config.ts": `import { defineConfig } from '@leostera/evalkit';
9249
9828
 
9250
9829
  export default defineConfig({
9251
- // Default-exported evals in evals/*.eval.ts are discovered automatically.
9830
+ // Default-exported evals in scenarios/**/*.eval.ts are discovered automatically;
9831
+ // each directory below scenarios/ is a suite.
9252
9832
  matrix: {
9253
9833
  id: 'styles',
9254
9834
  parameters: { style: ['plain', 'shout'] },
@@ -9256,7 +9836,16 @@ export default defineConfig({
9256
9836
  execution: { concurrency: 2, maxCells: 20 },
9257
9837
  });
9258
9838
  `,
9259
- "fixtures/greeting.txt": "Hello\n",
9839
+ "datasets/greeting/greeting.txt": "Hello\n",
9840
+ "datasets/greeting.dataset.ts": `import { defineDataset } from '@leostera/evalkit';
9841
+
9842
+ // Files the agent can read: copied into each trial's workspace under greeting/.
9843
+ // Paths are relative to the project root.
9844
+ export const greeting = defineDataset({
9845
+ id: 'greeting',
9846
+ files: 'datasets/greeting',
9847
+ });
9848
+ `,
9260
9849
  "agents/greeting-agent.ts": `import { readFile } from 'node:fs/promises';
9261
9850
  import { join } from 'node:path';
9262
9851
  import { defineAgent } from '@leostera/evalkit';
@@ -9264,23 +9853,18 @@ import { defineAgent } from '@leostera/evalkit';
9264
9853
  // Replace this in-process example with an adapter for your own agent.
9265
9854
  export const greetingAgent = defineAgent({
9266
9855
  identity: { id: 'greeting-agent', kind: 'in-process' },
9267
- runtimes: { local: { kind: 'in-process' } },
9268
- async start({ context, onEvent }) {
9856
+ async start({ context }) {
9269
9857
  return {
9858
+ // Return the reply; EvalKit records it. Call onEvent instead to stream events.
9270
9859
  async send(message: string) {
9271
- // Fixtures copied to the candidate workspace are visible to the AUT.
9272
- const prefix = (await readFile(join(context.workspace.root, 'greeting.txt'), 'utf8')).trim();
9860
+ const file = join(context.workspace.root, 'greeting', 'greeting.txt');
9861
+ const prefix = (await readFile(file, 'utf8')).trim();
9273
9862
  const style = context.parameters?.style;
9274
9863
  if (style !== 'plain' && style !== 'shout')
9275
9864
  throw new Error(\`Unknown style: \${String(style)}\`);
9276
9865
  const greeting = \`\${prefix}, \${message}!\`;
9277
- await onEvent({
9278
- kind: 'message', role: 'assistant',
9279
- content: style === 'shout' ? greeting.toUpperCase() : greeting,
9280
- timestamp: new Date().toISOString(),
9281
- });
9866
+ return style === 'shout' ? greeting.toUpperCase() : greeting;
9282
9867
  },
9283
- async close() {},
9284
9868
  };
9285
9869
  },
9286
9870
  });
@@ -9288,25 +9872,20 @@ export const greetingAgent = defineAgent({
9288
9872
  "judges/matches-greeting.ts": `import { predicate } from '@leostera/evalkit';
9289
9873
 
9290
9874
  // Deterministic scorers need no judge model; add a separate judge agent for judge(...) rules.
9291
- export const matchesGreeting = predicate('matches greeting and style', ({ context, trajectory }) => {
9292
- const reply = trajectory.events.filter(
9293
- (event) => event.source === 'aut' && event.kind === 'message' && event.role === 'assistant',
9294
- ).at(-1);
9875
+ export const matchesGreeting = predicate('matches greeting and style', ({ context, output }) => {
9295
9876
  const expected = context.parameters?.style === 'shout' ? 'HELLO, ADA!' : 'Hello, Ada!';
9296
- return {
9297
- value: reply?.kind === 'message' && reply.content === expected ? 1 : 0,
9298
- explanation: \`Expected \${expected}\`,
9299
- };
9877
+ return { value: output === expected ? 1 : 0, explanation: \`Expected \${expected}\` };
9300
9878
  });
9301
9879
  `,
9302
- "evals/greeting.eval.ts": `import { defineEval, file, user } from '@leostera/evalkit';
9880
+ "scenarios/greeting.eval.ts": `import { defineEval, user } from '@leostera/evalkit';
9303
9881
  import { greetingAgent } from '../agents/greeting-agent.js';
9882
+ import { greeting } from '../datasets/greeting.dataset.js';
9304
9883
  import { matchesGreeting } from '../judges/matches-greeting.js';
9305
9884
 
9306
9885
  export default defineEval({
9307
9886
  id: 'greeting',
9308
9887
  agent: greetingAgent,
9309
- fixtures: [file('fixtures/greeting.txt', { dst: 'greeting.txt', visibility: 'candidate' })],
9888
+ datasets: [greeting],
9310
9889
  transcript: [user('Ada')],
9311
9890
  scoring: [matchesGreeting],
9312
9891
  });
@@ -9315,10 +9894,11 @@ export default defineEval({
9315
9894
  function starterReadme(name) {
9316
9895
  return `# ${name}
9317
9896
 
9318
- A local EvalKit project. Each default-exported eval in \`evals/\` is discovered automatically.
9319
- Start with \`evals/greeting.eval.ts\`: it imports an in-process agent from \`agents/\`,
9320
- a deterministic scorer from \`judges/\`, and a candidate-visible file from \`fixtures/\`.
9321
- Replace these examples with your own agent, tasks, and checks.
9897
+ A local EvalKit project. Each default-exported eval in \`scenarios/\` is discovered
9898
+ automatically, and each directory below \`scenarios/\` is a suite. Start with
9899
+ \`scenarios/greeting.eval.ts\`: it imports an in-process agent from \`agents/\`, a
9900
+ dataset of files from \`datasets/\`, and a deterministic scorer from \`judges/\`.
9901
+ Keep each scenario small and concrete; share agents, datasets and judges between them.
9322
9902
 
9323
9903
  \`evalkit.config.ts\` defines a provider-free \`styles\` matrix; the example agent reads
9324
9904
  \`context.parameters.style\`. Run:
@@ -9332,6 +9912,9 @@ bun run evalkit dev # inspect local reports and laun
9332
9912
  bun run evalkit build # write a read-only, shareable snapshot
9333
9913
  \`\`\`
9334
9914
 
9915
+ Coding agents can use the EvalKit skills that ship with the package, in
9916
+ \`node_modules/@leostera/evalkit/dist/skills/\` (start with \`evalkit/SKILL.md\`).
9917
+
9335
9918
  EvalKit installs from npm. Reports, trial workspaces, and dashboard snapshots stay local
9336
9919
  and are ignored by Git. A snapshot in \`_evalkit-dashboard/\` contains report messages,
9337
9920
  scores, evidence, and candidate files (never evaluator workspaces): review it before sharing.
@@ -9339,7 +9922,7 @@ scores, evidence, and candidate files (never evaluator workspaces): review it be
9339
9922
  }
9340
9923
  //#endregion
9341
9924
  //#region ../cli/src/new-project.ts
9342
- const npmDependency = "^0.4.0";
9925
+ const npmDependency = "^0.6.0";
9343
9926
  const ignored = [
9344
9927
  "node_modules/",
9345
9928
  "_evalkit-results/",
@@ -9403,9 +9986,9 @@ async function checkExistingScaffold(root) {
9403
9986
  for (const extension of ["js", "mjs"]) if (await exists(resolve(root, `evalkit.config.${extension}`))) throw new Error(`EvalKit config already exists: evalkit.config.${extension}`);
9404
9987
  for (const directory of [
9405
9988
  "agents",
9406
- "fixtures",
9407
- "evals",
9408
- "judges"
9989
+ "datasets",
9990
+ "judges",
9991
+ "scenarios"
9409
9992
  ]) {
9410
9993
  const path = resolve(root, directory);
9411
9994
  if (await exists(path) && !(await stat(path)).isDirectory()) throw new Error(`Scaffold directory is not a directory: ${path}`);
@@ -9451,8 +10034,300 @@ async function writeStarterReadme(root, name, inPlace) {
9451
10034
  if (!inPlace && !await exists(readmePath)) await writeFile(readmePath, starterReadme(name));
9452
10035
  }
9453
10036
  //#endregion
10037
+ //#region ../cli/src/compare.ts
10038
+ const FINISHED = /* @__PURE__ */ new Set([
10039
+ "passed",
10040
+ "failed",
10041
+ "errored"
10042
+ ]);
10043
+ function unitKey(run) {
10044
+ return `${run.evalId}\0${canonicalParameters(run.parameters ?? {})}`;
10045
+ }
10046
+ /** The run that represents each unit: the latest finished one, else the latest at all. */
10047
+ function latestByUnit(runs) {
10048
+ const byUnit = /* @__PURE__ */ new Map();
10049
+ let ignored = 0;
10050
+ const ordered = [...runs].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
10051
+ for (const run of ordered) {
10052
+ if (run.status === "running") continue;
10053
+ const key = unitKey(run);
10054
+ const current = byUnit.get(key);
10055
+ if (!current) byUnit.set(key, run);
10056
+ else {
10057
+ ignored += 1;
10058
+ if (!FINISHED.has(current.status) && FINISHED.has(run.status)) byUnit.set(key, run);
10059
+ }
10060
+ }
10061
+ return {
10062
+ byUnit,
10063
+ ignored
10064
+ };
10065
+ }
10066
+ function scorerSignature(run) {
10067
+ return (run.scorers ?? []).map((scorer) => `${scorer.kind}:${scorer.name}`).sort().join("\n");
10068
+ }
10069
+ function classify(base, head) {
10070
+ if (!base) return { change: "new" };
10071
+ if (!head) return { change: "removed" };
10072
+ for (const [side, run] of [["base", base], ["head", head]]) if (!FINISHED.has(run.status) || run.status === "errored") return {
10073
+ change: "not-comparable",
10074
+ reason: `${run.status} on ${side}`
10075
+ };
10076
+ if (scorerSignature(base) !== scorerSignature(head)) return {
10077
+ change: "not-comparable",
10078
+ reason: "different scorers"
10079
+ };
10080
+ if (base.status === "passed" && head.status === "failed") return { change: "regressed" };
10081
+ if (base.status === "failed" && head.status === "passed") return { change: "fixed" };
10082
+ return { change: "unchanged" };
10083
+ }
10084
+ function label(run, suite) {
10085
+ const parameters = Object.entries(run.parameters ?? {}).map(([key, value]) => `${key}=${typeof value === "string" ? value : JSON.stringify(value)}`).join(" ");
10086
+ const name = suite === "default" ? run.evalId : `${suite}/${run.evalId}`;
10087
+ return parameters ? `${name} [${parameters}]` : name;
10088
+ }
10089
+ function mean(values) {
10090
+ return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : void 0;
10091
+ }
10092
+ function suiteScorers(units) {
10093
+ const sides = {
10094
+ base: /* @__PURE__ */ new Map(),
10095
+ head: /* @__PURE__ */ new Map()
10096
+ };
10097
+ const kinds = /* @__PURE__ */ new Map();
10098
+ for (const unit of units) {
10099
+ if (unit.change === "not-comparable" || !unit.base || !unit.head) continue;
10100
+ for (const side of ["base", "head"]) for (const scorer of unit[side].scorers ?? []) {
10101
+ const key = `${scorer.kind}\0${scorer.name}`;
10102
+ kinds.set(key, {
10103
+ name: scorer.name,
10104
+ kind: scorer.kind
10105
+ });
10106
+ if (scorer.mean === void 0) continue;
10107
+ sides[side].set(key, [...sides[side].get(key) ?? [], scorer.mean]);
10108
+ }
10109
+ }
10110
+ for (const unit of units) {
10111
+ if (!unit.base || !unit.head) continue;
10112
+ const names = (run) => new Set((run.scorers ?? []).map((s) => `${s.kind}\0${s.name}`));
10113
+ const baseNames = names(unit.base);
10114
+ const headNames = names(unit.head);
10115
+ for (const scorer of unit.base.scorers ?? []) if (!headNames.has(`${scorer.kind}\0${scorer.name}`)) kinds.set(`${scorer.kind}\0${scorer.name}`, {
10116
+ name: scorer.name,
10117
+ kind: scorer.kind,
10118
+ onlyOn: "base"
10119
+ });
10120
+ for (const scorer of unit.head.scorers ?? []) if (!baseNames.has(`${scorer.kind}\0${scorer.name}`)) kinds.set(`${scorer.kind}\0${scorer.name}`, {
10121
+ name: scorer.name,
10122
+ kind: scorer.kind,
10123
+ onlyOn: "head"
10124
+ });
10125
+ }
10126
+ return [...kinds].map(([key, scorer]) => {
10127
+ const base = mean(sides.base.get(key) ?? []);
10128
+ const head = mean(sides.head.get(key) ?? []);
10129
+ return {
10130
+ ...scorer,
10131
+ ...base === void 0 || scorer.onlyOn ? {} : { base },
10132
+ ...head === void 0 || scorer.onlyOn ? {} : { head }
10133
+ };
10134
+ }).sort((a, b) => a.name.localeCompare(b.name));
10135
+ }
10136
+ /**
10137
+ * Matches base and head runs by eval ID and parameters and classifies each
10138
+ * unit. It reads recorded verdicts and means; it never re-scores.
10139
+ */
10140
+ function compareRuns(baseRuns, headRuns) {
10141
+ const base = latestByUnit(baseRuns);
10142
+ const head = latestByUnit(headRuns);
10143
+ const units = [.../* @__PURE__ */ new Set([...base.byUnit.keys(), ...head.byUnit.keys()])].map((key) => {
10144
+ const baseRun = base.byUnit.get(key);
10145
+ const headRun = head.byUnit.get(key);
10146
+ const run = headRun ?? baseRun;
10147
+ const suite = headRun?.suiteId ?? baseRun?.suiteId ?? "default";
10148
+ return {
10149
+ label: label(run, suite),
10150
+ evalId: run.evalId,
10151
+ suite,
10152
+ parameters: run.parameters ?? {},
10153
+ ...classify(baseRun, headRun),
10154
+ ...baseRun ? { base: baseRun } : {},
10155
+ ...headRun ? { head: headRun } : {}
10156
+ };
10157
+ }).sort((a, b) => a.label.localeCompare(b.label));
10158
+ const changedDatasets = /* @__PURE__ */ new Set();
10159
+ for (const unit of units) {
10160
+ if (!unit.base || !unit.head) continue;
10161
+ const digests = (run) => new Map((run.datasets ?? []).map((d) => [d.id, d.digests.join(",")]));
10162
+ const before = digests(unit.base);
10163
+ const after = digests(unit.head);
10164
+ for (const id of /* @__PURE__ */ new Set([...before.keys(), ...after.keys()])) if (before.get(id) !== after.get(id)) changedDatasets.add(id);
10165
+ }
10166
+ const bySuite = /* @__PURE__ */ new Map();
10167
+ for (const unit of units) bySuite.set(unit.suite, [...bySuite.get(unit.suite) ?? [], unit]);
10168
+ const suites = [...bySuite].map(([suite, members]) => {
10169
+ const side = (pick) => {
10170
+ const runs = members.flatMap((unit) => unit[pick] ? [unit[pick]] : []);
10171
+ return {
10172
+ passed: runs.filter((run) => run.status === "passed").length,
10173
+ total: runs.length
10174
+ };
10175
+ };
10176
+ return {
10177
+ suite,
10178
+ base: side("base"),
10179
+ head: side("head"),
10180
+ scorers: suiteScorers(members)
10181
+ };
10182
+ }).sort((a, b) => a.suite.localeCompare(b.suite));
10183
+ const counts = {
10184
+ regressed: 0,
10185
+ fixed: 0,
10186
+ new: 0,
10187
+ removed: 0,
10188
+ unchanged: 0,
10189
+ "not-comparable": 0
10190
+ };
10191
+ for (const unit of units) counts[unit.change] += 1;
10192
+ return {
10193
+ units,
10194
+ suites,
10195
+ changedDatasets: [...changedDatasets].sort(),
10196
+ ignoredRuns: {
10197
+ base: base.ignored,
10198
+ head: head.ignored
10199
+ },
10200
+ counts
10201
+ };
10202
+ }
10203
+ function parseFailOn(values) {
10204
+ return values.flatMap((value) => value.split(",")).map((value) => {
10205
+ if (value === "regression") return { kind: "regression" };
10206
+ const drop = /^score-drop=(\d*\.?\d+)$/.exec(value);
10207
+ if (drop) {
10208
+ const threshold = Number(drop[1]);
10209
+ if (threshold >= 0 && threshold <= 1) return {
10210
+ kind: "score-drop",
10211
+ threshold
10212
+ };
10213
+ }
10214
+ throw new Error(`Unknown --fail-on ${JSON.stringify(value)}; use regression or score-drop=0.05`);
10215
+ });
10216
+ }
10217
+ /** Human-readable reasons the comparison fails the given conditions; empty when it passes. */
10218
+ function gateFailures(comparison, conditions) {
10219
+ const failures = [];
10220
+ for (const condition of conditions) {
10221
+ if (condition.kind === "regression" && comparison.counts.regressed) failures.push(`${comparison.counts.regressed} regressed: ${comparison.units.filter((unit) => unit.change === "regressed").map((unit) => unit.label).join(", ")}`);
10222
+ if (condition.kind === "score-drop") {
10223
+ for (const suite of comparison.suites) for (const scorer of suite.scorers) if (scorer.base !== void 0 && scorer.head !== void 0 && scorer.base - scorer.head > condition.threshold) failures.push(`${suite.suite} ${scorer.name} dropped ${scorer.base.toFixed(2)} → ${scorer.head.toFixed(2)} (more than ${condition.threshold})`);
10224
+ }
10225
+ }
10226
+ return failures;
10227
+ }
10228
+ const fixed2 = (value) => value === void 0 ? "–" : value.toFixed(2);
10229
+ function scorerText(scorer) {
10230
+ if (scorer.onlyOn) return `${scorer.name} only on ${scorer.onlyOn}`;
10231
+ return `${scorer.name} ${fixed2(scorer.base)} → ${fixed2(scorer.head)}`;
10232
+ }
10233
+ /** A merge request comment. */
10234
+ function comparisonMarkdown(comparison, heading) {
10235
+ const { counts } = comparison;
10236
+ const list = (change) => comparison.units.filter((unit) => unit.change === change).map((unit) => `- \`${unit.label}\`${unit.reason ? ` (${unit.reason})` : ""}`);
10237
+ const lines = [
10238
+ `### EvalKit: ${heading.base} → ${heading.head}`,
10239
+ "",
10240
+ `**${counts.regressed} regressed** · ${counts.fixed} fixed · ${counts.new} new · ${counts.removed} removed · ${counts.unchanged} unchanged${counts["not-comparable"] ? ` · ${counts["not-comparable"]} not comparable` : ""}`,
10241
+ ""
10242
+ ];
10243
+ for (const [title, change] of [
10244
+ ["Regressed", "regressed"],
10245
+ ["Fixed", "fixed"],
10246
+ ["New", "new"],
10247
+ ["Removed", "removed"],
10248
+ ["Not comparable", "not-comparable"]
10249
+ ]) {
10250
+ const items = list(change);
10251
+ if (items.length) lines.push(`**${title}**`, "", ...items, "");
10252
+ }
10253
+ lines.push("| Suite | Passed | Scores |", "| --- | --- | --- |");
10254
+ for (const suite of comparison.suites) lines.push(`| ${suite.suite} | ${suite.base.passed}/${suite.base.total} → ${suite.head.passed}/${suite.head.total} | ${suite.scorers.map(scorerText).join("<br>") || "–"} |`);
10255
+ if (comparison.changedDatasets.length) lines.push("", `Datasets changed: ${comparison.changedDatasets.map((id) => `\`${id}\``).join(", ")}`);
10256
+ return `${lines.join("\n")}\n`;
10257
+ }
10258
+ function comparisonLines(comparison) {
10259
+ const { counts } = comparison;
10260
+ const labels = (change) => comparison.units.filter((unit) => unit.change === change).map((unit) => unit.reason ? `${unit.label} (${unit.reason})` : unit.label);
10261
+ return {
10262
+ units: [
10263
+ "regressed",
10264
+ "fixed",
10265
+ "not-comparable"
10266
+ ].map((change) => ({
10267
+ change,
10268
+ labels: labels(change)
10269
+ })).filter((entry) => entry.labels.length),
10270
+ counts: `+ ${counts.new} new − ${counts.removed} removed ${counts.unchanged} unchanged`,
10271
+ suites: comparison.suites.map((suite) => `${suite.suite.padEnd(12)} ${suite.base.passed}/${suite.base.total} → ${suite.head.passed}/${suite.head.total} ${suite.scorers.map(scorerText).join(" · ")}`),
10272
+ ...comparison.changedDatasets.length ? { datasets: `datasets ${comparison.changedDatasets.join(", ")} changed` } : {}
10273
+ };
10274
+ }
10275
+ //#endregion
10276
+ //#region ../cli/src/compare-command.ts
10277
+ /** The runs at HEAD that `compare` uses: not running, not archived, clean unless allowed. */
10278
+ function headRuns(runs, head, options) {
10279
+ 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");
10280
+ }
10281
+ /** Base runs from a results directory, e.g. an artifact from the base pipeline. Dirty runs are never a base. */
10282
+ async function baseRunsFromDirectory(directory, registry) {
10283
+ return (await createDashboardReportReader(directory, registry).listRuns()).filter((run) => !run.source?.dirty && !run.archived);
10284
+ }
10285
+ /** A full SHA, a unique prefix of at least 7 characters, or a ref's latest shared commit. */
10286
+ function resolveSharedRef(commits, ref) {
10287
+ if (/^[0-9a-f]{40}$/.test(ref)) return commits.some((row) => row.commit === ref) ? ref : void 0;
10288
+ if (/^[0-9a-f]{7,39}$/.test(ref)) {
10289
+ const matches = commits.filter((row) => row.commit.startsWith(ref));
10290
+ if (matches.length === 1) return matches[0].commit;
10291
+ }
10292
+ return commits.find((row) => row.ref === ref)?.commit;
10293
+ }
10294
+ async function getJson(url, serverUrl, headers) {
10295
+ const response = await fetch(url, {
10296
+ headers,
10297
+ redirect: "manual"
10298
+ });
10299
+ await expectShareServer(response, serverUrl);
10300
+ if (response.status === 404) {
10301
+ await response.body?.cancel();
10302
+ return;
10303
+ }
10304
+ if (!response.ok) throw new Error(`Share server error ${response.status}: ${await response.text()}`);
10305
+ return await response.json();
10306
+ }
10307
+ /** Base runs shared for `ref` of this repository; `undefined` when the base was never shared. */
10308
+ async function baseRunsFromServer(options) {
10309
+ const repo = repoKey(options.head);
10310
+ const api = (path) => new URL(`/api/v1/repos/${repo}/-/${path}`, options.serverUrl);
10311
+ const listed = await getJson(api("commits"), options.serverUrl, options.headers);
10312
+ const commits = Array.isArray(listed?.commits) ? listed.commits : [];
10313
+ const commit = resolveSharedRef(commits, options.ref);
10314
+ if (!commit) return void 0;
10315
+ const data = await getJson(api(`${commit}/data/runs.json`), options.serverUrl, options.headers);
10316
+ const runs = (Array.isArray(data?.runs) ? data.runs : []).filter(isComparedRun);
10317
+ const ref = commits.find((row) => row.commit === commit)?.ref;
10318
+ return {
10319
+ commit,
10320
+ ...ref ? { ref } : {},
10321
+ runs: runs.filter((run) => !run.source?.dirty)
10322
+ };
10323
+ }
10324
+ /** Shared runs are uploaded by other EvalKit versions; keep only what compare can read. */
10325
+ function isComparedRun(value) {
10326
+ const run = value;
10327
+ return typeof run === "object" && run !== null && typeof run.id === "string" && typeof run.evalId === "string" && typeof run.status === "string" && typeof run.startedAt === "string";
10328
+ }
10329
+ //#endregion
9454
10330
  //#region ../cli/src/index.ts
9455
- const VERSION = "0.4.0";
9456
10331
  let projectRoot = process.cwd();
9457
10332
  let reportRoot = resolve(projectRoot, "_evalkit-results");
9458
10333
  const sandboxRoot = resolve(projectRoot, "_evalkit-sandbox");
@@ -9466,7 +10341,7 @@ function runtimeFor(evaluation) {
9466
10341
  "local",
9467
10342
  "remote",
9468
10343
  "sandbox"
9469
- ]) if (evaluation.agent.runtimes?.[runtime]) return runtime;
10344
+ ]) if (agentRuntimes(evaluation.agent)[runtime]) return runtime;
9470
10345
  }
9471
10346
  async function runEvals(options) {
9472
10347
  const registry = await loadRegistry();
@@ -9495,7 +10370,7 @@ async function runProvenance(registry) {
9495
10370
  ] });
9496
10371
  return {
9497
10372
  ...source ? { source } : {},
9498
- catalog: runCatalog(registry)
10373
+ catalog: runCatalog(registry, projectRoot)
9499
10374
  };
9500
10375
  }
9501
10376
  function registerMatrixRoutes(app, registry, dashboardMatrix, trials) {
@@ -9577,7 +10452,37 @@ function registerMatrixRoutes(app, registry, dashboardMatrix, trials) {
9577
10452
  });
9578
10453
  }
9579
10454
  function registerReportRoutes(app, registry, reports) {
9580
- app.get("/v1/catalog", (context) => context.json({ evals: registry.catalog() }));
10455
+ app.get("/v1/project", async (context) => {
10456
+ const source = await detectSource(projectRoot, { outputDirs: [
10457
+ reportRoot,
10458
+ sandboxRoot,
10459
+ resolve(projectRoot, "_evalkit-dashboard")
10460
+ ] });
10461
+ return context.json({ project: source ? projectInfo(source) : null });
10462
+ });
10463
+ app.get("/v1/datasets/:id/files", async (context) => {
10464
+ const dataset = projectDatasets(registry).get(context.req.param("id"));
10465
+ if (!dataset) return context.text("Unknown dataset", 404);
10466
+ try {
10467
+ return context.json(await datasetListing(dataset, projectRoot));
10468
+ } catch (error) {
10469
+ return context.text(error instanceof Error ? error.message : "Unable to list dataset", 400);
10470
+ }
10471
+ });
10472
+ app.get("/v1/datasets/:id/files/:path{.+}", async (context) => {
10473
+ const dataset = projectDatasets(registry).get(context.req.param("id"));
10474
+ if (!dataset) return context.text("Unknown dataset", 404);
10475
+ const file = context.req.param("path");
10476
+ try {
10477
+ return new Response(await staticDatasetFile(dataset, projectRoot, file), { headers: { "content-type": fileContentType(file) } });
10478
+ } catch (error) {
10479
+ return context.text(error instanceof Error ? error.message : "Unable to read dataset file", 404);
10480
+ }
10481
+ });
10482
+ app.get("/v1/catalog", (context) => context.json({
10483
+ evals: registry.catalog({ root: projectRoot }),
10484
+ loadErrors: project?.loadErrors ?? []
10485
+ }));
9581
10486
  app.get("/v1/evals", (context) => context.json({ evals: registry.metadata() }));
9582
10487
  app.get("/v1/suites", (context) => context.json({ suites: registry.suiteMetadata() }));
9583
10488
  app.get("/v1/runs", async (context) => context.json({ runs: await reports.listRuns() }));
@@ -9602,9 +10507,9 @@ function registerReportRoutes(app, registry, reports) {
9602
10507
  return context.text(error instanceof Error ? error.message : "Unable to list workspace", 400);
9603
10508
  }
9604
10509
  });
9605
- app.get("/v1/runs/:runId/trials/:trialId/workspace/*", async (context) => {
10510
+ app.get("/v1/runs/:runId/trials/:trialId/workspace/:path{.+}", async (context) => {
9606
10511
  try {
9607
- return await reports.candidateFile(context.req.param("runId"), context.req.param("trialId"), context.req.param("*") ?? "");
10512
+ return await reports.candidateFile(context.req.param("runId"), context.req.param("trialId"), context.req.param("path"));
9608
10513
  } catch (error) {
9609
10514
  return context.text(error instanceof Error ? error.message : "Unable to read workspace file", 400);
9610
10515
  }
@@ -9630,12 +10535,12 @@ function registerDashboardAssets(app, dashboardRoot) {
9630
10535
  const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
9631
10536
  const file = resolve(dashboardRoot, `.${pathname}`);
9632
10537
  if (!file.startsWith(dashboardRoot)) return context.text("Not found", 404);
9633
- let content = Bun.file(file);
9634
10538
  let servedFile = file;
9635
- if (!await content.exists()) {
10539
+ let content = await readFile(file).catch(() => void 0);
10540
+ if (!content) {
9636
10541
  if (extname(pathname)) return context.text("Not found", 404);
9637
10542
  servedFile = resolve(dashboardRoot, "index.html");
9638
- content = Bun.file(servedFile);
10543
+ content = await readFile(servedFile);
9639
10544
  }
9640
10545
  return new Response(content, { headers: { "content-type": fileContentType(servedFile) } });
9641
10546
  });
@@ -9663,10 +10568,7 @@ async function serveDashboard() {
9663
10568
  const port = Number(process.env.PORT ?? 4317);
9664
10569
  let server;
9665
10570
  try {
9666
- server = Bun.serve({
9667
- port,
9668
- fetch: app.fetch
9669
- });
10571
+ server = await serve(app.fetch, port);
9670
10572
  } catch (error) {
9671
10573
  if (error.code === "EADDRINUSE") throw new Error(`Port ${port} is already in use. Stop the other server or choose another port: PORT=4318 evalkit dev`);
9672
10574
  throw error;
@@ -9678,19 +10580,12 @@ async function serveDashboard() {
9678
10580
  const matrixCells = dashboardMatrix?.count({ parameters: {} });
9679
10581
  const row = (label, value) => console.log(` ${cyan("➜")} ${bold(label.padEnd(9))}${value}`);
9680
10582
  banner("dev", VERSION);
10583
+ printLoadErrors(project?.loadErrors ?? []);
9681
10584
  row("Local:", cyan(url));
9682
10585
  row("Project:", `${plural(registry.evals.length, "eval")} · ${plural(registry.suites.length, "suite")}` + (dashboardMatrix ? ` · matrix ${authoringId(dashboardMatrix)} (${plural(matrixCells, "cell")})` : ""));
9683
10586
  row("Reports:", `${relative(process.cwd(), reportRoot) || "."} ${dim(`(${plural(runs, "run")}${legacy ? `, ${legacy} from an older EvalKit hidden` : ""})`)}`);
9684
10587
  console.log(`\n ${dim("Runs started from the dashboard are logged below. Press Ctrl+C to stop.")}\n`);
9685
- if (process.env.EVALKIT_NO_OPEN !== "1") {
9686
- const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
9687
- try {
9688
- Bun.spawn([opener, url], {
9689
- stdout: "ignore",
9690
- stderr: "ignore"
9691
- });
9692
- } catch {}
9693
- }
10588
+ if (process.env.EVALKIT_NO_OPEN !== "1") openInBrowser(url);
9694
10589
  }
9695
10590
  const config = text("config").pipe(optional);
9696
10591
  const runOptions = {
@@ -9710,7 +10605,10 @@ const runOptions = {
9710
10605
  json: boolean("json"),
9711
10606
  dryRun: boolean("dry-run"),
9712
10607
  all: boolean("all"),
9713
- local: boolean("local")
10608
+ local: boolean("local"),
10609
+ junit: text("junit").pipe(optional),
10610
+ verbose: boolean("verbose"),
10611
+ skipBroken: boolean("skip-broken")
9714
10612
  };
9715
10613
  function runInput(options, positionals) {
9716
10614
  const unexpected = positionals.find((value) => value.startsWith("-"));
@@ -9732,7 +10630,10 @@ function runInput(options, positionals) {
9732
10630
  json: options.json,
9733
10631
  "dry-run": options.dryRun,
9734
10632
  all: options.all,
9735
- local: options.local
10633
+ local: options.local,
10634
+ junit: Option.getOrUndefined(options.junit),
10635
+ verbose: options.verbose,
10636
+ "skip-broken": options.skipBroken
9736
10637
  }, positionals);
9737
10638
  }
9738
10639
  const execute = (input) => Effect.tryPromise({
@@ -9775,6 +10676,12 @@ const buildCommand = make$1("build", {
9775
10676
  })) await runAllEvals(configPath);
9776
10677
  const result = await buildDashboard({
9777
10678
  registry: project.registry,
10679
+ root: projectRoot,
10680
+ source: await detectSource(projectRoot, { outputDirs: [
10681
+ reportRoot,
10682
+ sandboxRoot,
10683
+ resolve(projectRoot, "_evalkit-dashboard")
10684
+ ] }),
9778
10685
  reportRoot,
9779
10686
  dashboardRoot: dashboardAssets(),
9780
10687
  outDir: resolve(projectRoot, Option.getOrElse(out, () => "_evalkit-dashboard"))
@@ -9791,8 +10698,13 @@ const buildCommand = make$1("build", {
9791
10698
  async function loadProjectStep(configPath) {
9792
10699
  await step("Eval project", () => useProject(configPath), () => {
9793
10700
  const where = relative(process.cwd(), projectRoot);
9794
- return `${plural(project.registry.evals.length, "eval")} loaded${where ? ` from ${where}` : ""}`;
10701
+ const broken = project.loadErrors.length;
10702
+ return `${plural(project.registry.evals.length, "eval")} loaded${where ? ` from ${where}` : ""}${broken ? yellow(` · ${plural(broken, "file")} failed to load`) : ""}`;
9795
10703
  });
10704
+ if (project.loadErrors.length) {
10705
+ console.log("");
10706
+ printLoadErrors(project.loadErrors);
10707
+ }
9796
10708
  }
9797
10709
  /**
9798
10710
  * A snapshot needs data: with no current results, run the evals. Otherwise ask,
@@ -9850,10 +10762,7 @@ const shareServeCommand = make$1("serve", {
9850
10762
  });
9851
10763
  let server;
9852
10764
  try {
9853
- server = Bun.serve({
9854
- port,
9855
- fetch: app.fetch
9856
- });
10765
+ server = await serve(app.fetch, port);
9857
10766
  } catch (error) {
9858
10767
  if (error.code === "EADDRINUSE") throw new Error(`Port ${port} is already in use. Choose another: evalkit share serve --port 4319`);
9859
10768
  throw error;
@@ -9891,6 +10800,14 @@ const shareCommand = make$1("share", {
9891
10800
  if (!selection.selected.length) throw new Error(noRunsMessage(selection, head, { all }));
9892
10801
  const result = await shareRuns({
9893
10802
  serverUrl,
10803
+ headers: await step("Server", async () => {
10804
+ const headers = await resolveShareHeaders(project.config.share?.headers);
10805
+ await checkShareServer(serverUrl, headers);
10806
+ return headers;
10807
+ }, (headers) => {
10808
+ const names = Object.keys(headers);
10809
+ return `${new URL(serverUrl).host}${names.length ? dim(` with ${names.join(", ")}`) : ""}`;
10810
+ }),
9894
10811
  reportRoot,
9895
10812
  registry,
9896
10813
  runs: selection.selected,
@@ -9905,13 +10822,118 @@ const shareCommand = make$1("share", {
9905
10822
  },
9906
10823
  catch: (error) => error
9907
10824
  })).pipe(withSubcommands([shareServeCommand]));
10825
+ const compareCommand = make$1("compare", {
10826
+ config,
10827
+ base: text("base").pipe(optional),
10828
+ baseDir: text("base-dir").pipe(optional),
10829
+ markdown: text("markdown").pipe(optional),
10830
+ json: boolean("json"),
10831
+ failOn: text("fail-on").pipe(repeated),
10832
+ requireBase: boolean("require-base"),
10833
+ allowDirty: boolean("allow-dirty")
10834
+ }, (options) => Effect.tryPromise({
10835
+ try: () => compareCommandAction({
10836
+ config: Option.getOrUndefined(options.config),
10837
+ base: Option.getOrUndefined(options.base),
10838
+ baseDir: Option.getOrUndefined(options.baseDir),
10839
+ markdown: Option.getOrUndefined(options.markdown),
10840
+ json: options.json,
10841
+ failOn: options.failOn,
10842
+ requireBase: options.requireBase,
10843
+ allowDirty: options.allowDirty
10844
+ }),
10845
+ catch: (error) => error
10846
+ }));
10847
+ async function compareCommandAction(options) {
10848
+ if (Boolean(options.base) === Boolean(options.baseDir)) throw new Error("Give exactly one of --base <ref> or --base-dir <dir>");
10849
+ const conditions = parseFailOn(options.failOn);
10850
+ const say = options.json ? () => {} : (line = "") => console.log(line);
10851
+ if (!options.json) banner("compare", VERSION);
10852
+ await useProject(options.config);
10853
+ const registry = project.registry;
10854
+ const head = await detectSource(projectRoot, { outputDirs: [
10855
+ reportRoot,
10856
+ sandboxRoot,
10857
+ resolve(projectRoot, "_evalkit-dashboard")
10858
+ ] });
10859
+ 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.");
10860
+ const headSide = headRuns(await createDashboardReportReader(reportRoot, registry).listRuns(), head, { allowDirty: options.allowDirty });
10861
+ let base;
10862
+ if (options.baseDir) {
10863
+ const runs = await baseRunsFromDirectory(resolve(process.cwd(), options.baseDir), registry);
10864
+ base = runs.length ? {
10865
+ label: options.baseDir,
10866
+ runs
10867
+ } : void 0;
10868
+ } else {
10869
+ const serverUrl = process.env.EVALKIT_SHARE_URL ?? project.config.share?.url;
10870
+ if (!serverUrl) throw new Error("No share server configured for --base. Set share: { url } in evalkit.config.ts or EVALKIT_SHARE_URL, or use --base-dir.");
10871
+ const found = await baseRunsFromServer({
10872
+ serverUrl,
10873
+ headers: await resolveShareHeaders(project.config.share?.headers),
10874
+ head,
10875
+ ref: options.base
10876
+ });
10877
+ base = found?.runs.length ? {
10878
+ label: `${found.commit.slice(0, 7)}${found.ref ? ` (${found.ref})` : ""}`,
10879
+ runs: found.runs
10880
+ } : void 0;
10881
+ }
10882
+ const headLabel = `${shortSource(head)}${head.ref ? ` (${head.ref})` : ""}`;
10883
+ if (!base) {
10884
+ const message = `No shared results for base ${options.base ?? options.baseDir}; nothing to compare.`;
10885
+ if (options.json) console.log(JSON.stringify({
10886
+ base: null,
10887
+ head: headLabel,
10888
+ message
10889
+ }));
10890
+ else say(` ${yellow("◇")} ${message}\n`);
10891
+ if (options.requireBase) process.exitCode = 1;
10892
+ return;
10893
+ }
10894
+ if (!headSide.length) throw new Error(`No results at ${headLabel} to compare. Run \`evalkit run\` first${head.dirty ? ", or pass --allow-dirty" : ""}.`);
10895
+ const comparison = compareRuns(base.runs, headSide);
10896
+ const failures = gateFailures(comparison, conditions);
10897
+ if (options.markdown) await writeFile(resolve(process.cwd(), options.markdown), comparisonMarkdown(comparison, {
10898
+ base: base.label,
10899
+ head: headLabel
10900
+ }));
10901
+ if (options.json) console.log(JSON.stringify({
10902
+ base: base.label,
10903
+ head: headLabel,
10904
+ ...comparison,
10905
+ failures
10906
+ }));
10907
+ else {
10908
+ const lines = comparisonLines(comparison);
10909
+ say(` base ${bold(base.label)} → head ${bold(headLabel)}\n`);
10910
+ const marks = {
10911
+ regressed: red("✗"),
10912
+ fixed: green("✓"),
10913
+ "not-comparable": yellow("◌")
10914
+ };
10915
+ for (const { change, labels } of lines.units) say(` ${marks[change]} ${`${labels.length} ${change.replace("-", " ")}`.padEnd(18)} ${labels.join(", ")}`);
10916
+ say(` ${dim(lines.counts)}`);
10917
+ const ignored = comparison.ignoredRuns.base + comparison.ignoredRuns.head;
10918
+ if (ignored) say(` ${dim(`${plural(ignored, "older run")} of the same scenarios ignored`)}`);
10919
+ say("");
10920
+ for (const line of lines.suites) say(` ${line}`);
10921
+ if (lines.datasets) say(` ${lines.datasets}`);
10922
+ say("");
10923
+ for (const failure of failures) say(` ${red("✗")} ${failure}`);
10924
+ if (options.markdown) say(` ${dim(`Markdown: ${options.markdown}`)}`);
10925
+ if (failures.length || options.markdown) say("");
10926
+ }
10927
+ if (failures.length) process.exitCode = 1;
10928
+ }
9908
10929
  const cli = run(make$1("evalkit").pipe(withSubcommands([
9909
10930
  newCommand,
9910
10931
  runCommand,
9911
10932
  devCommand,
9912
10933
  buildCommand,
9913
10934
  tidyCommand,
9914
- shareCommand
10935
+ shareCommand,
10936
+ compareCommand
9915
10937
  ])), {
9916
10938
  name: "EvalKit",
9917
10939
  version: VERSION
@@ -9920,6 +10942,7 @@ const argv = process.argv.slice();
9920
10942
  if (argv[2] === "help") argv.splice(2, 1, "--help");
9921
10943
  if (argv.at(-2) === "--" && argv.at(-1) === "--help") argv.splice(-2, 1);
9922
10944
  try {
10945
+ assertSupportedRuntime();
9923
10946
  await Effect.runPromise(cli(argv).pipe(Effect.provide(layer)));
9924
10947
  } catch (error) {
9925
10948
  console.error(error instanceof Error ? error.message : String(error));
@@ -9927,5 +10950,3 @@ try {
9927
10950
  }
9928
10951
  //#endregion
9929
10952
  export {};
9930
-
9931
- //# sourceMappingURL=cli.mjs.map