simt-dev 0.7.0 → 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 (235) hide show
  1. package/CHANGELOG.md +191 -870
  2. package/README.md +188 -213
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
  5. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  7. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  8. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  9. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  10. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  11. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  12. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  13. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  14. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  15. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  16. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  17. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  18. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  19. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  20. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  21. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  22. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  23. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  24. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  25. package/ecosystems/bun.json +6 -0
  26. package/ecosystems/cargo.json +25 -0
  27. package/ecosystems/composer.json +6 -0
  28. package/ecosystems/dart.json +24 -0
  29. package/ecosystems/deno.json +21 -0
  30. package/ecosystems/devcontainers.json +6 -0
  31. package/ecosystems/docker-compose.json +6 -0
  32. package/ecosystems/docker.json +6 -0
  33. package/ecosystems/dotnet.json +25 -0
  34. package/ecosystems/elixir.json +23 -0
  35. package/ecosystems/gitsubmodule.json +6 -0
  36. package/ecosystems/go.json +25 -0
  37. package/ecosystems/gradle.json +13 -0
  38. package/ecosystems/maven.json +23 -0
  39. package/ecosystems/node.json +25 -0
  40. package/ecosystems/python.json +20 -0
  41. package/ecosystems/ruby.json +23 -0
  42. package/ecosystems/swift.json +25 -0
  43. package/ecosystems/terraform.json +6 -0
  44. package/ecosystems/uv.json +6 -0
  45. package/harnesses/antigravity.json +162 -0
  46. package/harnesses/claude.json +206 -0
  47. package/harnesses/codex.json +136 -0
  48. package/harnesses/copilot.json +88 -0
  49. package/harnesses/cursor.json +140 -0
  50. package/harnesses/deepseek.json +99 -0
  51. package/harnesses/gemini.json +122 -0
  52. package/harnesses/opencode.json +173 -0
  53. package/package.json +12 -2
  54. package/rules/core.md +7 -0
  55. package/rules/mdc/intake-questions.mdc +8 -0
  56. package/rules/mdc/slice-commit.mdc +57 -4
  57. package/src/advice/advice.mjs +3 -2
  58. package/src/agenthooks/prompt.mjs +5 -2
  59. package/src/agenthooks/render.mjs +86 -180
  60. package/src/agenthooks/shim.mjs +1 -1
  61. package/src/agents/render.mjs +15 -18
  62. package/src/api.mjs +31 -0
  63. package/src/argv.mjs +48 -3
  64. package/src/bench/ablate.mjs +3 -4
  65. package/src/bench/adapters.json +163 -105
  66. package/src/bench/adapters.mjs +466 -0
  67. package/src/bench/arms.mjs +333 -0
  68. package/src/bench/grade.mjs +33 -73
  69. package/src/bench/graders.mjs +42 -0
  70. package/src/bench/mine.mjs +121 -475
  71. package/src/bench/probe-ledger.mjs +149 -0
  72. package/src/bench/probes.catalog.mjs +918 -0
  73. package/src/bench/probes.mjs +79 -1064
  74. package/src/bench/render.mjs +111 -0
  75. package/src/bench/report.mjs +120 -962
  76. package/src/bench/run.mjs +185 -802
  77. package/src/bench/variants.mjs +4 -5
  78. package/src/bench/workspace.mjs +160 -0
  79. package/src/brief/assemble.mjs +48 -15
  80. package/src/ci/dependabot.mjs +25 -55
  81. package/src/ci/workflow.mjs +30 -7
  82. package/src/cli.mjs +45 -0
  83. package/src/commands/agent.mjs +4 -13
  84. package/src/commands/agenthook.mjs +70 -40
  85. package/src/commands/bench.mjs +191 -18
  86. package/src/commands/brief.mjs +5 -4
  87. package/src/commands/ci.mjs +13 -14
  88. package/src/commands/compare.mjs +42 -42
  89. package/src/commands/completions.mjs +2 -6
  90. package/src/commands/decide.mjs +4 -4
  91. package/src/commands/distill.mjs +4 -3
  92. package/src/commands/docs.mjs +88 -0
  93. package/src/commands/doctor.mjs +11 -7
  94. package/src/commands/evidence.mjs +6 -9
  95. package/src/commands/explain.mjs +0 -1
  96. package/src/commands/gate.mjs +26 -25
  97. package/src/commands/graph.mjs +2 -3
  98. package/src/commands/harness.mjs +6 -5
  99. package/src/commands/help.mjs +168 -19
  100. package/src/commands/hooks.mjs +119 -49
  101. package/src/commands/ignore.mjs +13 -8
  102. package/src/commands/impact.mjs +8 -15
  103. package/src/commands/index.mjs +318 -193
  104. package/src/commands/init-scan.mjs +90 -0
  105. package/src/commands/init.mjs +124 -105
  106. package/src/commands/lease.mjs +73 -25
  107. package/src/commands/lessons.mjs +10 -9
  108. package/src/commands/local.mjs +3 -3
  109. package/src/commands/map.mjs +24 -14
  110. package/src/commands/mcp.mjs +2 -18
  111. package/src/commands/pack.mjs +9 -6
  112. package/src/commands/plan.mjs +6 -5
  113. package/src/commands/plugin.mjs +2 -2
  114. package/src/commands/prbody.mjs +3 -3
  115. package/src/commands/repoindex.mjs +12 -11
  116. package/src/commands/review.mjs +2 -2
  117. package/src/commands/rules.mjs +37 -16
  118. package/src/commands/shared.mjs +14 -19
  119. package/src/commands/skills.mjs +33 -32
  120. package/src/commands/skillscompile.mjs +7 -8
  121. package/src/commands/skillsinstall.mjs +7 -6
  122. package/src/commands/slice.mjs +220 -74
  123. package/src/commands/spec.mjs +167 -0
  124. package/src/commands/status.mjs +27 -18
  125. package/src/commands/sync.mjs +27 -25
  126. package/src/commands/tests.mjs +18 -21
  127. package/src/commands/tokens.mjs +21 -26
  128. package/src/commands/undo.mjs +5 -15
  129. package/src/commands/upgrade.mjs +15 -7
  130. package/src/commands/why.mjs +15 -21
  131. package/src/compare/hooksstate.mjs +2 -6
  132. package/src/compare/learn.mjs +3 -1
  133. package/src/compare/row.mjs +0 -2
  134. package/src/context.mjs +64 -0
  135. package/src/decisions/index-doc.mjs +46 -0
  136. package/src/decisions/index.mjs +3 -2
  137. package/src/diff.mjs +1 -1
  138. package/src/distill/distill.mjs +14 -1
  139. package/src/distill/engine.mjs +5 -1
  140. package/src/docs/commands.mjs +65 -0
  141. package/src/ecosystem/records.mjs +219 -0
  142. package/src/explain.mjs +31 -0
  143. package/src/gates/index.mjs +385 -0
  144. package/src/graph/compile.mjs +8 -4
  145. package/src/graph/load.mjs +6 -2
  146. package/src/graph/spec.mjs +10 -8
  147. package/src/harness/records.mjs +143 -0
  148. package/src/hooks/capability.mjs +7 -5
  149. package/src/hooks/dispatcher.mjs +209 -60
  150. package/src/hooks/gates.mjs +10 -43
  151. package/src/hooks/resolve.mjs +4 -14
  152. package/src/hooks/shim.mjs +5 -2
  153. package/src/hooks/where.mjs +2 -7
  154. package/src/identity.mjs +31 -12
  155. package/src/index/coverage.mjs +58 -0
  156. package/src/index/extract.mjs +6 -16
  157. package/src/index/gate.mjs +1 -1
  158. package/src/index/govern.mjs +26 -9
  159. package/src/index/impact.mjs +4 -8
  160. package/src/index/map.mjs +14 -15
  161. package/src/index/open.mjs +173 -0
  162. package/src/index/persist.mjs +224 -57
  163. package/src/index/store.mjs +82 -39
  164. package/src/index/tests.mjs +2 -6
  165. package/src/journal.mjs +94 -20
  166. package/src/kernel/card.mjs +37 -63
  167. package/src/kernel/checks.mjs +4 -10
  168. package/src/lease.mjs +102 -7
  169. package/src/ledger/index.mjs +150 -14
  170. package/src/local/client.mjs +4 -6
  171. package/src/locate.mjs +33 -9
  172. package/src/manifest/schema.mjs +213 -0
  173. package/src/mcp/paginate.mjs +33 -0
  174. package/src/mcp/resources.mjs +7 -10
  175. package/src/mcp/schemas.mjs +512 -0
  176. package/src/mcp/server.mjs +119 -25
  177. package/src/mcp/tools-facts.mjs +326 -0
  178. package/src/mcp/tools-read.mjs +221 -0
  179. package/src/mcp/tools.mjs +4 -967
  180. package/src/measure/corpus.mjs +150 -0
  181. package/src/measure/metrics.mjs +236 -0
  182. package/src/measure/records.mjs +211 -0
  183. package/src/measure/session.mjs +3 -2
  184. package/src/measure/stats.mjs +538 -0
  185. package/src/measure/task-schema.mjs +125 -0
  186. package/src/measure/tasks.mjs +69 -0
  187. package/src/memory/decide.mjs +6 -15
  188. package/src/memory/lesson-text.mjs +61 -0
  189. package/src/memory/lessons.mjs +29 -82
  190. package/src/name.mjs +7 -0
  191. package/src/node-version.mjs +37 -0
  192. package/src/output/index.mjs +209 -10
  193. package/src/packs/manage.mjs +32 -12
  194. package/src/packs.mjs +20 -2
  195. package/src/plan/index.mjs +1 -1
  196. package/src/plugins.mjs +22 -16
  197. package/src/prbody.mjs +7 -11
  198. package/src/projection/loaders.mjs +7 -692
  199. package/src/projection/lock.mjs +11 -1
  200. package/src/projection/manifest.mjs +29 -468
  201. package/src/projection/mdc.mjs +7 -8
  202. package/src/projection/project.mjs +126 -221
  203. package/src/projection/region.mjs +561 -0
  204. package/src/projection/rules.mjs +20 -550
  205. package/src/projection/surfaces.mjs +43 -210
  206. package/src/projection/targets.mjs +5 -5
  207. package/src/repo-root.mjs +31 -0
  208. package/src/resolve/eval.mjs +2 -1
  209. package/src/resolve/fingerprint.mjs +19 -22
  210. package/src/resolve/resolve.mjs +1 -41
  211. package/src/review/critic.mjs +10 -13
  212. package/src/run.mjs +98 -0
  213. package/src/secrets.mjs +14 -22
  214. package/src/state/events.mjs +129 -0
  215. package/src/status-snapshot.mjs +233 -0
  216. package/src/status.mjs +2 -6
  217. package/src/sys/git.mjs +82 -0
  218. package/src/sys/proc.mjs +151 -0
  219. package/src/testing/parsers.mjs +265 -0
  220. package/src/testing/patches.mjs +23 -0
  221. package/src/testing/paths.mjs +49 -0
  222. package/src/testing/runners.mjs +186 -0
  223. package/src/testing/vault.mjs +68 -0
  224. package/src/text/frontmatter.mjs +49 -0
  225. package/src/text/tokens.mjs +52 -0
  226. package/src/toolwords.mjs +3 -0
  227. package/src/util/fs.mjs +20 -0
  228. package/src/util/sort.mjs +12 -0
  229. package/src/workspace.mjs +58 -11
  230. package/src/write/plan.mjs +82 -0
  231. package/templates/plan/tracker.json +1 -0
  232. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  233. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  234. package/docs/decisions/README.md +0 -600
  235. package/src/bench/tasks.mjs +0 -36
package/src/bench/run.mjs CHANGED
@@ -1,708 +1,88 @@
1
- import { spawn } from 'node:child_process';
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.
2
8
  import { existsSync } from 'node:fs';
3
- 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';
4
10
  import { tmpdir } from 'node:os';
5
11
  import { dirname, join, resolve, sep } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
12
 
8
- import { loadRegistry } from '../graph/load.mjs';
9
13
  import { extractOpenedSkills, matchSkillsForPaths } from '../graph/compile.mjs';
10
- import { matchGlob } from '../index/govern.mjs';
11
- import { formatLessonParagraph, listPromotedLessons } from '../memory/lessons.mjs';
12
- import { MANIFEST_FILE, STATE_DIR } from '../name.mjs';
14
+ import { loadRegistry } from '../graph/load.mjs';
13
15
  import { LOCAL_DIR } from '../state.mjs';
16
+ import { packageVersion } from '../package-version.mjs';
14
17
  import { readManifest } from '../projection/manifest.mjs';
15
- import { plan } from '../projection/project.mjs';
16
- import { parse as parseRegion } from '../projection/markers.mjs';
17
- import { fingerprint } from '../resolve/fingerprint.mjs';
18
- import { rankSkills, tokenize } from '../resolve/resolve.mjs';
19
- import { gradeWorkspace } from './grade.mjs';
20
- import { resolveVaultDir } from './mine.mjs';
21
- import { loadTasks } from './tasks.mjs';
22
- import { ablateSectionInWorkspace, parseAblationArmName } from './ablate.mjs';
23
- import { applyVariantInWorkspace, parseVariantArmName } from './variants.mjs';
24
-
25
- const BENCH_DIR = dirname(fileURLToPath(import.meta.url));
26
- const ADAPTERS_PATH = join(BENCH_DIR, 'adapters.json');
27
-
28
- 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';
29
41
 
30
42
  const DEFAULT_REPS = 3;
31
43
  const DEFAULT_MAX_TURNS = 25;
32
44
  const DEFAULT_AGENT_TIMEOUT_MS = 120_000;
33
45
  const DEFAULT_CONCURRENCY = 4;
34
46
 
35
- let cachedAdapters = null;
36
-
37
- /**
38
- * Load the declarative harness adapter table (`src/bench/adapters.json`).
39
- *
40
- * @returns {Promise<Record<string, object>>}
41
- */
42
- export async function loadAdapters() {
43
- if (cachedAdapters) return cachedAdapters;
44
- const raw = JSON.parse(await readFile(ADAPTERS_PATH, 'utf8'));
45
- cachedAdapters = raw.adapters;
46
- return cachedAdapters;
47
- }
48
-
49
- function sanitizedEnv(extra = {}) {
50
- const env = { ...process.env, SIMT_DEV_WORKSPACE: 'off', ...extra };
51
- for (const key of Object.keys(env)) {
52
- if (key.startsWith('NODE_TEST')) delete env[key];
53
- }
54
- return env;
55
- }
56
-
57
- function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
58
- return new Promise((resolveOutcome) => {
59
- const child = spawn(cmd, args, {
60
- cwd,
61
- env: sanitizedEnv(env),
62
- stdio: ['pipe', 'pipe', 'pipe'],
63
- });
64
- const out = [];
65
- const err = [];
66
- let timedOut = false;
67
- const timer =
68
- timeoutMs > 0
69
- ? setTimeout(() => {
70
- timedOut = true;
71
- child.kill('SIGKILL');
72
- }, timeoutMs)
73
- : null;
74
-
75
- child.stdout.on('data', (chunk) => out.push(chunk));
76
- child.stderr.on('data', (chunk) => err.push(chunk));
77
- child.on('error', (error) => {
78
- if (timer) clearTimeout(timer);
79
- resolveOutcome({
80
- code: 1,
81
- timedOut,
82
- stdout: Buffer.concat(out),
83
- stderr: Buffer.from(String(error.message ?? error)),
84
- });
85
- });
86
- child.on('close', (code) => {
87
- if (timer) clearTimeout(timer);
88
- resolveOutcome({
89
- code: timedOut ? 124 : (code ?? 1),
90
- timedOut,
91
- stdout: Buffer.concat(out),
92
- stderr: Buffer.concat(err),
93
- });
94
- });
95
-
96
- if (input !== null) child.stdin.end(input);
97
- else child.stdin.end();
98
- });
99
- }
100
-
101
- function getByPath(obj, pathStr) {
102
- if (!obj || typeof obj !== 'object') return undefined;
103
- const parts = pathStr.split('.');
104
- let cur = obj;
105
- for (const part of parts) {
106
- if (cur === null || cur === undefined || typeof cur !== 'object') return undefined;
107
- cur = cur[part];
108
- }
109
- return cur;
47
+ function runsBaseDir(root) {
48
+ return join(root, LOCAL_DIR, 'bench', 'runs');
110
49
  }
111
50
 
112
- function firstNumeric(obj, paths = []) {
113
- for (const p of paths) {
114
- const val = getByPath(obj, p);
115
- if (typeof val === 'number' && Number.isFinite(val)) return val;
116
- }
117
- return null;
118
- }
119
-
120
- function emptyUsage() {
121
- return {
122
- usage: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, turns: 0, toolCalls: 0 },
123
- costUsd: null,
124
- };
125
- }
126
-
127
- function parseJsonObject(text) {
128
- const trimmed = String(text ?? '').trim();
129
- if (!trimmed) return null;
130
- try {
131
- return JSON.parse(trimmed);
132
- } catch {
133
- const lines = trimmed.split('\n').reverse();
134
- for (const line of lines) {
135
- const s = line.trim();
136
- if (s.startsWith('{') && s.endsWith('}')) {
137
- try {
138
- return JSON.parse(s);
139
- } catch {
140
- // try earlier line
141
- }
142
- }
143
- }
144
- const start = trimmed.indexOf('{');
145
- const end = trimmed.lastIndexOf('}');
146
- if (start !== -1 && end > start) {
147
- try {
148
- return JSON.parse(trimmed.slice(start, end + 1));
149
- } catch {
150
- return null;
151
- }
152
- }
153
- return null;
154
- }
155
- }
156
-
157
- /**
158
- * Parse token usage, turns, tool calls and USD cost from a harness's output.
159
- *
160
- * Uses the field paths declared in `src/bench/adapters.json` and supports single-JSON
161
- * outputs (`claude`, `gemini`, `cursor`), JSONL event streams (`codex`, `opencode`),
162
- * and `--analytics-log` JSONL files (`aider`).
163
- *
164
- * @param {string|object} adapterOrId
165
- * @param {string} stdoutText
166
- * @param {string} [analyticsText]
167
- * @returns {{usage: {input: number, cacheWrite: number, cacheRead: number, output: number, turns: number, toolCalls: number}, costUsd: number|null}}
168
- */
169
- export function parseAdapterUsage(adapterOrId, stdoutText = '', analyticsText = '') {
170
- const result = emptyUsage();
171
- const adapter =
172
- typeof adapterOrId === 'string'
173
- ? (cachedAdapters?.[adapterOrId] ?? { id: adapterOrId, format: 'json', fields: {} })
174
- : (adapterOrId ?? { id: 'unknown', format: 'json', fields: {} });
175
-
176
- if (adapter.format === 'builtin') {
177
- if (adapter.id === 'gold') {
178
- result.usage.turns = 1;
179
- result.usage.toolCalls = 1;
180
- }
181
- return result;
182
- }
183
-
184
- const fields = adapter.fields ?? {};
185
-
186
- if (adapter.format === 'json') {
187
- const obj = parseJsonObject(stdoutText);
188
- if (!obj || typeof obj !== 'object') return result;
189
-
190
- result.usage.input = firstNumeric(obj, fields.input) ?? 0;
191
- result.usage.cacheWrite = firstNumeric(obj, fields.cacheWrite) ?? 0;
192
- result.usage.cacheRead = firstNumeric(obj, fields.cacheRead) ?? 0;
193
- result.usage.output = firstNumeric(obj, fields.output) ?? 0;
194
- result.usage.turns = firstNumeric(obj, fields.turns) ?? 0;
195
- result.usage.toolCalls = firstNumeric(obj, fields.toolCalls) ?? 0;
196
- result.costUsd = firstNumeric(obj, fields.costUsd) ?? null;
197
-
198
- // Gemini CLI also nests per-model token totals under `stats.models.<name>.tokens`.
199
- const models = obj?.stats?.models;
200
- if (models && typeof models === 'object') {
201
- let promptSum = 0;
202
- let cachedSum = 0;
203
- let candidatesSum = 0;
204
- for (const m of Object.values(models)) {
205
- promptSum += Number(m?.tokens?.prompt ?? 0) || 0;
206
- cachedSum += Number(m?.tokens?.cached ?? 0) || 0;
207
- candidatesSum += Number(m?.tokens?.candidates ?? 0) || 0;
208
- }
209
- if (result.usage.input === 0 && promptSum > 0) result.usage.input = promptSum;
210
- if (result.usage.cacheRead === 0 && cachedSum > 0) result.usage.cacheRead = cachedSum;
211
- if (result.usage.output === 0 && candidatesSum > 0) result.usage.output = candidatesSum;
212
- }
213
-
214
- return result;
215
- }
216
-
217
- const rawStream = adapter.format === 'analytics-log' && analyticsText ? analyticsText : stdoutText;
218
- const lines = String(rawStream ?? '')
219
- .split('\n')
220
- .map((l) => l.trim())
221
- .filter(Boolean);
222
-
223
- for (const line of lines) {
224
- let event;
225
- try {
226
- event = JSON.parse(line);
227
- } catch {
228
- continue;
229
- }
230
- if (!event || typeof event !== 'object') continue;
231
-
232
- const input = firstNumeric(event, fields.input);
233
- const cacheWrite = firstNumeric(event, fields.cacheWrite);
234
- const cacheRead = firstNumeric(event, fields.cacheRead);
235
- const output = firstNumeric(event, fields.output);
236
- const turns = firstNumeric(event, fields.turns);
237
- const toolCalls = firstNumeric(event, fields.toolCalls);
238
- const cost = firstNumeric(event, fields.costUsd);
239
-
240
- if (input !== null) result.usage.input += input;
241
- if (cacheWrite !== null) result.usage.cacheWrite += cacheWrite;
242
- if (cacheRead !== null) result.usage.cacheRead += cacheRead;
243
- if (output !== null) result.usage.output += output;
244
- if (cost !== null) result.costUsd += cost;
245
-
246
- if (turns !== null) {
247
- result.usage.turns = Math.max(result.usage.turns, turns);
248
- } else if (
249
- event.type === 'turn.completed' ||
250
- event.type === 'step_finish' ||
251
- event.event === 'message_send'
252
- ) {
253
- result.usage.turns += 1;
254
- }
255
-
256
- if (toolCalls !== null) {
257
- result.usage.toolCalls = Math.max(result.usage.toolCalls, toolCalls);
258
- } else if (
259
- event.type === 'tool_use' ||
260
- (event.type === 'item.completed' &&
261
- (event.item?.type === 'command_execution' ||
262
- event.item?.type === 'tool_call' ||
263
- event.item?.type === 'mcp_tool_call'))
264
- ) {
265
- result.usage.toolCalls += 1;
266
- }
267
- }
268
-
269
- if (result.costUsd !== null) result.costUsd = Number(result.costUsd.toFixed(6));
270
- return result;
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]);
271
56
  }
272
57
 
273
58
  /**
274
- * Parse an `--arm` specification (`bare`, `current`, `kernel`, `brief`, or `name=path/to/arm.json`).
59
+ * The experiment a batch is measured under: the conditions every record in it shares.
275
60
  *
276
- * @param {string} rawSpec
277
- * @param {string} [cwd]
278
- * @returns {Promise<{name: string, kind: 'builtin'|'custom', base: string|null, write: Record<string, string>, remove: string[], env: Record<string, string>}>}
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.
279
63
  */
280
- export async function parseArmSpec(rawSpec, cwd = process.cwd()) {
281
- const spec = String(rawSpec ?? '').trim();
282
- if (!spec) throw new Error('empty --arm specification');
283
-
284
- const eq = spec.indexOf('=');
285
- if (eq === -1) {
286
- const ablateSpec = parseAblationArmName(spec);
287
- if (ablateSpec) {
288
- return {
289
- ...ablateSpec,
290
- write: {},
291
- remove: [],
292
- env: {},
293
- };
294
- }
295
- const variantSpec = parseVariantArmName(spec);
296
- if (variantSpec) {
297
- return {
298
- ...variantSpec,
299
- write: {},
300
- remove: [],
301
- env: {},
302
- };
303
- }
304
- if (!BUILTIN_ARMS.has(spec)) {
305
- throw new Error(
306
- `unknown arm "${spec}"; built-in arms are: ${[...BUILTIN_ARMS].join(', ')} (or pass --arm name=path/to/arm.json)`,
307
- );
308
- }
309
- return {
310
- name: spec,
311
- kind: 'builtin',
312
- base: spec,
313
- write: {},
314
- remove: [],
315
- env: {},
316
- };
317
- }
318
-
319
- const name = spec.slice(0, eq).trim();
320
- const relPath = spec.slice(eq + 1).trim();
321
- if (!name || !/^[A-Za-z0-9._-]+$/.test(name)) {
322
- throw new Error(`invalid custom arm name "${name}"`);
323
- }
324
- if (!relPath) {
325
- throw new Error(`custom arm "${name}" is missing a JSON file path`);
326
- }
327
-
328
- const fullPath = resolve(cwd, relPath);
329
- let data;
330
- try {
331
- data = JSON.parse(await readFile(fullPath, 'utf8'));
332
- } catch (error) {
333
- throw new Error(`could not read arm file ${relPath}: ${error.message ?? error}`);
334
- }
335
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
336
- throw new Error(`arm file ${relPath} must contain a JSON object`);
337
- }
338
- if (data.base !== undefined && data.base !== null && !BUILTIN_ARMS.has(data.base)) {
339
- throw new Error(`arm file ${relPath} has unknown base "${data.base}"`);
340
- }
341
- if (data.write !== undefined && (typeof data.write !== 'object' || data.write === null || Array.isArray(data.write))) {
342
- throw new Error(`arm file ${relPath} field "write" must be an object mapping paths to strings`);
343
- }
344
- if (data.remove !== undefined && !Array.isArray(data.remove)) {
345
- throw new Error(`arm file ${relPath} field "remove" must be an array of paths`);
346
- }
347
- if (data.env !== undefined && (typeof data.env !== 'object' || data.env === null || Array.isArray(data.env))) {
348
- throw new Error(`arm file ${relPath} field "env" must be an object mapping env keys to strings`);
349
- }
350
-
351
- const write = {};
352
- for (const [k, v] of Object.entries(data.write ?? {})) write[k] = String(v);
353
- const remove = (data.remove ?? []).map((p) => String(p));
354
- const env = {};
355
- for (const [k, v] of Object.entries(data.env ?? {})) env[k] = String(v);
356
-
357
- return {
358
- name,
359
- kind: 'custom',
360
- base: data.base ?? null,
361
- write,
362
- remove,
363
- env,
364
- };
365
- }
366
-
367
- function assertContained(workDir, relPath) {
368
- const target = resolve(workDir, relPath);
369
- if (target !== workDir && !target.startsWith(`${workDir}${sep}`)) {
370
- throw new Error(`arm path "${relPath}" escapes workspace`);
371
- }
372
- return target;
373
- }
374
-
375
- async function stripRegionFile(filePath, style) {
376
- let text;
377
- try {
378
- text = await readFile(filePath, 'utf8');
379
- } catch {
380
- return false;
381
- }
382
- const state = parseRegion(text, style);
383
- if (state.status !== 'ok') return false;
384
- const remaining = `${state.before.trimEnd()}\n${state.after.trimStart()}`.trim();
385
- if (!remaining) {
386
- await unlink(filePath).catch(() => {});
387
- } else {
388
- await writeFile(filePath, `${remaining}\n`, 'utf8');
389
- }
390
- return true;
391
- }
392
-
393
- async function stripBareArm(workDir) {
394
- await stripRegionFile(join(workDir, 'AGENTS.md'), 'html');
395
- await stripRegionFile(join(workDir, '.editorconfig'), 'hash');
396
- await stripRegionFile(join(workDir, '.gitattributes'), 'hash');
397
-
398
- const surfaceDirs = [
399
- '.cursor/rules',
400
- '.claude/rules',
401
- '.github/instructions',
402
- '.agents/rules',
403
- ];
404
- for (const relDir of surfaceDirs) {
405
- const absDir = join(workDir, relDir);
406
- const entries = await readdir(absDir).catch(() => []);
407
- for (const entry of entries) {
408
- const full = join(absDir, entry);
409
- const text = await readFile(full, 'utf8').catch(() => null);
410
- if (text !== null && parseRegion(text, 'html').status === 'ok') {
411
- await unlink(full).catch(() => {});
412
- }
413
- }
414
- }
415
-
416
- for (const rel of ['.githooks', '.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json', 'docs/lessons']) {
417
- await rm(join(workDir, rel), { recursive: true, force: true }).catch(() => {});
418
- }
419
-
420
- if (!existsSync(join(workDir, 'AGENTS.md'))) {
421
- for (const mirror of ['CLAUDE.md', 'GEMINI.md']) {
422
- const full = join(workDir, mirror);
423
- const st = await lstat(full).catch(() => null);
424
- if (st?.isSymbolicLink()) {
425
- await unlink(full).catch(() => {});
426
- }
427
- }
428
- }
429
- }
430
-
431
- async function applyProjectionMode(workDir, mode) {
432
- const manifestRes = await readManifest(workDir);
433
- const agentsText = await readFile(join(workDir, 'AGENTS.md'), 'utf8').catch(() => null);
434
- const hasRegion = agentsText !== null && parseRegion(agentsText, 'html').status === 'ok';
435
-
436
- // If the repository is neither adopted via manifest nor carrying a generated region,
437
- // `current` leaves its hand-written files as-is, while `kernel` projects a kernel
438
- // region only when the repo is adopted or already carries a region.
439
- if (!manifestRes.exists && !hasRegion) return;
440
-
441
- const manifestPath = manifestRes.path ?? join(workDir, MANIFEST_FILE);
442
- const hadManifest = manifestRes.exists;
443
- const originalManifestBytes = hadManifest ? await readFile(manifestPath, 'utf8').catch(() => null) : null;
444
-
445
- const baseManifest = manifestRes.data ?? {
446
- schemaVersion: 1,
447
- projection: { sha: '0000000000000000000000000000000000000000', sources: [] },
448
- gates: { preCommit: [], prePush: [] },
449
- };
450
- const nextManifest = { ...baseManifest };
451
- if (mode === 'kernel') nextManifest.mode = 'kernel';
452
- else delete nextManifest.mode;
453
-
454
- await writeFile(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
455
-
456
- const p = await plan(workDir);
457
- for (const t of p.targets ?? []) {
458
- if (t.action !== 'unchanged' && typeof t.text === 'string') {
459
- await mkdir(dirname(t.path), { recursive: true });
460
- await writeFile(t.path, t.text, 'utf8');
461
- }
462
- }
463
-
464
- if (!hadManifest) {
465
- await unlink(manifestPath).catch(() => {});
466
- } else if (originalManifestBytes !== null && mode !== 'kernel') {
467
- await writeFile(manifestPath, originalManifestBytes, 'utf8');
468
- }
469
- }
470
-
471
- async function writeTaskBrief(workDir, task) {
472
- const prompt = String(task?.prompt ?? '');
473
- const taskTokens = tokenize(prompt);
474
- const taskTokenSet = new Set(taskTokens);
475
- const [registry, fp, lessons] = await Promise.all([
476
- loadRegistry().catch(() => ({ skills: [] })),
477
- fingerprint(workDir).catch(() => ({ stacks: [], facts: new Set() })),
478
- listPromotedLessons(workDir).catch(() => []),
479
- ]);
480
- const skillsList = Array.isArray(registry.skills)
481
- ? registry.skills
482
- : Object.values(registry.skills ?? {});
483
- const { selected = [] } = rankSkills(skillsList, {
484
- taskTokens,
485
- paths: [],
486
- repoStack: fp.stack ?? [],
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,
487
74
  });
488
- const ranked = selected.slice(0, 5);
489
-
490
- const taskPaths = Array.isArray(task?.paths) ? task.paths : [];
491
- const matchingLessons = lessons.filter((lesson) => {
492
- const lessonGlobs = Array.isArray(lesson.paths) ? lesson.paths : [];
493
- if (taskPaths.some((p) => lessonGlobs.some((glob) => matchGlob(glob, p)))) return true;
494
- if (lessonGlobs.some((glob) => prompt.includes(glob.replace(/\/\*\*$/, '')))) return true;
495
- const kw = Array.isArray(lesson.keywords) ? lesson.keywords : [];
496
- return kw.some((k) => taskTokenSet.has(String(k).toLowerCase()));
497
- });
498
-
499
- const lines = [
500
- `# Task Brief: ${task?.id ?? 'task'}`,
501
- '',
502
- `Prompt: ${prompt}`,
503
- '',
504
- '## Ranked Skills',
505
- ...(ranked.length > 0
506
- ? ranked.map((r) => `- ${r.skill.name} (score ${r.score}): ${r.skill.description ?? ''}`)
507
- : ['- (none matched)']),
508
- ...(matchingLessons.length > 0
509
- ? [
510
- '',
511
- '## Lessons',
512
- ...matchingLessons.map((l) => `- ${formatLessonParagraph(l)}`),
513
- ]
514
- : []),
515
- '',
516
- ];
517
- const briefPath = join(workDir, LOCAL_DIR, 'brief.md');
518
- await mkdir(dirname(briefPath), { recursive: true });
519
- await writeFile(briefPath, lines.join('\n'), 'utf8');
520
75
  }
521
76
 
522
77
  /**
523
- * Materialise an experimental arm (`bare`, `current`, `kernel`, `brief`, or custom) in `workDir`.
78
+ * Run benchmark tasks across one or more arms and repetitions.
524
79
  *
525
- * @param {string} workDir
526
- * @param {{name: string, kind?: string, base?: string|null, write?: Record<string, string>, remove?: string[], env?: Record<string, string>}} armSpec
527
- * @param {object} [task]
528
- * @returns {Promise<{env: Record<string, string>}>}
529
- */
530
- export async function materialiseArm(workDir, armSpec, task = {}) {
531
- const ablate = armSpec.ablate ?? parseAblationArmName(armSpec.name)?.ablate ?? null;
532
- const variant = armSpec.variant ?? parseVariantArmName(armSpec.name)?.variant ?? null;
533
- const base = armSpec.base ?? (BUILTIN_ARMS.has(armSpec.name) ? armSpec.name : null);
534
- if (ablate) {
535
- await ablateSectionInWorkspace(workDir, ablate);
536
- } else if (variant) {
537
- await applyVariantInWorkspace(workDir, variant);
538
- } else if (base === 'bare') {
539
- await stripBareArm(workDir);
540
- } else if (base === 'current') {
541
- await applyProjectionMode(workDir, 'full');
542
- } else if (base === 'kernel') {
543
- await applyProjectionMode(workDir, 'kernel');
544
- } else if (base === 'brief') {
545
- await applyProjectionMode(workDir, 'full');
546
- await writeTaskBrief(workDir, task);
547
- }
548
-
549
- for (const relPath of armSpec.remove ?? []) {
550
- const target = assertContained(workDir, relPath);
551
- await rm(target, { recursive: true, force: true }).catch(() => {});
552
- }
553
-
554
- for (const [relPath, content] of Object.entries(armSpec.write ?? {})) {
555
- const target = assertContained(workDir, relPath);
556
- await mkdir(dirname(target), { recursive: true });
557
- await writeFile(target, String(content), 'utf8');
558
- }
559
-
560
- return { env: { ...(armSpec.env ?? {}) } };
561
- }
562
-
563
- /**
564
- * Export `baseSha` from `root` via `git archive` into a fresh temp directory, materialise
565
- * `armSpec`, and initialize a single-commit git repository so `git rev-list --all` has
566
- * 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.
567
83
  *
568
84
  * @param {string} root
569
- * @param {string} baseSha
570
- * @param {object} armSpec
571
- * @param {object} [task]
572
- * @param {{parentDir?: string}} [options]
573
- * @returns {Promise<{slotDir: string, workDir: string, initialHead: string, env: Record<string, string>}>}
574
- */
575
- export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}, { parentDir } = {}) {
576
- const slotDir = await mkdtemp(join(parentDir ?? tmpdir(), 'bench-run-'));
577
- const workDir = join(slotDir, 'workspace');
578
- await mkdir(workDir, { recursive: true });
579
-
580
- const archiveRes = await runCmd('git', ['archive', '--format=tar', baseSha], { cwd: root });
581
- if (archiveRes.code !== 0) {
582
- await rm(slotDir, { recursive: true, force: true }).catch(() => {});
583
- throw new Error(
584
- `git archive failed for ${baseSha}: ${archiveRes.stderr.toString('utf8').trim()}`,
585
- );
586
- }
587
-
588
- const tarRes = await runCmd('tar', ['-xf', '-', '-C', workDir], {
589
- cwd: workDir,
590
- input: archiveRes.stdout,
591
- });
592
- if (tarRes.code !== 0) {
593
- await rm(slotDir, { recursive: true, force: true }).catch(() => {});
594
- throw new Error(`tar unpack failed for ${baseSha}: ${tarRes.stderr.toString('utf8').trim()}`);
595
- }
596
-
597
- const rootLessonsDir = join(root, 'docs', 'lessons');
598
- const rootLessonFiles = (await readdir(rootLessonsDir).catch(() => [])).filter((f) => f.endsWith('.md'));
599
- if (rootLessonFiles.length > 0) {
600
- const destLessonsDir = join(workDir, 'docs', 'lessons');
601
- await mkdir(destLessonsDir, { recursive: true });
602
- for (const file of rootLessonFiles) {
603
- const target = join(destLessonsDir, file);
604
- if (!existsSync(target)) {
605
- const text = await readFile(join(rootLessonsDir, file), 'utf8');
606
- await writeFile(target, text, 'utf8');
607
- }
608
- }
609
- }
610
-
611
- const rootProfilesDir = join(root, STATE_DIR, 'profiles');
612
- const rootProfileFiles = (await readdir(rootProfilesDir).catch(() => [])).filter((f) => f.endsWith('.md'));
613
- if (rootProfileFiles.length > 0) {
614
- const destProfilesDir = join(workDir, STATE_DIR, 'profiles');
615
- await mkdir(destProfilesDir, { recursive: true });
616
- for (const file of rootProfileFiles) {
617
- const text = await readFile(join(rootProfilesDir, file), 'utf8');
618
- await writeFile(join(destProfilesDir, file), text, 'utf8');
619
- }
620
- }
621
-
622
- const { env } = await materialiseArm(workDir, armSpec, task);
623
-
624
- await runCmd('git', ['init', '-q'], { cwd: workDir });
625
- await runCmd('git', ['config', 'user.name', 'bench'], { cwd: workDir });
626
- await runCmd('git', ['config', 'user.email', 'bench@local'], { cwd: workDir });
627
- await runCmd('git', ['config', 'commit.gpgsign', 'false'], { cwd: workDir });
628
- await runCmd('git', ['add', '-A'], { cwd: workDir });
629
- const commitRes = await runCmd(
630
- 'git',
631
- ['commit', '-qm', 'base', '--allow-empty', '--no-verify'],
632
- { cwd: workDir },
633
- );
634
- if (commitRes.code !== 0) {
635
- await rm(slotDir, { recursive: true, force: true }).catch(() => {});
636
- throw new Error(`git commit failed in isolated workspace: ${commitRes.stderr.toString('utf8').trim()}`);
637
- }
638
-
639
- const headRes = await runCmd('git', ['rev-parse', 'HEAD'], { cwd: workDir });
640
- const initialHead = headRes.stdout.toString('utf8').trim();
641
-
642
- return { slotDir, workDir, initialHead, env };
643
- }
644
-
645
- function buildAdapterArgs(adapter, { prompt, turns, model, analyticsLog }) {
646
- const replaceToken = (token) =>
647
- String(token)
648
- .replaceAll('{prompt}', prompt)
649
- .replaceAll('{turns}', String(turns))
650
- .replaceAll('{model}', String(model ?? ''))
651
- .replaceAll('{analyticsLog}', String(analyticsLog ?? ''));
652
-
653
- const args = (adapter.args ?? []).map(replaceToken);
654
- if (model && model !== 'default' && model !== adapter.id && Array.isArray(adapter.modelArgs)) {
655
- args.push(...adapter.modelArgs.map(replaceToken));
656
- }
657
- return args;
658
- }
659
-
660
- async function executeAdapter(adapter, workDir, task, { vaultRoot, model, maxTurns, env, timeoutMs, slotDir }) {
661
- if (adapter.id === 'noop') return { ...parseAdapterUsage(adapter, ''), transcript: '' };
662
- if (adapter.id === 'gold') {
663
- const goldPatch = await readFile(join(vaultRoot, task.id, 'gold.patch'));
664
- if (goldPatch.length > 0) {
665
- const applyRes = await runCmd('git', ['apply', '-'], { cwd: workDir, input: goldPatch, timeoutMs });
666
- if (applyRes.code !== 0) {
667
- await runCmd('git', ['apply', '--3way', '-'], { cwd: workDir, input: goldPatch, timeoutMs });
668
- }
669
- }
670
- return { ...parseAdapterUsage(adapter, ''), transcript: '' };
671
- }
672
-
673
- const analyticsLog = join(slotDir, 'analytics.jsonl');
674
- const args = buildAdapterArgs(adapter, { prompt: String(task.prompt ?? ''), turns: maxTurns, model, analyticsLog });
675
- const outcome = await runCmd(adapter.bin, args, { cwd: workDir, env, timeoutMs });
676
- const stdoutText = outcome.stdout.toString('utf8');
677
- const analyticsText = adapter.format === 'analytics-log' ? await readFile(analyticsLog, 'utf8').catch(() => '') : '';
678
- return { ...parseAdapterUsage(adapter, stdoutText, analyticsText), transcript: `${stdoutText}\n${analyticsText}` };
679
- }
680
-
681
- // loadTasks lives in tasks.mjs (the advice engine became a second reader, so the loader
682
- // was extracted rather than copied — ADR-002).
683
-
684
- function runIdFor(harness, armName, rep) {
685
- return `${harness}-${armName}-r${rep}`;
686
- }
687
-
688
- async function mapPool(items, concurrency, fn) {
689
- const results = new Array(items.length);
690
- let cursor = 0;
691
- async function worker() {
692
- while (true) {
693
- const idx = cursor;
694
- cursor += 1;
695
- if (idx >= items.length) return;
696
- results[idx] = await fn(items[idx], idx);
697
- }
698
- }
699
- const count = Math.max(1, Math.min(concurrency, items.length));
700
- await Promise.all(Array.from({ length: count }, () => worker()));
701
- return results;
702
- }
703
-
704
- /**
705
- * Run benchmark tasks across one or more arms and repetitions.
85
+ * @param {object} [options]
706
86
  */
707
87
  export async function runBench(
708
88
  root,
@@ -718,14 +98,13 @@ export async function runBench(
718
98
  maxTurns = DEFAULT_MAX_TURNS,
719
99
  concurrency = DEFAULT_CONCURRENCY,
720
100
  cwd = process.cwd(),
101
+ adapters: adapterTable,
721
102
  } = {},
722
103
  ) {
723
- const adapters = await loadAdapters();
104
+ const adapters = adapterTable ?? (await loadAdapters());
724
105
  const adapter = adapters[harness];
725
106
  if (!adapter) {
726
- throw new Error(
727
- `unknown harness "${harness}"; known harnesses: ${Object.keys(adapters).sort().join(', ')}`,
728
- );
107
+ throw new Error(`unknown harness "${harness}"; known harnesses: ${Object.keys(adapters).sort().join(', ')}`);
729
108
  }
730
109
 
731
110
  if (!Number.isInteger(reps) || reps < 1) {
@@ -746,19 +125,19 @@ export async function runBench(
746
125
  );
747
126
  }
748
127
 
749
- const resolvedModel = model ?? (adapter.kind === 'builtin' ? adapter.id : 'default');
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';
132
+ const isolation = adapterIsolation(adapter);
133
+ const experiment = await batchExperiment(root, { adapter, tasks, isolation, model: resolvedModel });
134
+ const timeoutForRun = timeoutMs ?? adapter.timeoutMs;
135
+
750
136
  const plannedRuns = [];
751
137
  for (const armSpec of armSpecs) {
752
138
  for (let rep = 1; rep <= reps; rep += 1) {
753
- const runId = runIdFor(harness, armSpec.name, rep);
754
- for (const task of tasks) {
755
- plannedRuns.push({
756
- runId,
757
- armSpec,
758
- rep,
759
- task,
760
- });
761
- }
139
+ const runId = runIdFor({ harness, model: resolvedModel, arm: armSpec.name, rep });
140
+ for (const task of tasks) plannedRuns.push({ runId, armSpec, rep, task });
762
141
  }
763
142
  }
764
143
 
@@ -768,31 +147,32 @@ export async function runBench(
768
147
  root,
769
148
  harness,
770
149
  model: resolvedModel,
771
- arms: armSpecs.map((a) => a.name),
150
+ arms: armSpecs.map((arm) => arm.name),
151
+ armDescriptions: armSpecs.map((arm) => `${arm.name}: ${describeArm(arm)}`),
772
152
  reps,
773
153
  taskCount: tasks.length,
774
- runs: plannedRuns.map((item) => ({
775
- schemaVersion: 1,
776
- runId: item.runId,
777
- task: item.task.id,
778
- arm: item.armSpec.name,
779
- harness,
780
- model: resolvedModel,
781
- rep: item.rep,
782
- resolved: null,
783
- gatesPassed: null,
784
- usage: emptyUsage().usage,
785
- costUsd: null,
786
- diff: { files: 0, outsideBlastRadius: 0 },
787
- probe: null,
788
- })),
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
+ ),
789
169
  };
790
170
  }
791
171
 
792
- const runsBaseDir = join(root, LOCAL_DIR, 'bench', 'runs');
172
+ const baseRunsDir = runsBaseDir(root);
793
173
  for (const armSpec of armSpecs) {
794
174
  for (let rep = 1; rep <= reps; rep += 1) {
795
- const runDir = join(runsBaseDir, runIdFor(harness, armSpec.name, rep));
175
+ const runDir = join(baseRunsDir, runIdFor({ harness, model: resolvedModel, arm: armSpec.name, rep }));
796
176
  await mkdir(runDir, { recursive: true });
797
177
  await writeFile(join(runDir, '.arm.json'), `${JSON.stringify(armSpec, null, 2)}\n`, 'utf8');
798
178
  }
@@ -800,65 +180,96 @@ export async function runBench(
800
180
 
801
181
  const regRes = await loadRegistry().catch(() => ({ registry: { skills: [] } }));
802
182
  const registrySkills = regRes?.registry?.skills ?? [];
803
- const knownSkillNames = new Set(registrySkills.map((s) => s.name));
183
+ const knownSkillNames = new Set(registrySkills.map((skill) => skill.name));
804
184
  const parentDir = await mkdtemp(join(tmpdir(), 'bench-batch-'));
805
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
+
806
198
  const records = await mapPool(plannedRuns, concurrency, async (item) => {
807
- const { slotDir, workDir, initialHead, env } = await prepareIsolatedWorkspace(
808
- root,
809
- item.task.base,
810
- item.armSpec,
811
- item.task,
812
- { parentDir },
813
- );
199
+ let slotDir = null;
814
200
  try {
815
- const { usage, costUsd, transcript } = await executeAdapter(adapter, workDir, item.task, {
201
+ const prepared = await prepareIsolatedWorkspace(root, item.task.base, item.armSpec, item.task, {
202
+ parentDir,
203
+ baseDir: bases.get(item.task.base) ?? undefined,
204
+ });
205
+ slotDir = prepared.slotDir;
206
+ const { workDir, initialHead, env, promptPrefix } = prepared;
207
+ const { usage, costUsd, transcript, agent } = await executeAdapter(adapter, workDir, item.task, {
816
208
  vaultRoot,
817
209
  model: resolvedModel,
818
210
  maxTurns,
819
211
  env,
820
- timeoutMs,
212
+ timeoutMs: timeoutForRun,
821
213
  slotDir,
214
+ promptPrefix,
822
215
  });
823
216
 
824
- const grade = await gradeWorkspace(workDir, item.task, {
825
- vaultRoot,
826
- initialHead,
827
- timeoutMs,
828
- });
217
+ const grade = await gradeWithOracle(workDir, item.task, { vaultRoot, initialHead, timeoutMs: timeoutForRun });
829
218
  const expectedSkills = matchSkillsForPaths(registrySkills, grade.goldFiles ?? []);
830
219
  const openedSkills =
831
- adapter.id === 'gold'
832
- ? expectedSkills
833
- : adapter.id === 'noop'
834
- ? []
835
- : extractOpenedSkills(transcript, knownSkillNames);
836
-
837
- const record = {
838
- schemaVersion: 1,
839
- 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,
840
228
  arm: item.armSpec.name,
841
229
  harness,
842
230
  model: resolvedModel,
843
231
  rep: item.rep,
232
+ measured,
233
+ isolation,
234
+ experiment: { ...experiment, model: resolvedModel },
235
+ status: 'ok',
236
+ agent,
844
237
  resolved: grade.resolved,
845
238
  gatesPassed: grade.gatesPassed,
239
+ f2pPassed: grade.f2pPassed ?? [],
846
240
  usage,
847
241
  costUsd,
848
242
  diff: grade.diff,
849
243
  skills: { expected: expectedSkills, opened: openedSkills },
850
- probe: null,
851
- };
244
+ });
852
245
 
853
- const runDir = join(runsBaseDir, item.runId);
246
+ const runDir = join(baseRunsDir, item.runId);
854
247
  await Promise.all([
855
248
  writeFile(join(runDir, `${item.task.id}.json`), `${JSON.stringify(record, null, 2)}\n`, 'utf8'),
856
249
  writeFile(join(runDir, `${item.task.id}.patch`), grade.agentPatch),
857
250
  ]);
858
251
 
859
252
  return { ...record, runId: item.runId };
253
+ } catch (error) {
254
+ // One failed run is one exclusion, not a lost batch: the old path exited 3 and
255
+ // left a half-written run behind (review 0003 Q47).
256
+ return {
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
+ }),
269
+ runId: item.runId,
270
+ };
860
271
  } finally {
861
- await rm(slotDir, { recursive: true, force: true }).catch(() => {});
272
+ if (slotDir !== null) await rm(slotDir, { recursive: true, force: true }).catch(() => {});
862
273
  }
863
274
  });
864
275
 
@@ -867,9 +278,11 @@ export async function runBench(
867
278
  root,
868
279
  harness,
869
280
  model: resolvedModel,
870
- arms: armSpecs.map((a) => a.name),
281
+ arms: armSpecs.map((arm) => arm.name),
282
+ armDescriptions: armSpecs.map((arm) => `${arm.name}: ${describeArm(arm)}`),
871
283
  reps,
872
284
  taskCount: tasks.length,
285
+ experiment,
873
286
  runs: records,
874
287
  };
875
288
  } finally {
@@ -878,41 +291,39 @@ export async function runBench(
878
291
  }
879
292
 
880
293
  async function resolveRegradeTargets(root, target) {
881
- const runsBaseDir = join(root, LOCAL_DIR, 'bench', 'runs');
294
+ const base = runsBaseDir(root);
882
295
  const direct = resolve(root, target);
883
- const candidate = existsSync(direct) ? direct : join(runsBaseDir, target);
296
+ const candidate = existsSync(direct) ? direct : join(base, target);
884
297
 
885
298
  if (target === 'all') {
886
- const dirs = (await readdir(runsBaseDir, { withFileTypes: true }).catch(() => []))
887
- .filter((d) => d.isDirectory())
888
- .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))
889
302
  .sort();
890
303
  const files = [];
891
304
  for (const dir of dirs) {
892
305
  const entries = (await readdir(dir).catch(() => []))
893
- .filter((f) => f.endsWith('.json') && !f.startsWith('.'))
306
+ .filter((file) => file.endsWith('.json') && !file.startsWith('.'))
894
307
  .sort();
895
308
  for (const entry of entries) files.push(join(dir, entry));
896
309
  }
897
310
  return files;
898
311
  }
899
312
 
900
- const st = await stat(candidate).catch(() => null);
901
- if (!st) {
902
- throw new Error(`stored run not found: ${target}`);
903
- }
904
- if (st.isFile()) return [candidate];
905
- 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()) {
906
317
  const entries = (await readdir(candidate).catch(() => []))
907
- .filter((f) => f.endsWith('.json') && !f.startsWith('.'))
318
+ .filter((file) => file.endsWith('.json') && !file.startsWith('.'))
908
319
  .sort();
909
- return entries.map((f) => join(candidate, f));
320
+ return entries.map((file) => join(candidate, file));
910
321
  }
911
322
  return [];
912
323
  }
913
324
 
914
325
  /**
915
- * 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,
916
327
  * and verify that the reproduced grade matches the stored record.
917
328
  *
918
329
  * @param {string} root
@@ -930,28 +341,24 @@ export async function regradeRun(
930
341
  } = {},
931
342
  ) {
932
343
  const files = await resolveRegradeTargets(root, target);
933
- if (files.length === 0) {
934
- throw new Error(`no stored task runs found under ${target}`);
935
- }
344
+ if (files.length === 0) throw new Error(`no stored task runs found under ${target}`);
936
345
 
937
- const tasksDir = join(root, LOCAL_DIR, 'bench', 'runs');
938
- const taskDefsDir = join(root, '.simt', 'bench', 'tasks');
346
+ const base = runsBaseDir(root);
939
347
  const parentDir = await mkdtemp(join(tmpdir(), 'bench-regrade-'));
940
348
 
941
349
  try {
942
350
  const results = await mapPool(files, concurrency, async (runFilePath) => {
943
351
  const stored = JSON.parse(await readFile(runFilePath, 'utf8'));
944
352
  const runDir = dirname(runFilePath);
945
- const runId = runDir.startsWith(tasksDir)
946
- ? runDir.slice(tasksDir.length + 1)
947
- : dirname(runFilePath).split(sep).pop();
353
+ const runId = runDir.startsWith(base) ? runDir.slice(base.length + 1) : runDir.split(sep).pop();
948
354
 
949
- const taskPath = join(taskDefsDir, `${stored.task}.json`);
950
- 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`);
951
358
 
952
359
  const armMetaPath = join(runDir, '.arm.json');
953
360
  const armSpec = await readFile(armMetaPath, 'utf8')
954
- .then((s) => JSON.parse(s))
361
+ .then((text) => JSON.parse(text))
955
362
  .catch(() => ({
956
363
  name: stored.arm,
957
364
  kind: BUILTIN_ARMS.has(stored.arm) ? 'builtin' : 'custom',
@@ -964,38 +371,20 @@ export async function regradeRun(
964
371
  const patchPath = runFilePath.replace(/\.json$/, '.patch');
965
372
  const agentPatch = await readFile(patchPath).catch(() => Buffer.alloc(0));
966
373
 
967
- const { slotDir, workDir, initialHead } = await prepareIsolatedWorkspace(
968
- root,
969
- task.base,
970
- armSpec,
971
- task,
972
- { parentDir },
973
- );
374
+ const { slotDir, workDir, initialHead } = await prepareIsolatedWorkspace(root, task.base, armSpec, task, {
375
+ parentDir,
376
+ });
974
377
  try {
975
- if (agentPatch.length > 0) {
976
- const applyRes = await runCmd('git', ['apply', '--binary', '-'], {
977
- cwd: workDir,
978
- input: agentPatch,
979
- timeoutMs,
980
- });
981
- if (applyRes.code !== 0) {
982
- await runCmd('git', ['apply', '--3way', '-'], {
983
- cwd: workDir,
984
- input: agentPatch,
985
- timeoutMs,
986
- });
987
- }
988
- }
378
+ await applyPatch(workDir, agentPatch, { timeoutMs });
989
379
 
990
- const grade = await gradeWorkspace(workDir, task, {
991
- vaultRoot,
992
- initialHead,
993
- timeoutMs,
994
- });
380
+ const grade = await gradeWithOracle(workDir, task, { vaultRoot, initialHead, timeoutMs });
995
381
 
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).
996
384
  const reproduced =
997
385
  grade.resolved === stored.resolved &&
998
386
  grade.gatesPassed === stored.gatesPassed &&
387
+ sameStringSet(grade.f2pPassed ?? [], stored.f2pPassed ?? []) &&
999
388
  grade.diff.files === stored.diff?.files &&
1000
389
  grade.diff.outsideBlastRadius === stored.diff?.outsideBlastRadius;
1001
390
 
@@ -1003,18 +392,12 @@ export async function regradeRun(
1003
392
  ...stored,
1004
393
  resolved: grade.resolved,
1005
394
  gatesPassed: grade.gatesPassed,
395
+ f2pPassed: grade.f2pPassed ?? [],
1006
396
  diff: grade.diff,
1007
397
  };
1008
398
 
1009
- if (apply) {
1010
- await writeFile(runFilePath, `${JSON.stringify(updated, null, 2)}\n`, 'utf8');
1011
- }
1012
-
1013
- return {
1014
- ...updated,
1015
- runId,
1016
- reproduced,
1017
- };
399
+ if (apply) await writeFile(runFilePath, `${JSON.stringify(updated, null, 2)}\n`, 'utf8');
400
+ return { ...updated, runId, reproduced };
1018
401
  } finally {
1019
402
  await rm(slotDir, { recursive: true, force: true }).catch(() => {});
1020
403
  }
@@ -1025,8 +408,8 @@ export async function regradeRun(
1025
408
  root,
1026
409
  target,
1027
410
  regraded: results.length,
1028
- reproducedCount: results.filter((r) => r.reproduced).length,
1029
- allReproduced: results.every((r) => r.reproduced),
411
+ reproducedCount: results.filter((result) => result.reproduced).length,
412
+ allReproduced: results.every((result) => result.reproduced),
1030
413
  runs: results,
1031
414
  };
1032
415
  } finally {