openmerit 0.4.0 → 0.4.2

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 (71) hide show
  1. package/README.md +239 -58
  2. package/data/catalogue.json +6778 -0
  3. package/dist/catalogue/catalogue.d.ts +41 -0
  4. package/dist/catalogue/catalogue.js +33 -0
  5. package/dist/cli/prompt.d.ts +26 -0
  6. package/dist/cli/prompt.js +114 -0
  7. package/dist/cli/render.d.ts +12 -0
  8. package/dist/cli/render.js +104 -0
  9. package/dist/cli.js +245 -158
  10. package/dist/connect.d.ts +34 -0
  11. package/dist/connect.js +158 -0
  12. package/dist/connectors/langfuse/index.d.ts +39 -112
  13. package/dist/connectors/langfuse/index.js +95 -246
  14. package/dist/daemon/daemon.d.ts +81 -0
  15. package/dist/daemon/daemon.js +408 -0
  16. package/dist/daemon/registry.d.ts +111 -0
  17. package/dist/daemon/registry.js +109 -0
  18. package/dist/eval/proxy.d.ts +54 -0
  19. package/dist/eval/proxy.js +174 -0
  20. package/dist/eval/runner.d.ts +29 -0
  21. package/dist/eval/runner.js +80 -0
  22. package/dist/harness/claude-bridge.d.ts +21 -0
  23. package/dist/harness/claude-bridge.js +65 -0
  24. package/dist/harness/harness.d.ts +60 -0
  25. package/dist/harness/harness.js +221 -0
  26. package/dist/index.d.ts +10 -11
  27. package/dist/index.js +9 -10
  28. package/dist/local.d.ts +12 -0
  29. package/dist/local.js +40 -0
  30. package/dist/mcp.d.ts +1 -0
  31. package/dist/mcp.js +155 -0
  32. package/dist/scout/candidates.d.ts +1 -1
  33. package/dist/scout/pool.d.ts +17 -0
  34. package/dist/scout/pool.js +56 -0
  35. package/dist/scout/scan.d.ts +42 -0
  36. package/dist/scout/scan.js +169 -0
  37. package/dist/scout/search.d.ts +25 -98
  38. package/dist/scout/search.js +56 -156
  39. package/dist/scout/workflow.d.ts +221 -0
  40. package/dist/scout/workflow.js +941 -0
  41. package/dist/setup.d.ts +44 -0
  42. package/dist/setup.js +211 -0
  43. package/dist/state/config.d.ts +116 -0
  44. package/dist/state/config.js +60 -0
  45. package/dist/state/db.d.ts +159 -0
  46. package/dist/state/db.js +260 -0
  47. package/dist/state/paths.d.ts +12 -0
  48. package/dist/state/paths.js +23 -0
  49. package/dist/update.d.ts +22 -0
  50. package/dist/update.js +58 -0
  51. package/package.json +13 -18
  52. package/dist/connectors/langfuse/otlp.d.ts +0 -60
  53. package/dist/connectors/langfuse/otlp.js +0 -40
  54. package/dist/pi/extension.d.ts +0 -13
  55. package/dist/pi/extension.js +0 -148
  56. package/dist/pi/harness.d.ts +0 -20
  57. package/dist/pi/harness.js +0 -49
  58. package/dist/pi/instructions.d.ts +0 -2
  59. package/dist/pi/instructions.js +0 -12
  60. package/dist/scout/evaluators/module.d.ts +0 -24
  61. package/dist/scout/evaluators/module.js +0 -19
  62. package/dist/scout/evaluators/pi.d.ts +0 -53
  63. package/dist/scout/evaluators/pi.js +0 -117
  64. package/dist/scout/pipeline.d.ts +0 -111
  65. package/dist/scout/pipeline.js +0 -157
  66. package/dist/scout/report.d.ts +0 -3
  67. package/dist/scout/report.js +0 -42
  68. package/dist/scout/service.d.ts +0 -148
  69. package/dist/scout/service.js +0 -143
  70. package/examples/health-declaration-codes/dataset.jsonl +0 -14
  71. package/examples/sahra-enrollment/evaluator.ts +0 -151
@@ -0,0 +1,41 @@
1
+ export declare const CATALOGUE_FORMAT = 1;
2
+ export interface CatalogueModel {
3
+ /** OpenRouter-style ID, such as `openai/gpt-6.1-sol`. */
4
+ id: string;
5
+ name: string;
6
+ /** Other spellings code uses for the same model: the bare vendor ID, dated slugs. */
7
+ aliases: string[];
8
+ /** Epoch Capabilities Index: published, or ScoutBM's estimate. Null when neither exists. */
9
+ eci: number | null;
10
+ eciSource: "published" | "estimated" | "calibrated" | null;
11
+ eciError: number | null;
12
+ eciModel: string | null;
13
+ /** List prices in USD per million tokens; null when unknown. */
14
+ inputPerM: number | null;
15
+ outputPerM: number | null;
16
+ contextLength: number | null;
17
+ tools: boolean;
18
+ structuredOutputs: boolean;
19
+ vision: boolean;
20
+ released: string | null;
21
+ }
22
+ export interface Catalogue {
23
+ format: number;
24
+ /** Date of the snapshot, recorded with every run. */
25
+ version: string;
26
+ generatedAt: string;
27
+ sources: {
28
+ name: string;
29
+ url: string;
30
+ note: string;
31
+ }[];
32
+ models: CatalogueModel[];
33
+ }
34
+ export declare function loadCatalogue(path?: URL): Catalogue;
35
+ /** Finds a catalogue model by ID or alias, ignoring case and an `openrouter/` prefix. */
36
+ export declare function catalogueIndex(catalogue: Catalogue): {
37
+ find: (value: string) => CatalogueModel | null;
38
+ size: number;
39
+ };
40
+ /** List price of one request, or null when a price is unknown. */
41
+ export declare function requestPrice(model: Pick<CatalogueModel, "inputPerM" | "outputPerM"> | null, inputTokens: number, outputTokens: number): number | null;
@@ -0,0 +1,33 @@
1
+ // The model/evidence catalogue bundled with the npm package (data/catalogue.json). Scout reads only
2
+ // this file: no hosted catalogue, no download at run time. Package updates refresh it.
3
+ import { readFileSync } from "node:fs";
4
+ export const CATALOGUE_FORMAT = 1;
5
+ let cached = null;
6
+ export function loadCatalogue(path = new URL("../../data/catalogue.json", import.meta.url)) {
7
+ if (cached && path.href.endsWith("/data/catalogue.json"))
8
+ return cached;
9
+ const catalogue = JSON.parse(readFileSync(path, "utf8"));
10
+ if (catalogue.format !== CATALOGUE_FORMAT || !Array.isArray(catalogue.models))
11
+ throw new Error(`Unsupported catalogue format in ${path.pathname}`);
12
+ cached = catalogue;
13
+ return catalogue;
14
+ }
15
+ /** Anthropic's API writes `claude-sonnet-5-5` where OpenRouter writes `claude-sonnet-5.5`. */
16
+ const key = (value) => value.toLowerCase().replace(/^openrouter\//, "").replace(/(\d)\.(\d)/g, "$1-$2");
17
+ /** Finds a catalogue model by ID or alias, ignoring case and an `openrouter/` prefix. */
18
+ export function catalogueIndex(catalogue) {
19
+ const byKey = new Map();
20
+ for (const model of catalogue.models)
21
+ byKey.set(key(model.id), model);
22
+ for (const model of catalogue.models)
23
+ for (const alias of model.aliases)
24
+ if (!byKey.has(key(alias)))
25
+ byKey.set(key(alias), model);
26
+ return { find: (value) => byKey.get(key(value)) ?? null, size: byKey.size };
27
+ }
28
+ /** List price of one request, or null when a price is unknown. */
29
+ export function requestPrice(model, inputTokens, outputTokens) {
30
+ if (!model || model.inputPerM === null || model.outputPerM === null)
31
+ return null;
32
+ return (inputTokens * model.inputPerM + outputTokens * model.outputPerM) / 1e6;
33
+ }
@@ -0,0 +1,26 @@
1
+ import type { Answer, Question } from "../scout/workflow.js";
2
+ export interface Prompter {
3
+ /** `signal` cancels a pending question, which then rejects. */
4
+ ask(question: string, fallback?: string, signal?: AbortSignal): Promise<string>;
5
+ secret(question: string): Promise<string>;
6
+ confirm(question: string, fallback?: boolean): Promise<boolean>;
7
+ choose<T extends string>(question: string, options: {
8
+ value: T;
9
+ label: string;
10
+ }[], fallback?: T): Promise<T>;
11
+ /** Releases the terminal to `run`, such as a child process with inherited stdin, then takes it back. */
12
+ handOver<T>(run: () => Promise<T>): Promise<T>;
13
+ close(): void;
14
+ }
15
+ export declare function terminalPrompter(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream): Prompter;
16
+ /**
17
+ * On a terminal, shows that Scout is still working when nothing has been printed for `quietMs`: one
18
+ * line with a spinner, a rotating word and the seconds since the last output. `log` clears it before
19
+ * printing. Without a terminal it only prints.
20
+ */
21
+ export declare function workingTicker(output?: NodeJS.WriteStream, quietMs?: number): {
22
+ log(line: string): void;
23
+ stop(): void;
24
+ };
25
+ /** Asks one workflow question in the terminal. */
26
+ export declare function askQuestion(prompter: Prompter, question: Question, log: (line: string) => void): Promise<Answer>;
@@ -0,0 +1,114 @@
1
+ // Terminal prompts on stderr, so stdout stays clean for --json output.
2
+ import { createInterface } from "node:readline/promises";
3
+ export function terminalPrompter(input = process.stdin, output = process.stderr) {
4
+ let muted = false;
5
+ const write = output.write.bind(output);
6
+ const open = () => {
7
+ const next = createInterface({ input, output, terminal: input.isTTY === true });
8
+ next._writeToOutput = (text) => { if (!muted || text.includes("\n"))
9
+ write(muted ? "\n" : text); };
10
+ return next;
11
+ };
12
+ let rl = open();
13
+ const ask = async (question, fallback, signal) => {
14
+ const answer = (await rl.question(`${question}${fallback !== undefined && fallback !== "" ? ` [${fallback}]` : ""} `, signal ? { signal } : {})).trim();
15
+ return answer || fallback || "";
16
+ };
17
+ return {
18
+ ask,
19
+ async secret(question) {
20
+ write(`${question} `);
21
+ muted = true;
22
+ try {
23
+ return (await rl.question("")).trim();
24
+ }
25
+ finally {
26
+ muted = false;
27
+ }
28
+ },
29
+ async confirm(question, fallback = false) {
30
+ for (;;) {
31
+ const answer = (await ask(`${question} ${fallback ? "[Y/n]" : "[y/N]"}`)).toLowerCase();
32
+ if (!answer)
33
+ return fallback;
34
+ if (/^(y|yes)$/.test(answer))
35
+ return true;
36
+ if (/^(n|no)$/.test(answer))
37
+ return false;
38
+ }
39
+ },
40
+ async choose(question, options, fallback) {
41
+ write(`${question}\n${options.map((option, index) => ` ${index + 1}. ${option.label}`).join("\n")}\n`);
42
+ for (;;) {
43
+ const fallbackIndex = fallback ? options.findIndex(option => option.value === fallback) + 1 : 0;
44
+ const answer = await ask("Choose a number", fallbackIndex ? String(fallbackIndex) : undefined);
45
+ const picked = options[Number(answer) - 1] ?? options.find(option => option.value === answer);
46
+ if (picked)
47
+ return picked.value;
48
+ }
49
+ },
50
+ // An open readline keeps reading the terminal, so a child sharing it would lose keystrokes to it.
51
+ async handOver(run) {
52
+ rl.close();
53
+ try {
54
+ return await run();
55
+ }
56
+ finally {
57
+ rl = open();
58
+ }
59
+ },
60
+ close() { rl.close(); },
61
+ };
62
+ }
63
+ const WORKING = ["eval-ing", "checking", "understanding", "proof-reading", "comparing", "tallying", "weighing", "cross-checking", "pondering", "measuring"];
64
+ const SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
65
+ /**
66
+ * On a terminal, shows that Scout is still working when nothing has been printed for `quietMs`: one
67
+ * line with a spinner, a rotating word and the seconds since the last output. `log` clears it before
68
+ * printing. Without a terminal it only prints.
69
+ */
70
+ export function workingTicker(output = process.stderr, quietMs = 3000) {
71
+ let last = Date.now(), shown = false, frame = 0;
72
+ const first = Math.floor(Math.random() * WORKING.length);
73
+ const clear = () => { if (shown) {
74
+ output.write("\r\x1b[2K");
75
+ shown = false;
76
+ } };
77
+ const timer = output.isTTY ? setInterval(() => {
78
+ const quiet = Date.now() - last;
79
+ if (quiet < quietMs)
80
+ return;
81
+ output.write(`\r\x1b[2K${SPINNER[frame++ % SPINNER.length]} ${WORKING[(first + Math.floor(quiet / 4000)) % WORKING.length]}… ${Math.round(quiet / 1000)}s`);
82
+ shown = true;
83
+ }, 120) : null;
84
+ timer?.unref();
85
+ return {
86
+ log(line) { clear(); output.write(`${line}\n`); last = Date.now(); },
87
+ stop() { if (timer)
88
+ clearInterval(timer); clear(); },
89
+ };
90
+ }
91
+ /** Asks one workflow question in the terminal. */
92
+ export async function askQuestion(prompter, question, log) {
93
+ if (question.detail)
94
+ log(`\n${question.detail}\n`);
95
+ switch (question.kind) {
96
+ case "confirm": return prompter.confirm(question.prompt, question.default === true);
97
+ case "choice": return prompter.choose(question.prompt, question.options ?? [], typeof question.default === "string" ? question.default : undefined);
98
+ case "multi": {
99
+ const options = question.options ?? [];
100
+ log(`${question.prompt}\n${options.map((option, index) => ` ${index + 1}. ${option.label}${option.detail ? `\n ${option.detail}` : ""}`).join("\n")}`);
101
+ for (;;) {
102
+ const answer = await prompter.ask(`Numbers or "all"${question.hint ? `, ${question.hint}` : ""}`, options.length === 1 ? "1" : undefined);
103
+ // Entries with "=" (such as a mapping correction) pass through as typed; the rest are option numbers.
104
+ const items = answer.split(",").map(item => item.trim()).filter(Boolean);
105
+ const typed = question.hint ? items.filter(item => item.includes("=")) : [];
106
+ const numbers = items.filter(item => !typed.includes(item)).flatMap(item => item.split(/\s+/));
107
+ const picked = numbers.length === 1 && /^all$/i.test(numbers[0]) ? options : numbers.map(item => options[Number(item) - 1]);
108
+ if ((picked.length || typed.length) && picked.every(Boolean))
109
+ return [...typed, ...picked.map(option => option.value)];
110
+ }
111
+ }
112
+ case "text": return prompter.ask(question.prompt, typeof question.default === "string" ? question.default : undefined);
113
+ }
114
+ }
@@ -0,0 +1,12 @@
1
+ import type { RunRow, State } from "../state/db.js";
2
+ import type { SearchSummary, Step, TaskComparison } from "../scout/workflow.js";
3
+ export declare function renderComparison(task: TaskComparison, runId?: string): string;
4
+ /** The search path: each screen, in order, with its result on the sampled items. */
5
+ export declare function renderSearch(search: SearchSummary): string[];
6
+ /** The run's outcome; `comparisons: false` leaves out the task comparisons, such as when they were shown already. */
7
+ export declare function renderStep(step: Step, options?: {
8
+ comparisons?: boolean;
9
+ }): string;
10
+ export declare function spendLine(step: Pick<Step, "spend" | "budgetUsd" | "harnessUsage">): string;
11
+ export declare function renderHistory(state: State, runs: RunRow[]): string;
12
+ export declare function renderRun(state: State, run: RunRow, step: Step): string;
@@ -0,0 +1,104 @@
1
+ // Two significant digits below a cent, so per-item costs such as $0.000023 and $0.0000088 stay apart.
2
+ const money = (value, estimate = false) => (value === null ? "unknown" : `${estimate ? "~" : ""}$${value.toFixed(value > 0 && value < 0.01 ? Math.min(10, 1 - Math.floor(Math.log10(value))) : 4)}`);
3
+ const pad = (rows) => {
4
+ const widths = rows[0].map((_, column) => Math.max(...rows.map(row => (row[column] ?? "").length)));
5
+ return rows.map(row => row.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()).join("\n");
6
+ };
7
+ function row(label, result) {
8
+ return [label, result.model, result.endpoint, result.total ? `${result.passes}/${result.total}` : "—", money(result.costPerItemUsd, result.costIsEstimate),
9
+ result.meanLatencyMs === null ? "—" : `${Math.round(result.meanLatencyMs)} ms`, result.status === "measured" ? (result.role === "baseline" ? "baseline" : result.qualified ? "qualifies" : result.note || "below baseline") : result.note];
10
+ }
11
+ export function renderComparison(task, runId) {
12
+ const lines = [`Task "${task.task}"`, ` Eval: ${task.eval.command || "none"} (${task.eval.source}; per-item results ${task.eval.langfuse ? `from Langfuse, ${task.eval.langfuse}` : "reported by the eval"}${task.eval.threshold !== null ? `; required pass rate ${task.eval.threshold}` : ""})`];
13
+ if (task.status === "no_eval")
14
+ return [...lines, ` Not reviewed: ${task.note}`].join("\n");
15
+ if (task.search)
16
+ lines.push(...renderSearch(task.search));
17
+ const header = ["", "Model", "Endpoint", "Passed", "Cost/item", "Latency/request", "Status"];
18
+ const table = [header, ...(task.baseline ? [row("Current", task.baseline)] : []), ...task.top.map((result, index) => row(`#${index + 1}`, result))];
19
+ lines.push(pad(table).split("\n").map(line => ` ${line}`).join("\n"));
20
+ const others = task.candidates.filter(item => !task.top.includes(item) && !task.top.some(top => top.model === item.model));
21
+ if (others.length)
22
+ lines.push(" Other candidates:", pad([header, ...others.map(item => row("", item))]).split("\n").slice(1).map(line => ` ${line}`).join("\n"));
23
+ lines.push(` ${task.status === "ranked" ? "Top 3 cheapest models at least as good as the current model, measured on the same eval." : task.status === "no_qualified" ? "No finalist performed at least as well as the current model." : task.status === "non_discriminating" ? "The eval did not separate the models." : ""} ${task.note}`.trimEnd());
24
+ const experiments = [task.baseline, ...task.candidates].flatMap(item => item?.source === "langfuse" ? item.experiments : []);
25
+ if (experiments.length)
26
+ lines.push(` Read back from ${experiments.length} Langfuse experiments your eval recorded, named ${experiments.reduce((prefix, name) => { let at = 0; while (at < prefix.length && prefix[at] === name[at])
27
+ at++; return prefix.slice(0, at); }).replace(/[^-]*$/, "")}…; \`openmerit history\` lists each eval run's experiment.`);
28
+ if (task.top.some(item => item.costIsEstimate) || task.baseline?.costIsEstimate)
29
+ lines.push(" ~ cost estimated from list prices and reported tokens; the endpoint does not report cost.");
30
+ if (task.langfuse) {
31
+ if ("error" in task.langfuse)
32
+ lines.push(` Langfuse (read-only): not available (${task.langfuse.error})`);
33
+ else
34
+ lines.push(` Langfuse production evidence, ${task.langfuse.traceName} since ${task.langfuse.from.slice(0, 10)}: ${task.langfuse.models.map(model => `${model.model} ${model.generations} generations, ${model.costUsd === null ? "cost unknown" : `$${model.costUsd.toFixed(4)}`}`).join("; ") || "no generations"}${task.langfuse.scores.length ? `; scores ${task.langfuse.scores.map(score => `${score.name} ${score.average.toFixed(3)} (n=${score.count})`).join(", ")}` : ""}`);
35
+ }
36
+ lines.push(` Evidence: openmerit history ${runId ?? "<run id>"} lists every eval run (${[task.baseline, ...task.candidates].filter(Boolean).reduce((sum, item) => sum + item.evidence.length, 0)} recorded).`);
37
+ return lines.join("\n");
38
+ }
39
+ /** The search path: each screen, in order, with its result on the sampled items. */
40
+ export function renderSearch(search) {
41
+ const lines = [` Scout Model Binary Search over ${search.pool} ECI-ranked candidates${search.unranked ? ` (${search.unranked} without an ECI left out)` : ""}, screening on ${search.sampleIds.length || 5} items the current model passes:`];
42
+ for (const trial of search.trials)
43
+ lines.push(` ${trial.stage.padEnd(10)} ${trial.model} (ECI ${trial.eci.toFixed(1)}): ${trial.passes}/${trial.total} ${trial.accepted ? "pass" : "fail"}`);
44
+ for (const item of search.skipped)
45
+ lines.push(` skipped ${item.model} at ${item.stage}: ${item.reason.split("\n")[0]}`);
46
+ if (search.pick)
47
+ lines.push(` Weakest passing model: ${search.pick}. Finalists: ${search.finalists.join(", ") || "none"}${search.challengers.length ? `. Challengers: ${search.challengers.join(", ")}` : ""}.`);
48
+ if (search.shortfall)
49
+ lines.push(` ${search.shortfall}`);
50
+ return lines;
51
+ }
52
+ /** The run's outcome; `comparisons: false` leaves out the task comparisons, such as when they were shown already. */
53
+ export function renderStep(step, options = {}) {
54
+ const lines = [];
55
+ if (step.result && options.comparisons !== false)
56
+ for (const task of step.result.tasks)
57
+ lines.push(renderComparison(task, step.runId), "");
58
+ for (const [task, configure] of Object.entries(step.result?.configuration ?? {})) {
59
+ if (configure.decision === "no")
60
+ lines.push(`"${task}": not configured; your application configuration is unchanged.`);
61
+ else if (configure.applied)
62
+ lines.push(`"${task}": configured (${configure.edits.map(edit => `${edit.file}:${edit.line}`).join(", ")}).`);
63
+ for (const item of configure.manual ?? [])
64
+ lines.push(`"${task}" manual step: ${item}`);
65
+ }
66
+ lines.push(spendLine(step));
67
+ if (step.status !== "done")
68
+ lines.push(`Run ${step.runId}: ${step.status} at ${step.phase}${step.error ? ` — ${step.error}` : ""}. Resume with: openmerit scout --resume ${step.runId}`);
69
+ else
70
+ lines.push(`Run ${step.runId} saved. See it again with: openmerit history ${step.runId}`);
71
+ return lines.join("\n");
72
+ }
73
+ export function spendLine(step) {
74
+ const spend = step.spend;
75
+ const harness = step.harnessUsage;
76
+ return `Eval spend: $${spend.totalUsd.toFixed(4)} of $${step.budgetUsd.toFixed(2)} over ${spend.requests} requests${spend.estimatedUsd ? ` ($${spend.estimatedUsd.toFixed(4)} estimated from list prices)` : ""}${spend.inFlight ? `, $${spend.inFlightUsd.toFixed(4)} reserved for ${spend.inFlight} request(s) of unknown cost` : ""}${spend.unknown ? `, ${spend.unknown} unpriced` : ""}.`
77
+ + (harness ? ` Harness inference (separate from the eval budget): ${harness.calls} calls, ${harness.inputTokens + harness.outputTokens} tokens${harness.costUsd ? `, $${harness.costUsd.toFixed(4)}` : ""}${harness.unknownCost ? ` (${harness.unknownCost} calls billed by your coding-agent plan)` : ""}.` : "");
78
+ }
79
+ /** A run's selected tasks, cut to one short cell; `openmerit history <run>` lists them all. */
80
+ function tasksCell(selected) {
81
+ const text = selected.join(", ");
82
+ return !text ? "—" : text.length <= 40 ? text : `${text.slice(0, 39)}… (${selected.length})`;
83
+ }
84
+ export function renderHistory(state, runs) {
85
+ if (!runs.length)
86
+ return "No Scout runs in this repository yet. Start one with: openmerit scout";
87
+ return pad([["Run", "Started", "Status", "Phase", "Model", "Tasks", "Eval spend", "openmerit / catalogue"], ...runs.map(run => {
88
+ const data = run.state;
89
+ return [run.id, run.createdAt.slice(0, 16).replace("T", " "), run.status, run.phase, data.model ?? "—", tasksCell(data.selected ?? []), `$${state.spend(run.id).totalUsd.toFixed(4)} / $${run.budgetUsd.toFixed(2)}`, `${run.packageVersion} / ${run.catalogueVersion}`];
90
+ })]);
91
+ }
92
+ export function renderRun(state, run, step) {
93
+ const section = (title, lines, none) => ["", title, ...(lines.length ? lines : [` ${none}`])];
94
+ const lines = [
95
+ `Run ${run.id} (${run.status === "done" ? "done" : `${run.status} at ${run.phase}`})`, ` Repository ${run.repoRoot} at ${run.revision ?? "no commit"}`,
96
+ ` openmerit ${run.packageVersion}, catalogue ${run.catalogueVersion}, harness ${run.harness}`, "",
97
+ renderStep(step),
98
+ ...section("Eval runs:", state.units(run.id).map(unit => ` ${unit.status.padEnd(13)} ${unit.task} · ${unit.role} ${unit.model} on ${unit.endpoint} · run ${unit.repeat} · ${unit.attempts} attempt(s) · ${money(state.spend(run.id, unit.id).totalUsd)}${typeof unit.result?.total === "number" ? ` · ${String(unit.result.passes)}/${String(unit.result.total)} passed${unit.result.source === "langfuse" ? ` (Langfuse ${String(unit.result.experiment)})` : ""}` : ""}${unit.error ? ` · ${unit.error.split("\n")[0]}` : ""}`), "none"),
99
+ ...section("Budget:", state.budgetEvents(run.id).map(event => ` ${event.at.slice(0, 19)} ${event.kind}${event.toUsd !== null ? ` $${event.fromUsd.toFixed(2)} → $${event.toUsd.toFixed(2)} (${event.scope})` : ` at $${event.spentUsd.toFixed(4)} of $${event.fromUsd.toFixed(2)}`}`), `$${run.budgetUsd.toFixed(2)} for this run; not reached or changed.`),
100
+ ...section("Decisions:", state.decisions(run.id).map(decision => ` ${decision.at.slice(0, 19)} ${decision.kind}: ${decision.decision}${decision.kind === "configure" && decision.detail.diff ? `\n${decision.detail.diff.split("\n").map(line => ` ${line}`).join("\n")}` : ""}`), "none"),
101
+ ...section("Events:", state.events(run.id).map(event => ` ${event.at.slice(0, 19)} ${event.kind}: ${event.message}`), "none"),
102
+ ];
103
+ return lines.join("\n");
104
+ }