@leostera/evalkit 0.5.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 +55 -1
  2. package/README.md +10 -8
  3. package/dist/cli.mjs +1227 -258
  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-db-HdZXelOS.d.mts +0 -142
  37. package/dist/index-db-HdZXelOS.d.mts.map +0 -1
  38. package/dist/index-db-vbqEGFaU.mjs.map +0 -1
  39. package/dist/index-lOw0fmak.d.mts +0 -1028
  40. package/dist/index-lOw0fmak.d.mts.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,126 +9095,207 @@ 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)}`)}`;
8838
9255
  }
8839
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`;
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";
9298
+ //#endregion
8840
9299
  //#region ../cli/src/run-command.ts
8841
9300
  function parseRunArgs(args) {
8842
9301
  const { values, positionals } = parseArgs({
@@ -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` : ""
@@ -9192,7 +9726,7 @@ async function uploadBundle(serverUrl, bundle, options = {}) {
9192
9726
  if (error instanceof UploadRejected || error instanceof ShareServerError) throw error;
9193
9727
  lastError = error;
9194
9728
  }
9195
- if (attempt < attempts) await Bun.sleep((options.delayMs ?? 500) * 2 ** (attempt - 1));
9729
+ if (attempt < attempts) await sleep((options.delayMs ?? 500) * 2 ** (attempt - 1));
9196
9730
  }
9197
9731
  throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`Upload failed: ${String(lastError)}`);
9198
9732
  }
@@ -9271,7 +9805,7 @@ function formatBytes(size) {
9271
9805
  }
9272
9806
  //#endregion
9273
9807
  //#region ../cli/src/new-project-template.ts
9274
- /** 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. */
9275
9809
  const starterFiles = {
9276
9810
  "tsconfig.json": `${JSON.stringify({
9277
9811
  compilerOptions: {
@@ -9284,15 +9818,17 @@ const starterFiles = {
9284
9818
  },
9285
9819
  include: [
9286
9820
  "agents/**/*.ts",
9287
- "evals/**/*.ts",
9821
+ "datasets/**/*.ts",
9288
9822
  "judges/**/*.ts",
9823
+ "scenarios/**/*.ts",
9289
9824
  "evalkit.config.ts"
9290
9825
  ]
9291
9826
  }, null, 2)}\n`,
9292
9827
  "evalkit.config.ts": `import { defineConfig } from '@leostera/evalkit';
9293
9828
 
9294
9829
  export default defineConfig({
9295
- // 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.
9296
9832
  matrix: {
9297
9833
  id: 'styles',
9298
9834
  parameters: { style: ['plain', 'shout'] },
@@ -9300,7 +9836,16 @@ export default defineConfig({
9300
9836
  execution: { concurrency: 2, maxCells: 20 },
9301
9837
  });
9302
9838
  `,
9303
- "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
+ `,
9304
9849
  "agents/greeting-agent.ts": `import { readFile } from 'node:fs/promises';
9305
9850
  import { join } from 'node:path';
9306
9851
  import { defineAgent } from '@leostera/evalkit';
@@ -9308,23 +9853,18 @@ import { defineAgent } from '@leostera/evalkit';
9308
9853
  // Replace this in-process example with an adapter for your own agent.
9309
9854
  export const greetingAgent = defineAgent({
9310
9855
  identity: { id: 'greeting-agent', kind: 'in-process' },
9311
- runtimes: { local: { kind: 'in-process' } },
9312
- async start({ context, onEvent }) {
9856
+ async start({ context }) {
9313
9857
  return {
9858
+ // Return the reply; EvalKit records it. Call onEvent instead to stream events.
9314
9859
  async send(message: string) {
9315
- // Fixtures copied to the candidate workspace are visible to the AUT.
9316
- 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();
9317
9862
  const style = context.parameters?.style;
9318
9863
  if (style !== 'plain' && style !== 'shout')
9319
9864
  throw new Error(\`Unknown style: \${String(style)}\`);
9320
9865
  const greeting = \`\${prefix}, \${message}!\`;
9321
- await onEvent({
9322
- kind: 'message', role: 'assistant',
9323
- content: style === 'shout' ? greeting.toUpperCase() : greeting,
9324
- timestamp: new Date().toISOString(),
9325
- });
9866
+ return style === 'shout' ? greeting.toUpperCase() : greeting;
9326
9867
  },
9327
- async close() {},
9328
9868
  };
9329
9869
  },
9330
9870
  });
@@ -9332,25 +9872,20 @@ export const greetingAgent = defineAgent({
9332
9872
  "judges/matches-greeting.ts": `import { predicate } from '@leostera/evalkit';
9333
9873
 
9334
9874
  // Deterministic scorers need no judge model; add a separate judge agent for judge(...) rules.
9335
- export const matchesGreeting = predicate('matches greeting and style', ({ context, trajectory }) => {
9336
- const reply = trajectory.events.filter(
9337
- (event) => event.source === 'aut' && event.kind === 'message' && event.role === 'assistant',
9338
- ).at(-1);
9875
+ export const matchesGreeting = predicate('matches greeting and style', ({ context, output }) => {
9339
9876
  const expected = context.parameters?.style === 'shout' ? 'HELLO, ADA!' : 'Hello, Ada!';
9340
- return {
9341
- value: reply?.kind === 'message' && reply.content === expected ? 1 : 0,
9342
- explanation: \`Expected \${expected}\`,
9343
- };
9877
+ return { value: output === expected ? 1 : 0, explanation: \`Expected \${expected}\` };
9344
9878
  });
9345
9879
  `,
9346
- "evals/greeting.eval.ts": `import { defineEval, file, user } from '@leostera/evalkit';
9880
+ "scenarios/greeting.eval.ts": `import { defineEval, user } from '@leostera/evalkit';
9347
9881
  import { greetingAgent } from '../agents/greeting-agent.js';
9882
+ import { greeting } from '../datasets/greeting.dataset.js';
9348
9883
  import { matchesGreeting } from '../judges/matches-greeting.js';
9349
9884
 
9350
9885
  export default defineEval({
9351
9886
  id: 'greeting',
9352
9887
  agent: greetingAgent,
9353
- fixtures: [file('fixtures/greeting.txt', { dst: 'greeting.txt', visibility: 'candidate' })],
9888
+ datasets: [greeting],
9354
9889
  transcript: [user('Ada')],
9355
9890
  scoring: [matchesGreeting],
9356
9891
  });
@@ -9359,10 +9894,11 @@ export default defineEval({
9359
9894
  function starterReadme(name) {
9360
9895
  return `# ${name}
9361
9896
 
9362
- A local EvalKit project. Each default-exported eval in \`evals/\` is discovered automatically.
9363
- Start with \`evals/greeting.eval.ts\`: it imports an in-process agent from \`agents/\`,
9364
- a deterministic scorer from \`judges/\`, and a candidate-visible file from \`fixtures/\`.
9365
- 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.
9366
9902
 
9367
9903
  \`evalkit.config.ts\` defines a provider-free \`styles\` matrix; the example agent reads
9368
9904
  \`context.parameters.style\`. Run:
@@ -9376,6 +9912,9 @@ bun run evalkit dev # inspect local reports and laun
9376
9912
  bun run evalkit build # write a read-only, shareable snapshot
9377
9913
  \`\`\`
9378
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
+
9379
9918
  EvalKit installs from npm. Reports, trial workspaces, and dashboard snapshots stay local
9380
9919
  and are ignored by Git. A snapshot in \`_evalkit-dashboard/\` contains report messages,
9381
9920
  scores, evidence, and candidate files (never evaluator workspaces): review it before sharing.
@@ -9383,7 +9922,7 @@ scores, evidence, and candidate files (never evaluator workspaces): review it be
9383
9922
  }
9384
9923
  //#endregion
9385
9924
  //#region ../cli/src/new-project.ts
9386
- const npmDependency = "^0.5.0";
9925
+ const npmDependency = "^0.6.0";
9387
9926
  const ignored = [
9388
9927
  "node_modules/",
9389
9928
  "_evalkit-results/",
@@ -9447,9 +9986,9 @@ async function checkExistingScaffold(root) {
9447
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}`);
9448
9987
  for (const directory of [
9449
9988
  "agents",
9450
- "fixtures",
9451
- "evals",
9452
- "judges"
9989
+ "datasets",
9990
+ "judges",
9991
+ "scenarios"
9453
9992
  ]) {
9454
9993
  const path = resolve(root, directory);
9455
9994
  if (await exists(path) && !(await stat(path)).isDirectory()) throw new Error(`Scaffold directory is not a directory: ${path}`);
@@ -9495,8 +10034,300 @@ async function writeStarterReadme(root, name, inPlace) {
9495
10034
  if (!inPlace && !await exists(readmePath)) await writeFile(readmePath, starterReadme(name));
9496
10035
  }
9497
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
9498
10330
  //#region ../cli/src/index.ts
9499
- const VERSION = "0.5.0";
9500
10331
  let projectRoot = process.cwd();
9501
10332
  let reportRoot = resolve(projectRoot, "_evalkit-results");
9502
10333
  const sandboxRoot = resolve(projectRoot, "_evalkit-sandbox");
@@ -9510,7 +10341,7 @@ function runtimeFor(evaluation) {
9510
10341
  "local",
9511
10342
  "remote",
9512
10343
  "sandbox"
9513
- ]) if (evaluation.agent.runtimes?.[runtime]) return runtime;
10344
+ ]) if (agentRuntimes(evaluation.agent)[runtime]) return runtime;
9514
10345
  }
9515
10346
  async function runEvals(options) {
9516
10347
  const registry = await loadRegistry();
@@ -9539,7 +10370,7 @@ async function runProvenance(registry) {
9539
10370
  ] });
9540
10371
  return {
9541
10372
  ...source ? { source } : {},
9542
- catalog: runCatalog(registry)
10373
+ catalog: runCatalog(registry, projectRoot)
9543
10374
  };
9544
10375
  }
9545
10376
  function registerMatrixRoutes(app, registry, dashboardMatrix, trials) {
@@ -9621,7 +10452,37 @@ function registerMatrixRoutes(app, registry, dashboardMatrix, trials) {
9621
10452
  });
9622
10453
  }
9623
10454
  function registerReportRoutes(app, registry, reports) {
9624
- 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
+ }));
9625
10486
  app.get("/v1/evals", (context) => context.json({ evals: registry.metadata() }));
9626
10487
  app.get("/v1/suites", (context) => context.json({ suites: registry.suiteMetadata() }));
9627
10488
  app.get("/v1/runs", async (context) => context.json({ runs: await reports.listRuns() }));
@@ -9646,9 +10507,9 @@ function registerReportRoutes(app, registry, reports) {
9646
10507
  return context.text(error instanceof Error ? error.message : "Unable to list workspace", 400);
9647
10508
  }
9648
10509
  });
9649
- app.get("/v1/runs/:runId/trials/:trialId/workspace/*", async (context) => {
10510
+ app.get("/v1/runs/:runId/trials/:trialId/workspace/:path{.+}", async (context) => {
9650
10511
  try {
9651
- 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"));
9652
10513
  } catch (error) {
9653
10514
  return context.text(error instanceof Error ? error.message : "Unable to read workspace file", 400);
9654
10515
  }
@@ -9674,12 +10535,12 @@ function registerDashboardAssets(app, dashboardRoot) {
9674
10535
  const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
9675
10536
  const file = resolve(dashboardRoot, `.${pathname}`);
9676
10537
  if (!file.startsWith(dashboardRoot)) return context.text("Not found", 404);
9677
- let content = Bun.file(file);
9678
10538
  let servedFile = file;
9679
- if (!await content.exists()) {
10539
+ let content = await readFile(file).catch(() => void 0);
10540
+ if (!content) {
9680
10541
  if (extname(pathname)) return context.text("Not found", 404);
9681
10542
  servedFile = resolve(dashboardRoot, "index.html");
9682
- content = Bun.file(servedFile);
10543
+ content = await readFile(servedFile);
9683
10544
  }
9684
10545
  return new Response(content, { headers: { "content-type": fileContentType(servedFile) } });
9685
10546
  });
@@ -9707,10 +10568,7 @@ async function serveDashboard() {
9707
10568
  const port = Number(process.env.PORT ?? 4317);
9708
10569
  let server;
9709
10570
  try {
9710
- server = Bun.serve({
9711
- port,
9712
- fetch: app.fetch
9713
- });
10571
+ server = await serve(app.fetch, port);
9714
10572
  } catch (error) {
9715
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`);
9716
10574
  throw error;
@@ -9722,19 +10580,12 @@ async function serveDashboard() {
9722
10580
  const matrixCells = dashboardMatrix?.count({ parameters: {} });
9723
10581
  const row = (label, value) => console.log(` ${cyan("➜")} ${bold(label.padEnd(9))}${value}`);
9724
10582
  banner("dev", VERSION);
10583
+ printLoadErrors(project?.loadErrors ?? []);
9725
10584
  row("Local:", cyan(url));
9726
10585
  row("Project:", `${plural(registry.evals.length, "eval")} · ${plural(registry.suites.length, "suite")}` + (dashboardMatrix ? ` · matrix ${authoringId(dashboardMatrix)} (${plural(matrixCells, "cell")})` : ""));
9727
10586
  row("Reports:", `${relative(process.cwd(), reportRoot) || "."} ${dim(`(${plural(runs, "run")}${legacy ? `, ${legacy} from an older EvalKit hidden` : ""})`)}`);
9728
10587
  console.log(`\n ${dim("Runs started from the dashboard are logged below. Press Ctrl+C to stop.")}\n`);
9729
- if (process.env.EVALKIT_NO_OPEN !== "1") {
9730
- const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
9731
- try {
9732
- Bun.spawn([opener, url], {
9733
- stdout: "ignore",
9734
- stderr: "ignore"
9735
- });
9736
- } catch {}
9737
- }
10588
+ if (process.env.EVALKIT_NO_OPEN !== "1") openInBrowser(url);
9738
10589
  }
9739
10590
  const config = text("config").pipe(optional);
9740
10591
  const runOptions = {
@@ -9754,7 +10605,10 @@ const runOptions = {
9754
10605
  json: boolean("json"),
9755
10606
  dryRun: boolean("dry-run"),
9756
10607
  all: boolean("all"),
9757
- local: boolean("local")
10608
+ local: boolean("local"),
10609
+ junit: text("junit").pipe(optional),
10610
+ verbose: boolean("verbose"),
10611
+ skipBroken: boolean("skip-broken")
9758
10612
  };
9759
10613
  function runInput(options, positionals) {
9760
10614
  const unexpected = positionals.find((value) => value.startsWith("-"));
@@ -9776,7 +10630,10 @@ function runInput(options, positionals) {
9776
10630
  json: options.json,
9777
10631
  "dry-run": options.dryRun,
9778
10632
  all: options.all,
9779
- local: options.local
10633
+ local: options.local,
10634
+ junit: Option.getOrUndefined(options.junit),
10635
+ verbose: options.verbose,
10636
+ "skip-broken": options.skipBroken
9780
10637
  }, positionals);
9781
10638
  }
9782
10639
  const execute = (input) => Effect.tryPromise({
@@ -9819,6 +10676,12 @@ const buildCommand = make$1("build", {
9819
10676
  })) await runAllEvals(configPath);
9820
10677
  const result = await buildDashboard({
9821
10678
  registry: project.registry,
10679
+ root: projectRoot,
10680
+ source: await detectSource(projectRoot, { outputDirs: [
10681
+ reportRoot,
10682
+ sandboxRoot,
10683
+ resolve(projectRoot, "_evalkit-dashboard")
10684
+ ] }),
9822
10685
  reportRoot,
9823
10686
  dashboardRoot: dashboardAssets(),
9824
10687
  outDir: resolve(projectRoot, Option.getOrElse(out, () => "_evalkit-dashboard"))
@@ -9835,8 +10698,13 @@ const buildCommand = make$1("build", {
9835
10698
  async function loadProjectStep(configPath) {
9836
10699
  await step("Eval project", () => useProject(configPath), () => {
9837
10700
  const where = relative(process.cwd(), projectRoot);
9838
- 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`) : ""}`;
9839
10703
  });
10704
+ if (project.loadErrors.length) {
10705
+ console.log("");
10706
+ printLoadErrors(project.loadErrors);
10707
+ }
9840
10708
  }
9841
10709
  /**
9842
10710
  * A snapshot needs data: with no current results, run the evals. Otherwise ask,
@@ -9894,10 +10762,7 @@ const shareServeCommand = make$1("serve", {
9894
10762
  });
9895
10763
  let server;
9896
10764
  try {
9897
- server = Bun.serve({
9898
- port,
9899
- fetch: app.fetch
9900
- });
10765
+ server = await serve(app.fetch, port);
9901
10766
  } catch (error) {
9902
10767
  if (error.code === "EADDRINUSE") throw new Error(`Port ${port} is already in use. Choose another: evalkit share serve --port 4319`);
9903
10768
  throw error;
@@ -9957,13 +10822,118 @@ const shareCommand = make$1("share", {
9957
10822
  },
9958
10823
  catch: (error) => error
9959
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
+ }
9960
10929
  const cli = run(make$1("evalkit").pipe(withSubcommands([
9961
10930
  newCommand,
9962
10931
  runCommand,
9963
10932
  devCommand,
9964
10933
  buildCommand,
9965
10934
  tidyCommand,
9966
- shareCommand
10935
+ shareCommand,
10936
+ compareCommand
9967
10937
  ])), {
9968
10938
  name: "EvalKit",
9969
10939
  version: VERSION
@@ -9972,6 +10942,7 @@ const argv = process.argv.slice();
9972
10942
  if (argv[2] === "help") argv.splice(2, 1, "--help");
9973
10943
  if (argv.at(-2) === "--" && argv.at(-1) === "--help") argv.splice(-2, 1);
9974
10944
  try {
10945
+ assertSupportedRuntime();
9975
10946
  await Effect.runPromise(cli(argv).pipe(Effect.provide(layer)));
9976
10947
  } catch (error) {
9977
10948
  console.error(error instanceof Error ? error.message : String(error));
@@ -9979,5 +10950,3 @@ try {
9979
10950
  }
9980
10951
  //#endregion
9981
10952
  export {};
9982
-
9983
- //# sourceMappingURL=cli.mjs.map