simt-dev 0.7.1 → 0.8.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 (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
package/src/bench/run.mjs CHANGED
@@ -1,769 +1,88 @@
1
+ // Run benchmark tasks across arms, and re-grade a stored run.
2
+ //
3
+ // The orchestrator owns the plan and nothing else: an arm is materialised by the registry
4
+ // in `arms.mjs`, a harness is driven by the adapter record in `adapters.mjs`, a workspace
5
+ // is built by `workspace.mjs`, a verdict comes from the task's own oracle in `graders.mjs`,
6
+ // and a record is written by `records.mjs` (review 0003 Q78). What is left here is the
7
+ // order those five things happen in.
1
8
  import { existsSync } from 'node:fs';
2
- import { lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
9
+ import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
10
  import { tmpdir } from 'node:os';
4
11
  import { dirname, join, resolve, sep } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
12
 
7
- import { assembleBrief } from '../brief/assemble.mjs';
8
- import { DEFAULT_BRIEF_BYTES } from '../brief/packet.mjs';
9
- import { loadRegistry } from '../graph/load.mjs';
10
13
  import { extractOpenedSkills, matchSkillsForPaths } from '../graph/compile.mjs';
11
- import { MANIFEST_FILE, STATE_DIR } from '../name.mjs';
14
+ import { loadRegistry } from '../graph/load.mjs';
12
15
  import { LOCAL_DIR } from '../state.mjs';
16
+ import { packageVersion } from '../package-version.mjs';
13
17
  import { readManifest } from '../projection/manifest.mjs';
14
- import { plan } from '../projection/project.mjs';
15
- import { parse as parseRegion } from '../projection/markers.mjs';
16
- import { gradeWorkspace } from './grade.mjs';
17
- import { resolveVaultDir } from './mine.mjs';
18
- import { spawnBounded } from './process.mjs';
19
- import { loadTasks } from './tasks.mjs';
20
- import { ablateSectionInWorkspace, parseAblationArmName } from './ablate.mjs';
21
- import { applyVariantInWorkspace, parseVariantArmName } from './variants.mjs';
22
-
23
- const BENCH_DIR = dirname(fileURLToPath(import.meta.url));
24
- const ADAPTERS_PATH = join(BENCH_DIR, 'adapters.json');
25
-
26
- export const BUILTIN_ARMS = new Set(['bare', 'current', 'kernel', 'brief']);
18
+ import {
19
+ ADAPTER_SCHEMA_VERSION,
20
+ adapterIsolation,
21
+ applyPatch,
22
+ executeAdapter,
23
+ loadAdapters,
24
+ } from './adapters.mjs';
25
+ import { BUILTIN_ARMS, describeArm, parseArmSpec } from './arms.mjs';
26
+ import { gradeWithOracle } from './graders.mjs';
27
+ // Importing the tests grader registers the oracle it grades for; the runner asks the
28
+ // registry rather than the module, so a second oracle needs no change here.
29
+ import './grade.mjs';
30
+ import { resolveVaultDir } from '../testing/vault.mjs';
31
+ import {
32
+ RECORD_SCHEMA_VERSION,
33
+ corpusDigestOf,
34
+ experimentOf,
35
+ runIdFor,
36
+ runRecord,
37
+ } from '../measure/records.mjs';
38
+ import { DEFAULT_BOOTSTRAP_SEED } from '../measure/stats.mjs';
39
+ import { loadTasks, readTask } from '../measure/tasks.mjs';
40
+ import { mapPool, prepareBaseTree, prepareIsolatedWorkspace } from './workspace.mjs';
27
41
 
28
42
  const DEFAULT_REPS = 3;
29
43
  const DEFAULT_MAX_TURNS = 25;
30
44
  const DEFAULT_AGENT_TIMEOUT_MS = 120_000;
31
45
  const DEFAULT_CONCURRENCY = 4;
32
46
 
33
- let cachedAdapters = null;
34
-
35
- /**
36
- * Load the declarative harness adapter table (`src/bench/adapters.json`).
37
- *
38
- * @returns {Promise<Record<string, object>>}
39
- */
40
- export async function loadAdapters() {
41
- if (cachedAdapters) return cachedAdapters;
42
- const raw = JSON.parse(await readFile(ADAPTERS_PATH, 'utf8'));
43
- cachedAdapters = raw.adapters;
44
- return cachedAdapters;
45
- }
46
-
47
- function sanitizedEnv(extra = {}) {
48
- const env = { ...process.env, SIMT_DEV_WORKSPACE: 'off', ...extra };
49
- for (const key of Object.keys(env)) {
50
- if (key.startsWith('NODE_TEST')) delete env[key];
51
- }
52
- return env;
53
- }
54
-
55
- /**
56
- * What every benchmarked agent is allowed to inherit from the operator's shell.
57
- *
58
- * `PATH` and the locale are what a CLI needs to run at all. Nothing here names a
59
- * credential or a state directory: those come from the adapter's own `env` list.
60
- */
61
- const ENV_BASELINE = [
62
- 'PATH',
63
- 'LANG',
64
- 'LC_ALL',
65
- 'LC_CTYPE',
66
- 'TZ',
67
- 'TERM',
68
- 'SHELL',
69
- 'TMPDIR',
70
- 'TEMP',
71
- 'TMP',
72
- 'SystemRoot',
73
- 'WINDIR',
74
- 'USER',
75
- ];
76
-
77
- /** The isolation levels a run may record; `sandbox` is declared, not implemented. */
78
- const ISOLATION_LEVELS = new Set(['none', 'env', 'sandbox']);
79
-
80
- /**
81
- * The environment an arm's agent sees: the baseline, the adapter's declared keys,
82
- * and nothing else.
83
- *
84
- * The agent used to inherit everything the operator had — `SIMT_DEV_VAULT_DIR`,
85
- * `XDG_CACHE_HOME`, `HOME`, and every credential in the shell. A run that can read
86
- * the vault can read the hidden tests it is graded against, and a run sharing the
87
- * operator's HOME writes into their own configuration, so the number it produced
88
- * described the machine rather than the arm (review 0003 Q49). `home` is the run's
89
- * own slot, which is removed when the run ends.
90
- *
91
- * @param {object} adapter an entry from `adapters.json`
92
- * @param {{home?: string, extra?: Record<string, string>, base?: NodeJS.ProcessEnv}} [options]
93
- * @returns {Record<string, string>}
94
- */
95
- export function agentEnv(adapter, { home, extra = {}, base = process.env } = {}) {
96
- const allowed = new Set([...ENV_BASELINE, ...(adapter?.env ?? [])]);
97
- const env = {};
98
- for (const key of allowed) {
99
- if (typeof base[key] === 'string') env[key] = base[key];
100
- }
101
- for (const [key, value] of Object.entries(extra ?? {})) {
102
- if (typeof value === 'string') env[key] = value;
103
- }
104
- if (typeof home === 'string' && home.length > 0) env.HOME = home;
105
- env.SIMT_DEV_WORKSPACE = 'off';
106
- for (const key of Object.keys(env)) {
107
- if (key.startsWith('NODE_TEST')) delete env[key];
108
- }
109
- return env;
110
- }
111
-
112
- /**
113
- * The isolation an adapter's runs provide, as the record states it.
114
- *
115
- * `none` is a built-in that scripts the workspace, `env` is a CLI driven with a
116
- * scrubbed environment and a slot-local HOME, and `sandbox` is reserved for a
117
- * real filesystem sandbox — nothing claims it yet, so a record never says it.
118
- *
119
- * @param {object} adapter
120
- * @returns {'none'|'env'|'sandbox'}
121
- */
122
- export function adapterIsolation(adapter) {
123
- const declared = adapter?.isolation;
124
- return ISOLATION_LEVELS.has(declared) ? declared : 'none';
125
- }
126
-
127
- function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
128
- return spawnBounded(cmd, args, { cwd, env: sanitizedEnv(env), input, timeoutMs });
129
- }
130
-
131
- function getByPath(obj, pathStr) {
132
- if (!obj || typeof obj !== 'object') return undefined;
133
- const parts = pathStr.split('.');
134
- let cur = obj;
135
- for (const part of parts) {
136
- if (cur === null || cur === undefined || typeof cur !== 'object') return undefined;
137
- cur = cur[part];
138
- }
139
- return cur;
140
- }
141
-
142
- function firstNumeric(obj, paths = []) {
143
- for (const p of paths) {
144
- const val = getByPath(obj, p);
145
- if (typeof val === 'number' && Number.isFinite(val)) return val;
146
- }
147
- return null;
148
- }
149
-
150
- function emptyUsage() {
151
- return {
152
- usage: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, turns: 0, toolCalls: 0 },
153
- costUsd: null,
154
- };
155
- }
156
-
157
- function parseJsonObject(text) {
158
- const trimmed = String(text ?? '').trim();
159
- if (!trimmed) return null;
160
- try {
161
- return JSON.parse(trimmed);
162
- } catch {
163
- const lines = trimmed.split('\n').reverse();
164
- for (const line of lines) {
165
- const s = line.trim();
166
- if (s.startsWith('{') && s.endsWith('}')) {
167
- try {
168
- return JSON.parse(s);
169
- } catch {
170
- // try earlier line
171
- }
172
- }
173
- }
174
- const start = trimmed.indexOf('{');
175
- const end = trimmed.lastIndexOf('}');
176
- if (start !== -1 && end > start) {
177
- try {
178
- return JSON.parse(trimmed.slice(start, end + 1));
179
- } catch {
180
- return null;
181
- }
182
- }
183
- return null;
184
- }
185
- }
186
-
187
- /**
188
- * Parse token usage, turns, tool calls and USD cost from a harness's output.
189
- *
190
- * Uses the field paths declared in `src/bench/adapters.json` and supports single-JSON
191
- * outputs (`claude`, `gemini`, `cursor`), JSONL event streams (`codex`, `opencode`),
192
- * and `--analytics-log` JSONL files (`aider`).
193
- *
194
- * @param {string|object} adapterOrId
195
- * @param {string} stdoutText
196
- * @param {string} [analyticsText]
197
- * @returns {{usage: {input: number, cacheWrite: number, cacheRead: number, output: number, turns: number, toolCalls: number}, costUsd: number|null}}
198
- */
199
- export function parseAdapterUsage(adapterOrId, stdoutText = '', analyticsText = '') {
200
- const result = emptyUsage();
201
- const adapter =
202
- typeof adapterOrId === 'string'
203
- ? (cachedAdapters?.[adapterOrId] ?? { id: adapterOrId, format: 'json', fields: {} })
204
- : (adapterOrId ?? { id: 'unknown', format: 'json', fields: {} });
205
-
206
- if (adapter.format === 'builtin') {
207
- if (adapter.id === 'gold') {
208
- result.usage.turns = 1;
209
- result.usage.toolCalls = 1;
210
- }
211
- return result;
212
- }
213
-
214
- const fields = adapter.fields ?? {};
215
-
216
- if (adapter.format === 'json') {
217
- const obj = parseJsonObject(stdoutText);
218
- if (!obj || typeof obj !== 'object') return result;
219
-
220
- result.usage.input = firstNumeric(obj, fields.input) ?? 0;
221
- result.usage.cacheWrite = firstNumeric(obj, fields.cacheWrite) ?? 0;
222
- result.usage.cacheRead = firstNumeric(obj, fields.cacheRead) ?? 0;
223
- result.usage.output = firstNumeric(obj, fields.output) ?? 0;
224
- result.usage.turns = firstNumeric(obj, fields.turns) ?? 0;
225
- result.usage.toolCalls = firstNumeric(obj, fields.toolCalls) ?? 0;
226
- result.costUsd = firstNumeric(obj, fields.costUsd) ?? null;
227
-
228
- // Gemini CLI also nests per-model token totals under `stats.models.<name>.tokens`.
229
- const models = obj?.stats?.models;
230
- if (models && typeof models === 'object') {
231
- let promptSum = 0;
232
- let cachedSum = 0;
233
- let candidatesSum = 0;
234
- for (const m of Object.values(models)) {
235
- promptSum += Number(m?.tokens?.prompt ?? 0) || 0;
236
- cachedSum += Number(m?.tokens?.cached ?? 0) || 0;
237
- candidatesSum += Number(m?.tokens?.candidates ?? 0) || 0;
238
- }
239
- if (result.usage.input === 0 && promptSum > 0) result.usage.input = promptSum;
240
- if (result.usage.cacheRead === 0 && cachedSum > 0) result.usage.cacheRead = cachedSum;
241
- if (result.usage.output === 0 && candidatesSum > 0) result.usage.output = candidatesSum;
242
- }
243
-
244
- return result;
245
- }
246
-
247
- const rawStream = adapter.format === 'analytics-log' && analyticsText ? analyticsText : stdoutText;
248
- const lines = String(rawStream ?? '')
249
- .split('\n')
250
- .map((l) => l.trim())
251
- .filter(Boolean);
252
-
253
- for (const line of lines) {
254
- let event;
255
- try {
256
- event = JSON.parse(line);
257
- } catch {
258
- continue;
259
- }
260
- if (!event || typeof event !== 'object') continue;
261
-
262
- const input = firstNumeric(event, fields.input);
263
- const cacheWrite = firstNumeric(event, fields.cacheWrite);
264
- const cacheRead = firstNumeric(event, fields.cacheRead);
265
- const output = firstNumeric(event, fields.output);
266
- const turns = firstNumeric(event, fields.turns);
267
- const toolCalls = firstNumeric(event, fields.toolCalls);
268
- const cost = firstNumeric(event, fields.costUsd);
269
-
270
- if (input !== null) result.usage.input += input;
271
- if (cacheWrite !== null) result.usage.cacheWrite += cacheWrite;
272
- if (cacheRead !== null) result.usage.cacheRead += cacheRead;
273
- if (output !== null) result.usage.output += output;
274
- if (cost !== null) result.costUsd += cost;
275
-
276
- if (turns !== null) {
277
- result.usage.turns = Math.max(result.usage.turns, turns);
278
- } else if (
279
- event.type === 'turn.completed' ||
280
- event.type === 'step_finish' ||
281
- event.event === 'message_send'
282
- ) {
283
- result.usage.turns += 1;
284
- }
285
-
286
- if (toolCalls !== null) {
287
- result.usage.toolCalls = Math.max(result.usage.toolCalls, toolCalls);
288
- } else if (
289
- event.type === 'tool_use' ||
290
- (event.type === 'item.completed' &&
291
- (event.item?.type === 'command_execution' ||
292
- event.item?.type === 'tool_call' ||
293
- event.item?.type === 'mcp_tool_call'))
294
- ) {
295
- result.usage.toolCalls += 1;
296
- }
297
- }
298
-
299
- if (result.costUsd !== null) result.costUsd = Number(result.costUsd.toFixed(6));
300
- return result;
301
- }
302
-
303
- /**
304
- * Parse an `--arm` specification (`bare`, `current`, `kernel`, `brief`, or `name=path/to/arm.json`).
305
- *
306
- * @param {string} rawSpec
307
- * @param {string} [cwd]
308
- * @returns {Promise<{name: string, kind: 'builtin'|'custom', base: string|null, write: Record<string, string>, remove: string[], env: Record<string, string>}>}
309
- */
310
- export async function parseArmSpec(rawSpec, cwd = process.cwd()) {
311
- const spec = String(rawSpec ?? '').trim();
312
- if (!spec) throw new Error('empty --arm specification');
313
-
314
- const eq = spec.indexOf('=');
315
- if (eq === -1) {
316
- const ablateSpec = parseAblationArmName(spec);
317
- if (ablateSpec) {
318
- return {
319
- ...ablateSpec,
320
- write: {},
321
- remove: [],
322
- env: {},
323
- };
324
- }
325
- const variantSpec = parseVariantArmName(spec);
326
- if (variantSpec) {
327
- return {
328
- ...variantSpec,
329
- write: {},
330
- remove: [],
331
- env: {},
332
- };
333
- }
334
- if (!BUILTIN_ARMS.has(spec)) {
335
- throw new Error(
336
- `unknown arm "${spec}"; built-in arms are: ${[...BUILTIN_ARMS].join(', ')} (or pass --arm name=path/to/arm.json)`,
337
- );
338
- }
339
- return {
340
- name: spec,
341
- kind: 'builtin',
342
- base: spec,
343
- write: {},
344
- remove: [],
345
- env: {},
346
- };
347
- }
348
-
349
- const name = spec.slice(0, eq).trim();
350
- const relPath = spec.slice(eq + 1).trim();
351
- if (!name || !/^[A-Za-z0-9._-]+$/.test(name)) {
352
- throw new Error(`invalid custom arm name "${name}"`);
353
- }
354
- if (!relPath) {
355
- throw new Error(`custom arm "${name}" is missing a JSON file path`);
356
- }
357
-
358
- const fullPath = resolve(cwd, relPath);
359
- let data;
360
- try {
361
- data = JSON.parse(await readFile(fullPath, 'utf8'));
362
- } catch (error) {
363
- throw new Error(`could not read arm file ${relPath}: ${error.message ?? error}`);
364
- }
365
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
366
- throw new Error(`arm file ${relPath} must contain a JSON object`);
367
- }
368
- if (data.base !== undefined && data.base !== null && !BUILTIN_ARMS.has(data.base)) {
369
- throw new Error(`arm file ${relPath} has unknown base "${data.base}"`);
370
- }
371
- if (data.write !== undefined && (typeof data.write !== 'object' || data.write === null || Array.isArray(data.write))) {
372
- throw new Error(`arm file ${relPath} field "write" must be an object mapping paths to strings`);
373
- }
374
- if (data.remove !== undefined && !Array.isArray(data.remove)) {
375
- throw new Error(`arm file ${relPath} field "remove" must be an array of paths`);
376
- }
377
- if (data.env !== undefined && (typeof data.env !== 'object' || data.env === null || Array.isArray(data.env))) {
378
- throw new Error(`arm file ${relPath} field "env" must be an object mapping env keys to strings`);
379
- }
380
-
381
- const write = {};
382
- for (const [k, v] of Object.entries(data.write ?? {})) write[k] = String(v);
383
- const remove = (data.remove ?? []).map((p) => String(p));
384
- const env = {};
385
- for (const [k, v] of Object.entries(data.env ?? {})) env[k] = String(v);
386
-
387
- return {
388
- name,
389
- kind: 'custom',
390
- base: data.base ?? null,
391
- write,
392
- remove,
393
- env,
394
- };
395
- }
396
-
397
- function assertContained(workDir, relPath) {
398
- const target = resolve(workDir, relPath);
399
- if (target !== workDir && !target.startsWith(`${workDir}${sep}`)) {
400
- throw new Error(`arm path "${relPath}" escapes workspace`);
401
- }
402
- return target;
403
- }
404
-
405
- async function stripRegionFile(filePath, style) {
406
- let text;
407
- try {
408
- text = await readFile(filePath, 'utf8');
409
- } catch {
410
- return false;
411
- }
412
- const state = parseRegion(text, style);
413
- if (state.status !== 'ok') return false;
414
- const remaining = `${state.before.trimEnd()}\n${state.after.trimStart()}`.trim();
415
- if (!remaining) {
416
- await unlink(filePath).catch(() => {});
417
- } else {
418
- await writeFile(filePath, `${remaining}\n`, 'utf8');
419
- }
420
- return true;
421
- }
422
-
423
- async function stripBareArm(workDir) {
424
- await stripRegionFile(join(workDir, 'AGENTS.md'), 'html');
425
- await stripRegionFile(join(workDir, '.editorconfig'), 'hash');
426
- await stripRegionFile(join(workDir, '.gitattributes'), 'hash');
427
-
428
- const surfaceDirs = [
429
- '.cursor/rules',
430
- '.claude/rules',
431
- '.github/instructions',
432
- '.agents/rules',
433
- ];
434
- for (const relDir of surfaceDirs) {
435
- const absDir = join(workDir, relDir);
436
- const entries = await readdir(absDir).catch(() => []);
437
- for (const entry of entries) {
438
- const full = join(absDir, entry);
439
- const text = await readFile(full, 'utf8').catch(() => null);
440
- if (text !== null && parseRegion(text, 'html').status === 'ok') {
441
- await unlink(full).catch(() => {});
442
- }
443
- }
444
- }
445
-
446
- for (const rel of ['.githooks', '.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json', 'docs/lessons']) {
447
- await rm(join(workDir, rel), { recursive: true, force: true }).catch(() => {});
448
- }
449
-
450
- if (!existsSync(join(workDir, 'AGENTS.md'))) {
451
- for (const mirror of ['CLAUDE.md', 'GEMINI.md']) {
452
- const full = join(workDir, mirror);
453
- const st = await lstat(full).catch(() => null);
454
- if (st?.isSymbolicLink()) {
455
- await unlink(full).catch(() => {});
456
- }
457
- }
458
- }
47
+ function runsBaseDir(root) {
48
+ return join(root, LOCAL_DIR, 'bench', 'runs');
459
49
  }
460
50
 
461
- async function applyProjectionMode(workDir, mode) {
462
- const manifestRes = await readManifest(workDir);
463
- const agentsText = await readFile(join(workDir, 'AGENTS.md'), 'utf8').catch(() => null);
464
- const hasRegion = agentsText !== null && parseRegion(agentsText, 'html').status === 'ok';
465
-
466
- // If the repository is neither adopted via manifest nor carrying a generated region,
467
- // `current` leaves its hand-written files as-is, while `kernel` projects a kernel
468
- // region only when the repo is adopted or already carries a region.
469
- if (!manifestRes.exists && !hasRegion) return;
470
-
471
- const manifestPath = manifestRes.path ?? join(workDir, MANIFEST_FILE);
472
- const hadManifest = manifestRes.exists;
473
- const originalManifestBytes = hadManifest ? await readFile(manifestPath, 'utf8').catch(() => null) : null;
474
-
475
- const baseManifest = manifestRes.data ?? {
476
- schemaVersion: 1,
477
- projection: { sha: '0000000000000000000000000000000000000000', sources: [] },
478
- gates: { preCommit: [], prePush: [] },
479
- };
480
- const nextManifest = { ...baseManifest };
481
- if (mode === 'kernel') nextManifest.mode = 'kernel';
482
- else delete nextManifest.mode;
483
-
484
- await writeFile(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
485
-
486
- const p = await plan(workDir);
487
- for (const t of p.targets ?? []) {
488
- if (t.action !== 'unchanged' && typeof t.text === 'string') {
489
- await mkdir(dirname(t.path), { recursive: true });
490
- await writeFile(t.path, t.text, 'utf8');
491
- }
492
- }
493
-
494
- if (!hadManifest) {
495
- await unlink(manifestPath).catch(() => {});
496
- } else if (originalManifestBytes !== null && mode !== 'kernel') {
497
- await writeFile(manifestPath, originalManifestBytes, 'utf8');
498
- }
51
+ /** Two string lists hold the same members, ignoring order and duplicates. */
52
+ function sameStringSet(a, b) {
53
+ const left = [...new Set(a)].sort();
54
+ const right = [...new Set(b)].sort();
55
+ return left.length === right.length && left.every((value, index) => value === right[index]);
499
56
  }
500
57
 
501
58
  /**
502
- * The `brief` arm's context: the packet `simt brief` delivers, assembled by the same
503
- * function the command and the prompt-submit hook call.
59
+ * The experiment a batch is measured under: the conditions every record in it shares.
504
60
  *
505
- * The arm used to re-implement the packet from `registry.skills` — a property the
506
- * registry does not have, so every run reported "(none matched)" while the real
507
- * command resolved skills — and wrote it to a file no agent was told to read. An arm
508
- * meant to measure the packet measured a different artefact (review 0003 Q49).
61
+ * Recorded rather than inferred, because two batches that differ in rule text, corpus or
62
+ * adapter were not the same experiment and a report must be able to say so.
509
63
  */
510
- async function buildBriefPrefix(workDir, task) {
511
- // A packet is built from git's answer; a directory that is not a repository has none,
512
- // and asking would print a failure into a bench log for no measurement.
513
- if (!existsSync(join(workDir, '.git'))) return '';
514
- const assembled = await assembleBrief({
515
- root: workDir,
516
- task: String(task?.prompt ?? ''),
517
- paths: '',
518
- budget: DEFAULT_BRIEF_BYTES,
519
- build: true,
520
- federate: false,
521
- }).catch(() => null);
522
- if (!assembled || assembled.error || assembled.cold) return '';
523
- return assembled.packed?.text ?? '';
64
+ async function batchExperiment(root, { adapter, tasks, isolation, model }) {
65
+ const manifest = await readManifest(root).catch(() => ({ data: null }));
66
+ const projection = manifest?.data?.projection ?? {};
67
+ return experimentOf({
68
+ simtVersion: packageVersion(),
69
+ rulesDigest: projection.sha ?? projection.simtDevSha ?? null,
70
+ adapterVersion: `${adapter.id}@${ADAPTER_SCHEMA_VERSION}${model ? `:${model}` : ''}`,
71
+ corpusDigest: corpusDigestOf(tasks),
72
+ seed: DEFAULT_BOOTSTRAP_SEED,
73
+ isolation,
74
+ });
524
75
  }
525
76
 
526
77
  /**
527
- * Materialise an experimental arm (`bare`, `current`, `kernel`, `brief`, or custom) in `workDir`.
78
+ * Run benchmark tasks across one or more arms and repetitions.
528
79
  *
529
- * @param {string} workDir
530
- * @param {{name: string, kind?: string, base?: string|null, write?: Record<string, string>, remove?: string[], env?: Record<string, string>}} armSpec
531
- * @param {object} [task]
532
- * @returns {Promise<{env: Record<string, string>, promptPrefix: string}>}
533
- */
534
- export async function materialiseArm(workDir, armSpec, task = {}) {
535
- let promptPrefix = '';
536
- const ablate = armSpec.ablate ?? parseAblationArmName(armSpec.name)?.ablate ?? null;
537
- const variant = armSpec.variant ?? parseVariantArmName(armSpec.name)?.variant ?? null;
538
- const base = armSpec.base ?? (BUILTIN_ARMS.has(armSpec.name) ? armSpec.name : null);
539
- if (ablate) {
540
- await ablateSectionInWorkspace(workDir, ablate);
541
- } else if (variant) {
542
- await applyVariantInWorkspace(workDir, variant);
543
- } else if (base === 'bare') {
544
- await stripBareArm(workDir);
545
- } else if (base === 'current') {
546
- await applyProjectionMode(workDir, 'full');
547
- } else if (base === 'kernel') {
548
- await applyProjectionMode(workDir, 'kernel');
549
- } else if (base === 'brief') {
550
- await applyProjectionMode(workDir, 'full');
551
- promptPrefix = await buildBriefPrefix(workDir, task);
552
- }
553
-
554
- for (const relPath of armSpec.remove ?? []) {
555
- const target = assertContained(workDir, relPath);
556
- await rm(target, { recursive: true, force: true }).catch(() => {});
557
- }
558
-
559
- for (const [relPath, content] of Object.entries(armSpec.write ?? {})) {
560
- const target = assertContained(workDir, relPath);
561
- await mkdir(dirname(target), { recursive: true });
562
- await writeFile(target, String(content), 'utf8');
563
- }
564
-
565
- return { env: { ...(armSpec.env ?? {}) }, promptPrefix };
566
- }
567
-
568
- /**
569
- * Export `baseSha` from `root` via `git archive` into a fresh temp directory, materialise
570
- * `armSpec`, and initialize a single-commit git repository so `git rev-list --all` has
571
- * exactly one commit and the object store shares nothing with `root`.
80
+ * A preview answers the plan and writes nothing; `apply` prepares one base tree per task,
81
+ * materialises each arm over a copy of it, drives the adapter, grades through the task's
82
+ * oracle, and stores the record beside the agent's patch.
572
83
  *
573
84
  * @param {string} root
574
- * @param {string} baseSha
575
- * @param {object} armSpec
576
- * @param {object} [task]
577
- * @param {{parentDir?: string}} [options]
578
- * @returns {Promise<{slotDir: string, workDir: string, initialHead: string, env: Record<string, string>, promptPrefix: string}>}
579
- */
580
- export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}, { parentDir } = {}) {
581
- const slotDir = await mkdtemp(join(parentDir ?? tmpdir(), 'bench-run-'));
582
- const workDir = join(slotDir, 'workspace');
583
- await mkdir(workDir, { recursive: true });
584
-
585
- const archiveRes = await runCmd('git', ['archive', '--format=tar', baseSha], { cwd: root });
586
- if (archiveRes.code !== 0) {
587
- await rm(slotDir, { recursive: true, force: true }).catch(() => {});
588
- throw new Error(
589
- `git archive failed for ${baseSha}: ${archiveRes.stderr.toString('utf8').trim()}`,
590
- );
591
- }
592
-
593
- const tarRes = await runCmd('tar', ['-xf', '-', '-C', workDir], {
594
- cwd: workDir,
595
- input: archiveRes.stdout,
596
- });
597
- if (tarRes.code !== 0) {
598
- await rm(slotDir, { recursive: true, force: true }).catch(() => {});
599
- throw new Error(`tar unpack failed for ${baseSha}: ${tarRes.stderr.toString('utf8').trim()}`);
600
- }
601
-
602
- const rootLessonsDir = join(root, 'docs', 'lessons');
603
- const rootLessonFiles = (await readdir(rootLessonsDir).catch(() => [])).filter((f) => f.endsWith('.md'));
604
- if (rootLessonFiles.length > 0) {
605
- const destLessonsDir = join(workDir, 'docs', 'lessons');
606
- await mkdir(destLessonsDir, { recursive: true });
607
- for (const file of rootLessonFiles) {
608
- const target = join(destLessonsDir, file);
609
- if (!existsSync(target)) {
610
- const text = await readFile(join(rootLessonsDir, file), 'utf8');
611
- await writeFile(target, text, 'utf8');
612
- }
613
- }
614
- }
615
-
616
- const rootProfilesDir = join(root, STATE_DIR, 'profiles');
617
- const rootProfileFiles = (await readdir(rootProfilesDir).catch(() => [])).filter((f) => f.endsWith('.md'));
618
- if (rootProfileFiles.length > 0) {
619
- const destProfilesDir = join(workDir, STATE_DIR, 'profiles');
620
- await mkdir(destProfilesDir, { recursive: true });
621
- for (const file of rootProfileFiles) {
622
- const text = await readFile(join(rootProfilesDir, file), 'utf8');
623
- await writeFile(join(destProfilesDir, file), text, 'utf8');
624
- }
625
- }
626
-
627
- await runCmd('git', ['init', '-q'], { cwd: workDir });
628
- await runCmd('git', ['config', 'user.name', 'bench'], { cwd: workDir });
629
- await runCmd('git', ['config', 'user.email', 'bench@local'], { cwd: workDir });
630
- await runCmd('git', ['config', 'commit.gpgsign', 'false'], { cwd: workDir });
631
-
632
- // The repository exists before an arm materialises: the `brief` arm assembles its
633
- // packet from git's answer, which a directory that is not yet a repository cannot give.
634
- const { env, promptPrefix } = await materialiseArm(workDir, armSpec, task);
635
- // Assembling a packet writes tool state. It belongs to no arm, and leaving it in the
636
- // base commit would make the `brief` workspace differ by an index as well as by context.
637
- await rm(join(workDir, LOCAL_DIR), { recursive: true, force: true }).catch(() => {});
638
-
639
- await runCmd('git', ['add', '-A'], { cwd: workDir });
640
- const commitRes = await runCmd(
641
- 'git',
642
- ['commit', '-qm', 'base', '--allow-empty', '--no-verify'],
643
- { cwd: workDir },
644
- );
645
- if (commitRes.code !== 0) {
646
- await rm(slotDir, { recursive: true, force: true }).catch(() => {});
647
- throw new Error(`git commit failed in isolated workspace: ${commitRes.stderr.toString('utf8').trim()}`);
648
- }
649
-
650
- const headRes = await runCmd('git', ['rev-parse', 'HEAD'], { cwd: workDir });
651
- const initialHead = headRes.stdout.toString('utf8').trim();
652
-
653
- return { slotDir, workDir, initialHead, env, promptPrefix };
654
- }
655
-
656
- function buildAdapterArgs(adapter, { prompt, turns, model, analyticsLog }) {
657
- const replaceToken = (token) =>
658
- String(token)
659
- .replaceAll('{prompt}', prompt)
660
- .replaceAll('{turns}', String(turns))
661
- .replaceAll('{model}', String(model ?? ''))
662
- .replaceAll('{analyticsLog}', String(analyticsLog ?? ''));
663
-
664
- const args = (adapter.args ?? []).map(replaceToken);
665
- if (model && model !== 'default' && model !== adapter.id && Array.isArray(adapter.modelArgs)) {
666
- args.push(...adapter.modelArgs.map(replaceToken));
667
- }
668
- return args;
669
- }
670
-
671
- /**
672
- * Apply a patch to a workspace, falling back to a three-way merge.
673
- *
674
- * The gold oracle and the regrade path land operator-supplied bytes the same way. They
675
- * used to disagree (`--binary` on one side only), so a patch that applied under one read
676
- * as unreproducible under the other (review 0003 Q59).
677
- */
678
- async function applyPatch(workDir, patch, { timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
679
- if (!patch || patch.length === 0) return { code: 0 };
680
- const applied = await runCmd('git', ['apply', '--binary', '-'], {
681
- cwd: workDir,
682
- input: patch,
683
- timeoutMs,
684
- });
685
- if (applied.code === 0) return applied;
686
- return runCmd('git', ['apply', '--3way', '-'], { cwd: workDir, input: patch, timeoutMs });
687
- }
688
-
689
- export async function executeAdapter(
690
- adapter,
691
- workDir,
692
- task,
693
- { vaultRoot, model, maxTurns = DEFAULT_MAX_TURNS, env, timeoutMs, slotDir, promptPrefix = '' },
694
- ) {
695
- if (adapter.id === 'noop') {
696
- return {
697
- ...parseAdapterUsage(adapter, ''),
698
- transcript: '',
699
- agent: { exitCode: 0, timedOut: false, wallMs: 0 },
700
- };
701
- }
702
- if (adapter.id === 'gold') {
703
- const startedAt = Date.now();
704
- const goldPatch = await readFile(join(vaultRoot, task.id, 'gold.patch'));
705
- await applyPatch(workDir, goldPatch, { timeoutMs });
706
- return {
707
- ...parseAdapterUsage(adapter, ''),
708
- transcript: '',
709
- agent: { exitCode: 0, timedOut: false, wallMs: Date.now() - startedAt },
710
- };
711
- }
712
-
713
- const analyticsLog = join(slotDir, 'analytics.jsonl');
714
- // The arm's context arrives as a prefix rather than a file the agent must be told to
715
- // read, so the prompt an adapter receives is the whole thing it was given. A `gold`
716
- // or `noop` run never reaches this line: it scripts the workspace instead.
717
- const prompt = [String(promptPrefix ?? ''), String(task.prompt ?? '')]
718
- .filter((part) => part.length > 0)
719
- .join('\n\n');
720
- const args = buildAdapterArgs(adapter, { prompt, turns: maxTurns, model, analyticsLog });
721
- const outcome = await runCmd(adapter.bin, args, {
722
- cwd: workDir,
723
- env: agentEnv(adapter, { home: slotDir, extra: env }),
724
- timeoutMs,
725
- });
726
- // A binary that could not start produced no run. Grading its empty workspace would
727
- // record an unresolved arm instead of the missing harness it is (review 0003 Q47).
728
- if (outcome.spawnError) {
729
- throw new Error(`harness ${adapter.id} could not start ${adapter.bin}: ${outcome.spawnError}`);
730
- }
731
- const stdoutText = outcome.stdout.toString('utf8');
732
- const analyticsText = adapter.format === 'analytics-log' ? await readFile(analyticsLog, 'utf8').catch(() => '') : '';
733
- return {
734
- ...parseAdapterUsage(adapter, stdoutText, analyticsText),
735
- transcript: `${stdoutText}\n${analyticsText}`,
736
- // A run's own exit code, deadline and wall time belong in its record: the old one
737
- // stored none, so a timed-out run was indistinguishable from a completed one.
738
- agent: { exitCode: outcome.code, timedOut: outcome.timedOut, wallMs: outcome.wallMs },
739
- };
740
- }
741
-
742
- // loadTasks lives in tasks.mjs (the advice engine became a second reader, so the loader
743
- // was extracted rather than copied — ADR-002).
744
-
745
- function runIdFor(harness, armName, rep) {
746
- return `${harness}-${armName}-r${rep}`;
747
- }
748
-
749
- async function mapPool(items, concurrency, fn) {
750
- const results = new Array(items.length);
751
- let cursor = 0;
752
- async function worker() {
753
- while (true) {
754
- const idx = cursor;
755
- cursor += 1;
756
- if (idx >= items.length) return;
757
- results[idx] = await fn(items[idx], idx);
758
- }
759
- }
760
- const count = Math.max(1, Math.min(concurrency, items.length));
761
- await Promise.all(Array.from({ length: count }, () => worker()));
762
- return results;
763
- }
764
-
765
- /**
766
- * Run benchmark tasks across one or more arms and repetitions.
85
+ * @param {object} [options]
767
86
  */
768
87
  export async function runBench(
769
88
  root,
@@ -785,9 +104,7 @@ export async function runBench(
785
104
  const adapters = adapterTable ?? (await loadAdapters());
786
105
  const adapter = adapters[harness];
787
106
  if (!adapter) {
788
- throw new Error(
789
- `unknown harness "${harness}"; known harnesses: ${Object.keys(adapters).sort().join(', ')}`,
790
- );
107
+ throw new Error(`unknown harness "${harness}"; known harnesses: ${Object.keys(adapters).sort().join(', ')}`);
791
108
  }
792
109
 
793
110
  if (!Number.isInteger(reps) || reps < 1) {
@@ -808,23 +125,19 @@ export async function runBench(
808
125
  );
809
126
  }
810
127
 
811
- const resolvedModel = model ?? (adapter.kind === 'builtin' ? adapter.id : 'default');
812
- // A built-in harness scripts the workspace instead of driving an agent, so its
813
- // numbers calibrate the grader and are never an arm's measurement (review 0003 Q49).
814
- const measured = adapter.kind !== 'builtin';
128
+ const resolvedModel = model ?? (adapter.kind === 'oracle' ? adapter.id : 'default');
129
+ // An oracle harness scripts the workspace instead of driving an agent, so its numbers
130
+ // calibrate the grader and are never an arm's measurement (review 0003 Q49).
131
+ const measured = adapter.kind !== 'oracle';
815
132
  const isolation = adapterIsolation(adapter);
133
+ const experiment = await batchExperiment(root, { adapter, tasks, isolation, model: resolvedModel });
134
+ const timeoutForRun = timeoutMs ?? adapter.timeoutMs;
135
+
816
136
  const plannedRuns = [];
817
137
  for (const armSpec of armSpecs) {
818
138
  for (let rep = 1; rep <= reps; rep += 1) {
819
- const runId = runIdFor(harness, armSpec.name, rep);
820
- for (const task of tasks) {
821
- plannedRuns.push({
822
- runId,
823
- armSpec,
824
- rep,
825
- task,
826
- });
827
- }
139
+ const runId = runIdFor({ harness, model: resolvedModel, arm: armSpec.name, rep });
140
+ for (const task of tasks) plannedRuns.push({ runId, armSpec, rep, task });
828
141
  }
829
142
  }
830
143
 
@@ -834,33 +147,32 @@ export async function runBench(
834
147
  root,
835
148
  harness,
836
149
  model: resolvedModel,
837
- arms: armSpecs.map((a) => a.name),
150
+ arms: armSpecs.map((arm) => arm.name),
151
+ armDescriptions: armSpecs.map((arm) => `${arm.name}: ${describeArm(arm)}`),
838
152
  reps,
839
153
  taskCount: tasks.length,
840
- runs: plannedRuns.map((item) => ({
841
- schemaVersion: 1,
842
- runId: item.runId,
843
- task: item.task.id,
844
- arm: item.armSpec.name,
845
- harness,
846
- model: resolvedModel,
847
- rep: item.rep,
848
- measured,
849
- isolation, status: 'planned',
850
- resolved: null,
851
- gatesPassed: null,
852
- usage: emptyUsage().usage,
853
- costUsd: null,
854
- diff: { files: 0, outsideBlastRadius: 0 },
855
- probe: null,
856
- })),
154
+ experiment,
155
+ runs: plannedRuns.map((item) =>
156
+ runRecord({
157
+ task: item.task,
158
+ arm: item.armSpec.name,
159
+ harness,
160
+ model: resolvedModel,
161
+ rep: item.rep,
162
+ measured,
163
+ isolation,
164
+ experiment: { ...experiment, model: resolvedModel },
165
+ status: 'planned',
166
+ usage: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, turns: 0, toolCalls: 0 },
167
+ }),
168
+ ),
857
169
  };
858
170
  }
859
171
 
860
- const runsBaseDir = join(root, LOCAL_DIR, 'bench', 'runs');
172
+ const baseRunsDir = runsBaseDir(root);
861
173
  for (const armSpec of armSpecs) {
862
174
  for (let rep = 1; rep <= reps; rep += 1) {
863
- const runDir = join(runsBaseDir, runIdFor(harness, armSpec.name, rep));
175
+ const runDir = join(baseRunsDir, runIdFor({ harness, model: resolvedModel, arm: armSpec.name, rep }));
864
176
  await mkdir(runDir, { recursive: true });
865
177
  await writeFile(join(runDir, '.arm.json'), `${JSON.stringify(armSpec, null, 2)}\n`, 'utf8');
866
178
  }
@@ -868,19 +180,28 @@ export async function runBench(
868
180
 
869
181
  const regRes = await loadRegistry().catch(() => ({ registry: { skills: [] } }));
870
182
  const registrySkills = regRes?.registry?.skills ?? [];
871
- const knownSkillNames = new Set(registrySkills.map((s) => s.name));
183
+ const knownSkillNames = new Set(registrySkills.map((skill) => skill.name));
872
184
  const parentDir = await mkdtemp(join(tmpdir(), 'bench-batch-'));
873
185
  try {
186
+ // One export per task, copied per run: the harness is what the batch measures, not
187
+ // the cost of unpacking the same commit once per arm and repetition.
188
+ const bases = new Map();
189
+ for (const task of tasks) {
190
+ if (bases.has(task.base)) continue;
191
+ try {
192
+ bases.set(task.base, await prepareBaseTree(root, task.base, parentDir));
193
+ } catch {
194
+ bases.set(task.base, null);
195
+ }
196
+ }
197
+
874
198
  const records = await mapPool(plannedRuns, concurrency, async (item) => {
875
199
  let slotDir = null;
876
200
  try {
877
- const prepared = await prepareIsolatedWorkspace(
878
- root,
879
- item.task.base,
880
- item.armSpec,
881
- item.task,
882
- { parentDir },
883
- );
201
+ const prepared = await prepareIsolatedWorkspace(root, item.task.base, item.armSpec, item.task, {
202
+ parentDir,
203
+ baseDir: bases.get(item.task.base) ?? undefined,
204
+ });
884
205
  slotDir = prepared.slotDir;
885
206
  const { workDir, initialHead, env, promptPrefix } = prepared;
886
207
  const { usage, costUsd, transcript, agent } = await executeAdapter(adapter, workDir, item.task, {
@@ -888,33 +209,29 @@ export async function runBench(
888
209
  model: resolvedModel,
889
210
  maxTurns,
890
211
  env,
891
- timeoutMs,
212
+ timeoutMs: timeoutForRun,
892
213
  slotDir,
893
214
  promptPrefix,
894
215
  });
895
216
 
896
- const grade = await gradeWorkspace(workDir, item.task, {
897
- vaultRoot,
898
- initialHead,
899
- timeoutMs,
900
- });
217
+ const grade = await gradeWithOracle(workDir, item.task, { vaultRoot, initialHead, timeoutMs: timeoutForRun });
901
218
  const expectedSkills = matchSkillsForPaths(registrySkills, grade.goldFiles ?? []);
902
219
  const openedSkills =
903
- adapter.id === 'gold'
904
- ? expectedSkills
905
- : adapter.id === 'noop'
906
- ? []
907
- : extractOpenedSkills(transcript, knownSkillNames);
908
-
909
- const record = {
910
- schemaVersion: 1,
911
- task: item.task.id,
220
+ adapter.kind === 'oracle'
221
+ ? adapter.oracle?.from === 'vault'
222
+ ? expectedSkills
223
+ : []
224
+ : extractOpenedSkills(transcript, knownSkillNames);
225
+
226
+ const record = runRecord({
227
+ task: item.task,
912
228
  arm: item.armSpec.name,
913
229
  harness,
914
230
  model: resolvedModel,
915
231
  rep: item.rep,
916
232
  measured,
917
233
  isolation,
234
+ experiment: { ...experiment, model: resolvedModel },
918
235
  status: 'ok',
919
236
  agent,
920
237
  resolved: grade.resolved,
@@ -924,10 +241,9 @@ export async function runBench(
924
241
  costUsd,
925
242
  diff: grade.diff,
926
243
  skills: { expected: expectedSkills, opened: openedSkills },
927
- probe: null,
928
- };
244
+ });
929
245
 
930
- const runDir = join(runsBaseDir, item.runId);
246
+ const runDir = join(baseRunsDir, item.runId);
931
247
  await Promise.all([
932
248
  writeFile(join(runDir, `${item.task.id}.json`), `${JSON.stringify(record, null, 2)}\n`, 'utf8'),
933
249
  writeFile(join(runDir, `${item.task.id}.patch`), grade.agentPatch),
@@ -938,29 +254,22 @@ export async function runBench(
938
254
  // One failed run is one exclusion, not a lost batch: the old path exited 3 and
939
255
  // left a half-written run behind (review 0003 Q47).
940
256
  return {
941
- schemaVersion: 1,
257
+ ...runRecord({
258
+ task: item.task,
259
+ arm: item.armSpec.name,
260
+ harness,
261
+ model: resolvedModel,
262
+ rep: item.rep,
263
+ measured,
264
+ isolation,
265
+ experiment: { ...experiment, model: resolvedModel },
266
+ status: 'error',
267
+ error: String(error?.message ?? error),
268
+ }),
942
269
  runId: item.runId,
943
- task: item.task.id,
944
- arm: item.armSpec.name,
945
- harness,
946
- model: resolvedModel,
947
- rep: item.rep,
948
- measured,
949
- isolation,
950
- status: 'error',
951
- error: String(error?.message ?? error),
952
- resolved: false,
953
- gatesPassed: false,
954
- f2pPassed: [],
955
- usage: emptyUsage().usage,
956
- costUsd: null,
957
- diff: { files: 0, outsideBlastRadius: 0 },
958
- probe: null,
959
270
  };
960
271
  } finally {
961
- if (slotDir !== null) {
962
- await rm(slotDir, { recursive: true, force: true }).catch(() => {});
963
- }
272
+ if (slotDir !== null) await rm(slotDir, { recursive: true, force: true }).catch(() => {});
964
273
  }
965
274
  });
966
275
 
@@ -969,9 +278,11 @@ export async function runBench(
969
278
  root,
970
279
  harness,
971
280
  model: resolvedModel,
972
- arms: armSpecs.map((a) => a.name),
281
+ arms: armSpecs.map((arm) => arm.name),
282
+ armDescriptions: armSpecs.map((arm) => `${arm.name}: ${describeArm(arm)}`),
973
283
  reps,
974
284
  taskCount: tasks.length,
285
+ experiment,
975
286
  runs: records,
976
287
  };
977
288
  } finally {
@@ -979,49 +290,40 @@ export async function runBench(
979
290
  }
980
291
  }
981
292
 
982
- /** Two string lists hold the same members, ignoring order and duplicates. */
983
- function sameStringSet(a, b) {
984
- const left = [...new Set(a)].sort();
985
- const right = [...new Set(b)].sort();
986
- return left.length === right.length && left.every((value, index) => value === right[index]);
987
- }
988
-
989
293
  async function resolveRegradeTargets(root, target) {
990
- const runsBaseDir = join(root, LOCAL_DIR, 'bench', 'runs');
294
+ const base = runsBaseDir(root);
991
295
  const direct = resolve(root, target);
992
- const candidate = existsSync(direct) ? direct : join(runsBaseDir, target);
296
+ const candidate = existsSync(direct) ? direct : join(base, target);
993
297
 
994
298
  if (target === 'all') {
995
- const dirs = (await readdir(runsBaseDir, { withFileTypes: true }).catch(() => []))
996
- .filter((d) => d.isDirectory())
997
- .map((d) => join(runsBaseDir, d.name))
299
+ const dirs = (await readdir(base, { withFileTypes: true }).catch(() => []))
300
+ .filter((entry) => entry.isDirectory())
301
+ .map((entry) => join(base, entry.name))
998
302
  .sort();
999
303
  const files = [];
1000
304
  for (const dir of dirs) {
1001
305
  const entries = (await readdir(dir).catch(() => []))
1002
- .filter((f) => f.endsWith('.json') && !f.startsWith('.'))
306
+ .filter((file) => file.endsWith('.json') && !file.startsWith('.'))
1003
307
  .sort();
1004
308
  for (const entry of entries) files.push(join(dir, entry));
1005
309
  }
1006
310
  return files;
1007
311
  }
1008
312
 
1009
- const st = await stat(candidate).catch(() => null);
1010
- if (!st) {
1011
- throw new Error(`stored run not found: ${target}`);
1012
- }
1013
- if (st.isFile()) return [candidate];
1014
- if (st.isDirectory()) {
313
+ const targetStat = await stat(candidate).catch(() => null);
314
+ if (!targetStat) throw new Error(`stored run not found: ${target}`);
315
+ if (targetStat.isFile()) return [candidate];
316
+ if (targetStat.isDirectory()) {
1015
317
  const entries = (await readdir(candidate).catch(() => []))
1016
- .filter((f) => f.endsWith('.json') && !f.startsWith('.'))
318
+ .filter((file) => file.endsWith('.json') && !file.startsWith('.'))
1017
319
  .sort();
1018
- return entries.map((f) => join(candidate, f));
320
+ return entries.map((file) => join(candidate, file));
1019
321
  }
1020
322
  return [];
1021
323
  }
1022
324
 
1023
325
  /**
1024
- * Re-grade a stored run (or run directory) deterministically from its saved agent patch
326
+ * Re-grade a stored run (or run directory) deterministically from its saved agent patch,
1025
327
  * and verify that the reproduced grade matches the stored record.
1026
328
  *
1027
329
  * @param {string} root
@@ -1039,28 +341,24 @@ export async function regradeRun(
1039
341
  } = {},
1040
342
  ) {
1041
343
  const files = await resolveRegradeTargets(root, target);
1042
- if (files.length === 0) {
1043
- throw new Error(`no stored task runs found under ${target}`);
1044
- }
344
+ if (files.length === 0) throw new Error(`no stored task runs found under ${target}`);
1045
345
 
1046
- const tasksDir = join(root, LOCAL_DIR, 'bench', 'runs');
1047
- const taskDefsDir = join(root, STATE_DIR, 'bench', 'tasks');
346
+ const base = runsBaseDir(root);
1048
347
  const parentDir = await mkdtemp(join(tmpdir(), 'bench-regrade-'));
1049
348
 
1050
349
  try {
1051
350
  const results = await mapPool(files, concurrency, async (runFilePath) => {
1052
351
  const stored = JSON.parse(await readFile(runFilePath, 'utf8'));
1053
352
  const runDir = dirname(runFilePath);
1054
- const runId = runDir.startsWith(tasksDir)
1055
- ? runDir.slice(tasksDir.length + 1)
1056
- : dirname(runFilePath).split(sep).pop();
353
+ const runId = runDir.startsWith(base) ? runDir.slice(base.length + 1) : runDir.split(sep).pop();
1057
354
 
1058
- const taskPath = join(taskDefsDir, `${stored.task}.json`);
1059
- const task = JSON.parse(await readFile(taskPath, 'utf8'));
355
+ const task = await readTask(root, stored.task);
356
+ if (!task) throw new Error(`task descriptor missing for ${stored.task}`);
357
+ if (!task.base) throw new Error(`task ${stored.task} names no base commit`);
1060
358
 
1061
359
  const armMetaPath = join(runDir, '.arm.json');
1062
360
  const armSpec = await readFile(armMetaPath, 'utf8')
1063
- .then((s) => JSON.parse(s))
361
+ .then((text) => JSON.parse(text))
1064
362
  .catch(() => ({
1065
363
  name: stored.arm,
1066
364
  kind: BUILTIN_ARMS.has(stored.arm) ? 'builtin' : 'custom',
@@ -1073,24 +371,16 @@ export async function regradeRun(
1073
371
  const patchPath = runFilePath.replace(/\.json$/, '.patch');
1074
372
  const agentPatch = await readFile(patchPath).catch(() => Buffer.alloc(0));
1075
373
 
1076
- const { slotDir, workDir, initialHead } = await prepareIsolatedWorkspace(
1077
- root,
1078
- task.base,
1079
- armSpec,
1080
- task,
1081
- { parentDir },
1082
- );
374
+ const { slotDir, workDir, initialHead } = await prepareIsolatedWorkspace(root, task.base, armSpec, task, {
375
+ parentDir,
376
+ });
1083
377
  try {
1084
378
  await applyPatch(workDir, agentPatch, { timeoutMs });
1085
379
 
1086
- const grade = await gradeWorkspace(workDir, task, {
1087
- vaultRoot,
1088
- initialHead,
1089
- timeoutMs,
1090
- });
380
+ const grade = await gradeWithOracle(workDir, task, { vaultRoot, initialHead, timeoutMs });
1091
381
 
1092
- // The verdicts alone cannot tell "the same tests passed" from "the same number
1093
- // of tests passed"; the fail-to-pass set is compared too (review 0003 Q59).
382
+ // The verdicts alone cannot tell "the same tests passed" from "the same number of
383
+ // tests passed"; the fail-to-pass set is compared too (review 0003 Q59).
1094
384
  const reproduced =
1095
385
  grade.resolved === stored.resolved &&
1096
386
  grade.gatesPassed === stored.gatesPassed &&
@@ -1106,15 +396,8 @@ export async function regradeRun(
1106
396
  diff: grade.diff,
1107
397
  };
1108
398
 
1109
- if (apply) {
1110
- await writeFile(runFilePath, `${JSON.stringify(updated, null, 2)}\n`, 'utf8');
1111
- }
1112
-
1113
- return {
1114
- ...updated,
1115
- runId,
1116
- reproduced,
1117
- };
399
+ if (apply) await writeFile(runFilePath, `${JSON.stringify(updated, null, 2)}\n`, 'utf8');
400
+ return { ...updated, runId, reproduced };
1118
401
  } finally {
1119
402
  await rm(slotDir, { recursive: true, force: true }).catch(() => {});
1120
403
  }
@@ -1125,8 +408,8 @@ export async function regradeRun(
1125
408
  root,
1126
409
  target,
1127
410
  regraded: results.length,
1128
- reproducedCount: results.filter((r) => r.reproduced).length,
1129
- allReproduced: results.every((r) => r.reproduced),
411
+ reproducedCount: results.filter((result) => result.reproduced).length,
412
+ allReproduced: results.every((result) => result.reproduced),
1130
413
  runs: results,
1131
414
  };
1132
415
  } finally {