openmerit 0.1.6-preview.5 → 0.1.7-preview.1

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 (60) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +58 -98
  3. package/dist/eval/src/adapters.mjs +73 -0
  4. package/dist/eval/src/cli.mjs +103 -0
  5. package/dist/eval/src/config.mjs +102 -0
  6. package/dist/eval/src/process.mjs +44 -0
  7. package/dist/eval/src/report.mjs +51 -0
  8. package/dist/eval/src/runner.mjs +106 -0
  9. package/dist/eval/src/scout.mjs +37 -0
  10. package/dist/terminal/public/app.js +36 -36
  11. package/dist/terminal/public/flow.css +1 -1
  12. package/dist/terminal/public/flow.js +26 -26
  13. package/dist/terminal/public/index.html +3 -2
  14. package/dist/terminal/public/styles.css +320 -91
  15. package/dist/terminal/src/cli.mjs +2 -0
  16. package/dist/terminal/src/demo-policy.mjs +205 -0
  17. package/dist/terminal/src/enterprise-sample.mjs +151 -0
  18. package/docs/adapter-guide.md +2 -2
  19. package/docs/agent-evaluations.md +101 -0
  20. package/docs/architecture.md +3 -3
  21. package/docs/automation.md +5 -5
  22. package/docs/budgets.md +2 -2
  23. package/docs/commands.md +8 -6
  24. package/docs/demo-backfill.md +3 -1
  25. package/docs/demo-fieldkit.md +3 -1
  26. package/docs/demo-placement.md +3 -1
  27. package/docs/demo-spam.md +3 -1
  28. package/docs/demo-support.md +3 -1
  29. package/docs/demo.md +22 -5
  30. package/docs/first-trial.md +2 -2
  31. package/docs/getting-started.md +23 -12
  32. package/docs/index.md +21 -21
  33. package/docs/inference-terminal.md +94 -56
  34. package/docs/interactive-demo.md +118 -0
  35. package/docs/lifecycle.md +4 -4
  36. package/docs/metrics-and-evidence.md +3 -3
  37. package/docs/operations.md +3 -5
  38. package/docs/pi-extension.md +6 -6
  39. package/docs/roadmap.md +17 -17
  40. package/docs/scout.md +206 -0
  41. package/docs/security.md +12 -6
  42. package/docs/site-demo.md +4 -0
  43. package/docs/site-design.md +7 -1
  44. package/docs/site-documentation.md +9 -3
  45. package/docs/site-domain-migration.md +57 -0
  46. package/docs/site-dynamic-og.md +2 -2
  47. package/docs/site-faq-maintenance.md +125 -3
  48. package/docs/site-hero-resolution.md +2 -0
  49. package/docs/site-illustration-sequences.md +3 -1
  50. package/docs/site-illustration-transparency.md +80 -0
  51. package/docs/site-memo.md +6 -0
  52. package/docs/site-og-image.md +1 -1
  53. package/docs/site-publication-2026-10-14.md +29 -0
  54. package/docs/site-scout-decisions-experiment.md +87 -0
  55. package/docs/site-scout.md +254 -0
  56. package/docs/site-terminal-preview.md +96 -3
  57. package/docs/site-themes.md +30 -0
  58. package/docs/testing.md +37 -78
  59. package/docs/troubleshooting.md +3 -3
  60. package/package.json +9 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.7-preview.1 — 2026-10-15
4
+
5
+ - Add `openmerit eval init/doctor/plan/run/watch/report`: Scout shortlists, explicit model scope, Claude Code/Codex/OpenCode adapters, fresh task attempts, local graders, balanced adaptive 3–10 repetitions, revision comparisons and cumulative budget accounting.
6
+ - Bundle project agent instructions without install-time hooks. Missing usage/cost remains unknown and partial comparisons are disclosed. Terminal/Pi behavior is unchanged.
7
+ - Use the hosted Scout HTTP/MCP `modelIds` filter to select eligible candidates before ranking. This API change was deployed separately on 15 October 2026.
8
+
3
9
  ## 0.1.6-preview.5 — 2026-10-10
4
10
 
5
11
  - Open the terminal before initial collection, then index repository declarations in a background worker with visible discovery, indexing, and linking progress. Replace repository-wide scan caps with continuing work batches.
package/README.md CHANGED
@@ -1,137 +1,97 @@
1
1
  # OpenMerit
2
2
 
3
- OpenMerit is a harness-neutral orchestration layer that helps a coding harness continuously find and maintain the Pareto frontier of models for a real product task.
3
+ OpenMerit provides a local Inference Terminal for understanding an application's models, services, recorded activity, and compute, plus a Pi integration for evidence-driven model comparisons.
4
4
 
5
- OpenMerit does not perform the intelligent work. It nudges a connected coding harness, such as Pi, with typed outcome requests. The harness understands the project, establishes evaluations and observability, gathers evidence, discovers and tests candidates, calculates the Pareto frontier, and performs an authorized model change. OpenMerit validates the returned contracts, preserves evidence and preferences, and advances the approved lifecycle.
5
+ The terminal reads existing evidence. The Pi integration asks a coding harness to build evaluations, measure candidates, and carry out authorized changes. OpenMerit coordinates that workflow and validates its structured results; the terminal does not route requests, run evaluations, or swap models.
6
6
 
7
- The npm package `openmerit` contains the Pi extension, the reusable coordinator,
8
- and the protocol in one install. The Pi adapter is the first integration; other
9
- harnesses can use the same core and protocol exports.
7
+ ## Open the terminal
10
8
 
11
- ## Why OpenMerit
9
+ Requires Node.js 22.19 or newer and npm:
12
10
 
13
- The cheapest model, fastest model, and highest-quality model are often different. Public benchmarks are useful before product evidence exists, but they cannot establish which model is best for a particular workload. OpenMerit connects the initial choice to production evidence and repeated reassessment.
14
-
15
- It distinguishes:
16
-
17
- - one-run observations from rate, percentile, consistency, and reliability claims;
18
- - missing evidence from poor performance;
19
- - feasible candidates from dominated, unresolved, or ineligible candidates;
20
- - an automatic nudge from an intelligent action performed by the harness;
21
- - a recommendation from an authorized, verified model swap.
22
-
23
- ## Current capabilities
24
-
25
- - Pi 0.87 extension with a single `/openmerit` command surface.
26
- - Harness-inferred, user-confirmed task profiles with explicit metrics, sampling designs, constraints, and evaluation budgets.
27
- - The complete initial quality, reliability, cost, latency, efficiency, and human-intervention metric catalogue.
28
- - Setup-time preliminary model research in the source checkout, followed by baseline evidence collection, refreshed candidate discovery, and controlled challenger trials. The published `0.1.5` package still begins candidate discovery after the baseline.
29
- - A normative, uncertainty-aware Pareto definition owned by OpenMerit.
30
- - Harness-calculated frontier results independently checked by OpenMerit's conformance verifier.
31
- - Versioned result schemas for all ten intents, reusable across harness adapters and exposed dynamically by Pi.
32
- - Configurable swap approval, post-swap verification requests, and policy-based rollback requests. Automatic swaps can begin immediately if explicitly enabled with a zero verified-swap threshold.
33
- - Project-local snapshots, redacted append-only audit events, evidence manifests, and optional best-effort event exporters under `.openmerit/`.
34
- - Model-catalog change detection that can nudge the harness to reassess a mature baseline.
35
- - User-confirmed task-count, elapsed-time, regression, catalogue-change, and post-swap check policies.
36
- - Idempotent harness signals, durable pending work, native wakeup planning, and explicit scheduler capability gaps.
37
- - In the unreleased source checkout, an opt-in macOS Pi LaunchAgent for time-based checks and a durable pause; published `0.1.5` still requires an external scheduler and has a partial, session-local pause.
11
+ ```sh
12
+ npm install -g openmerit
13
+ cd /path/to/your/application
14
+ openmerit dash
15
+ ```
38
16
 
39
- ## Responsibility boundary
17
+ Stable `openmerit@0.1.6` includes the terminal. No Pi installation or provider API key is needed. It opens a browser connected to a read-only loopback service. Supported source/configuration declarations establish inventory; existing application logs or exports supply request history. Prompts, responses, and credentials are not retained. Unsupported or missing evidence remains unknown.
40
18
 
41
- | OpenMerit | Coding harness |
42
- | --- | --- |
43
- | Defines typed outcomes and Pareto conformance rules | Understands the product and user task |
44
- | Tracks lifecycle, policy, budget, and evidence references | Builds evals and observability |
45
- | Determines which approved nudge is due | Collects and calculates metrics |
46
- | Verifies returned structure and frontier conformance | Discovers candidates and calculates the frontier |
47
- | Selects intents according to confirmation and rollback policy | Applies and verifies an authorized model change |
19
+ The source interface and hosted dashboard follow the device's light/dark preference without a theme control. This appearance change is not included in npm 0.1.6.
48
20
 
49
- Jev System One, when configured, belongs to the coding harness. Pi may use it aggressively for bounded ranking, triage, and fast judgment. OpenMerit contains no Jev client and accepts no Jev credential.
21
+ Explore the [hosted sample](https://dash.openmerit.ai/) or [before/after dashboard demo](https://dash.openmerit.ai/demo). Both use fictional data and make no inference calls. See [the terminal guide](docs/inference-terminal.md) for configuration, discovery, diagnostics, history limits, and agent access.
50
22
 
51
- ## Install with Pi
23
+ ## Compare models with Pi
52
24
 
53
- Requirements:
25
+ The **npm preview `0.1.7-preview.1`** adds `openmerit eval` for evaluating Claude Code, Codex and OpenCode with your CLI. Choose existing connections or Scout's full index, freeze a shortlist, and compare 3–10 fresh attempts after source changes. Install with `npm install -g openmerit@preview`. See [agent evaluations](https://unpkg.com/openmerit@0.1.7-preview.1/docs/agent-evaluations.md) for setup, graders, budgets and availability. This is separate from the Pi workflow below.
54
26
 
55
- - Node.js 22.19 or newer
56
- - Pi 0.87 with a supported model provider configured
27
+ With Pi 0.87 and a model provider configured:
57
28
 
58
29
  ```sh
59
30
  pi install npm:openmerit
60
31
  pi list
61
32
  ```
62
33
 
63
- When Pi enters an unconfigured project with an interactive UI, the adapter first looks for an actual application LLM call pattern. If the project starts empty, the unreleased source checkout scans again after each settled build turn and automatically begins setup when Pi creates the application call. `/openmerit setup` remains a recovery command rather than a required demo step. Pi confirms the application target and exact incumbent application model, infers task-relevant metrics and their repeated-input or representative-case sampling designs, explains the proposed collection plan and estimated cost, and asks the user to confirm or edit it. It then establishes task-specific eval and observability artifacts, verifies them, and reports evidence to OpenMerit. Pi's own model is never the application target.
34
+ Open the product repository in interactive Pi. An unconfigured project is checked for an application LLM call at session start and after settled build turns. Pi confirms the application target and incumbent model, proposes task-specific metrics and sampling, explains estimated evaluation cost, and requests confirmation of the plan, budget, and permissions. `/openmerit setup` can recover or rerun setup. Pi's own model is never the application target.
64
35
 
65
- Run `/openmerit doctor` inside Pi to check the installation. OpenMerit keeps
66
- project evidence under `.openmerit/` in the product repository. Version 0.1.5
67
- replaces the 0.1.4 background watcher and standalone CLI with this
68
- harness-driven lifecycle; it does not migrate the earlier home-directory state.
36
+ Core checks stored baseline windows against the confirmed requirements. When ready, it asks Pi to discover candidates, run controlled comparisons, and calculate a frontier. Core validates the returned evidence contracts and Pareto conformance. The harness must execute the work and provide honest evidence.
69
37
 
70
- ## Commands
38
+ Model changes normally require approval. Explicit automatic-swap opt-in can allow changes once the configured verified-swap threshold is reached, including a threshold of zero. Post-swap evaluation and conditional rollback depend on the harness performing and reporting them.
71
39
 
72
- - `/openmerit` or `/openmerit status` — show evidence readiness and lifecycle stage.
73
- - `/openmerit setup` — explicitly rerun or recover automatic setup.
74
- - `/openmerit assess` — check stored application metric windows against the baseline thresholds now (in the unreleased source checkout); start candidate discovery only when ready. Published `0.1.5` still asks Pi to assess.
75
- - `/openmerit logs` — show the canonical audit, exporter-error, evidence, and state paths.
76
- - `/openmerit frontier` — ask the harness to calculate a frontier now; OpenMerit verifies it.
77
- - `/openmerit approve` — approve a verified proposal during supervised graduation.
78
- - `/openmerit pause` and `/openmerit resume` — control some proactive nudges in the current Pi session; task-triggered checks can still run in 0.1.5. See [pause limitations](docs/commands.md#pause-and-resume).
79
- - `/openmerit doctor` — show sanitized adapter diagnostics.
40
+ Read [getting started](docs/getting-started.md) and [your first supervised trial](docs/first-trial.md).
80
41
 
81
- ## Inference Terminal (test preview)
42
+ ## Implemented orchestration
82
43
 
83
- A local, dark-only overview of application inference inventory, connections, activity, and compute. Coding-harness usage is outside its scope. It reads existing metadata passively and makes no model calls.
44
+ - Task profiles with confirmed metrics, sampling plans, constraints, budgets, and permissions.
45
+ - Preliminary model research, incumbent identity, durable candidate handoffs, and controlled challenger experiments.
46
+ - Deterministic baseline checks and uncertainty-aware frontier validation.
47
+ - Versioned result schemas for all ten intents and a reusable harness adapter boundary.
48
+ - Challenger-spend accounting, bounded intent admission, approval, verification, and rollback requests.
49
+ - Project-local state, redacted audit events, evidence manifests, and optional event exporters.
50
+ - Task-count, elapsed-time, catalogue-change, regression, and post-swap signals.
51
+ - Durable pause and authorized macOS user LaunchAgents; other platforms require an external scheduler.
84
52
 
85
- With Node.js 22.19+ installed, run this from your application's directory:
53
+ These implementations have automated coverage. They do not establish application-quality improvements or reliable unattended model changes. See [current limitations](docs/roadmap.md).
86
54
 
87
- ```sh
88
- npm install -g openmerit@preview
89
- openmerit dash
90
- ```
55
+ ## Pi commands
91
56
 
92
- The preview uses the existing `openmerit` npm package, including its coordinator, protocol, Pi adapter, and compiled terminal assets. It opens its localhost URL in your default browser. The stable npm package `openmerit@0.1.5` does not include this command. See the [Inference Terminal guide](docs/inference-terminal.md) for supported sources, updating or removing the preview, source builds, and read-only agent access.
57
+ - `/openmerit` or `/openmerit status` — inspect readiness, active work, and lifecycle state.
58
+ - `/openmerit setup` — request or recover setup.
59
+ - `/openmerit assess` — check stored baseline windows; discover candidates only when ready.
60
+ - `/openmerit frontier` — request a harness-calculated comparison for validation.
61
+ - `/openmerit approve` — approve the current verified proposal.
62
+ - `/openmerit pause` / `/openmerit resume` — persist or clear the automatic-work pause and reconcile authorized scheduling; active work is not cancelled.
63
+ - `/openmerit cancel` — record abandonment of an interrupted intent; it does not terminate a provider request.
64
+ - `/openmerit logs` — locate local state, audit, and evidence files.
65
+ - `/openmerit doctor` — inspect adapter and scheduler diagnostics.
93
66
 
94
67
  ## Documentation
95
68
 
96
- Read the searchable [OpenMerit documentation](https://openmerit.site/docs/). The Markdown sources below also ship with the package.
69
+ The [documentation overview](docs/index.md) links all current guides. The website at [openmerit.ai/docs](https://openmerit.ai/docs/) is deployed separately from the package and this checkout.
97
70
 
98
- - [Getting started](docs/getting-started.md)
99
- - [Your first trial](docs/first-trial.md)
100
- - [Command reference](docs/commands.md)
101
- - [Budgets and permissions](docs/budgets.md)
102
- - [Troubleshooting](docs/troubleshooting.md)
103
- - [Architecture and responsibility boundary](docs/architecture.md)
71
+ - [Inference Terminal](docs/inference-terminal.md)
104
72
  - [Metrics and evidence](docs/metrics-and-evidence.md)
105
- - [Improvement lifecycle](docs/lifecycle.md)
106
- - [Harness-neutral automation](docs/automation.md)
107
- - [Pi extension guide](docs/pi-extension.md)
108
- - [Building another harness adapter](docs/adapter-guide.md)
109
- - [Pareto conformance specification](docs/pareto-spec.md)
110
- - [Project state and operations](docs/operations.md)
111
- - [Security](docs/security.md)
112
- - [Testing](docs/testing.md)
113
- - [Current limitations and roadmap](docs/roadmap.md)
114
-
115
- ### Maintaining documentation
73
+ - [Lifecycle](docs/lifecycle.md), [automation](docs/automation.md), and [budgets](docs/budgets.md)
74
+ - [Commands](docs/commands.md), [operations](docs/operations.md), and [troubleshooting](docs/troubleshooting.md)
75
+ - [Security and data handling](docs/security.md)
76
+ - [Architecture](docs/architecture.md), [Pi adapter](docs/pi-extension.md), and [adapter development](docs/adapter-guide.md)
77
+ - [Pareto specification](docs/pareto-spec.md) and [testing](docs/testing.md)
116
78
 
117
- Edit the Markdown in `docs/` alongside product changes. The public documentation is generated from these same files; there is no separate article copy to update in `site/`. Run `npm run check:docs` to rebuild and validate it. The normal Cloudflare deployment rebuilds and publishes the documentation. See [the documentation workflow](docs/site-documentation.md) for adding guides and previewing changes.
118
-
119
- ## Repository layout
120
-
121
- - `packages/protocol` — harness-neutral types for intents, results, metrics, policies, and frontier evidence.
122
- - `packages/core` — adapter SDK, durable coordinator, pluggable persistence, policy, and frontier conformance verification.
123
- - `packages/pi` — Pi extension that turns OpenMerit intents into harness work.
124
- - `docs` — user, operator, architecture, and normative documentation.
125
-
126
- The workspace modules are private development boundaries. The published
127
- package exposes `openmerit/core`, `openmerit/protocol`, and `openmerit/pi`.
128
-
129
- ## Development from source
79
+ ## Development
130
80
 
131
81
  ```sh
132
82
  npm ci
133
83
  npm run verify
84
+ npm run dash -- /path/to/application
85
+ ```
86
+
87
+ For Pi extension development:
88
+
89
+ ```sh
134
90
  ./node_modules/.bin/pi --no-extensions -e ./packages/pi/src/index.ts
135
91
  ```
136
92
 
137
- `--no-extensions` prevents an installed npm copy of OpenMerit from registering the same tools as the source extension. Credentials must never be committed. The repository intentionally contains no provider or Jev API key.
93
+ `--no-extensions` avoids registering an installed npm copy alongside the source extension. Credentials belong to the harness or its environment and must never be committed. Jev, if configured, is invoked by the harness; OpenMerit provides no Jev client.
94
+
95
+ `packages/terminal` contains the CLI, collectors, hosted sample, and browser interface. `packages/protocol`, `packages/core`, and `packages/pi` provide the protocol, coordinator, and Pi adapter. The package exports `openmerit/core`, `openmerit/protocol`, and `openmerit/pi`.
96
+
97
+ Edit documentation in `docs/`, then run `npm run check:docs`. The website is generated from those Markdown files. See [documentation maintenance](docs/site-documentation.md) and the [production-source reconciliation note](docs/site-domain-migration.md) before deployment; a local build does not publish changes.
@@ -0,0 +1,73 @@
1
+ import { execute, agentEnvironment } from './process.mjs';
2
+
3
+ const number = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
4
+ const sum = values => values.length && values.every(v => v !== null) ? values.reduce((a, b) => a + b, 0) : null;
5
+ export const agents = ['claude', 'codex', 'opencode'];
6
+
7
+ export function connectionFailure(text) {
8
+ if (/401|unauthorized|authentication[_ -]?(?:error|required)|invalid api key|not logged in|please log in/i.test(text)) return 'authentication';
9
+ if (/model.{0,100}(?:not supported|not found|not available|does not exist)|(?:unknown|invalid|unsupported) model|ProviderModelNotFoundError/i.test(text)) return 'model_unavailable';
10
+ if (/rejected permission|permission.{0,30}(?:denied|rejected)|not allowed to use.{0,30}tool/i.test(text)) return 'permission_denied';
11
+ return null;
12
+ }
13
+
14
+ export function invocation(connection, reservationUsd) {
15
+ const model = connection.model;
16
+ if (connection.agent === 'claude') return ['-p', '--output-format', 'json', '--model', model, '--no-session-persistence', '--permission-mode', 'dontAsk', '--allowedTools', 'Read,Glob,Grep,Bash,Edit,Write', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--setting-sources', '', '--max-budget-usd', String(reservationUsd)];
17
+ if (connection.agent === 'codex') return ['exec', '--json', '--model', model, '--sandbox', 'workspace-write', '--skip-git-repo-check', '--ephemeral', '-'];
18
+ if (connection.agent === 'opencode') return ['run', '--format', 'json', '--model', model, '--auto'];
19
+ throw new Error('Unknown agent. Use claude, codex or opencode.');
20
+ }
21
+
22
+ export function parseOutput(agent, stdout) {
23
+ let events;
24
+ try { const parsed = JSON.parse(stdout); events = Array.isArray(parsed) ? parsed : [parsed]; }
25
+ catch { events = stdout.split('\n').flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } }); }
26
+ let output = '', usage = null, costUsd = null, completed = false, failed = false;
27
+ const observedModels = new Set();
28
+ if (agent === 'claude') {
29
+ const result = events.findLast(e => e.type === 'result');
30
+ if (result) {
31
+ completed = result.subtype === 'success' && !result.is_error;
32
+ failed = !completed; output = result.result ?? '';
33
+ const u = result.usage;
34
+ if (u) usage = { input: sum([number(u.input_tokens), number(u.cache_read_input_tokens ?? 0), number(u.cache_creation_input_tokens ?? 0)]), output: number(u.output_tokens), cached: number(u.cache_read_input_tokens ?? 0) };
35
+ costUsd = number(result.total_cost_usd);
36
+ for (const model of Object.keys(result.modelUsage ?? {})) observedModels.add(model);
37
+ }
38
+ } else if (agent === 'codex') {
39
+ const turns = events.filter(e => e.type === 'turn.completed');
40
+ completed = turns.length > 0;
41
+ failed = events.some(e => ['turn.failed', 'error'].includes(e.type));
42
+ output = events.findLast(e => e.type === 'item.completed' && e.item?.type === 'agent_message')?.item.text ?? '';
43
+ if (turns.length) usage = { input: sum(turns.map(e => number(e.usage?.input_tokens))), output: sum(turns.map(e => number(e.usage?.output_tokens))), cached: sum(turns.map(e => number(e.usage?.cached_input_tokens))) };
44
+ } else if (agent === 'opencode') {
45
+ const steps = events.filter(e => e.type === 'step_finish');
46
+ const finalStep = steps.findLast(e => ['stop', 'end_turn'].includes(e.part?.reason));
47
+ completed = !!finalStep;
48
+ failed = events.some(e => e.type === 'error');
49
+ const texts = events.filter(e => e.type === 'text');
50
+ output = finalStep?.part?.messageID ? texts.filter(e => e.part?.messageID === finalStep.part.messageID).map(e => e.part?.text ?? '').join('\n') : texts.at(-1)?.part?.text ?? '';
51
+ if (steps.length) {
52
+ usage = { input: sum(steps.map(e => sum([number(e.part?.tokens?.input), number(e.part?.tokens?.cache?.read ?? 0), number(e.part?.tokens?.cache?.write ?? 0)]))), output: sum(steps.map(e => sum([number(e.part?.tokens?.output), number(e.part?.tokens?.reasoning ?? 0)]))), cached: sum(steps.map(e => number(e.part?.tokens?.cache?.read ?? 0))) };
53
+ costUsd = sum(steps.map(e => number(e.part?.cost)));
54
+ }
55
+ }
56
+ return { output, usage, costUsd, completed: completed && !failed, observedModels: [...observedModels] };
57
+ }
58
+
59
+ export async function preflight(connection) {
60
+ const command = connection.command ?? connection.agent;
61
+ const version = await execute(command, ['--version'], { timeoutMs: 10_000, env: agentEnvironment(connection.env) });
62
+ const help = await execute(command, connection.agent === 'codex' ? ['exec', '--help'] : connection.agent === 'opencode' ? ['run', '--help'] : ['--help'], { timeoutMs: 10_000, env: agentEnvironment(connection.env) });
63
+ const flags = connection.agent === 'codex' ? ['--json', '--ephemeral', '--sandbox'] : connection.agent === 'claude' ? ['--output-format', '--no-session-persistence', '--max-budget-usd'] : ['--format', '--model', '--auto'];
64
+ if (version.code !== 0 || help.code !== 0 || !flags.every(f => (help.stdout + help.stderr).includes(f))) throw new Error(`${connection.agent} lacks the required non-interactive JSON interface. Update it or set this connection's command to a compatible executable.`);
65
+ return version.stdout.trim().slice(0, 160);
66
+ }
67
+
68
+ export async function runAgent(connection, prompt, options) {
69
+ const result = await execute(connection.command ?? connection.agent, invocation(connection, options.reservationUsd), { ...options, input: prompt, env: agentEnvironment(connection.env) });
70
+ const parsed = parseOutput(connection.agent, result.stdout);
71
+ const status = result.reason ?? (result.code !== 0 || !parsed.completed ? 'agent_error' : 'ok');
72
+ return { ...parsed, costUsd: status === 'ok' ? parsed.costUsd : null, partialCostUsd: status === 'ok' ? null : parsed.costUsd, seconds: result.seconds, status, failureReason: status === 'agent_error' ? connectionFailure(result.stdout + result.stderr) : null };
73
+ }
@@ -0,0 +1,103 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { agents, preflight } from './adapters.mjs';
5
+ import { configPath, statePath, starter, loadConfig, readJSON, saveJSON } from './config.mjs';
6
+ import { makePlan, savePlan } from './scout.mjs';
7
+ import { acquireLock, evaluate, watch } from './runner.mjs';
8
+ import { formatReport } from './report.mjs';
9
+
10
+ export const help = `OpenMerit · Agent evaluations (source preview)
11
+
12
+ openmerit eval init Add a starter suite and project agent instructions
13
+ openmerit eval doctor Check installed Claude Code, Codex and OpenCode CLIs
14
+ openmerit eval plan [--scope existing|all]
15
+ Ask scope, then freeze Scout's shortlist per task
16
+ openmerit eval run [--json] Evaluate the frozen shortlist in fresh workspaces
17
+ openmerit eval watch [--json] Repeat after selected CLI/docs/fixture files change
18
+ openmerit eval report [--json] Read the latest result without running models
19
+
20
+ --path <directory> Project directory (default: current directory)
21
+
22
+ Configure .openmerit/eval.json: explicit agent connections, task fixtures, local
23
+ graders, CLI variants, 3–10 repeats, and a cumulative budget. Init starts at $0.
24
+ Scout receives task descriptions and, for existing scope, model IDs only.
25
+ Run/watch execute local agents and may incur charges. No background daemon.
26
+ `;
27
+
28
+ const instructions = `<!-- openmerit-eval:start -->
29
+ ## OpenMerit agent evaluations
30
+
31
+ When building or changing this project's CLI, tools or harness documentation, use
32
+ \`openmerit eval\` to compare task performance. Skip this workflow when
33
+ \`OPENMERIT_EVAL=1\` to avoid recursive evaluations.
34
+
35
+ - Read .openmerit/eval.json and run \`openmerit eval doctor\`.
36
+ - Ask once: "Models available through this project's existing connections, or all models Scout indexes?" Use \`eval plan --scope existing\` or \`--scope all\`. Reuse the saved choice. Never infer endpoint access from a model name or expose credentials.
37
+ - Declare real connections with Scout model IDs, agent-native model IDs, and environment variable NAMES. Existing scope uses this project-declared inventory. All scope may require new authenticated connections before execution.
38
+ - Replace starter cases with representative tasks, fresh fixtures and deterministic standalone Node graders. A grader reads {workspace, output} JSON on stdin and returns {quality: 0..1}. Keep answer keys outside agent workspaces. Include a bare variant and a CLI/docs variant where useful.
39
+ - Confirm the suite, variants and cumulative budget with the user before enabling paid runs. Then plan once, run a baseline, make the requested CLI changes, and run again. Use \`eval watch\` only when the user wants continuous local reruns; it exits with the terminal and retains budget usage across restarts.
40
+ - Report quality, failures, tokens, reported costs and elapsed time per task/model/variant. A shortlist is a prior, not measured success. Do not claim missing costs are zero or that 3–10 repeats prove an exact ordering. Keep fresh holdouts for final validation.
41
+ - If a compatible watcher is already active, let it run; never start overlapping loops. Do not overwrite the frozen plan merely because the CLI changed.
42
+ <!-- openmerit-eval:end -->`;
43
+
44
+ export async function initialize(root) {
45
+ // Refuse partial overwrite of an existing suite or someone's fixtures.
46
+ const files = {
47
+ [configPath]: JSON.stringify(starter, null, 2) + '\n',
48
+ 'eval/fixtures/json/request.txt': 'Customer: Mira Chen\nItems: 7\n',
49
+ 'eval/tool.mjs': `import {readFileSync} from 'node:fs';\nconst s=readFileSync(process.argv[2],'utf8');\nconsole.log(JSON.stringify({name:s.match(/Customer: (.*)/)?.[1],count:Number(s.match(/Items: (\\d+)/)?.[1])}));\n`,
50
+ 'eval/grade.mjs': `let input=''; for await (const chunk of process.stdin) input+=chunk;\nconst {output}=JSON.parse(input); let quality=0;\ntry {const v=JSON.parse(output); quality=(Number(v.name==='Mira Chen')+Number(v.count===7)+Number(Object.keys(v).sort().join(',')==='count,name'))/3;} catch {}\nconsole.log(JSON.stringify({quality}));\n`,
51
+ };
52
+ for (const file of Object.keys(files)) {
53
+ try { await readFile(join(root, file)); throw new Error(`Already exists: ${file}. Init does not overwrite a suite.`); } catch (error) { if (error.code !== 'ENOENT') throw error; }
54
+ }
55
+ for (const [file, body] of Object.entries(files)) { await mkdir(join(root, file, '..'), { recursive: true }); await writeFile(join(root, file), body, { flag: 'wx' }); }
56
+ for (const name of ['AGENTS.md', 'CLAUDE.md']) {
57
+ let body = ''; try { body = await readFile(join(root, name), 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
58
+ if (!body.includes('<!-- openmerit-eval:start -->')) await writeFile(join(root, name), `${body}${body ? '\n\n' : ''}${instructions}\n`);
59
+ }
60
+ let ignore = ''; try { ignore = await readFile(join(root, '.gitignore'), 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
61
+ if (!ignore.split('\n').includes('/.openmerit/eval/')) await writeFile(join(root, '.gitignore'), `${ignore}${ignore.endsWith('\n') || !ignore ? '' : '\n'}/.openmerit/eval/\n`);
62
+ }
63
+
64
+ export async function main(args) {
65
+ if (!args.length || args.includes('--help') || args.includes('-h')) { console.log(help); return; }
66
+ const action = args.shift(); let root = process.cwd(), scope, json = false;
67
+ for (let i = 0; i < args.length; i++) {
68
+ if (args[i] === '--path' && args[i + 1]) root = resolve(args[++i]);
69
+ else if (args[i] === '--scope' && ['existing', 'all'].includes(args[i + 1])) scope = args[++i];
70
+ else if (args[i] === '--json') json = true;
71
+ else throw new Error(`Unknown eval argument: ${args[i]}`);
72
+ }
73
+ if (scope && action !== 'plan') throw new Error('--scope belongs to eval plan.');
74
+ if (action === 'init') { await initialize(root); console.log('Created .openmerit/eval.json, starter fixtures, and AGENTS.md / CLAUDE.md instructions. Declare connections, replace the starter task, and set an approved budget before running.'); return; }
75
+ if (action === 'doctor') {
76
+ const results = [];
77
+ for (const agent of agents) { try { results.push({ agent, version: await preflight({ agent }), ready: true }); } catch (error) { results.push({ agent, ready: false, reason: error.message }); } }
78
+ console.log(JSON.stringify(results, null, 2)); return;
79
+ }
80
+ if (action === 'report') { const report = await readJSON(join(root, statePath, 'latest.json')); console.log(json ? JSON.stringify(report, null, 2) : formatReport(report)); return; }
81
+ if (!['plan', 'run', 'watch'].includes(action)) throw new Error('Unknown eval command. Run openmerit eval --help.');
82
+ const unlock = await acquireLock(root);
83
+ const controller = new AbortController(), stop = () => controller.abort();
84
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
85
+ try {
86
+ if (action === 'plan') {
87
+ const config = await loadConfig(root);
88
+ if (scope) config.scope = scope;
89
+ if (!config.scope && process.stdin.isTTY && !json) {
90
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
91
+ try { const answer = await rl.question('Use existing project connections or all models Scout indexes? [existing/all] ', { signal: controller.signal }); if (['existing', 'all'].includes(answer.trim())) config.scope = answer.trim(); } finally { rl.close(); }
92
+ }
93
+ const plan = await makePlan(config);
94
+ await saveJSON(join(root, configPath), config); await savePlan(root, plan);
95
+ console.log(JSON.stringify(plan, null, 2));
96
+ return;
97
+ }
98
+ const onReport = report => { console.log(json ? JSON.stringify(report) : formatReport(report)); if (report.status !== 'complete') process.exitCode = 1; };
99
+ const onProgress = json ? () => {} : row => process.stderr.write(`${row.task} · ${row.connection} · ${row.variant} · ${row.round}: ${row.status}\n`);
100
+ if (action === 'run') onReport(await evaluate(root, { signal: controller.signal, onProgress }));
101
+ else { if (!json) console.log('Watching selected files. Ctrl+C stops; budget usage persists.'); await watch(root, { signal: controller.signal, onReport, onProgress }); }
102
+ } finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); await unlock(); }
103
+ }
@@ -0,0 +1,102 @@
1
+ import { readFile, writeFile, mkdir, rename, readdir, lstat, realpath } from 'node:fs/promises';
2
+ import { resolve, relative, join, dirname, isAbsolute } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { agents } from './adapters.mjs';
5
+
6
+ export const configPath = '.openmerit/eval.json';
7
+ export const statePath = '.openmerit/eval';
8
+ export const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
9
+ export async function readJSON(path) { return JSON.parse(await readFile(path, 'utf8')); }
10
+ export async function saveJSON(path, data) {
11
+ await mkdir(dirname(path), { recursive: true });
12
+ const temp = `${path}.${process.pid}.tmp`;
13
+ await writeFile(temp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); await rename(temp, path);
14
+ }
15
+ const text = v => typeof v === 'string' && v.trim().length > 0;
16
+ const id = v => text(v) && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,79}$/.test(v);
17
+ function requireThat(ok, message) { if (!ok) throw new Error(`Eval config: ${message}`); }
18
+ export function safePath(path) {
19
+ requireThat(text(path) && !isAbsolute(path) && !path.split(/[\\/]/).some(p => ['..', '.', '.git', '.openmerit', 'node_modules'].includes(p) || p.startsWith('.env')), `unsafe file path ${path}. Use explicit project-relative source/fixture paths, without secrets or symlinks.`);
20
+ return path;
21
+ }
22
+ export function validateConfig(config) {
23
+ requireThat(config.version === 1, 'version must be 1.');
24
+ requireThat([null, 'existing', 'all'].includes(config.scope), 'scope must be null, existing or all.');
25
+ requireThat(Array.isArray(config.connections) && config.connections.length <= 100, 'connections must be a list of up to 100 routes.');
26
+ requireThat(new Set(config.connections.map(c => c.id)).size === config.connections.length, 'connection IDs must be unique.');
27
+ for (const c of config.connections) {
28
+ requireThat(id(c.id) && agents.includes(c.agent) && text(c.scoutId) && text(c.model), 'each connection needs id, scoutId, agent and native model.');
29
+ requireThat(!c.model.startsWith('-') && c.model.length <= 200, 'invalid native model.');
30
+ requireThat(c.env === undefined || Array.isArray(c.env) && c.env.every(k => /^[A-Z][A-Z0-9_]*$/.test(k)), 'env contains variable names, never credential values.');
31
+ requireThat(c.command === undefined || text(c.command), 'command must be an executable path.');
32
+ }
33
+ requireThat(Array.isArray(config.tasks) && config.tasks.length > 0 && config.tasks.length <= 30, 'provide 1–30 tasks.');
34
+ requireThat(new Set(config.tasks.map(t => t.id)).size === config.tasks.length, 'task IDs must be unique.');
35
+ for (const t of config.tasks) {
36
+ requireThat(id(t.id) && text(t.description) && t.description.length <= 4000 && text(t.prompt), 'each task needs id, description for Scout and a local execution prompt.');
37
+ safePath(t.fixture); safePath(t.grader);
38
+ }
39
+ requireThat(Array.isArray(config.variants) && config.variants.length > 0 && config.variants.length <= 5, 'provide 1–5 variants.');
40
+ requireThat(new Set(config.variants.map(v => v.id)).size === config.variants.length, 'variant IDs must be unique.');
41
+ for (const v of config.variants) {
42
+ requireThat(id(v.id) && Array.isArray(v.files) && typeof v.instructions === 'string', 'each variant needs id, files and instructions.');
43
+ v.files.forEach(safePath);
44
+ }
45
+ requireThat(Number.isInteger(config.repeats?.min) && Number.isInteger(config.repeats?.max) && config.repeats.min >= 3 && config.repeats.max <= 10 && config.repeats.min <= config.repeats.max, 'repeats must be between 3 and 10.');
46
+ requireThat(Number.isFinite(config.budget?.maxUsd) && config.budget.maxUsd >= 0 && Number.isFinite(config.budget.reservationUsd) && config.budget.reservationUsd > 0, 'set nonnegative maxUsd and positive reservationUsd.');
47
+ requireThat(Number.isInteger(config.timeoutSeconds) && config.timeoutSeconds >= 1 && config.timeoutSeconds <= 1800, 'timeoutSeconds must be 1–1800.');
48
+ if (config.scoutURL !== undefined) {
49
+ const url = new URL(config.scoutURL);
50
+ requireThat(!url.username && !url.password && (url.protocol === 'https:' || url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)), 'scoutURL requires HTTPS, or HTTP on loopback for development.');
51
+ }
52
+ return config;
53
+ }
54
+ export async function loadConfig(root) { return validateConfig(await readJSON(join(root, configPath))); }
55
+
56
+ // Snapshot exact bytes before executing; edits during a run belong to the next revision.
57
+ export async function filesAt(root, paths) {
58
+ const files = Object.create(null); let bytes = 0; const canonicalRoot = await realpath(root);
59
+ async function visit(path) {
60
+ safePath(path);
61
+ const full = resolve(root, path), info = await lstat(full);
62
+ if (info.isSymbolicLink() || relative(canonicalRoot, await realpath(full)).startsWith('..')) throw new Error(`Eval refuses symlink/outside path: ${path}`);
63
+ if (info.isDirectory()) {
64
+ for (const name of (await readdir(full)).sort()) await visit(join(path, name));
65
+ } else if (info.isFile()) {
66
+ bytes += info.size;
67
+ if (bytes > 25_000_000) throw new Error('Eval fixture/source snapshot exceeds 25 MB. Select smaller paths.');
68
+ files[path] = { data: (await readFile(full)).toString('base64'), executable: !!(info.mode & 0o111) };
69
+ } else throw new Error(`Eval requires regular files: ${path}`);
70
+ }
71
+ for (const path of paths) await visit(path);
72
+ return files;
73
+ }
74
+ export async function snapshot(root, config) {
75
+ const tasks = [];
76
+ for (const task of config.tasks) {
77
+ if (!(await lstat(join(root, task.fixture))).isDirectory() || !(await lstat(join(root, task.grader))).isFile()) throw new Error(`${task.id}: fixture must be a directory and grader a standalone file.`);
78
+ tasks.push({ ...task, fixtureFiles: await filesAt(root, [task.fixture]), graderFiles: await filesAt(root, [task.grader]) });
79
+ }
80
+ const variants = [];
81
+ for (const variant of config.variants) variants.push({ ...variant, sourceFiles: await filesAt(root, variant.files) });
82
+ for (const task of tasks) for (const variant of variants) {
83
+ const fixtureNames = new Set(Object.keys(task.fixtureFiles).map(path => relative(task.fixture, path)));
84
+ if (Object.keys(variant.sourceFiles).some(path => fixtureNames.has(path))) throw new Error(`${variant.id} overwrites a fixture file for ${task.id}. Keep CLI files and task inputs separate.`);
85
+ }
86
+ const compatibility = digest({ tasks, connections: config.connections, repeats: config.repeats, variantIds: variants.map(v => v.id), timeoutSeconds: config.timeoutSeconds });
87
+ return { tasks, variants, compatibility, revision: digest({ tasks, variants, config }) };
88
+ }
89
+ export async function materialize(files, dest, strip = '') {
90
+ for (const [path, file] of Object.entries(files)) {
91
+ const target = join(dest, strip ? relative(strip, path) : path);
92
+ await mkdir(dirname(target), { recursive: true });
93
+ await writeFile(target, Buffer.from(file.data, 'base64'), { mode: file.executable ? 0o700 : 0o600 });
94
+ }
95
+ }
96
+
97
+ export const starter = {
98
+ version: 1, scope: null, connections: [],
99
+ tasks: [{ id: 'json', description: 'Extract a customer name and item count from text into a strict JSON object.', prompt: 'Read request.txt. Return only JSON with keys name (string) and count (number).', fixture: 'eval/fixtures/json', grader: 'eval/grade.mjs' }],
100
+ variants: [{ id: 'bare', files: [], instructions: '' }, { id: 'cli', files: ['eval/tool.mjs'], instructions: 'You can use node eval/tool.mjs request.txt to extract the fields.' }],
101
+ repeats: { min: 3, max: 10 }, budget: { maxUsd: 0, reservationUsd: 0.25 }, timeoutSeconds: 180,
102
+ };
@@ -0,0 +1,44 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ // No shell interpolation. Bound both time and output, and stop descendants too.
4
+ export function execute(command, args, { cwd, env = process.env, input = '', timeoutMs = 180_000, signal } = {}) {
5
+ return new Promise(resolve => {
6
+ const start = performance.now();
7
+ let stdout = '', stderr = '', bytes = 0, reason = null, escalation;
8
+ const child = spawn(command, args, { cwd, env, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
9
+ const kill = sig => {
10
+ try {
11
+ if (process.platform === 'win32') { const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); killer.on('error', () => child.kill(sig)); }
12
+ else process.kill(-child.pid, sig);
13
+ } catch {}
14
+ };
15
+ const stop = why => { if (reason) return; reason = why; kill('SIGTERM'); escalation = setTimeout(() => kill('SIGKILL'), 500); };
16
+ const abort = () => stop('cancelled');
17
+ signal?.addEventListener('abort', abort, { once: true });
18
+ if (signal?.aborted) abort();
19
+ const timer = setTimeout(() => stop('timeout'), timeoutMs);
20
+ const collect = (chunk, which) => {
21
+ bytes += chunk.length;
22
+ if (bytes > 4_000_000) return stop('output_limit');
23
+ if (which === 'out') stdout += chunk; else stderr += chunk;
24
+ };
25
+ child.stdout.on('data', chunk => collect(chunk, 'out'));
26
+ child.stderr.on('data', chunk => collect(chunk, 'err'));
27
+ child.on('error', () => { reason = 'spawn_failed'; });
28
+ child.stdin.on('error', () => {});
29
+ child.stdin.end(input);
30
+ child.on('close', code => {
31
+ clearTimeout(timer); clearTimeout(escalation); signal?.removeEventListener('abort', abort);
32
+ resolve({ code, reason, stdout, stderr, seconds: (performance.now() - start) / 1000 });
33
+ });
34
+ });
35
+ }
36
+
37
+ export function agentEnvironment(names = []) {
38
+ const result = {};
39
+ for (const key of ['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'TERM', ...names]) {
40
+ if (process.env[key] !== undefined) result[key] = process.env[key];
41
+ }
42
+ result.OPENMERIT_EVAL = '1';
43
+ return result;
44
+ }
@@ -0,0 +1,51 @@
1
+ const mean = values => values.length ? values.reduce((a, b) => a + b, 0) / values.length : null;
2
+ const median = values => { const sorted = [...values].sort((a, b) => a - b); return sorted.length ? (sorted[Math.floor((sorted.length - 1) / 2)] + sorted[Math.floor(sorted.length / 2)]) / 2 : null; };
3
+ const completeMean = values => values.every(v => v !== null) ? mean(values) : null;
4
+ export function summarize(rows) {
5
+ const groups = new Map();
6
+ for (const row of rows) {
7
+ const key = [row.task, row.connection, row.variant].join('/');
8
+ if (!groups.has(key)) groups.set(key, []); groups.get(key).push(row);
9
+ }
10
+ return [...groups].map(([key, runs]) => ({
11
+ key, task: runs[0].task, connection: runs[0].connection, variant: runs[0].variant, runs: runs.length,
12
+ quality: completeMean(runs.map(r => r.quality)), failures: runs.filter(r => r.status !== 'ok').length,
13
+ qualityRange: runs.every(r => r.quality !== null) ? [Math.min(...runs.map(r => r.quality)), Math.max(...runs.map(r => r.quality))] : null,
14
+ inputTokens: completeMean(runs.map(r => r.usage?.input ?? null)), outputTokens: completeMean(runs.map(r => r.usage?.output ?? null)),
15
+ reportedCostUsd: runs.filter(r => r.costUsd !== null).reduce((s, r) => s + r.costUsd, 0), unknownCosts: runs.filter(r => r.costUsd === null).length,
16
+ meanCostUsd: completeMean(runs.map(r => r.costUsd)), medianSeconds: median(runs.map(r => r.seconds)),
17
+ }));
18
+ }
19
+ export function isVolatile(rows) {
20
+ const spread = values => {
21
+ if (values.length < 3 || values.some(v => v === null)) return false;
22
+ const avg = mean(values);
23
+ return avg > 0 && Math.sqrt(mean(values.map(v => (v - avg) ** 2))) / avg > .25;
24
+ };
25
+ for (const group of summarize(rows)) {
26
+ const runs = rows.filter(r => `${r.task}/${r.connection}/${r.variant}` === group.key);
27
+ if (group.qualityRange && group.qualityRange[1] - group.qualityRange[0] > .05 || runs.some(r => r.status !== 'ok') || spread(runs.map(r => r.seconds)) || spread(runs.map(r => r.costUsd)) || spread(runs.map(r => r.usage?.input ?? null))) return true;
28
+ }
29
+ return false;
30
+ }
31
+ export function compareReports(current, previous) {
32
+ if (!previous || previous.compatibility !== current.compatibility || previous.planHash !== current.planHash || previous.status !== 'complete' || current.status !== 'complete') return null;
33
+ return current.summary.map(now => {
34
+ const before = previous.summary.find(row => row.key === now.key);
35
+ if (!before) return null;
36
+ const delta = key => now[key] === null || before[key] === null ? null : now[key] - before[key];
37
+ return { key: now.key, previousRuns: before.runs, runs: now.runs, qualityDelta: delta('quality'), inputTokensDelta: delta('inputTokens'), outputTokensDelta: delta('outputTokens'), meanCostUsdDelta: delta('meanCostUsd'), medianSecondsDelta: delta('medianSeconds') };
38
+ }).filter(Boolean);
39
+ }
40
+ const fmt = (v, digits = 2) => v === null || v === undefined ? '?' : v.toFixed(digits);
41
+ export function formatReport(report) {
42
+ const lines = [`OpenMerit eval · ${report.status} · ${report.revision.slice(0, 12)}`, '', 'Task / connection / variant | runs | quality | input / output tokens | reported $/run | seconds (median)'];
43
+ for (const row of report.summary) lines.push(`${row.key} | ${row.runs} | ${fmt(row.quality === null ? null : row.quality * 100, 1)}% | ${fmt(row.inputTokens, 0)} / ${fmt(row.outputTokens, 0)} | ${fmt(row.meanCostUsd, 5)} | ${fmt(row.medianSeconds)}`);
44
+ if (report.comparison) {
45
+ lines.push('', 'Change from the previous compatible revision (negative tokens/cost/time is lower):');
46
+ for (const row of report.comparison) lines.push(`${row.key}: quality ${fmt(row.qualityDelta === null ? null : row.qualityDelta * 100, 1)} pp; tokens ${fmt(row.inputTokensDelta, 0)} / ${fmt(row.outputTokensDelta, 0)}; $ ${fmt(row.meanCostUsdDelta, 5)}; seconds ${fmt(row.medianSecondsDelta)}`);
47
+ } else lines.push('', 'No complete, compatible previous revision for comparison.');
48
+ lines.push('', '3–10 repetitions measure variability, not statistical certainty. ? means unavailable.', 'Costs are agent-reported estimates, not reconciled bills. Compare agents as different harnesses.');
49
+ if (report.reason) lines.push(`Stopped: ${report.reason}`);
50
+ return lines.join('\n');
51
+ }