simt-dev 0.2.2 → 0.3.1
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 +100 -0
- package/README.md +129 -75
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +2 -1
- package/rules/core.md +1 -1
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +54 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +371 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/harness.mjs +156 -5
- package/src/commands/help.mjs +89 -1
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +60 -1
- package/src/commands/map.mjs +91 -0
- package/src/commands/plan.mjs +99 -0
- package/src/commands/repoindex.mjs +52 -0
- package/src/commands/shared.mjs +398 -0
- package/src/commands/sync.mjs +72 -3
- package/src/commands/tests.mjs +192 -0
- package/src/commands/tokens.mjs +61 -1
- package/src/commands/why.mjs +177 -0
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +101 -0
- package/src/index/govern.mjs +222 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +393 -0
- package/src/index/persist.mjs +172 -0
- package/src/index/store.mjs +301 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/ledger/index.mjs +112 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/server.mjs +37 -6
- package/src/mcp/tools.mjs +84 -0
- package/src/measure/context.mjs +184 -11
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/lock.mjs +93 -0
- package/src/projection/manifest.mjs +78 -3
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +452 -43
- package/src/projection/shared.mjs +428 -0
- package/src/projection/surfaces.mjs +22 -8
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +71 -0
- package/templates/plan/tracker.json +36 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import { loadRegistry } from '../graph/load.mjs';
|
|
9
|
+
import { CONFIG_DIR, MANIFEST_FILE } from '../name.mjs';
|
|
10
|
+
import { readManifest } from '../projection/manifest.mjs';
|
|
11
|
+
import { plan } from '../projection/project.mjs';
|
|
12
|
+
import { parse as parseRegion } from '../projection/markers.mjs';
|
|
13
|
+
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
14
|
+
import { rankSkills, tokenize } from '../resolve/resolve.mjs';
|
|
15
|
+
import { gradeWorkspace } from './grade.mjs';
|
|
16
|
+
import { resolveVaultDir } from './mine.mjs';
|
|
17
|
+
import { ablateSectionInWorkspace, parseAblationArmName } from './ablate.mjs';
|
|
18
|
+
|
|
19
|
+
const STATE_DIR = `.${CONFIG_DIR}`;
|
|
20
|
+
|
|
21
|
+
const BENCH_DIR = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const ADAPTERS_PATH = join(BENCH_DIR, 'adapters.json');
|
|
23
|
+
|
|
24
|
+
export const BUILTIN_ARMS = new Set(['bare', 'current', 'kernel', 'brief']);
|
|
25
|
+
|
|
26
|
+
const DEFAULT_REPS = 3;
|
|
27
|
+
const DEFAULT_MAX_TURNS = 25;
|
|
28
|
+
const DEFAULT_AGENT_TIMEOUT_MS = 120_000;
|
|
29
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
30
|
+
|
|
31
|
+
let cachedAdapters = null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load the declarative harness adapter table (`src/bench/adapters.json`).
|
|
35
|
+
*
|
|
36
|
+
* @returns {Promise<Record<string, object>>}
|
|
37
|
+
*/
|
|
38
|
+
export async function loadAdapters() {
|
|
39
|
+
if (cachedAdapters) return cachedAdapters;
|
|
40
|
+
const raw = JSON.parse(await readFile(ADAPTERS_PATH, 'utf8'));
|
|
41
|
+
cachedAdapters = raw.adapters;
|
|
42
|
+
return cachedAdapters;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sanitizedEnv(extra = {}) {
|
|
46
|
+
const env = { ...process.env, SIMT_DEV_WORKSPACE: 'off', ...extra };
|
|
47
|
+
for (const key of Object.keys(env)) {
|
|
48
|
+
if (key.startsWith('NODE_TEST')) delete env[key];
|
|
49
|
+
}
|
|
50
|
+
return env;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_AGENT_TIMEOUT_MS } = {}) {
|
|
54
|
+
return new Promise((resolveOutcome) => {
|
|
55
|
+
const child = spawn(cmd, args, {
|
|
56
|
+
cwd,
|
|
57
|
+
env: sanitizedEnv(env),
|
|
58
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
59
|
+
});
|
|
60
|
+
const out = [];
|
|
61
|
+
const err = [];
|
|
62
|
+
let timedOut = false;
|
|
63
|
+
const timer =
|
|
64
|
+
timeoutMs > 0
|
|
65
|
+
? setTimeout(() => {
|
|
66
|
+
timedOut = true;
|
|
67
|
+
child.kill('SIGKILL');
|
|
68
|
+
}, timeoutMs)
|
|
69
|
+
: null;
|
|
70
|
+
|
|
71
|
+
child.stdout.on('data', (chunk) => out.push(chunk));
|
|
72
|
+
child.stderr.on('data', (chunk) => err.push(chunk));
|
|
73
|
+
child.on('error', (error) => {
|
|
74
|
+
if (timer) clearTimeout(timer);
|
|
75
|
+
resolveOutcome({
|
|
76
|
+
code: 1,
|
|
77
|
+
timedOut,
|
|
78
|
+
stdout: Buffer.concat(out),
|
|
79
|
+
stderr: Buffer.from(String(error.message ?? error)),
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
child.on('close', (code) => {
|
|
83
|
+
if (timer) clearTimeout(timer);
|
|
84
|
+
resolveOutcome({
|
|
85
|
+
code: timedOut ? 124 : (code ?? 1),
|
|
86
|
+
timedOut,
|
|
87
|
+
stdout: Buffer.concat(out),
|
|
88
|
+
stderr: Buffer.concat(err),
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (input !== null) child.stdin.end(input);
|
|
93
|
+
else child.stdin.end();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getByPath(obj, pathStr) {
|
|
98
|
+
if (!obj || typeof obj !== 'object') return undefined;
|
|
99
|
+
const parts = pathStr.split('.');
|
|
100
|
+
let cur = obj;
|
|
101
|
+
for (const part of parts) {
|
|
102
|
+
if (cur === null || cur === undefined || typeof cur !== 'object') return undefined;
|
|
103
|
+
cur = cur[part];
|
|
104
|
+
}
|
|
105
|
+
return cur;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function firstNumeric(obj, paths = []) {
|
|
109
|
+
for (const p of paths) {
|
|
110
|
+
const val = getByPath(obj, p);
|
|
111
|
+
if (typeof val === 'number' && Number.isFinite(val)) return val;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function emptyUsage() {
|
|
117
|
+
return {
|
|
118
|
+
usage: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, turns: 0, toolCalls: 0 },
|
|
119
|
+
costUsd: 0.0,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseJsonObject(text) {
|
|
124
|
+
const trimmed = String(text ?? '').trim();
|
|
125
|
+
if (!trimmed) return null;
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(trimmed);
|
|
128
|
+
} catch {
|
|
129
|
+
const lines = trimmed.split('\n').reverse();
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
const s = line.trim();
|
|
132
|
+
if (s.startsWith('{') && s.endsWith('}')) {
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(s);
|
|
135
|
+
} catch {
|
|
136
|
+
// try earlier line
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const start = trimmed.indexOf('{');
|
|
141
|
+
const end = trimmed.lastIndexOf('}');
|
|
142
|
+
if (start !== -1 && end > start) {
|
|
143
|
+
try {
|
|
144
|
+
return JSON.parse(trimmed.slice(start, end + 1));
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Parse token usage, turns, tool calls and USD cost from a harness's output.
|
|
155
|
+
*
|
|
156
|
+
* Uses the field paths declared in `src/bench/adapters.json` and supports single-JSON
|
|
157
|
+
* outputs (`claude`, `gemini`, `cursor`), JSONL event streams (`codex`, `opencode`),
|
|
158
|
+
* and `--analytics-log` JSONL files (`aider`).
|
|
159
|
+
*
|
|
160
|
+
* @param {string|object} adapterOrId
|
|
161
|
+
* @param {string} stdoutText
|
|
162
|
+
* @param {string} [analyticsText]
|
|
163
|
+
* @returns {{usage: {input: number, cacheWrite: number, cacheRead: number, output: number, turns: number, toolCalls: number}, costUsd: number}}
|
|
164
|
+
*/
|
|
165
|
+
export function parseAdapterUsage(adapterOrId, stdoutText = '', analyticsText = '') {
|
|
166
|
+
const result = emptyUsage();
|
|
167
|
+
const adapter =
|
|
168
|
+
typeof adapterOrId === 'string'
|
|
169
|
+
? (cachedAdapters?.[adapterOrId] ?? { id: adapterOrId, format: 'json', fields: {} })
|
|
170
|
+
: (adapterOrId ?? { id: 'unknown', format: 'json', fields: {} });
|
|
171
|
+
|
|
172
|
+
if (adapter.format === 'builtin') {
|
|
173
|
+
if (adapter.id === 'gold') {
|
|
174
|
+
result.usage.turns = 1;
|
|
175
|
+
result.usage.toolCalls = 1;
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const fields = adapter.fields ?? {};
|
|
181
|
+
|
|
182
|
+
if (adapter.format === 'json') {
|
|
183
|
+
const obj = parseJsonObject(stdoutText);
|
|
184
|
+
if (!obj || typeof obj !== 'object') return result;
|
|
185
|
+
|
|
186
|
+
result.usage.input = firstNumeric(obj, fields.input) ?? 0;
|
|
187
|
+
result.usage.cacheWrite = firstNumeric(obj, fields.cacheWrite) ?? 0;
|
|
188
|
+
result.usage.cacheRead = firstNumeric(obj, fields.cacheRead) ?? 0;
|
|
189
|
+
result.usage.output = firstNumeric(obj, fields.output) ?? 0;
|
|
190
|
+
result.usage.turns = firstNumeric(obj, fields.turns) ?? 0;
|
|
191
|
+
result.usage.toolCalls = firstNumeric(obj, fields.toolCalls) ?? 0;
|
|
192
|
+
result.costUsd = firstNumeric(obj, fields.costUsd) ?? 0.0;
|
|
193
|
+
|
|
194
|
+
// Gemini CLI also nests per-model token totals under `stats.models.<name>.tokens`.
|
|
195
|
+
const models = obj?.stats?.models;
|
|
196
|
+
if (models && typeof models === 'object') {
|
|
197
|
+
let promptSum = 0;
|
|
198
|
+
let cachedSum = 0;
|
|
199
|
+
let candidatesSum = 0;
|
|
200
|
+
for (const m of Object.values(models)) {
|
|
201
|
+
promptSum += Number(m?.tokens?.prompt ?? 0) || 0;
|
|
202
|
+
cachedSum += Number(m?.tokens?.cached ?? 0) || 0;
|
|
203
|
+
candidatesSum += Number(m?.tokens?.candidates ?? 0) || 0;
|
|
204
|
+
}
|
|
205
|
+
if (result.usage.input === 0 && promptSum > 0) result.usage.input = promptSum;
|
|
206
|
+
if (result.usage.cacheRead === 0 && cachedSum > 0) result.usage.cacheRead = cachedSum;
|
|
207
|
+
if (result.usage.output === 0 && candidatesSum > 0) result.usage.output = candidatesSum;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const rawStream = adapter.format === 'analytics-log' && analyticsText ? analyticsText : stdoutText;
|
|
214
|
+
const lines = String(rawStream ?? '')
|
|
215
|
+
.split('\n')
|
|
216
|
+
.map((l) => l.trim())
|
|
217
|
+
.filter(Boolean);
|
|
218
|
+
|
|
219
|
+
for (const line of lines) {
|
|
220
|
+
let event;
|
|
221
|
+
try {
|
|
222
|
+
event = JSON.parse(line);
|
|
223
|
+
} catch {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (!event || typeof event !== 'object') continue;
|
|
227
|
+
|
|
228
|
+
const input = firstNumeric(event, fields.input);
|
|
229
|
+
const cacheWrite = firstNumeric(event, fields.cacheWrite);
|
|
230
|
+
const cacheRead = firstNumeric(event, fields.cacheRead);
|
|
231
|
+
const output = firstNumeric(event, fields.output);
|
|
232
|
+
const turns = firstNumeric(event, fields.turns);
|
|
233
|
+
const toolCalls = firstNumeric(event, fields.toolCalls);
|
|
234
|
+
const cost = firstNumeric(event, fields.costUsd);
|
|
235
|
+
|
|
236
|
+
if (input !== null) result.usage.input += input;
|
|
237
|
+
if (cacheWrite !== null) result.usage.cacheWrite += cacheWrite;
|
|
238
|
+
if (cacheRead !== null) result.usage.cacheRead += cacheRead;
|
|
239
|
+
if (output !== null) result.usage.output += output;
|
|
240
|
+
if (cost !== null) result.costUsd += cost;
|
|
241
|
+
|
|
242
|
+
if (turns !== null) {
|
|
243
|
+
result.usage.turns = Math.max(result.usage.turns, turns);
|
|
244
|
+
} else if (
|
|
245
|
+
event.type === 'turn.completed' ||
|
|
246
|
+
event.type === 'step_finish' ||
|
|
247
|
+
event.event === 'message_send'
|
|
248
|
+
) {
|
|
249
|
+
result.usage.turns += 1;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (toolCalls !== null) {
|
|
253
|
+
result.usage.toolCalls = Math.max(result.usage.toolCalls, toolCalls);
|
|
254
|
+
} else if (
|
|
255
|
+
event.type === 'tool_use' ||
|
|
256
|
+
(event.type === 'item.completed' &&
|
|
257
|
+
(event.item?.type === 'command_execution' ||
|
|
258
|
+
event.item?.type === 'tool_call' ||
|
|
259
|
+
event.item?.type === 'mcp_tool_call'))
|
|
260
|
+
) {
|
|
261
|
+
result.usage.toolCalls += 1;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
result.costUsd = Number(result.costUsd.toFixed(6));
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Parse an `--arm` specification (`bare`, `current`, `kernel`, `brief`, or `name=path/to/arm.json`).
|
|
271
|
+
*
|
|
272
|
+
* @param {string} rawSpec
|
|
273
|
+
* @param {string} [cwd]
|
|
274
|
+
* @returns {Promise<{name: string, kind: 'builtin'|'custom', base: string|null, write: Record<string, string>, remove: string[], env: Record<string, string>}>}
|
|
275
|
+
*/
|
|
276
|
+
export async function parseArmSpec(rawSpec, cwd = process.cwd()) {
|
|
277
|
+
const spec = String(rawSpec ?? '').trim();
|
|
278
|
+
if (!spec) throw new Error('empty --arm specification');
|
|
279
|
+
|
|
280
|
+
const eq = spec.indexOf('=');
|
|
281
|
+
if (eq === -1) {
|
|
282
|
+
const ablateSpec = parseAblationArmName(spec);
|
|
283
|
+
if (ablateSpec) {
|
|
284
|
+
return {
|
|
285
|
+
...ablateSpec,
|
|
286
|
+
write: {},
|
|
287
|
+
remove: [],
|
|
288
|
+
env: {},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (!BUILTIN_ARMS.has(spec)) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`unknown arm "${spec}"; built-in arms are: ${[...BUILTIN_ARMS].join(', ')} (or pass --arm name=path/to/arm.json)`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
name: spec,
|
|
298
|
+
kind: 'builtin',
|
|
299
|
+
base: spec,
|
|
300
|
+
write: {},
|
|
301
|
+
remove: [],
|
|
302
|
+
env: {},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const name = spec.slice(0, eq).trim();
|
|
307
|
+
const relPath = spec.slice(eq + 1).trim();
|
|
308
|
+
if (!name || !/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
309
|
+
throw new Error(`invalid custom arm name "${name}"`);
|
|
310
|
+
}
|
|
311
|
+
if (!relPath) {
|
|
312
|
+
throw new Error(`custom arm "${name}" is missing a JSON file path`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const fullPath = resolve(cwd, relPath);
|
|
316
|
+
let data;
|
|
317
|
+
try {
|
|
318
|
+
data = JSON.parse(await readFile(fullPath, 'utf8'));
|
|
319
|
+
} catch (error) {
|
|
320
|
+
throw new Error(`could not read arm file ${relPath}: ${error.message ?? error}`);
|
|
321
|
+
}
|
|
322
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
323
|
+
throw new Error(`arm file ${relPath} must contain a JSON object`);
|
|
324
|
+
}
|
|
325
|
+
if (data.base !== undefined && data.base !== null && !BUILTIN_ARMS.has(data.base)) {
|
|
326
|
+
throw new Error(`arm file ${relPath} has unknown base "${data.base}"`);
|
|
327
|
+
}
|
|
328
|
+
if (data.write !== undefined && (typeof data.write !== 'object' || data.write === null || Array.isArray(data.write))) {
|
|
329
|
+
throw new Error(`arm file ${relPath} field "write" must be an object mapping paths to strings`);
|
|
330
|
+
}
|
|
331
|
+
if (data.remove !== undefined && !Array.isArray(data.remove)) {
|
|
332
|
+
throw new Error(`arm file ${relPath} field "remove" must be an array of paths`);
|
|
333
|
+
}
|
|
334
|
+
if (data.env !== undefined && (typeof data.env !== 'object' || data.env === null || Array.isArray(data.env))) {
|
|
335
|
+
throw new Error(`arm file ${relPath} field "env" must be an object mapping env keys to strings`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const write = {};
|
|
339
|
+
for (const [k, v] of Object.entries(data.write ?? {})) write[k] = String(v);
|
|
340
|
+
const remove = (data.remove ?? []).map((p) => String(p));
|
|
341
|
+
const env = {};
|
|
342
|
+
for (const [k, v] of Object.entries(data.env ?? {})) env[k] = String(v);
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
name,
|
|
346
|
+
kind: 'custom',
|
|
347
|
+
base: data.base ?? null,
|
|
348
|
+
write,
|
|
349
|
+
remove,
|
|
350
|
+
env,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function assertContained(workDir, relPath) {
|
|
355
|
+
const target = resolve(workDir, relPath);
|
|
356
|
+
if (target !== workDir && !target.startsWith(`${workDir}${sep}`)) {
|
|
357
|
+
throw new Error(`arm path "${relPath}" escapes workspace`);
|
|
358
|
+
}
|
|
359
|
+
return target;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function stripRegionFile(filePath, style) {
|
|
363
|
+
let text;
|
|
364
|
+
try {
|
|
365
|
+
text = await readFile(filePath, 'utf8');
|
|
366
|
+
} catch {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
const state = parseRegion(text, style);
|
|
370
|
+
if (state.status !== 'ok') return false;
|
|
371
|
+
const remaining = `${state.before.trimEnd()}\n${state.after.trimStart()}`.trim();
|
|
372
|
+
if (!remaining) {
|
|
373
|
+
await unlink(filePath).catch(() => {});
|
|
374
|
+
} else {
|
|
375
|
+
await writeFile(filePath, `${remaining}\n`, 'utf8');
|
|
376
|
+
}
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function stripBareArm(workDir) {
|
|
381
|
+
await stripRegionFile(join(workDir, 'AGENTS.md'), 'html');
|
|
382
|
+
await stripRegionFile(join(workDir, '.editorconfig'), 'hash');
|
|
383
|
+
await stripRegionFile(join(workDir, '.gitattributes'), 'hash');
|
|
384
|
+
|
|
385
|
+
const surfaceDirs = [
|
|
386
|
+
'.cursor/rules',
|
|
387
|
+
'.claude/rules',
|
|
388
|
+
'.github/instructions',
|
|
389
|
+
'.agents/rules',
|
|
390
|
+
];
|
|
391
|
+
for (const relDir of surfaceDirs) {
|
|
392
|
+
const absDir = join(workDir, relDir);
|
|
393
|
+
const entries = await readdir(absDir).catch(() => []);
|
|
394
|
+
for (const entry of entries) {
|
|
395
|
+
const full = join(absDir, entry);
|
|
396
|
+
const text = await readFile(full, 'utf8').catch(() => null);
|
|
397
|
+
if (text !== null && parseRegion(text, 'html').status === 'ok') {
|
|
398
|
+
await unlink(full).catch(() => {});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const rel of ['.githooks', '.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json']) {
|
|
404
|
+
await rm(join(workDir, rel), { recursive: true, force: true }).catch(() => {});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!existsSync(join(workDir, 'AGENTS.md'))) {
|
|
408
|
+
for (const mirror of ['CLAUDE.md', 'GEMINI.md']) {
|
|
409
|
+
const full = join(workDir, mirror);
|
|
410
|
+
const st = await lstat(full).catch(() => null);
|
|
411
|
+
if (st?.isSymbolicLink()) {
|
|
412
|
+
await unlink(full).catch(() => {});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function applyProjectionMode(workDir, mode) {
|
|
419
|
+
const manifestRes = await readManifest(workDir);
|
|
420
|
+
const agentsText = await readFile(join(workDir, 'AGENTS.md'), 'utf8').catch(() => null);
|
|
421
|
+
const hasRegion = agentsText !== null && parseRegion(agentsText, 'html').status === 'ok';
|
|
422
|
+
|
|
423
|
+
// If the repository is neither adopted via manifest nor carrying a generated region,
|
|
424
|
+
// `current` leaves its hand-written files as-is, while `kernel` projects a kernel
|
|
425
|
+
// region only when the repo is adopted or already carries a region.
|
|
426
|
+
if (!manifestRes.exists && !hasRegion) return;
|
|
427
|
+
|
|
428
|
+
const manifestPath = manifestRes.path ?? join(workDir, MANIFEST_FILE);
|
|
429
|
+
const hadManifest = manifestRes.exists;
|
|
430
|
+
const originalManifestBytes = hadManifest ? await readFile(manifestPath, 'utf8').catch(() => null) : null;
|
|
431
|
+
|
|
432
|
+
const baseManifest = manifestRes.data ?? {
|
|
433
|
+
schemaVersion: 1,
|
|
434
|
+
projection: { sha: '0000000000000000000000000000000000000000', sources: [] },
|
|
435
|
+
gates: { preCommit: [], prePush: [] },
|
|
436
|
+
};
|
|
437
|
+
const nextManifest = { ...baseManifest };
|
|
438
|
+
if (mode === 'kernel') nextManifest.mode = 'kernel';
|
|
439
|
+
else delete nextManifest.mode;
|
|
440
|
+
|
|
441
|
+
await writeFile(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
|
|
442
|
+
|
|
443
|
+
const p = await plan(workDir);
|
|
444
|
+
for (const t of p.targets ?? []) {
|
|
445
|
+
if (t.action !== 'unchanged' && typeof t.text === 'string') {
|
|
446
|
+
await mkdir(dirname(t.path), { recursive: true });
|
|
447
|
+
await writeFile(t.path, t.text, 'utf8');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!hadManifest) {
|
|
452
|
+
await unlink(manifestPath).catch(() => {});
|
|
453
|
+
} else if (originalManifestBytes !== null && mode !== 'kernel') {
|
|
454
|
+
await writeFile(manifestPath, originalManifestBytes, 'utf8');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function writeTaskBrief(workDir, task) {
|
|
459
|
+
const prompt = String(task?.prompt ?? '');
|
|
460
|
+
const [registry, fp] = await Promise.all([
|
|
461
|
+
loadRegistry().catch(() => ({ skills: [] })),
|
|
462
|
+
fingerprint(workDir).catch(() => ({ stacks: [], facts: new Set() })),
|
|
463
|
+
]);
|
|
464
|
+
const skillsList = Array.isArray(registry.skills)
|
|
465
|
+
? registry.skills
|
|
466
|
+
: Object.values(registry.skills ?? {});
|
|
467
|
+
const { selected = [] } = rankSkills(skillsList, {
|
|
468
|
+
taskTokens: tokenize(prompt),
|
|
469
|
+
paths: [],
|
|
470
|
+
repoStack: fp.stack ?? [],
|
|
471
|
+
});
|
|
472
|
+
const ranked = selected.slice(0, 5);
|
|
473
|
+
|
|
474
|
+
const lines = [
|
|
475
|
+
`# Task Brief: ${task?.id ?? 'task'}`,
|
|
476
|
+
'',
|
|
477
|
+
`Prompt: ${prompt}`,
|
|
478
|
+
'',
|
|
479
|
+
'## Ranked Skills',
|
|
480
|
+
...(ranked.length > 0
|
|
481
|
+
? ranked.map((r) => `- ${r.skill.name} (score ${r.score}): ${r.skill.description ?? ''}`)
|
|
482
|
+
: ['- (none matched)']),
|
|
483
|
+
'',
|
|
484
|
+
];
|
|
485
|
+
const briefPath = join(workDir, STATE_DIR, 'brief.md');
|
|
486
|
+
await mkdir(dirname(briefPath), { recursive: true });
|
|
487
|
+
await writeFile(briefPath, lines.join('\n'), 'utf8');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Materialise an experimental arm (`bare`, `current`, `kernel`, `brief`, or custom) in `workDir`.
|
|
492
|
+
*
|
|
493
|
+
* @param {string} workDir
|
|
494
|
+
* @param {{name: string, kind?: string, base?: string|null, write?: Record<string, string>, remove?: string[], env?: Record<string, string>}} armSpec
|
|
495
|
+
* @param {object} [task]
|
|
496
|
+
* @returns {Promise<{env: Record<string, string>}>}
|
|
497
|
+
*/
|
|
498
|
+
export async function materialiseArm(workDir, armSpec, task = {}) {
|
|
499
|
+
const ablate = armSpec.ablate ?? parseAblationArmName(armSpec.name)?.ablate ?? null;
|
|
500
|
+
const base = armSpec.base ?? (BUILTIN_ARMS.has(armSpec.name) ? armSpec.name : null);
|
|
501
|
+
if (ablate) {
|
|
502
|
+
await ablateSectionInWorkspace(workDir, ablate);
|
|
503
|
+
} else if (base === 'bare') {
|
|
504
|
+
await stripBareArm(workDir);
|
|
505
|
+
} else if (base === 'current') {
|
|
506
|
+
await applyProjectionMode(workDir, 'full');
|
|
507
|
+
} else if (base === 'kernel') {
|
|
508
|
+
await applyProjectionMode(workDir, 'kernel');
|
|
509
|
+
} else if (base === 'brief') {
|
|
510
|
+
await applyProjectionMode(workDir, 'full');
|
|
511
|
+
await writeTaskBrief(workDir, task);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
for (const relPath of armSpec.remove ?? []) {
|
|
515
|
+
const target = assertContained(workDir, relPath);
|
|
516
|
+
await rm(target, { recursive: true, force: true }).catch(() => {});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
for (const [relPath, content] of Object.entries(armSpec.write ?? {})) {
|
|
520
|
+
const target = assertContained(workDir, relPath);
|
|
521
|
+
await mkdir(dirname(target), { recursive: true });
|
|
522
|
+
await writeFile(target, String(content), 'utf8');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return { env: { ...(armSpec.env ?? {}) } };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Export `baseSha` from `root` via `git archive` into a fresh temp directory, materialise
|
|
530
|
+
* `armSpec`, and initialize a single-commit git repository so `git rev-list --all` has
|
|
531
|
+
* exactly one commit and the object store shares nothing with `root`.
|
|
532
|
+
*
|
|
533
|
+
* @param {string} root
|
|
534
|
+
* @param {string} baseSha
|
|
535
|
+
* @param {object} armSpec
|
|
536
|
+
* @param {object} [task]
|
|
537
|
+
* @param {{parentDir?: string}} [options]
|
|
538
|
+
* @returns {Promise<{slotDir: string, workDir: string, initialHead: string, env: Record<string, string>}>}
|
|
539
|
+
*/
|
|
540
|
+
export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}, { parentDir } = {}) {
|
|
541
|
+
const slotDir = await mkdtemp(join(parentDir ?? tmpdir(), 'bench-run-'));
|
|
542
|
+
const workDir = join(slotDir, 'workspace');
|
|
543
|
+
await mkdir(workDir, { recursive: true });
|
|
544
|
+
|
|
545
|
+
const archiveRes = await runCmd('git', ['archive', '--format=tar', baseSha], { cwd: root });
|
|
546
|
+
if (archiveRes.code !== 0) {
|
|
547
|
+
await rm(slotDir, { recursive: true, force: true }).catch(() => {});
|
|
548
|
+
throw new Error(
|
|
549
|
+
`git archive failed for ${baseSha}: ${archiveRes.stderr.toString('utf8').trim()}`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const tarRes = await runCmd('tar', ['-xf', '-', '-C', workDir], {
|
|
554
|
+
cwd: workDir,
|
|
555
|
+
input: archiveRes.stdout,
|
|
556
|
+
});
|
|
557
|
+
if (tarRes.code !== 0) {
|
|
558
|
+
await rm(slotDir, { recursive: true, force: true }).catch(() => {});
|
|
559
|
+
throw new Error(`tar unpack failed for ${baseSha}: ${tarRes.stderr.toString('utf8').trim()}`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const { env } = await materialiseArm(workDir, armSpec, task);
|
|
563
|
+
|
|
564
|
+
await runCmd('git', ['init', '-q'], { cwd: workDir });
|
|
565
|
+
await runCmd('git', ['config', 'user.name', 'bench'], { cwd: workDir });
|
|
566
|
+
await runCmd('git', ['config', 'user.email', 'bench@local'], { cwd: workDir });
|
|
567
|
+
await runCmd('git', ['config', 'commit.gpgsign', 'false'], { cwd: workDir });
|
|
568
|
+
await runCmd('git', ['add', '-A'], { cwd: workDir });
|
|
569
|
+
const commitRes = await runCmd(
|
|
570
|
+
'git',
|
|
571
|
+
['commit', '-qm', 'base', '--allow-empty', '--no-verify'],
|
|
572
|
+
{ cwd: workDir },
|
|
573
|
+
);
|
|
574
|
+
if (commitRes.code !== 0) {
|
|
575
|
+
await rm(slotDir, { recursive: true, force: true }).catch(() => {});
|
|
576
|
+
throw new Error(`git commit failed in isolated workspace: ${commitRes.stderr.toString('utf8').trim()}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const headRes = await runCmd('git', ['rev-parse', 'HEAD'], { cwd: workDir });
|
|
580
|
+
const initialHead = headRes.stdout.toString('utf8').trim();
|
|
581
|
+
|
|
582
|
+
return { slotDir, workDir, initialHead, env };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function buildAdapterArgs(adapter, { prompt, turns, model, analyticsLog }) {
|
|
586
|
+
const replaceToken = (token) =>
|
|
587
|
+
String(token)
|
|
588
|
+
.replaceAll('{prompt}', prompt)
|
|
589
|
+
.replaceAll('{turns}', String(turns))
|
|
590
|
+
.replaceAll('{model}', String(model ?? ''))
|
|
591
|
+
.replaceAll('{analyticsLog}', String(analyticsLog ?? ''));
|
|
592
|
+
|
|
593
|
+
const args = (adapter.args ?? []).map(replaceToken);
|
|
594
|
+
if (model && model !== 'default' && model !== adapter.id && Array.isArray(adapter.modelArgs)) {
|
|
595
|
+
args.push(...adapter.modelArgs.map(replaceToken));
|
|
596
|
+
}
|
|
597
|
+
return args;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function executeAdapter(adapter, workDir, task, { vaultRoot, model, maxTurns, env, timeoutMs, slotDir }) {
|
|
601
|
+
if (adapter.id === 'noop') {
|
|
602
|
+
return parseAdapterUsage(adapter, '');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (adapter.id === 'gold') {
|
|
606
|
+
const goldPatchPath = join(vaultRoot, task.id, 'gold.patch');
|
|
607
|
+
const goldPatch = await readFile(goldPatchPath);
|
|
608
|
+
if (goldPatch.length > 0) {
|
|
609
|
+
const applyRes = await runCmd('git', ['apply', '-'], {
|
|
610
|
+
cwd: workDir,
|
|
611
|
+
input: goldPatch,
|
|
612
|
+
timeoutMs,
|
|
613
|
+
});
|
|
614
|
+
if (applyRes.code !== 0) {
|
|
615
|
+
await runCmd('git', ['apply', '--3way', '-'], {
|
|
616
|
+
cwd: workDir,
|
|
617
|
+
input: goldPatch,
|
|
618
|
+
timeoutMs,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return parseAdapterUsage(adapter, '');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const analyticsLog = join(slotDir, 'analytics.jsonl');
|
|
626
|
+
const args = buildAdapterArgs(adapter, {
|
|
627
|
+
prompt: String(task.prompt ?? ''),
|
|
628
|
+
turns: maxTurns,
|
|
629
|
+
model,
|
|
630
|
+
analyticsLog,
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const outcome = await runCmd(adapter.bin, args, {
|
|
634
|
+
cwd: workDir,
|
|
635
|
+
env,
|
|
636
|
+
timeoutMs,
|
|
637
|
+
});
|
|
638
|
+
const analyticsText =
|
|
639
|
+
adapter.format === 'analytics-log'
|
|
640
|
+
? await readFile(analyticsLog, 'utf8').catch(() => '')
|
|
641
|
+
: '';
|
|
642
|
+
|
|
643
|
+
return parseAdapterUsage(adapter, outcome.stdout.toString('utf8'), analyticsText);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function loadTasks(root, filterIds = []) {
|
|
647
|
+
const tasksDir = join(root, STATE_DIR, 'bench', 'tasks');
|
|
648
|
+
const entries = (await readdir(tasksDir).catch(() => []))
|
|
649
|
+
.filter((f) => f.endsWith('.json'))
|
|
650
|
+
.sort();
|
|
651
|
+
|
|
652
|
+
const filterSet = new Set(filterIds);
|
|
653
|
+
const tasks = [];
|
|
654
|
+
for (const entry of entries) {
|
|
655
|
+
const data = JSON.parse(await readFile(join(tasksDir, entry), 'utf8'));
|
|
656
|
+
if (filterSet.size > 0 && !filterSet.has(data.id)) continue;
|
|
657
|
+
tasks.push(data);
|
|
658
|
+
}
|
|
659
|
+
return tasks.sort((a, b) => a.id.localeCompare(b.id));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function runIdFor(harness, armName, rep) {
|
|
663
|
+
return `${harness}-${armName}-r${rep}`;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function mapPool(items, concurrency, fn) {
|
|
667
|
+
const results = new Array(items.length);
|
|
668
|
+
let cursor = 0;
|
|
669
|
+
async function worker() {
|
|
670
|
+
while (true) {
|
|
671
|
+
const idx = cursor;
|
|
672
|
+
cursor += 1;
|
|
673
|
+
if (idx >= items.length) return;
|
|
674
|
+
results[idx] = await fn(items[idx], idx);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const count = Math.max(1, Math.min(concurrency, items.length));
|
|
678
|
+
await Promise.all(Array.from({ length: count }, () => worker()));
|
|
679
|
+
return results;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Run benchmark tasks across one or more arms and repetitions.
|
|
684
|
+
*/
|
|
685
|
+
export async function runBench(
|
|
686
|
+
root,
|
|
687
|
+
{
|
|
688
|
+
apply = false,
|
|
689
|
+
arms = ['current'],
|
|
690
|
+
harness = 'gold',
|
|
691
|
+
model,
|
|
692
|
+
reps = DEFAULT_REPS,
|
|
693
|
+
tasks: filterTaskIds = [],
|
|
694
|
+
vaultRoot = resolveVaultDir(root),
|
|
695
|
+
timeoutMs = DEFAULT_AGENT_TIMEOUT_MS,
|
|
696
|
+
maxTurns = DEFAULT_MAX_TURNS,
|
|
697
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
698
|
+
cwd = process.cwd(),
|
|
699
|
+
} = {},
|
|
700
|
+
) {
|
|
701
|
+
const adapters = await loadAdapters();
|
|
702
|
+
const adapter = adapters[harness];
|
|
703
|
+
if (!adapter) {
|
|
704
|
+
throw new Error(
|
|
705
|
+
`unknown harness "${harness}"; known harnesses: ${Object.keys(adapters).sort().join(', ')}`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (!Number.isInteger(reps) || reps < 1) {
|
|
710
|
+
throw new Error(`--reps must be a positive integer, received ${reps}`);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const armSpecs = [];
|
|
714
|
+
for (const raw of arms.length > 0 ? arms : ['current']) {
|
|
715
|
+
armSpecs.push(await parseArmSpec(raw, cwd));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const tasks = await loadTasks(root, filterTaskIds);
|
|
719
|
+
if (tasks.length === 0 && (apply || filterTaskIds.length > 0)) {
|
|
720
|
+
throw new Error(
|
|
721
|
+
filterTaskIds.length > 0
|
|
722
|
+
? `no mined tasks matched: ${filterTaskIds.join(', ')}`
|
|
723
|
+
: `no mined tasks found in ${STATE_DIR}/bench/tasks`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const resolvedModel = model ?? (adapter.kind === 'builtin' ? adapter.id : 'default');
|
|
728
|
+
const plannedRuns = [];
|
|
729
|
+
for (const armSpec of armSpecs) {
|
|
730
|
+
for (let rep = 1; rep <= reps; rep += 1) {
|
|
731
|
+
const runId = runIdFor(harness, armSpec.name, rep);
|
|
732
|
+
for (const task of tasks) {
|
|
733
|
+
plannedRuns.push({
|
|
734
|
+
runId,
|
|
735
|
+
armSpec,
|
|
736
|
+
rep,
|
|
737
|
+
task,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (!apply) {
|
|
744
|
+
return {
|
|
745
|
+
applied: false,
|
|
746
|
+
root,
|
|
747
|
+
harness,
|
|
748
|
+
model: resolvedModel,
|
|
749
|
+
arms: armSpecs.map((a) => a.name),
|
|
750
|
+
reps,
|
|
751
|
+
taskCount: tasks.length,
|
|
752
|
+
runs: plannedRuns.map((item) => ({
|
|
753
|
+
schemaVersion: 1,
|
|
754
|
+
runId: item.runId,
|
|
755
|
+
task: item.task.id,
|
|
756
|
+
arm: item.armSpec.name,
|
|
757
|
+
harness,
|
|
758
|
+
model: resolvedModel,
|
|
759
|
+
rep: item.rep,
|
|
760
|
+
resolved: null,
|
|
761
|
+
gatesPassed: null,
|
|
762
|
+
usage: emptyUsage().usage,
|
|
763
|
+
costUsd: 0.0,
|
|
764
|
+
diff: { files: 0, outsideBlastRadius: 0 },
|
|
765
|
+
probe: null,
|
|
766
|
+
})),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const runsBaseDir = join(root, STATE_DIR, 'bench', 'runs');
|
|
771
|
+
for (const armSpec of armSpecs) {
|
|
772
|
+
for (let rep = 1; rep <= reps; rep += 1) {
|
|
773
|
+
const runDir = join(runsBaseDir, runIdFor(harness, armSpec.name, rep));
|
|
774
|
+
await mkdir(runDir, { recursive: true });
|
|
775
|
+
await writeFile(join(runDir, '.arm.json'), `${JSON.stringify(armSpec, null, 2)}\n`, 'utf8');
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const parentDir = await mkdtemp(join(tmpdir(), 'bench-batch-'));
|
|
780
|
+
try {
|
|
781
|
+
const records = await mapPool(plannedRuns, concurrency, async (item) => {
|
|
782
|
+
const { slotDir, workDir, initialHead, env } = await prepareIsolatedWorkspace(
|
|
783
|
+
root,
|
|
784
|
+
item.task.base,
|
|
785
|
+
item.armSpec,
|
|
786
|
+
item.task,
|
|
787
|
+
{ parentDir },
|
|
788
|
+
);
|
|
789
|
+
try {
|
|
790
|
+
const { usage, costUsd } = await executeAdapter(adapter, workDir, item.task, {
|
|
791
|
+
vaultRoot,
|
|
792
|
+
model: resolvedModel,
|
|
793
|
+
maxTurns,
|
|
794
|
+
env,
|
|
795
|
+
timeoutMs,
|
|
796
|
+
slotDir,
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
const grade = await gradeWorkspace(workDir, item.task, {
|
|
800
|
+
vaultRoot,
|
|
801
|
+
initialHead,
|
|
802
|
+
timeoutMs,
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
const record = {
|
|
806
|
+
schemaVersion: 1,
|
|
807
|
+
task: item.task.id,
|
|
808
|
+
arm: item.armSpec.name,
|
|
809
|
+
harness,
|
|
810
|
+
model: resolvedModel,
|
|
811
|
+
rep: item.rep,
|
|
812
|
+
resolved: grade.resolved,
|
|
813
|
+
gatesPassed: grade.gatesPassed,
|
|
814
|
+
usage,
|
|
815
|
+
costUsd,
|
|
816
|
+
diff: grade.diff,
|
|
817
|
+
probe: null,
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
const runDir = join(runsBaseDir, item.runId);
|
|
821
|
+
await Promise.all([
|
|
822
|
+
writeFile(join(runDir, `${item.task.id}.json`), `${JSON.stringify(record, null, 2)}\n`, 'utf8'),
|
|
823
|
+
writeFile(join(runDir, `${item.task.id}.patch`), grade.agentPatch),
|
|
824
|
+
]);
|
|
825
|
+
|
|
826
|
+
return { ...record, runId: item.runId };
|
|
827
|
+
} finally {
|
|
828
|
+
await rm(slotDir, { recursive: true, force: true }).catch(() => {});
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
return {
|
|
833
|
+
applied: true,
|
|
834
|
+
root,
|
|
835
|
+
harness,
|
|
836
|
+
model: resolvedModel,
|
|
837
|
+
arms: armSpecs.map((a) => a.name),
|
|
838
|
+
reps,
|
|
839
|
+
taskCount: tasks.length,
|
|
840
|
+
runs: records,
|
|
841
|
+
};
|
|
842
|
+
} finally {
|
|
843
|
+
await rm(parentDir, { recursive: true, force: true }).catch(() => {});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function resolveRegradeTargets(root, target) {
|
|
848
|
+
const runsBaseDir = join(root, STATE_DIR, 'bench', 'runs');
|
|
849
|
+
const direct = resolve(root, target);
|
|
850
|
+
const candidate = existsSync(direct) ? direct : join(runsBaseDir, target);
|
|
851
|
+
|
|
852
|
+
if (target === 'all') {
|
|
853
|
+
const dirs = (await readdir(runsBaseDir, { withFileTypes: true }).catch(() => []))
|
|
854
|
+
.filter((d) => d.isDirectory())
|
|
855
|
+
.map((d) => join(runsBaseDir, d.name))
|
|
856
|
+
.sort();
|
|
857
|
+
const files = [];
|
|
858
|
+
for (const dir of dirs) {
|
|
859
|
+
const entries = (await readdir(dir).catch(() => []))
|
|
860
|
+
.filter((f) => f.endsWith('.json') && !f.startsWith('.'))
|
|
861
|
+
.sort();
|
|
862
|
+
for (const entry of entries) files.push(join(dir, entry));
|
|
863
|
+
}
|
|
864
|
+
return files;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const st = await stat(candidate).catch(() => null);
|
|
868
|
+
if (!st) {
|
|
869
|
+
throw new Error(`stored run not found: ${target}`);
|
|
870
|
+
}
|
|
871
|
+
if (st.isFile()) return [candidate];
|
|
872
|
+
if (st.isDirectory()) {
|
|
873
|
+
const entries = (await readdir(candidate).catch(() => []))
|
|
874
|
+
.filter((f) => f.endsWith('.json') && !f.startsWith('.'))
|
|
875
|
+
.sort();
|
|
876
|
+
return entries.map((f) => join(candidate, f));
|
|
877
|
+
}
|
|
878
|
+
return [];
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Re-grade a stored run (or run directory) deterministically from its saved agent patch
|
|
883
|
+
* and verify that the reproduced grade matches the stored record.
|
|
884
|
+
*
|
|
885
|
+
* @param {string} root
|
|
886
|
+
* @param {string} target
|
|
887
|
+
* @param {{apply?: boolean, vaultRoot?: string, timeoutMs?: number, concurrency?: number}} [options]
|
|
888
|
+
*/
|
|
889
|
+
export async function regradeRun(
|
|
890
|
+
root,
|
|
891
|
+
target,
|
|
892
|
+
{
|
|
893
|
+
apply = false,
|
|
894
|
+
vaultRoot = resolveVaultDir(root),
|
|
895
|
+
timeoutMs = DEFAULT_AGENT_TIMEOUT_MS,
|
|
896
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
897
|
+
} = {},
|
|
898
|
+
) {
|
|
899
|
+
const files = await resolveRegradeTargets(root, target);
|
|
900
|
+
if (files.length === 0) {
|
|
901
|
+
throw new Error(`no stored task runs found under ${target}`);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const tasksDir = join(root, STATE_DIR, 'bench', 'runs');
|
|
905
|
+
const taskDefsDir = join(root, STATE_DIR, 'bench', 'tasks');
|
|
906
|
+
const parentDir = await mkdtemp(join(tmpdir(), 'bench-regrade-'));
|
|
907
|
+
|
|
908
|
+
try {
|
|
909
|
+
const results = await mapPool(files, concurrency, async (runFilePath) => {
|
|
910
|
+
const stored = JSON.parse(await readFile(runFilePath, 'utf8'));
|
|
911
|
+
const runDir = dirname(runFilePath);
|
|
912
|
+
const runId = runDir.startsWith(tasksDir)
|
|
913
|
+
? runDir.slice(tasksDir.length + 1)
|
|
914
|
+
: dirname(runFilePath).split(sep).pop();
|
|
915
|
+
|
|
916
|
+
const taskPath = join(taskDefsDir, `${stored.task}.json`);
|
|
917
|
+
const task = JSON.parse(await readFile(taskPath, 'utf8'));
|
|
918
|
+
|
|
919
|
+
const armMetaPath = join(runDir, '.arm.json');
|
|
920
|
+
const armSpec = await readFile(armMetaPath, 'utf8')
|
|
921
|
+
.then((s) => JSON.parse(s))
|
|
922
|
+
.catch(() => ({
|
|
923
|
+
name: stored.arm,
|
|
924
|
+
kind: BUILTIN_ARMS.has(stored.arm) ? 'builtin' : 'custom',
|
|
925
|
+
base: BUILTIN_ARMS.has(stored.arm) ? stored.arm : null,
|
|
926
|
+
write: {},
|
|
927
|
+
remove: [],
|
|
928
|
+
env: {},
|
|
929
|
+
}));
|
|
930
|
+
|
|
931
|
+
const patchPath = runFilePath.replace(/\.json$/, '.patch');
|
|
932
|
+
const agentPatch = await readFile(patchPath).catch(() => Buffer.alloc(0));
|
|
933
|
+
|
|
934
|
+
const { slotDir, workDir, initialHead } = await prepareIsolatedWorkspace(
|
|
935
|
+
root,
|
|
936
|
+
task.base,
|
|
937
|
+
armSpec,
|
|
938
|
+
task,
|
|
939
|
+
{ parentDir },
|
|
940
|
+
);
|
|
941
|
+
try {
|
|
942
|
+
if (agentPatch.length > 0) {
|
|
943
|
+
const applyRes = await runCmd('git', ['apply', '--binary', '-'], {
|
|
944
|
+
cwd: workDir,
|
|
945
|
+
input: agentPatch,
|
|
946
|
+
timeoutMs,
|
|
947
|
+
});
|
|
948
|
+
if (applyRes.code !== 0) {
|
|
949
|
+
await runCmd('git', ['apply', '--3way', '-'], {
|
|
950
|
+
cwd: workDir,
|
|
951
|
+
input: agentPatch,
|
|
952
|
+
timeoutMs,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const grade = await gradeWorkspace(workDir, task, {
|
|
958
|
+
vaultRoot,
|
|
959
|
+
initialHead,
|
|
960
|
+
timeoutMs,
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
const reproduced =
|
|
964
|
+
grade.resolved === stored.resolved &&
|
|
965
|
+
grade.gatesPassed === stored.gatesPassed &&
|
|
966
|
+
grade.diff.files === stored.diff?.files &&
|
|
967
|
+
grade.diff.outsideBlastRadius === stored.diff?.outsideBlastRadius;
|
|
968
|
+
|
|
969
|
+
const updated = {
|
|
970
|
+
...stored,
|
|
971
|
+
resolved: grade.resolved,
|
|
972
|
+
gatesPassed: grade.gatesPassed,
|
|
973
|
+
diff: grade.diff,
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
if (apply) {
|
|
977
|
+
await writeFile(runFilePath, `${JSON.stringify(updated, null, 2)}\n`, 'utf8');
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return {
|
|
981
|
+
...updated,
|
|
982
|
+
runId,
|
|
983
|
+
reproduced,
|
|
984
|
+
};
|
|
985
|
+
} finally {
|
|
986
|
+
await rm(slotDir, { recursive: true, force: true }).catch(() => {});
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
return {
|
|
991
|
+
applied: apply,
|
|
992
|
+
root,
|
|
993
|
+
target,
|
|
994
|
+
regraded: results.length,
|
|
995
|
+
reproducedCount: results.filter((r) => r.reproduced).length,
|
|
996
|
+
allReproduced: results.every((r) => r.reproduced),
|
|
997
|
+
runs: results,
|
|
998
|
+
};
|
|
999
|
+
} finally {
|
|
1000
|
+
await rm(parentDir, { recursive: true, force: true }).catch(() => {});
|
|
1001
|
+
}
|
|
1002
|
+
}
|