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