openmerit 0.2.0 → 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 (77) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +12 -25
  3. package/dist/terminal/scout-update.json +1 -1
  4. package/dist/terminal/src/browser.mjs +2 -4
  5. package/dist/terminal/src/cli.mjs +4 -56
  6. package/dist/terminal/src/install.mjs +2 -2
  7. package/dist/terminal/src/scout-pi.js +1 -1
  8. package/dist/terminal/src/scout.js +3 -3
  9. package/dist/terminal/src/telemetry.mjs +8 -60
  10. package/docs/agent-evaluations.md +8 -148
  11. package/docs/commands.md +3 -9
  12. package/docs/getting-started.md +2 -16
  13. package/docs/index.md +4 -18
  14. package/docs/inference-terminal.md +13 -594
  15. package/docs/roadmap.md +2 -2
  16. package/docs/scout.md +6 -2
  17. package/docs/security.md +5 -18
  18. package/docs/testing.md +4 -16
  19. package/docs/troubleshooting.md +2 -2
  20. package/package.json +10 -23
  21. package/dist/eval/src/adapters.mjs +0 -112
  22. package/dist/eval/src/cli.mjs +0 -151
  23. package/dist/eval/src/config.mjs +0 -108
  24. package/dist/eval/src/process.mjs +0 -44
  25. package/dist/eval/src/report.mjs +0 -59
  26. package/dist/eval/src/runner.mjs +0 -123
  27. package/dist/eval/src/scout.mjs +0 -48
  28. package/dist/terminal/public/app.js +0 -298
  29. package/dist/terminal/public/brands/anthropic.png +0 -0
  30. package/dist/terminal/public/brands/baai.png +0 -0
  31. package/dist/terminal/public/brands/baseten.png +0 -0
  32. package/dist/terminal/public/brands/cerebras.png +0 -0
  33. package/dist/terminal/public/brands/cohere.png +0 -0
  34. package/dist/terminal/public/brands/deepseek.ico +0 -0
  35. package/dist/terminal/public/brands/google.png +0 -0
  36. package/dist/terminal/public/brands/groq.ico +0 -0
  37. package/dist/terminal/public/brands/huggingface.svg +0 -8
  38. package/dist/terminal/public/brands/lm-studio.png +0 -0
  39. package/dist/terminal/public/brands/meta.ico +0 -0
  40. package/dist/terminal/public/brands/mistral.png +0 -0
  41. package/dist/terminal/public/brands/nomic.png +0 -0
  42. package/dist/terminal/public/brands/ollama.png +0 -0
  43. package/dist/terminal/public/brands/openai.png +0 -0
  44. package/dist/terminal/public/brands/openrouter.png +0 -0
  45. package/dist/terminal/public/brands/qwen.png +0 -0
  46. package/dist/terminal/public/brands/vllm.png +0 -0
  47. package/dist/terminal/public/favicon.svg +0 -1
  48. package/dist/terminal/public/index.html +0 -22
  49. package/dist/terminal/public/styles.css +0 -921
  50. package/dist/terminal/src/activity-merge.mjs +0 -66
  51. package/dist/terminal/src/collect.mjs +0 -370
  52. package/dist/terminal/src/coverage.mjs +0 -33
  53. package/dist/terminal/src/discovery.mjs +0 -99
  54. package/dist/terminal/src/hardware.mjs +0 -57
  55. package/dist/terminal/src/history.mjs +0 -32
  56. package/dist/terminal/src/log-continuity.mjs +0 -26
  57. package/dist/terminal/src/project-activity.mjs +0 -157
  58. package/dist/terminal/src/repository-background.mjs +0 -37
  59. package/dist/terminal/src/repository-index.mjs +0 -267
  60. package/dist/terminal/src/repository-policy.mjs +0 -3
  61. package/dist/terminal/src/repository-syntax.mjs +0 -236
  62. package/dist/terminal/src/repository-worker.mjs +0 -15
  63. package/dist/terminal/src/sample.mjs +0 -171
  64. package/dist/terminal/src/scout-credentials.d.ts +0 -12
  65. package/dist/terminal/src/scout-credentials.ts +0 -44
  66. package/dist/terminal/src/scout-instructions.d.ts +0 -5
  67. package/dist/terminal/src/scout-instructions.ts +0 -119
  68. package/dist/terminal/src/scout-pi.d.ts +0 -44
  69. package/dist/terminal/src/scout-pi.ts +0 -76
  70. package/dist/terminal/src/scout.d.ts +0 -77
  71. package/dist/terminal/src/scout.ts +0 -164
  72. package/dist/terminal/src/server.mjs +0 -95
  73. package/dist/terminal/src/service-registry.mjs +0 -78
  74. package/dist/terminal/src/services.mjs +0 -44
  75. package/dist/terminal/src/topology.mjs +0 -30
  76. package/dist/terminal/src/updates.d.ts +0 -62
  77. package/dist/terminal/src/updates.ts +0 -313
package/docs/roadmap.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Current status
4
4
 
5
- The Inference Terminal provides a read-only view of existing application evidence. The Pi **1.5.0** adapter in OpenMerit **0.1.12** provides only native Scout MCP and shared guidance, not evaluation coordination. The standalone evaluation CLI remains separate. Neither a populated dashboard nor passing implementation tests establishes that an application has better models, trustworthy evaluations, or reliable unattended automation.
5
+ The Pi **1.5.0** adapter in OpenMerit **0.1.12** provides only native Scout MCP and shared guidance, not evaluation coordination. OpenMerit **0.3.0** retires the Inference Terminal and the standalone evaluation CLI; unreleased Scout source retires the legacy recommendation API. Passing implementation tests do not establish that an application has better models, trustworthy evaluations, or reliable unattended automation.
6
6
 
7
7
  ## Known limitations
8
8
 
@@ -13,4 +13,4 @@ The Inference Terminal provides a read-only view of existing application evidenc
13
13
  - The Pi 0.87 evaluation adapter is removed in source. Pi 1.5.0 setup needs explicit browser approval, a new session and an explicit installer rerun for updates. It has no lifecycle commands, scheduler, LaunchAgent or auto-update hooks. The host owns tools, model, permissions and sessions; SDK hosts must load Pi's native MCP extension explicitly.
14
14
  - Scout rule/skill availability does not guarantee invocation or measured application quality. Missing project policy requires asking; unavailable or insufficient Scout evidence requires pausing the model choice.
15
15
  - Existing legacy Pi 0.87 background jobs require separate cleanup during migration.
16
- - Agent-evaluation budget admission depends on reported costs. It cannot independently meter provider billing or stop an in-flight request. Use provider spending controls as well.
16
+ - Scout guidance is not execution or evaluation. Use representative application checks and provider spending controls independently.
package/docs/scout.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # Scout by OpenMerit
2
2
 
3
- Scout is OpenMerit's model search agent. Describe your task and get model recommendations. Scout returns up to five model-and-endpoint candidates per task. Comparable measurements support performance ranking; narrow decision and vision tasks use an explicitly labelled task-fit shortlist when quality is unmeasured. The `/scout/` page and JSON API use the same index and ranking.
3
+ Scout is OpenMerit's model search agent. Describe your task and get provisional model guidance through the conversational page or MCP. Public catalogue evidence is not proof of application quality.
4
4
 
5
- Scout is a separate hosted beta product. Npm 0.1.7 introduced optional Claude Code setup for it. OpenMerit 0.1.12 adds native Scout access for Pi 1.5.0, replacing the old Pi evaluation adapter without changing the standalone evaluation CLI or Inference Terminal. Recommendations are evidence-based candidates for your own evaluation, not a guarantee of task accuracy or an independently validated state-of-the-art ranking.
5
+ **Unreleased retirement, November 2026:** this source removes `/scout/api/recommend`, its alpha/versioned aliases and the complete standalone `openmerit eval` CLI, including `plan`. Retired paths return 404 before authentication, redirects or asset fallback. The agent, canonical `/scout/mcp`, coding-agent setup, catalogue publisher, status and account/privacy capabilities remain. No remote data or existing local evaluation files are deleted. Package publication and Worker deployment require separate approval; neither is implied by this source change.
6
+
7
+ Dated receipts and the old search/ranking/JSON protocol descriptions below are historical, not supported interfaces in this source. There is no replacement frozen-plan API. See [evaluation retirement](agent-evaluations.md) and [commands](commands.md) for migration boundaries.
8
+
9
+ Scout is a separate hosted beta product. Npm 0.1.7 introduced optional Claude Code setup for it. OpenMerit 0.1.12 added native Scout access for Pi 1.5.0, replacing the old Pi evaluation adapter. Recommendations are evidence-based candidates for your own evaluation, not a guarantee of task accuracy or an independently validated state-of-the-art ranking.
6
10
 
7
11
  **Website rebuild — published 24 October 2026:** the rebuild preserves the existing landing page at `/` and Scout at `/scout/`, and takes all hosted `/docs/` pages offline, including this guide. `/connect/`, the JSON API/MCP and the standalone `/scout/data-policy/` remain available. The guide remains maintained in Markdown; this does not remove the local CLI or its documentation from the package. See the [cutover notes](site-domain-migration.md) for publication and verification details.
8
12
 
package/docs/security.md CHANGED
@@ -2,19 +2,19 @@
2
2
 
3
3
  **Pi adapter — npm 0.1.12 and later:** Pi 1.5.0 uses native MCP plus shared Scout rule/skill sections, not the former evaluation adapter. It never initiates login, changes the host model/tools/session, or provisions background jobs. Browser-approved credentials are independent per client/scope/project, stored privately outside the repository, and passed as MCP HTTP headers, never instructions. Pi's installed connection JSON contains only the private credential path. See [Pi paths and targeted removal](pi-extension.md). The standalone evaluation CLI remains separate.
4
4
 
5
- The [preview agent-evaluation runner](agent-evaluations.md) is a separate execution path: it runs trusted local agents/graders, passes only basic OS variables and explicitly named environment variables, and preserves agent-owned authentication. It sends task descriptions and optional model IDs to Scout. Local metric reports and budget reservations persist; temporary task outputs are removed. Fresh directories are not security sandboxes. Its budget admission controls do not enforce provider billing limits.
5
+ **Retirement:** npm 0.3.0 removes the standalone [agent-evaluation runner](agent-evaluations.md), including planning; unreleased Scout source removes `/scout/api/recommend`. Existing local reports, budgets, configuration and host-agent instructions are not deleted; review them separately. No provider billing control or remote data deletion is implied.
6
6
 
7
- The [budget selector](agent-evaluations.md#budget-selection) in `0.1.7-preview.2` requires an explicit $10, custom or no-fixed-cap choice after model scope. No fixed cap removes monetary admission limits, including Claude's per-attempt dollar flag, while retaining tasks, repetition bounds, timeouts, native permissions and accounting. Saved choices apply across reruns; a foreground watcher can incur further charges on each change until stopped. Provider limits remain independent.
7
+ The Scout agent, MCP, publisher and account analytics/deletion capabilities remain. Historical analytics and stored catalogue/research data are not erased by endpoint retirement. Necessary provider processing for the agent and publisher continues under the [Scout data policy](scout-data-policy.md).
8
8
 
9
9
  ## Credential ownership
10
10
 
11
11
  OpenMerit accepts no model-provider or Jev credential. Credentials belong to the coding harness, its environment, or an external secret store.
12
12
 
13
- Never place secrets in evaluation configuration, task fixtures, Scout project policy, terminal configuration, activity exports, traces, or committed environment files.
13
+ Never place secrets in evaluation configuration, task fixtures, Scout project policy, traces, or committed environment files.
14
14
 
15
15
  ## Local state
16
16
 
17
- `.openmerit/` may hold evaluation configuration, task fixtures, reports and budget usage, Scout project policy, and terminal configuration or activity exports. It may reveal task names, model choices, budgets or endpoint identifiers. Treat it as sensitive project metadata even though it must not contain credentials.
17
+ `.openmerit/` may hold evaluation configuration, task fixtures, reports and budget usage, and Scout project policy, plus files left by the retired Inference Terminal (`terminal.json`, `inference.jsonl`), which upgrades do not delete. It may reveal task names, model choices, budgets or endpoint identifiers. Treat it as sensitive project metadata even though it must not contain credentials.
18
18
 
19
19
  With native Scout, Pi manages its own session/tool records and provider context; the adapter adds no separate evaluation-history store. Task descriptions sent to hosted Scout follow its data policy. Local OpenMerit storage does not imply that all processing stays on your machine.
20
20
 
@@ -24,19 +24,6 @@ OpenMerit does not establish the truth of imported or external evidence. Use aut
24
24
 
25
25
  Do not include credentials, private traces, or proprietary evaluation data in public issues.
26
26
 
27
- ## Inference Terminal
28
-
29
- The terminal reads supported project metadata, request logs, explicit exports, and authorized loopback runtime inventory/metrics endpoints. It calls no inference endpoint and exposes no mutation API. A local syntax index reads supported Python, JavaScript/TypeScript, JSON, YAML, TOML, and project-local dotenv declarations. It does not execute or import project code, source shell files, or resolve arbitrary expressions. Known credential-variable presence can identify configuration; credential values are discarded and never used for provider calls. Endpoint addresses are sanitized before retention. Static URLs found by the syntax index never authorize network probes.
30
-
31
- Loopback runtime probes require supported project endpoint declarations or explicit terminal configuration. No standard ports, remote provider accounts, credential stores, running processes, or general home directories are scanned. Whole-machine readings are context, not per-request measurements; the compute view requires explicit links or supplied host evidence.
32
-
33
- Coding-harness histories are outside the terminal's application scope and are not scanned or imported. Application request logs and explicit exports retain only allowlisted metadata, without prompts or responses. Sources must belong to the selected application; the terminal cannot infer the purpose of an unlabeled API response. Records explicitly marked `scope: "development"` are rejected.
34
-
35
- The local web service binds to `127.0.0.1` and rejects unexpected Host, Origin, and cross-site browser requests. It only serves bundled assets and specific read-only JSON routes. It is not authenticated against other local processes or machine users. Model names, endpoint identifiers, and relative source paths can still be sensitive. Names and identifiers in imported metadata must be kept free of secrets.
36
-
37
- Repository metadata and usage caches live in process memory and disappear when the terminal exits. The terminal creates no persistent index or files in the application. Per-file/parser protections, request-log read limits, runtime inventory limits, and explicit coverage gaps still apply. See [terminal data handling and limits](inference-terminal.md) for supported sources, precedence, retention bounds, diagnostics, and sample-data separation.
38
-
39
-
40
27
  ## Hosted Scout authentication
41
28
 
42
29
  Hosted Scout uses Clerk Google/GitHub sign-in. The page and status are public; JSON recommendations require a valid Clerk session bearer token, not just a cookie. MCP methods require a device key from the setup below. Missing/invalid credentials return HTTP 401, and missing authentication configuration fails closed with HTTP 503. Clerk remains the identity system; the analytics integration below can synchronize selected profile fields to PostHog. Raw account IDs are used transiently for the per-user edge limiter and are not joined to historical Cloudflare prompt records. A prompt draft is temporarily stored in the current browser tab during sign-in. No Clerk Billing or automatic agent OAuth is included. See [Scout account sign-in](scout.md#account-sign-in-published-21-october-2026) for token lifetime, rate limits, data handling and the remaining real-login validation limits.
@@ -79,7 +66,7 @@ Historical records are accessible only through authenticated Cloudflare account
79
66
 
80
67
  ### Optional product analytics
81
68
 
82
- Scout has a visible privacy disclosure and controls. Public homepage/docs navigation events remain source-only; npm 0.1.7 includes opt-in local terminal/map events and aggregate counts. The terminal relay cannot mutate project or inference state, retains loopback Host/Origin guards, and forwards only bounded allowlisted telemetry. Terminal and CLI use a process-only random ID; website events use a separate tab ID. Neither is implicitly linked to Clerk. Application names, raw inputs/outputs, local identifiers and source files remain local. Sanitized error classes/categories (and Scout application file/line frames) exclude raw messages, credentials, source context, arbitrary URLs and filesystem paths. No replay is enabled. All expanded account-linked events pass the same preference/deletion gate as profile synchronization; ordinary event delivery remains best effort. New search details link to identified accounts when profile sync is active, unlike the legacy Cloudflare archive. No external retention setting is changed.
69
+ Scout has a visible privacy disclosure and controls. Public homepage/docs navigation events remain source-only; npm 0.1.7 includes opt-in local terminal/map events and aggregate counts, which npm 0.3.0 removes with the [Inference Terminal](inference-terminal.md). The terminal relay cannot mutate project or inference state, retains loopback Host/Origin guards, and forwards only bounded allowlisted telemetry. Terminal and CLI use a process-only random ID; website events use a separate tab ID. Neither is implicitly linked to Clerk. Application names, raw inputs/outputs, local identifiers and source files remain local. Sanitized error classes/categories (and Scout application file/line frames) exclude raw messages, credentials, source context, arbitrary URLs and filesystem paths. No replay is enabled. All expanded account-linked events pass the same preference/deletion gate as profile synchronization; ordinary event delivery remains best effort. New search details link to identified accounts when profile sync is active, unlike the legacy Cloudflare archive. No external retention setting is changed.
83
70
 
84
71
  The [PostHog integration](scout.md#product-analytics) uses the EU ingestion endpoint; unconfigured deployments remain off. Verified Clerk user IDs become namespaced SHA-256 pseudonyms for cross-client account retention. Profile sync can associate those identities with names and verified email addresses, so they are identifiable personal data. No identity is added to the historical Cloudflare prompt archive. The browser retains an anonymous ID and hashed account/session transition markers in tab storage, rotating the anonymous ID on account changes. There is no autocapture, replay or visitor-IP forwarding. PostHog has its own retention/access policies, not Cloudflare's three-month policy.
85
72
 
package/docs/testing.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Testing and validation
2
2
 
3
+ **Cleanup:** npm 0.3.0 retires the standalone evaluation CLI and the Inference Terminal; unreleased source also retires the legacy JSON recommendation pipeline and unpublished docs website shell. Dated evaluation/API acceptance records below describe historical releases, not current supported commands. Current verification retains agent, MCP, publisher, status, policy and protected-memo coverage; it does not publish or deploy anything.
4
+
3
5
  **Pi adapter — npm 0.1.12 and later:** the Pi 1.5.0 adapter uses native Scout MCP and shared guidance only. Dated acceptance records below describe the packages tested at the time; some mention package exports that have since been removed. Standalone evaluation tests remain separate.
4
6
 
5
7
  ## Stable 0.1.7 acceptance — 24 October 2026
@@ -58,25 +60,11 @@ npm run verify
58
60
  npm pack --dry-run
59
61
  ```
60
62
 
61
- `verify` runs workspace tests, syntax checks, strict TypeScript checks and the TypeScript migration ratchet, the package build, demo tests, generated-documentation checks, and whitespace validation. It does not publish a package or deploy the website.
62
-
63
- ## Inference Terminal coverage
64
-
65
- ```sh
66
- npm test --workspace=@openmerit/terminal
67
- ```
68
-
69
- Tests cover metadata projection and redaction, source containment, recursive syntax indexing, phased worker progress, incremental reuse, coverage limits, endpoint classification, authorized loopback probes, supported request-log formats, UTF-8 boundaries, rotation/truncation, source reconciliation, historical ranges, unknown versus zero measurements, and the read-only API.
70
-
71
- Inventory and UI coverage includes model/service identity, explicit deployment and host links, shared capacity, stale/missing hardware, path classification, map completeness, search and filtering, and preservation of unknown costs. Runtime fixtures establish which endpoints collectors call; they do not prove compatibility with every external runtime version.
72
-
73
- The local fictional sample (`--demo` or the sample selector) has 40 agents, 140 tasks, 21 models and 9,600 requests over 24 hours. Sample tests exercise interface scale and arithmetic, not measured throughput, application quality, or hardware performance.
74
-
75
- Browser review covers the six views, inspectors, search, activity ranges, map and chart interaction, keyboard navigation, reduced motion, and desktop/narrow layouts. Browser checks are distinct from the committed unit suite.
63
+ `verify` runs workspace tests, syntax checks, strict TypeScript checks and the TypeScript migration ratchet, the package build, memo tests, policy/memo build and Markdown-guide checks, Scout tests/build, and whitespace validation. It does not publish a package or deploy the website. `npm run check:worker-types` separately checks generated Worker types; CI also performs packed-install, CLI help and Pi load smoke checks.
76
64
 
77
65
  ## Pi load and package smoke tests
78
66
 
79
- CI packs the package, installs it in a temporary directory, imports `openmerit/pi`, smoke-tests the installed terminal command, and loads the installed extension in Pi with `--list-models`. These checks establish packaging and loading, not successful provider authentication or Scout recommendations.
67
+ CI packs the package, installs it in a temporary directory, imports `openmerit/pi`, checks the installed `openmerit --help`, and loads the installed extension in Pi with `--list-models`. These checks establish packaging and loading, not successful provider authentication or Scout recommendations.
80
68
 
81
69
  ## What evidence establishes
82
70
 
@@ -39,9 +39,9 @@ They were removed, not renamed. The Pi adapter now supplies only native Scout MC
39
39
 
40
40
  The rule and skill are model guidance, not a guaranteed tool gate. Confirm they loaded in a new session and that the host permits the MCP tools. When project model policy is missing, ask the user; when Scout is unavailable or lacks suitable evidence, pause the model choice rather than invent a result. Host model/tool/session restrictions remain authoritative. Scout candidates are not measurements of your application's quality.
41
41
 
42
- ## Evaluation or terminal problems
42
+ ## `openmerit dash` or `openmerit eval` is an unknown command
43
43
 
44
- The standalone products are separate from Pi setup. Use [agent-evaluation diagnostics](agent-evaluations.md) for tasks, graders, budgets and execution blocks, or [terminal diagnostics](inference-terminal.md) for missing inventory and request evidence. A populated dashboard is not proof of successful evaluations. Never mark missing or rejected evidence successful merely to advance a workflow.
44
+ Both are retired in npm 0.3.0; see the [Inference Terminal](inference-terminal.md) and [evaluation CLI](agent-evaluations.md) retirement notes. Existing local files are not deleted. Never mark missing or rejected evidence successful merely to advance a workflow.
45
45
 
46
46
  ## Report a reproducible problem
47
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openmerit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Evidence-driven model decisions for coding harnesses.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "files": [
17
17
  "dist/pi/",
18
18
  "dist/terminal/",
19
- "dist/eval/",
19
+ "!dist/terminal/src/*.ts",
20
20
  "docs/",
21
21
  "CHANGELOG.md",
22
22
  "LICENSE",
@@ -55,6 +55,7 @@
55
55
  "scripts": {
56
56
  "check": "npm run check --workspaces --if-present",
57
57
  "test": "npm run test --workspaces --if-present",
58
+ "pretypecheck": "npm run build:site",
58
59
  "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.scout.json && tsc -p scout/agent/tsconfig.json",
59
60
  "check:typescript": "node scripts/check-typescript.ts",
60
61
  "types:workers": "node scripts/worker-types.ts",
@@ -62,26 +63,17 @@
62
63
  "deploy": "node scripts/deploy.ts",
63
64
  "deploy:status": "node scripts/deploy.ts status",
64
65
  "build": "node scripts/build.mjs",
65
- "predash": "node scripts/build-terminal.mjs",
66
- "dash": "node packages/terminal/src/cli.mjs dash",
67
- "build:docs": "node scripts/build-docs.mjs",
68
- "check:docs": "npm run build:docs && node --test scripts/docs.test.mjs",
66
+ "build:site": "node scripts/build-site.ts",
67
+ "check:docs": "npm run build:site && node --test scripts/docs.test.ts",
69
68
  "memo": "node demo/memo/preview.ts",
70
- "check:demo": "npm ci --ignore-scripts --prefix demo && npm run build:docs && tsc -p tsconfig.memo.json && node --test demo/test/*.test.mjs demo/test/*.test.ts",
69
+ "check:demo": "npm ci --ignore-scripts --prefix demo && npm run build:site && tsc -p tsconfig.memo.json && node --test demo/test/*.test.mjs demo/test/*.test.ts",
71
70
  "prepare": "npm run build",
72
71
  "verify": "npm test && npm run check && npm run typecheck && npm run check:typescript && npm run build && npm run check:demo && npm run check:scout && npm run check:docs && npm run build:scout && git diff --check",
73
72
  "prepublishOnly": "npm run verify",
74
- "scout": "node scout/dev.mjs",
75
73
  "scout:refresh": "node scout/refresh.mjs",
76
74
  "check:scout": "node --test scout/test/*.test.mjs scout/test/*.test.ts && node --check scout/worker.mjs",
77
75
  "build:scout": "node scripts/build-scout.mjs"
78
76
  },
79
- "dependencies": {
80
- "@iarna/toml": "2.2.5",
81
- "@lezer/javascript": "1.5.6",
82
- "@lezer/python": "1.1.19",
83
- "yaml": "2.9.1"
84
- },
85
77
  "peerDependencies": {
86
78
  "@earendil-works/pi-coding-agent": "1.5.0"
87
79
  },
@@ -91,26 +83,21 @@
91
83
  }
92
84
  },
93
85
  "devDependencies": {
94
- "@earendil-works/pi-coding-agent": "1.5.0",
95
86
  "@clerk/backend": "3.26.1",
96
87
  "@clerk/shared": "4.41.0",
88
+ "@earendil-works/pi-coding-agent": "1.5.0",
89
+ "@lezer/javascript": "1.5.6",
97
90
  "@resvg/resvg-js": "2.6.2",
98
- "@types/markdown-it": "14.1.2",
99
91
  "@types/node": "^22.18.6",
100
92
  "esbuild": "0.28.2",
101
93
  "fflate": "0.8.2",
102
94
  "hyparquet": "1.31.5",
103
- "liveline": "0.0.7",
104
95
  "markdown-it": "15.0.2",
105
96
  "miniflare": "5.20260921.0-alpha",
106
- "minisearch": "7.2.0",
107
- "morphdom": "2.7.8",
108
- "react": "19.3.0",
109
- "react-dom": "19.3.0",
110
- "torph": "0.1.3",
111
97
  "typebox": "1.3.27",
112
98
  "typescript": "^5.9.3",
113
99
  "web-vitals": "6.2.3",
114
- "wrangler": "4.136.3"
100
+ "wrangler": "4.136.3",
101
+ "yaml": "2.9.1"
115
102
  }
116
103
  }
@@ -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
- }