openmerit 0.1.12 → 0.3.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 (108) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +11 -27
  3. package/dist/pi/scout-extension.mjs +23 -10
  4. package/dist/terminal/scout-update.json +1 -1
  5. package/dist/terminal/src/browser.mjs +2 -4
  6. package/dist/terminal/src/cli.mjs +6 -56
  7. package/dist/terminal/src/install.mjs +3 -2
  8. package/dist/terminal/src/scout-instructions.js +23 -10
  9. package/dist/terminal/src/scout-pi.js +1 -1
  10. package/dist/terminal/src/scout.js +181 -0
  11. package/dist/terminal/src/telemetry.mjs +8 -60
  12. package/docs/agent-evaluations.md +8 -148
  13. package/docs/commands.md +4 -86
  14. package/docs/getting-started.md +2 -16
  15. package/docs/index.md +4 -27
  16. package/docs/inference-terminal.md +13 -655
  17. package/docs/pi-extension.md +1 -1
  18. package/docs/roadmap.md +4 -17
  19. package/docs/scout.md +34 -251
  20. package/docs/security.md +8 -33
  21. package/docs/testing.md +7 -47
  22. package/docs/troubleshooting.md +3 -3
  23. package/package.json +13 -46
  24. package/dist/core/src/index.d.ts +0 -101
  25. package/dist/core/src/index.js +0 -1649
  26. package/dist/core/src/store.d.ts +0 -35
  27. package/dist/core/src/store.js +0 -102
  28. package/dist/eval/src/adapters.mjs +0 -112
  29. package/dist/eval/src/cli.mjs +0 -151
  30. package/dist/eval/src/config.mjs +0 -108
  31. package/dist/eval/src/process.mjs +0 -44
  32. package/dist/eval/src/report.mjs +0 -59
  33. package/dist/eval/src/runner.mjs +0 -123
  34. package/dist/eval/src/scout.mjs +0 -48
  35. package/dist/protocol/src/index.d.ts +0 -484
  36. package/dist/protocol/src/index.js +0 -47
  37. package/dist/protocol/src/schemas.d.ts +0 -576
  38. package/dist/protocol/src/schemas.js +0 -280
  39. package/dist/terminal/public/app.js +0 -298
  40. package/dist/terminal/public/brands/anthropic.png +0 -0
  41. package/dist/terminal/public/brands/baai.png +0 -0
  42. package/dist/terminal/public/brands/baseten.png +0 -0
  43. package/dist/terminal/public/brands/cerebras.png +0 -0
  44. package/dist/terminal/public/brands/cohere.png +0 -0
  45. package/dist/terminal/public/brands/deepseek.ico +0 -0
  46. package/dist/terminal/public/brands/google.png +0 -0
  47. package/dist/terminal/public/brands/groq.ico +0 -0
  48. package/dist/terminal/public/brands/huggingface.svg +0 -8
  49. package/dist/terminal/public/brands/lm-studio.png +0 -0
  50. package/dist/terminal/public/brands/meta.ico +0 -0
  51. package/dist/terminal/public/brands/mistral.png +0 -0
  52. package/dist/terminal/public/brands/nomic.png +0 -0
  53. package/dist/terminal/public/brands/ollama.png +0 -0
  54. package/dist/terminal/public/brands/openai.png +0 -0
  55. package/dist/terminal/public/brands/openrouter.png +0 -0
  56. package/dist/terminal/public/brands/qwen.png +0 -0
  57. package/dist/terminal/public/brands/vllm.ico +0 -0
  58. package/dist/terminal/public/brands/vllm.png +0 -0
  59. package/dist/terminal/public/favicon.svg +0 -1
  60. package/dist/terminal/public/flow.css +0 -1
  61. package/dist/terminal/public/flow.js +0 -770
  62. package/dist/terminal/public/index.html +0 -22
  63. package/dist/terminal/public/styles.css +0 -1046
  64. package/dist/terminal/src/activity-merge.mjs +0 -66
  65. package/dist/terminal/src/collect.mjs +0 -370
  66. package/dist/terminal/src/coverage.mjs +0 -33
  67. package/dist/terminal/src/demo-policy.mjs +0 -205
  68. package/dist/terminal/src/discovery.mjs +0 -99
  69. package/dist/terminal/src/enterprise-sample.mjs +0 -151
  70. package/dist/terminal/src/hardware.mjs +0 -57
  71. package/dist/terminal/src/history.mjs +0 -32
  72. package/dist/terminal/src/log-continuity.mjs +0 -26
  73. package/dist/terminal/src/project-activity.mjs +0 -157
  74. package/dist/terminal/src/repository-background.mjs +0 -37
  75. package/dist/terminal/src/repository-index.mjs +0 -267
  76. package/dist/terminal/src/repository-policy.mjs +0 -3
  77. package/dist/terminal/src/repository-syntax.mjs +0 -236
  78. package/dist/terminal/src/repository-worker.mjs +0 -15
  79. package/dist/terminal/src/sample.mjs +0 -171
  80. package/dist/terminal/src/scout-credentials.d.ts +0 -12
  81. package/dist/terminal/src/scout-credentials.ts +0 -44
  82. package/dist/terminal/src/scout-instructions.d.ts +0 -5
  83. package/dist/terminal/src/scout-instructions.ts +0 -106
  84. package/dist/terminal/src/scout-pi.d.ts +0 -44
  85. package/dist/terminal/src/scout-pi.ts +0 -76
  86. package/dist/terminal/src/server.mjs +0 -95
  87. package/dist/terminal/src/service-registry.mjs +0 -78
  88. package/dist/terminal/src/services.mjs +0 -44
  89. package/dist/terminal/src/topology.mjs +0 -30
  90. package/dist/terminal/src/updates.d.ts +0 -62
  91. package/dist/terminal/src/updates.ts +0 -313
  92. package/docs/adapter-guide.md +0 -191
  93. package/docs/architecture.md +0 -61
  94. package/docs/automation.md +0 -76
  95. package/docs/budgets.md +0 -39
  96. package/docs/demo-backfill.md +0 -31
  97. package/docs/demo-fieldkit.md +0 -49
  98. package/docs/demo-placement.md +0 -32
  99. package/docs/demo-spam.md +0 -17
  100. package/docs/demo-support.md +0 -44
  101. package/docs/demo.md +0 -74
  102. package/docs/first-trial.md +0 -62
  103. package/docs/interactive-demo.md +0 -118
  104. package/docs/lifecycle.md +0 -32
  105. package/docs/metrics-and-evidence.md +0 -48
  106. package/docs/operations.md +0 -40
  107. package/docs/pareto-spec.md +0 -76
  108. package/docs/ux-reference.md +0 -32
@@ -1,35 +0,0 @@
1
- import type { EvidenceManifest, OpenMeritAuditEvent, OpenMeritProjectConfig, OpenMeritProjectState } from "../../protocol/src/index.ts";
2
- export declare const OPENMERIT_DIRECTORY = ".openmerit";
3
- export interface OpenMeritStore {
4
- initialize(config: OpenMeritProjectConfig, state: OpenMeritProjectState): Promise<void>;
5
- readConfig(): Promise<OpenMeritProjectConfig | undefined>;
6
- writeConfig(config: OpenMeritProjectConfig): Promise<void>;
7
- readState(): Promise<OpenMeritProjectState | undefined>;
8
- writeState(state: OpenMeritProjectState): Promise<void>;
9
- appendEvent(event: OpenMeritAuditEvent): Promise<void>;
10
- writeEvidenceManifest(manifest: EvidenceManifest): Promise<string>;
11
- }
12
- export interface AuditEventExporter {
13
- readonly id: string;
14
- export(event: OpenMeritAuditEvent): Promise<void>;
15
- }
16
- export interface ProjectStoreOptions {
17
- readonly eventExporters?: readonly AuditEventExporter[];
18
- }
19
- export declare class ProjectStore implements OpenMeritStore {
20
- readonly directory: string;
21
- readonly configPath: string;
22
- readonly statePath: string;
23
- readonly eventsPath: string;
24
- readonly exportErrorsPath: string;
25
- readonly evidenceDirectory: string;
26
- private readonly eventExporters;
27
- constructor(projectRoot: string, options?: ProjectStoreOptions);
28
- initialize(config: OpenMeritProjectConfig, state: OpenMeritProjectState): Promise<void>;
29
- readConfig(): Promise<OpenMeritProjectConfig | undefined>;
30
- writeConfig(config: OpenMeritProjectConfig): Promise<void>;
31
- readState(): Promise<OpenMeritProjectState | undefined>;
32
- writeState(state: OpenMeritProjectState): Promise<void>;
33
- appendEvent(event: OpenMeritAuditEvent): Promise<void>;
34
- writeEvidenceManifest(manifest: EvidenceManifest): Promise<string>;
35
- }
@@ -1,102 +0,0 @@
1
- import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
- export const OPENMERIT_DIRECTORY = ".openmerit";
4
- export class ProjectStore {
5
- directory;
6
- configPath;
7
- statePath;
8
- eventsPath;
9
- exportErrorsPath;
10
- evidenceDirectory;
11
- eventExporters;
12
- constructor(projectRoot, options = {}) {
13
- this.directory = join(projectRoot, OPENMERIT_DIRECTORY);
14
- this.configPath = join(this.directory, "config.json");
15
- this.statePath = join(this.directory, "state.json");
16
- this.eventsPath = join(this.directory, "events.jsonl");
17
- this.exportErrorsPath = join(this.directory, "audit-export-errors.jsonl");
18
- this.evidenceDirectory = join(this.directory, "evidence");
19
- this.eventExporters = options.eventExporters ?? [];
20
- }
21
- async initialize(config, state) {
22
- await mkdir(this.evidenceDirectory, { recursive: true });
23
- await this.writeConfig(config);
24
- await this.writeState(state);
25
- }
26
- async readConfig() {
27
- return readJsonIfPresent(this.configPath);
28
- }
29
- async writeConfig(config) {
30
- await atomicWriteJson(this.configPath, config);
31
- }
32
- async readState() {
33
- return readJsonIfPresent(this.statePath);
34
- }
35
- async writeState(state) {
36
- await atomicWriteJson(this.statePath, state);
37
- }
38
- async appendEvent(event) {
39
- await mkdir(dirname(this.eventsPath), { recursive: true });
40
- const safeEvent = redactSensitive(event);
41
- await appendFile(this.eventsPath, `${JSON.stringify(safeEvent)}\n`, { encoding: "utf8", mode: 0o600 });
42
- const results = await Promise.allSettled(this.eventExporters.map((exporter) => exporter.export(safeEvent)));
43
- for (const [index, result] of results.entries()) {
44
- if (result.status === "fulfilled")
45
- continue;
46
- await appendFile(this.exportErrorsPath, `${JSON.stringify({
47
- occurredAt: new Date().toISOString(),
48
- eventId: safeEvent.id,
49
- exporterId: this.eventExporters[index]?.id ?? "unknown",
50
- error: safeErrorMessage(result.reason),
51
- })}\n`, { encoding: "utf8", mode: 0o600 });
52
- }
53
- }
54
- async writeEvidenceManifest(manifest) {
55
- const path = join(this.evidenceDirectory, `${safeFileComponent(manifest.id)}.json`);
56
- await atomicWriteJson(path, redactSensitive(manifest));
57
- return path;
58
- }
59
- }
60
- const SENSITIVE_KEY = /(?:authorization|api[_-]?key|token|secret|password|cookie)/i;
61
- const SECRET_VALUE = /\b(?:sk-[a-zA-Z0-9_-]{12,}|apikey_[a-zA-Z0-9_-]{12,})\b/g;
62
- function redactSensitive(value, key) {
63
- if (key && SENSITIVE_KEY.test(key))
64
- return "[REDACTED]";
65
- if (typeof value === "string")
66
- return value.replaceAll(SECRET_VALUE, "[REDACTED]");
67
- if (Array.isArray(value))
68
- return value.map((item) => redactSensitive(item));
69
- if (value && typeof value === "object") {
70
- return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [
71
- childKey,
72
- redactSensitive(child, childKey),
73
- ]));
74
- }
75
- return value;
76
- }
77
- function safeErrorMessage(reason) {
78
- const message = reason instanceof Error ? reason.message : String(reason);
79
- return redactSensitive(message);
80
- }
81
- function safeFileComponent(value) {
82
- const safe = value.replaceAll(/[^a-zA-Z0-9._-]/g, "-");
83
- if (!safe || safe === "." || safe === "..")
84
- throw new Error("Evidence manifest ID is not file-safe.");
85
- return safe;
86
- }
87
- async function readJsonIfPresent(path) {
88
- try {
89
- return JSON.parse(await readFile(path, "utf8"));
90
- }
91
- catch (error) {
92
- if (error.code === "ENOENT")
93
- return undefined;
94
- throw error;
95
- }
96
- }
97
- async function atomicWriteJson(path, value) {
98
- await mkdir(dirname(path), { recursive: true });
99
- const temporaryPath = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
100
- await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
101
- await rename(temporaryPath, path);
102
- }
@@ -1,112 +0,0 @@
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
- const maximum = values => values.some(v => v !== null) ? Math.max(...values.filter(v => v !== null)) : null;
6
- export const agents = ['claude', 'codex', 'opencode'];
7
-
8
- export function connectionFailure(text) {
9
- if (/budget_exhausted|error_max_budget_usd|(?:maximum|exceeded|exhausted|insufficient).{0,25}(?:budget|credits)|(?:budget|credits).{0,25}(?:exceeded|exhausted)/i.test(text)) return 'budget_exhausted';
10
- if (/(?:bwrap|bubblewrap|sandbox|landlock|seccomp|unshare|user namespace|uid_map).{0,160}(?:failed|not permitted|unavailable|denied)|failed to (?:start|initialize|create).{0,40}sandbox/i.test(text)) return 'sandbox_unavailable';
11
- if (/\b(?:HTTP\s*)?401\b.{0,30}(?:unauthorized|authentication)|unauthorized|authentication[_ -]?(?:error|required)|invalid api key|not logged in|please log in/i.test(text)) return 'authentication';
12
- if (/model.{0,100}(?:not supported|not found|not available|does not exist)|(?:unknown|invalid|unsupported) model|ProviderModelNotFoundError/i.test(text)) return 'model_unavailable';
13
- if (/rejected permission|permission.{0,30}(?:denied|rejected)|not allowed to use.{0,30}tool/i.test(text)) return 'permission_denied';
14
- return null;
15
- }
16
-
17
- function toolFailure(text) {
18
- const reason = connectionFailure(text);
19
- // A task's own HTTP/auth/budget errors are task outcomes, not evidence that
20
- // the coding agent's provider connection failed.
21
- return ['sandbox_unavailable', 'permission_denied'].includes(reason) ? reason : null;
22
- }
23
-
24
- export function invocation(connection, reservationUsd, unlimitedBudget = false) {
25
- const model = connection.model;
26
- 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', '', ...(unlimitedBudget ? [] : ['--max-budget-usd', String(reservationUsd)])];
27
- if (connection.agent === 'codex') return ['exec', '--json', '--model', model, '--sandbox', 'workspace-write', '--skip-git-repo-check', '--ephemeral', '-'];
28
- if (connection.agent === 'opencode') return ['run', '--format', 'json', '--model', model, '--auto'];
29
- throw new Error('Unknown agent. Use claude, codex or opencode.');
30
- }
31
-
32
- export function parseOutput(agent, stdout) {
33
- let events;
34
- try { const parsed = JSON.parse(stdout); events = Array.isArray(parsed) ? parsed : [parsed]; }
35
- catch { events = stdout.split('\n').flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } }); }
36
- let output = '', usage = null, costUsd = null, partialCostUsd = null, completed = false, failed = false, failureReason = null;
37
- // Only native failure fields are evidence of a blocked execution. Task text,
38
- // successful tool output and model commentary can legitimately discuss errors.
39
- const errors = events.filter(e => ['error', 'turn.failed'].includes(e.type));
40
- for (const event of errors) failureReason ??= connectionFailure(JSON.stringify(event));
41
- const observedModels = new Set();
42
- if (agent === 'claude') {
43
- const result = events.findLast(e => e.type === 'result');
44
- if (result) {
45
- completed = result.subtype === 'success' && !result.is_error;
46
- failed = !completed; output = result.result ?? '';
47
- const u = result.usage;
48
- 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) };
49
- const models = Object.values(result.modelUsage ?? {});
50
- if (models.length) {
51
- const modelUsage = { input: sum(models.map(m => sum([number(m.inputTokens), number(m.cacheReadInputTokens ?? 0), number(m.cacheCreationInputTokens ?? 0)]))), output: sum(models.map(m => number(m.outputTokens))), cached: sum(models.map(m => number(m.cacheReadInputTokens ?? 0))) };
52
- // A budget stop can emit zero aggregate usage but nonzero per-model usage.
53
- usage = Object.fromEntries(Object.keys(modelUsage).map(k => [k, maximum([usage?.[k] ?? null, modelUsage[k]])]));
54
- }
55
- costUsd = maximum([number(result.total_cost_usd), sum(models.map(m => number(m.costUSD)))]);
56
- for (const model of Object.keys(result.modelUsage ?? {})) observedModels.add(model);
57
- if (failed) failureReason ??= connectionFailure(JSON.stringify({ subtype: result.subtype, terminal_reason: result.terminal_reason, errors: result.errors }));
58
- if (result.permission_denials?.length) failureReason ??= 'permission_denied';
59
- }
60
- } else if (agent === 'codex') {
61
- const turns = events.filter(e => e.type === 'turn.completed');
62
- completed = turns.length > 0;
63
- failed = events.some(e => ['turn.failed', 'error'].includes(e.type));
64
- output = events.findLast(e => e.type === 'item.completed' && e.item?.type === 'agent_message')?.item.text ?? '';
65
- 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))) };
66
- for (const event of events.filter(e => e.type === 'item.completed' && e.item?.type === 'command_execution' && (e.item.exit_code > 0 || e.item.status === 'failed'))) {
67
- failureReason ??= toolFailure(event.item.aggregated_output ?? '');
68
- }
69
- } else if (agent === 'opencode') {
70
- const steps = events.filter(e => e.type === 'step_finish');
71
- const finalStep = steps.findLast(e => ['stop', 'end_turn'].includes(e.part?.reason));
72
- completed = !!finalStep;
73
- failed = events.some(e => e.type === 'error');
74
- const texts = events.filter(e => e.type === 'text');
75
- 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 ?? '';
76
- if (steps.length) {
77
- 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))) };
78
- const costs = steps.map(e => number(e.part?.cost));
79
- costUsd = costs.every(c => c !== null && c > 0) ? sum(costs) : null;
80
- if (costUsd === null) partialCostUsd = sum(costs.filter(c => c !== null && c > 0));
81
- }
82
- for (const event of events.filter(e => e.type === 'tool_use' && e.part?.state?.status === 'error')) failureReason ??= toolFailure(event.part.state.error ?? '');
83
- }
84
- // Native zero prices can mean missing pricing metadata, not free inference.
85
- return { output, usage, costUsd: costUsd > 0 ? costUsd : null, partialCostUsd, completed: completed && !failed, failureReason, observedModels: [...observedModels] };
86
- }
87
-
88
- export async function preflight(connection, { cwd, executeCommand = execute, platform = process.platform } = {}) {
89
- const command = connection.command ?? connection.agent;
90
- const options = { cwd, timeoutMs: 10_000, env: agentEnvironment(connection.env) };
91
- const version = await executeCommand(command, ['--version'], options);
92
- const help = await executeCommand(command, connection.agent === 'codex' ? ['exec', '--help'] : connection.agent === 'opencode' ? ['run', '--help'] : ['--help'], options);
93
- const flags = connection.agent === 'codex' ? ['--json', '--ephemeral', '--sandbox'] : connection.agent === 'claude' ? ['--output-format', '--no-session-persistence', '--max-budget-usd'] : ['--format', '--model', '--auto'];
94
- 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.`);
95
- if (connection.agent === 'codex' && platform === 'linux') {
96
- const sandboxHelp = await executeCommand(command, ['sandbox', '--help'], options);
97
- const legacy = /^\s+linux\s/m.test(sandboxHelp.stdout);
98
- const probe = await executeCommand(command, ['-c', 'sandbox_mode="workspace-write"', 'sandbox', ...(legacy ? ['linux'] : []), '--', process.execPath, '-e', 'process.stdout.write("openmerit-sandbox-ok")'], options);
99
- if (probe.code !== 0 || probe.reason || probe.stdout !== 'openmerit-sandbox-ok') {
100
- throw Object.assign(new Error('Codex sandbox could not run a local probe. Fix host sandbox support before evaluating; no model request was made.'), { failureReason: 'sandbox_unavailable' });
101
- }
102
- }
103
- return version.stdout.trim().slice(0, 160);
104
- }
105
-
106
- export async function runAgent(connection, prompt, options) {
107
- const result = await execute(connection.command ?? connection.agent, invocation(connection, options.reservationUsd, options.unlimitedBudget), { ...options, input: prompt, env: agentEnvironment(connection.env) });
108
- const parsed = parseOutput(connection.agent, result.stdout);
109
- const failureReason = parsed.failureReason ?? (result.code !== 0 ? connectionFailure(result.stderr) : null) ?? (['spawn_failed', 'cancelled', 'output_limit'].includes(result.reason) ? result.reason : null);
110
- const status = failureReason === 'budget_exhausted' ? 'budget_exhausted' : ['sandbox_unavailable', 'permission_denied', 'spawn_failed'].includes(failureReason) ? 'environment_error' : result.reason ?? (failureReason || result.code !== 0 || !parsed.completed ? 'agent_error' : 'ok');
111
- return { ...parsed, costUsd: status === 'ok' ? parsed.costUsd : null, partialCostUsd: status === 'ok' ? parsed.partialCostUsd : maximum([parsed.costUsd, parsed.partialCostUsd]), costBasis: parsed.costUsd === null && parsed.partialCostUsd === null ? 'unknown' : 'agent_estimate', seconds: result.seconds, status, failureReason };
112
- }
@@ -1,151 +0,0 @@
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] [--budget <USD|unlimited>]
15
- Choose scope and budget, then freeze Scout's shortlist
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
- Plan asks for $10, a custom amount, or no fixed cap; nothing is preselected.
25
- Saved choices and prior budget usage persist across reruns. --budget changes
26
- the total allowance, not the amount remaining. Reservations are automatic.
27
- Scout receives task descriptions and, for existing scope, model IDs only.
28
- Run/watch execute local agents and may incur charges. No background daemon.
29
- `;
30
-
31
- const instructions = `<!-- openmerit-eval:start -->
32
- ## OpenMerit agent evaluations
33
-
34
- When building or changing this project's CLI, tools or harness documentation, use
35
- \`openmerit eval\` to compare task performance. Skip this workflow when
36
- \`OPENMERIT_EVAL=1\` to avoid recursive evaluations.
37
-
38
- - Read .openmerit/eval.json and run \`openmerit eval doctor\`.
39
- - 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.
40
- - Then ask once: "What budget should OpenMerit use for this project's evaluations? 1. $10; 2. Custom amount (how much?); 3. No fixed cap." Pass the explicit answer as \`eval plan --budget 10\`, \`--budget <amount>\` or \`--budget unlimited\`, together with scope on first setup. Never choose a default or infer unlimited spending. Reuse an existing positive or unlimited budget unless the user changes it. The allowance is cumulative across reruns, not reset by edits or replanning. Reservations are internal; do not ask the user to calculate them. No fixed cap still has bounded tasks, 3–10 repetitions, timeouts and user-controlled watch; subsequent watched edits can incur further charges until stopped.
41
- - 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.
42
- - 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.
43
- - Confirm the suite and variants before enabling paid runs, using the budget already selected above. Do not ask again for an unchanged authorized budget. 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.
44
- - 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.
45
- - 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.
46
- <!-- openmerit-eval:end -->`;
47
-
48
- export async function initialize(root) {
49
- // Refuse partial overwrite of an existing suite or someone's fixtures.
50
- const files = {
51
- [configPath]: JSON.stringify(starter, null, 2) + '\n',
52
- 'eval/fixtures/json/request.txt': 'Customer: Mira Chen\nItems: 7\n',
53
- '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`,
54
- '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`,
55
- };
56
- for (const file of Object.keys(files)) {
57
- 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; }
58
- }
59
- for (const [file, body] of Object.entries(files)) { await mkdir(join(root, file, '..'), { recursive: true }); await writeFile(join(root, file), body, { flag: 'wx' }); }
60
- for (const name of ['AGENTS.md', 'CLAUDE.md']) {
61
- let body = ''; try { body = await readFile(join(root, name), 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
62
- if (!body.includes('<!-- openmerit-eval:start -->')) await writeFile(join(root, name), `${body}${body ? '\n\n' : ''}${instructions}\n`);
63
- }
64
- let ignore = ''; try { ignore = await readFile(join(root, '.gitignore'), 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
65
- if (!ignore.split('\n').includes('/.openmerit/eval/')) await writeFile(join(root, '.gitignore'), `${ignore}${ignore.endsWith('\n') || !ignore ? '' : '\n'}/.openmerit/eval/\n`);
66
- }
67
-
68
- export function parseBudget(value) {
69
- const input = value.trim();
70
- if (input === 'unlimited') return input;
71
- const amount = input.replace(/^\$/, '');
72
- if (!/^(?:\d+(?:\.\d{1,2})?|\.\d{1,2})$/.test(amount) || Number(amount) <= 0 || Number(amount) > Number.MAX_SAFE_INTEGER / 100) throw new Error('Enter a positive USD amount with at most two decimal places, or "unlimited" for no fixed cap.');
73
- return Number(amount);
74
- }
75
-
76
- // ask is supplied only for an interactive terminal. Coding agents pass explicit
77
- // flags after asking the user; EOF, blank input and missing flags never consent.
78
- export async function choosePlanOptions(config, { scope, budget, ask, tell = () => {} } = {}) {
79
- const next = structuredClone(config);
80
- if (scope) next.scope = scope;
81
- while (!['existing', 'all'].includes(next.scope)) {
82
- if (!ask) throw new Error('Choose a scope with eval plan --scope existing or --scope all, then choose --budget <USD|unlimited>.');
83
- const answer = (await ask('Use existing project connections or all models Scout indexes? [existing/all] ')).trim();
84
- if (['existing', 'all'].includes(answer)) next.scope = answer;
85
- else tell('Choose existing or all.');
86
- }
87
- if (budget !== undefined) next.budget = { maxUsd: parseBudget(budget), reservationUsd: 'auto' };
88
- while (next.budget.maxUsd === 0) {
89
- if (!ask) throw new Error('Choose an evaluation budget after model scope: --budget 10, --budget <USD>, or --budget unlimited. No amount is selected automatically.');
90
- const choice = (await ask('What budget should OpenMerit use for this project’s evaluations?\n 1. $10\n 2. Custom amount\n 3. No fixed cap (watched changes can incur further charges until stopped)\nChoose [1/2/3]: ')).trim();
91
- if (choice === '1') next.budget = { maxUsd: 10, reservationUsd: 'auto' };
92
- else if (choice === '3') next.budget = { maxUsd: 'unlimited', reservationUsd: 'auto' };
93
- else if (choice === '2') {
94
- while (next.budget.maxUsd === 0) {
95
- const amount = await ask('How much in USD? ');
96
- try {
97
- const parsed = parseBudget(amount);
98
- if (parsed === 'unlimited') throw new Error('Enter a custom USD amount.');
99
- next.budget = { maxUsd: parsed, reservationUsd: 'auto' };
100
- } catch { tell('Enter a positive USD amount with at most two decimal places.'); }
101
- }
102
- } else tell('Choose 1, 2 or 3. Nothing is selected automatically.');
103
- }
104
- return next;
105
- }
106
-
107
- export async function main(args) {
108
- if (!args.length || args.includes('--help') || args.includes('-h')) { console.log(help); return; }
109
- const action = args.shift(); let root = process.cwd(), scope, budget, json = false;
110
- for (let i = 0; i < args.length; i++) {
111
- if (args[i] === '--path' && args[i + 1]) root = resolve(args[++i]);
112
- else if (args[i] === '--scope' && ['existing', 'all'].includes(args[i + 1])) scope = args[++i];
113
- else if (args[i] === '--budget' && args[i + 1] && budget === undefined) { budget = args[++i]; parseBudget(budget); }
114
- else if (args[i] === '--json') json = true;
115
- else throw new Error(`Unknown eval argument: ${args[i]}`);
116
- }
117
- if (scope && action !== 'plan') throw new Error('--scope belongs to eval plan.');
118
- if (budget !== undefined && action !== 'plan') throw new Error('--budget belongs to eval plan.');
119
- 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; }
120
- if (action === 'doctor') {
121
- const results = [];
122
- 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 }); } }
123
- console.log(JSON.stringify(results, null, 2)); return;
124
- }
125
- if (action === 'report') { const report = await readJSON(join(root, statePath, 'latest.json')); console.log(json ? JSON.stringify(report, null, 2) : formatReport(report)); return; }
126
- if (!['plan', 'run', 'watch'].includes(action)) throw new Error('Unknown eval command. Run openmerit eval --help.');
127
- const unlock = await acquireLock(root);
128
- const controller = new AbortController(), stop = () => controller.abort();
129
- process.once('SIGINT', stop); process.once('SIGTERM', stop);
130
- try {
131
- if (action === 'plan') {
132
- const saved = await loadConfig(root);
133
- const rl = process.stdin.isTTY && !json ? createInterface({ input: process.stdin, output: process.stdout }) : null;
134
- const inputClosed = new AbortController();
135
- rl?.once('close', () => inputClosed.abort());
136
- let config;
137
- try { config = await choosePlanOptions(saved, { scope, budget, ask: rl ? question => rl.question(question, { signal: AbortSignal.any([controller.signal, inputClosed.signal]) }) : undefined, tell: message => console.log(message) }); }
138
- finally { rl?.close(); }
139
- // Preserve explicit choices even if Scout is temporarily unavailable.
140
- await saveJSON(join(root, configPath), config);
141
- const plan = await makePlan(config);
142
- await savePlan(root, plan);
143
- console.log(JSON.stringify(plan, null, 2));
144
- return;
145
- }
146
- const onReport = report => { console.log(json ? JSON.stringify(report) : formatReport(report)); if (report.status !== 'complete') process.exitCode = 1; };
147
- const onProgress = json ? () => {} : row => process.stderr.write(`${row.task} · ${row.connection} · ${row.variant} · ${row.round}: ${row.status}\n`);
148
- if (action === 'run') onReport(await evaluate(root, { signal: controller.signal, onProgress }));
149
- else { if (!json) console.log('Watching selected files. Ctrl+C stops; budget usage persists.'); await watch(root, { signal: controller.signal, onReport, onProgress }); }
150
- } finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); await unlock(); }
151
- }
@@ -1,108 +0,0 @@
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((config.budget?.maxUsd === 'unlimited' || Number.isFinite(config.budget?.maxUsd) && config.budget.maxUsd >= 0) && (config.budget.reservationUsd === 'auto' || Number.isFinite(config.budget.reservationUsd) && config.budget.reservationUsd > 0), 'set nonnegative maxUsd or explicit "unlimited", and reservationUsd to "auto" or a positive amount.');
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
- export function reservationFor(config, plan) {
57
- if (config.budget.reservationUsd !== 'auto') return config.budget.reservationUsd;
58
- const attempts = plan.tasks.reduce((total, task) => total + task.candidates.reduce((n, model) => n + model.connections.length, 0), 0) * config.variants.length * config.repeats.min;
59
- return config.budget.maxUsd === 'unlimited' || config.budget.maxUsd === 0 ? .25 : Math.min(.25, config.budget.maxUsd / Math.max(1, attempts));
60
- }
61
-
62
- // Snapshot exact bytes before executing; edits during a run belong to the next revision.
63
- export async function filesAt(root, paths) {
64
- const files = Object.create(null); let bytes = 0; const canonicalRoot = await realpath(root);
65
- async function visit(path) {
66
- safePath(path);
67
- const full = resolve(root, path), info = await lstat(full);
68
- if (info.isSymbolicLink() || relative(canonicalRoot, await realpath(full)).startsWith('..')) throw new Error(`Eval refuses symlink/outside path: ${path}`);
69
- if (info.isDirectory()) {
70
- for (const name of (await readdir(full)).sort()) await visit(join(path, name));
71
- } else if (info.isFile()) {
72
- bytes += info.size;
73
- if (bytes > 25_000_000) throw new Error('Eval fixture/source snapshot exceeds 25 MB. Select smaller paths.');
74
- files[path] = { data: (await readFile(full)).toString('base64'), executable: !!(info.mode & 0o111) };
75
- } else throw new Error(`Eval requires regular files: ${path}`);
76
- }
77
- for (const path of paths) await visit(path);
78
- return files;
79
- }
80
- export async function snapshot(root, config) {
81
- const tasks = [];
82
- for (const task of config.tasks) {
83
- 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.`);
84
- tasks.push({ ...task, fixtureFiles: await filesAt(root, [task.fixture]), graderFiles: await filesAt(root, [task.grader]) });
85
- }
86
- const variants = [];
87
- for (const variant of config.variants) variants.push({ ...variant, sourceFiles: await filesAt(root, variant.files) });
88
- for (const task of tasks) for (const variant of variants) {
89
- const fixtureNames = new Set(Object.keys(task.fixtureFiles).map(path => relative(task.fixture, path)));
90
- 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.`);
91
- }
92
- const compatibility = digest({ tasks, connections: config.connections, repeats: config.repeats, variantIds: variants.map(v => v.id), timeoutSeconds: config.timeoutSeconds });
93
- return { tasks, variants, compatibility, revision: digest({ tasks, variants, config }) };
94
- }
95
- export async function materialize(files, dest, strip = '') {
96
- for (const [path, file] of Object.entries(files)) {
97
- const target = join(dest, strip ? relative(strip, path) : path);
98
- await mkdir(dirname(target), { recursive: true });
99
- await writeFile(target, Buffer.from(file.data, 'base64'), { mode: file.executable ? 0o700 : 0o600 });
100
- }
101
- }
102
-
103
- export const starter = {
104
- version: 1, scope: null, connections: [],
105
- 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' }],
106
- 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.' }],
107
- repeats: { min: 3, max: 10 }, budget: { maxUsd: 0, reservationUsd: 'auto' }, timeoutSeconds: 180,
108
- };
@@ -1,44 +0,0 @@
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
- }
@@ -1,59 +0,0 @@
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' && r.quality !== null).length,
13
- blockedAttempts: runs.filter(r => r.failureReason).length, scoredRuns: runs.filter(r => r.quality !== null).length,
14
- failedTaskChecks: runs.filter(r => r.status === 'ok' && r.quality === 0).length,
15
- qualityRange: runs.every(r => r.quality !== null) ? [Math.min(...runs.map(r => r.quality)), Math.max(...runs.map(r => r.quality))] : null,
16
- inputTokens: completeMean(runs.map(r => r.usage?.input ?? null)), outputTokens: completeMean(runs.map(r => r.usage?.output ?? null)),
17
- reportedCostUsd: runs.some(r => r.costUsd !== null) ? runs.filter(r => r.costUsd !== null).reduce((s, r) => s + r.costUsd, 0) : null, unknownCosts: runs.filter(r => r.costUsd === null).length,
18
- partialReportedCostUsd: runs.reduce((s, r) => s + (r.partialCostUsd ?? 0), 0),
19
- meanCostUsd: completeMean(runs.map(r => r.costUsd)), medianSeconds: median(runs.map(r => r.seconds)),
20
- }));
21
- }
22
- export function isVolatile(rows) {
23
- const spread = values => {
24
- if (values.length < 3 || values.some(v => v === null)) return false;
25
- const avg = mean(values);
26
- return avg > 0 && Math.sqrt(mean(values.map(v => (v - avg) ** 2))) / avg > .25;
27
- };
28
- for (const group of summarize(rows)) {
29
- const runs = rows.filter(r => `${r.task}/${r.connection}/${r.variant}` === group.key);
30
- 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;
31
- }
32
- return false;
33
- }
34
- export function compareReports(current, previous) {
35
- if (!previous || previous.compatibility !== current.compatibility || previous.planHash !== current.planHash || previous.status !== 'complete' || current.status !== 'complete') return null;
36
- return current.summary.map(now => {
37
- const before = previous.summary.find(row => row.key === now.key);
38
- if (!before) return null;
39
- const delta = key => now[key] === null || before[key] === null ? null : now[key] - before[key];
40
- 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') };
41
- }).filter(Boolean);
42
- }
43
- const fmt = (v, digits = 2) => v === null || v === undefined ? '?' : v.toFixed(digits);
44
- export function formatReport(report) {
45
- const lines = [`OpenMerit eval · ${report.status} · ${report.revision.slice(0, 12)}`, '', 'Task / connection / variant | scored / attempts | blocked | quality | input / output tokens | estimated $/run | seconds (median)'];
46
- for (const row of report.summary) {
47
- lines.push(`${row.key} | ${row.scoredRuns ?? row.runs} / ${row.runs} | ${row.blockedAttempts ?? 0} | ${row.quality === null ? '?' : fmt(row.quality * 100, 1) + '%'} | ${fmt(row.inputTokens, 0)} / ${fmt(row.outputTokens, 0)} | ${fmt(row.meanCostUsd, 5)} | ${fmt(row.medianSeconds)}`);
48
- if (row.partialReportedCostUsd > 0) lines.push(` Partial cost estimate: $${fmt(row.partialReportedCostUsd, 5)}; full attempt cost unknown.`);
49
- }
50
- if (report.comparison) {
51
- lines.push('', 'Change from the previous compatible revision (negative tokens/cost/time is lower):');
52
- 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)}`);
53
- } else lines.push('', 'No complete, compatible previous revision for comparison.');
54
- 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.');
55
- if (report.budgetLimitUsd !== undefined) lines.push(report.budgetLimitUsd === 'unlimited' ? 'Budget: no fixed cap. Task/repetition limits and timeouts still apply; watched changes can incur further charges until stopped.' : `Budget: $${fmt(report.budgetLimitUsd)} cumulative across reruns.`);
56
- lines.push(`Reserved allowance used: $${fmt(report.budgetUsedUsd, 5)} (not billed spend). Blocked attempts are unscored.`);
57
- if (report.reason) lines.push(`Stopped: ${report.reason}`);
58
- return lines.join('\n');
59
- }