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.
- package/CHANGELOG.md +36 -1
- package/README.md +169 -80
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
- package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +3 -1
- package/rules/mdc/intake-questions.mdc +8 -0
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +7 -5
- package/src/argv.mjs +19 -0
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +155 -113
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +12 -26
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +17 -535
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +15 -1037
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +116 -971
- package/src/bench/run.mjs +164 -881
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +11 -13
- package/src/ci/dependabot.mjs +25 -55
- package/src/commands/agent.mjs +1 -9
- package/src/commands/agenthook.mjs +66 -35
- package/src/commands/bench.mjs +2 -2
- package/src/commands/ci.mjs +11 -13
- package/src/commands/docs.mjs +88 -0
- package/src/commands/gate.mjs +10 -13
- package/src/commands/harness.mjs +3 -3
- package/src/commands/help.mjs +44 -3
- package/src/commands/hooks.mjs +20 -20
- package/src/commands/ignore.mjs +11 -7
- package/src/commands/impact.mjs +4 -8
- package/src/commands/index.mjs +49 -9
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +122 -104
- package/src/commands/lease.mjs +67 -18
- package/src/commands/map.mjs +10 -5
- package/src/commands/mcp.mjs +2 -17
- package/src/commands/repoindex.mjs +8 -8
- package/src/commands/rules.mjs +8 -4
- package/src/commands/shared.mjs +12 -16
- package/src/commands/skills.mjs +5 -6
- package/src/commands/skillscompile.mjs +3 -3
- package/src/commands/skillsinstall.mjs +5 -4
- package/src/commands/slice.mjs +199 -64
- package/src/commands/spec.mjs +78 -62
- package/src/commands/status.mjs +24 -12
- package/src/commands/sync.mjs +14 -14
- package/src/commands/tests.mjs +12 -12
- package/src/commands/tokens.mjs +19 -24
- package/src/commands/upgrade.mjs +8 -4
- package/src/commands/why.mjs +6 -13
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/decisions/index.mjs +3 -2
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +2 -1
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +5 -3
- package/src/hooks/dispatcher.mjs +26 -32
- package/src/hooks/gates.mjs +12 -2
- package/src/hooks/resolve.mjs +2 -7
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +2 -11
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +16 -11
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +124 -17
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +49 -9
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -1005
- package/src/{bench → measure}/corpus.mjs +1 -1
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/output/index.mjs +122 -31
- package/src/packs/manage.mjs +15 -9
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/manifest.mjs +10 -435
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +16 -547
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +13 -1
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +0 -7
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +8 -12
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +1 -1
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +3 -1
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- 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 {
|
|
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 {
|
|
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 {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
const
|
|
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
|
|
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
|
-
*
|
|
506
|
-
*
|
|
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
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
*
|
|
78
|
+
* Run benchmark tasks across one or more arms and repetitions.
|
|
528
79
|
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
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 {
|
|
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 === '
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
const measured = adapter.kind !== '
|
|
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((
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
usage:
|
|
853
|
-
|
|
854
|
-
|
|
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
|
|
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(
|
|
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((
|
|
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
|
-
|
|
879
|
-
item.task.base,
|
|
880
|
-
|
|
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
|
|
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.
|
|
904
|
-
?
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
const record = {
|
|
910
|
-
|
|
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
|
-
|
|
928
|
-
};
|
|
244
|
+
});
|
|
929
245
|
|
|
930
|
-
const runDir = join(
|
|
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
|
-
|
|
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((
|
|
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
|
|
294
|
+
const base = runsBaseDir(root);
|
|
991
295
|
const direct = resolve(root, target);
|
|
992
|
-
const candidate = existsSync(direct) ? direct : join(
|
|
296
|
+
const candidate = existsSync(direct) ? direct : join(base, target);
|
|
993
297
|
|
|
994
298
|
if (target === 'all') {
|
|
995
|
-
const dirs = (await readdir(
|
|
996
|
-
.filter((
|
|
997
|
-
.map((
|
|
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((
|
|
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
|
|
1010
|
-
if (!
|
|
1011
|
-
|
|
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((
|
|
318
|
+
.filter((file) => file.endsWith('.json') && !file.startsWith('.'))
|
|
1017
319
|
.sort();
|
|
1018
|
-
return entries.map((
|
|
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
|
|
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(
|
|
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
|
|
1059
|
-
|
|
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((
|
|
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
|
-
|
|
1078
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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((
|
|
1129
|
-
allReproduced: results.every((
|
|
411
|
+
reproducedCount: results.filter((result) => result.reproduced).length,
|
|
412
|
+
allReproduced: results.every((result) => result.reproduced),
|
|
1130
413
|
runs: results,
|
|
1131
414
|
};
|
|
1132
415
|
} finally {
|