opencode-harness-kit 0.4.3 → 0.4.4
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 +527 -18
- package/CONTRIBUTING.md +242 -0
- package/GLOBAL.md +34 -5
- package/MIGRATION.md +151 -0
- package/README.md +307 -37
- package/SECURITY.md +30 -0
- package/bin/baseline.mjs +15 -7
- package/bin/git-hooks/pre-commit +41 -9
- package/bin/oc-harness.mjs +1265 -82
- package/dist/config.d.ts +127 -0
- package/dist/denylist.d.ts +33 -0
- package/dist/effort.d.ts +64 -0
- package/dist/guard.d.ts +23 -0
- package/dist/health.d.ts +43 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +532 -149
- package/dist/plugin.d.ts +15 -0
- package/dist/profile.d.ts +399 -0
- package/dist/project.d.ts +8 -0
- package/dist/prune.d.ts +6 -0
- package/dist/router.d.ts +3 -0
- package/dist/rules.d.ts +109 -0
- package/dist/safe.d.ts +7 -0
- package/dist/telemetry-store.d.ts +12 -0
- package/dist/telemetry.d.ts +3 -0
- package/docs/EXIT-CODES.md +71 -0
- package/docs/KNOWN-LIMITS.md +45 -21
- package/docs/RESEARCH.md +9 -4
- package/docs/ROUTING.md +53 -8
- package/eval/RESULTS.md +443 -0
- package/eval/ab.mjs +743 -0
- package/eval/attribute.mjs +170 -0
- package/eval/flags.mjs +88 -0
- package/eval/measure-install.mjs +323 -0
- package/eval/measure-prune.mjs +339 -0
- package/eval/metrics.js +79 -0
- package/eval/premises.mjs +200 -0
- package/eval/prepare.mjs +233 -0
- package/eval/run.mjs +376 -0
- package/eval/tasks-self.json +81 -0
- package/eval/tasks-ts.json +50 -0
- package/eval/tasks.json +83 -0
- package/eval/thresholds.mjs +134 -0
- package/eval/verify.mjs +287 -0
- package/package.json +44 -17
- package/profiles/fleet-all.json +6 -1
- package/profiles/fleet-safe.json +7 -3
- package/profiles/schema.json +182 -51
- package/prompts/build.md +4 -2
- package/skills/space-build/SKILL.md +4 -2
- package/skills/space-finish/SKILL.md +6 -2
- package/skills/space-plan/reference/task-template.md +13 -5
- package/src/config.js +182 -32
- package/src/denylist.js +95 -0
- package/src/driver/run.mjs +81 -16
- package/src/effort.js +15 -4
- package/src/guard.ts +165 -13
- package/src/health.js +80 -0
- package/src/index.ts +15 -4
- package/src/lane-usage.js +72 -18
- package/src/plugin-type-check.ts +38 -0
- package/src/plugin.ts +39 -0
- package/src/profile.js +225 -26
- package/src/project.js +47 -0
- package/src/prune.ts +110 -31
- package/src/router.ts +32 -63
- package/src/rules.js +99 -7
- package/src/safe.ts +27 -0
- package/src/sqlite.js +144 -0
- package/src/tasks.js +112 -29
- package/src/telemetry-store.ts +62 -0
- package/src/telemetry.ts +60 -57
- package/src/trust.js +70 -9
package/eval/ab.mjs
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ab.mjs - the honest version of the effectiveness question.
|
|
3
|
+
//
|
|
4
|
+
// eval/run.mjs proves the pruner and the allowlist work. measure-install.mjs
|
|
5
|
+
// shows the kit is followed by better numbers. Neither says the kit *caused*
|
|
6
|
+
// them, because the before-window is a different mix of work. This runs the
|
|
7
|
+
// same task twice - kit on, kit off - and compares.
|
|
8
|
+
//
|
|
9
|
+
// node eval/ab.mjs --target <dir> --task "<prompt>" --reps 3
|
|
10
|
+
//
|
|
11
|
+
// The kit-off arm is a throwaway HOME with no ~/.config/opencode. It is not
|
|
12
|
+
// OPENCODE_CONFIG and not --pure: both of those still load GLOBAL.md, which an
|
|
13
|
+
// earlier smoke test showed (system prompt 8,745 vs 8,758 tokens - no change).
|
|
14
|
+
// Swapping HOME removes the instruction file, the agent definitions and the
|
|
15
|
+
// plugins at once, and ~/.local/share/opencode is symlinked back to the real
|
|
16
|
+
// one so auth still works.
|
|
17
|
+
//
|
|
18
|
+
// The live ~/.config/opencode is never touched.
|
|
19
|
+
//
|
|
20
|
+
// Arms alternate so any drift in the model route lands on both sides equally.
|
|
21
|
+
//
|
|
22
|
+
// --rescore <streamdir> re-grades a previous run's saved streams without
|
|
23
|
+
// spawning a single agent. The grader has been wrong five times in this repo and
|
|
24
|
+
// every fix so far would otherwise have cost another twenty runs.
|
|
25
|
+
|
|
26
|
+
import { spawnSync } from "node:child_process"
|
|
27
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync } from "node:fs"
|
|
28
|
+
import { createHash } from "node:crypto"
|
|
29
|
+
import { homedir, tmpdir } from "node:os"
|
|
30
|
+
import { join } from "node:path"
|
|
31
|
+
|
|
32
|
+
import { elapsedSeconds, median } from "./metrics.js"
|
|
33
|
+
import { countFlag, numberFlag } from "./flags.mjs"
|
|
34
|
+
import { gradeAnswer, gradeMutation } from "./verify.mjs"
|
|
35
|
+
import { checkPremises } from "./premises.mjs"
|
|
36
|
+
import { parseJsonc, configDir } from "../src/profile.js"
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The message of a caught value, whatever it turned out to be.
|
|
40
|
+
*
|
|
41
|
+
* `catch` gives `unknown` under strict mode, and the file is not strict -
|
|
42
|
+
* but a message is what these scripts exist to print.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} err
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function errorMessage(err) {
|
|
48
|
+
return err instanceof Error ? err.message : String(err)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const args = process.argv.slice(2)
|
|
52
|
+
const flag = (...n) => {
|
|
53
|
+
const i = args.findIndex((a) => n.includes(a))
|
|
54
|
+
return i >= 0 ? args[i + 1] : undefined
|
|
55
|
+
}
|
|
56
|
+
// Collect the values after a repeatable flag. Not `args.filter`: that returns
|
|
57
|
+
// the flag names, which made every run score MISS on a correct answer.
|
|
58
|
+
const all = (...n) =>
|
|
59
|
+
args.reduce((acc, a, i) => {
|
|
60
|
+
if (n.includes(a) && args[i + 1] !== undefined && !args[i + 1].startsWith("--")) acc.push(args[i + 1])
|
|
61
|
+
return acc
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
const target = flag("--target")
|
|
65
|
+
const task = flag("--task")
|
|
66
|
+
// A malformed count is an error. NaN made the loop body never run, and the
|
|
67
|
+
// suite reported success having run nothing at all.
|
|
68
|
+
let reps
|
|
69
|
+
try {
|
|
70
|
+
reps = countFlag("--reps") ?? 3
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error(String(errorMessage(err)))
|
|
73
|
+
process.exit(1)
|
|
74
|
+
}
|
|
75
|
+
const expect = all("--expect")
|
|
76
|
+
// A suite is a file of {id, task, expect}. One prompt can only say so much
|
|
77
|
+
// about the kit; four prompts asked four different ways is the difference
|
|
78
|
+
// between an anecdote and a measurement, at four times the agent cost.
|
|
79
|
+
const suitePath = flag("--suite")
|
|
80
|
+
// Re-grade a previous run's saved streams without spawning agents. The grader
|
|
81
|
+
// has been wrong five times in this repo and every fix would otherwise have
|
|
82
|
+
// cost another twenty runs.
|
|
83
|
+
const rescoreDir = flag("--rescore")
|
|
84
|
+
// One pathological run burned 4.4 minutes once. Without a ceiling a single bad
|
|
85
|
+
// arm can hold the whole suite open indefinitely.
|
|
86
|
+
//
|
|
87
|
+
// A timeout of zero would REMOVE the ceiling - node only applies a timeout
|
|
88
|
+
// when it is above zero - which is the opposite of what the flag says. And a
|
|
89
|
+
// non-numeric one was NaN, which node rejects with a RangeError out of
|
|
90
|
+
// spawnSync.
|
|
91
|
+
let runTimeoutMs
|
|
92
|
+
try {
|
|
93
|
+
const minutes = numberFlag("--timeout") ?? 600
|
|
94
|
+
if (minutes <= 0) throw new Error("--timeout must be more than 0, or the ceiling is removed")
|
|
95
|
+
runTimeoutMs = minutes * 1000
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error(String(errorMessage(err)))
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!target || (!task && !suitePath)) {
|
|
102
|
+
console.error('usage: node eval/ab.mjs --target <dir> (--task "<prompt>" | --suite <file>) [--reps 3] [--expect token]... [--timeout 600]')
|
|
103
|
+
process.exit(1)
|
|
104
|
+
}
|
|
105
|
+
if (!existsSync(target)) {
|
|
106
|
+
console.error(`target not found: ${target}`)
|
|
107
|
+
process.exit(1)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A short alternative matches almost any answer. `--expect "judge.py|7"` scored
|
|
112
|
+
* 3/3 against 1/3 on runs that had not found judge.py at all, because "7" is
|
|
113
|
+
* in every line number. This has already produced one wrong result in this
|
|
114
|
+
* repo's own notes; the check is cheap enough to be worth a warning.
|
|
115
|
+
*/
|
|
116
|
+
function warnWeak(label, groups) {
|
|
117
|
+
const weak = groups
|
|
118
|
+
.flatMap((e) => e.split("|").map((s) => s.trim()))
|
|
119
|
+
.filter((s) => s.length > 0 && s.length < 4)
|
|
120
|
+
if (weak.length) {
|
|
121
|
+
console.error(`warning: ${label} expectation alternatives too short to be meaningful: ${weak.map((w) => JSON.stringify(w)).join(", ")}`)
|
|
122
|
+
console.error(" a bare digit or short token will match almost any answer.")
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let jobs
|
|
127
|
+
if (suitePath) {
|
|
128
|
+
if (!existsSync(suitePath)) {
|
|
129
|
+
console.error(`suite not found: ${suitePath}`)
|
|
130
|
+
process.exit(1)
|
|
131
|
+
}
|
|
132
|
+
let parsed
|
|
133
|
+
try {
|
|
134
|
+
parsed = JSON.parse(readFileSync(suitePath, "utf8"))
|
|
135
|
+
} catch (e) {
|
|
136
|
+
console.error(`suite is not valid JSON: ${errorMessage(e)}`)
|
|
137
|
+
process.exit(1)
|
|
138
|
+
}
|
|
139
|
+
const list = parsed.tasks ?? []
|
|
140
|
+
if (!list.length) {
|
|
141
|
+
console.error(`suite has no tasks: ${suitePath}`)
|
|
142
|
+
process.exit(1)
|
|
143
|
+
}
|
|
144
|
+
// Every read task names the answer as a substring of one real source line,
|
|
145
|
+
// and verify.mjs resolves the agent's citation against the snapshot to find
|
|
146
|
+
// it. Nothing is matched against prose, so no task can be passed by saying a
|
|
147
|
+
// number, and none can be failed by not repeating a filename the agent had
|
|
148
|
+
// every reason to abbreviate.
|
|
149
|
+
jobs = list.map((t) => {
|
|
150
|
+
if (!t.id || !t.task) {
|
|
151
|
+
console.error(`suite task is missing id or task: ${JSON.stringify(t.id ?? t)}`)
|
|
152
|
+
process.exit(1)
|
|
153
|
+
}
|
|
154
|
+
if (t.mutate) {
|
|
155
|
+
if (!t.mutate.file) {
|
|
156
|
+
console.error(`suite mutate task is missing mutate.file: ${t.id}`)
|
|
157
|
+
process.exit(1)
|
|
158
|
+
}
|
|
159
|
+
return { id: t.id, task: t.task, mutate: t.mutate }
|
|
160
|
+
}
|
|
161
|
+
if (typeof t.want !== "string" || !t.want.length) {
|
|
162
|
+
console.error(`suite read task is missing want: ${JSON.stringify(t.id)}`)
|
|
163
|
+
process.exit(1)
|
|
164
|
+
}
|
|
165
|
+
return { id: t.id, task: t.task, want: t.want }
|
|
166
|
+
})
|
|
167
|
+
console.error(`note: graded against ${parsed.snapshot ?? "an unnamed snapshot"} by resolving citations, not by matching prose`)
|
|
168
|
+
} else {
|
|
169
|
+
warnWeak("task", expect)
|
|
170
|
+
jobs = [{ id: "single", task, expect }]
|
|
171
|
+
}
|
|
172
|
+
const REAL_HOME = homedir()
|
|
173
|
+
const TARGET_REAL = realpathSync(target)
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Files in the subject that are the SUITE's own, and whose contents are the
|
|
177
|
+
* answer key rather than the code.
|
|
178
|
+
*
|
|
179
|
+
* The self suite's subject is this repository, and this file names the
|
|
180
|
+
* answer. An agent that greps for the symbol and cites the suite passes
|
|
181
|
+
* without reading anything.
|
|
182
|
+
*/
|
|
183
|
+
const SUITE_FILES = [
|
|
184
|
+
"tasks-self.json",
|
|
185
|
+
"tasks.json",
|
|
186
|
+
"tasks-ts.json",
|
|
187
|
+
// A citation to a BUILD OUTPUT is not evidence about the source, and a
|
|
188
|
+
// citation to a TEST is not evidence either - the test quotes the answer.
|
|
189
|
+
"dist/",
|
|
190
|
+
"test/",
|
|
191
|
+
"eval/",
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* True when `p` is `root` or something under it. Symlinks are resolved on both
|
|
196
|
+
* sides so `/tmp` -> `/private/tmp` does not read as an escape, and so does an
|
|
197
|
+
* agent that echoes the fully resolved `/private` form.
|
|
198
|
+
*
|
|
199
|
+
* Relative paths count as inside: the agent resolves them against its working
|
|
200
|
+
* directory, which is the run copy. A path is only "escaped" when it names a
|
|
201
|
+
* location outside, which is the case worth stopping for.
|
|
202
|
+
*/
|
|
203
|
+
function isInsideTarget(p, root = TARGET_REAL) {
|
|
204
|
+
if (!p || typeof p !== "string") return true
|
|
205
|
+
const abs = p.startsWith("/") ? realpathSafe(p) : join(root, p)
|
|
206
|
+
// The pristine subject is never allowed, even though it lives in the same
|
|
207
|
+
// temp directory: it is the tree the run copy was made from, and a run
|
|
208
|
+
// that modifies it invalidates the second rep. This is checked BEFORE the
|
|
209
|
+
// root test, because in the rescore path the root IS the pristine subject.
|
|
210
|
+
if (abs === TARGET_REAL || abs.startsWith(TARGET_REAL + "/")) return false
|
|
211
|
+
if (abs === root || abs.startsWith(root + "/")) return true
|
|
212
|
+
// The OS temp directory is the ONE exception the kit makes, and GLOBAL.md
|
|
213
|
+
// says so: "This kit tells agents to put scratch files in a temp dir
|
|
214
|
+
// outside the workspace". A kit-on agent that follows that rule writes a
|
|
215
|
+
// scratch file outside the subject, and the escape check called that an
|
|
216
|
+
// escape and stopped the whole experiment.
|
|
217
|
+
//
|
|
218
|
+
// The check is about the agent walking out into the real repository or the
|
|
219
|
+
// user's files, not into a scratch file in /tmp.
|
|
220
|
+
//
|
|
221
|
+
// The exception is broad, and it is a known limit: a repository that
|
|
222
|
+
// happens to live under the temp directory is inside the exception, so a
|
|
223
|
+
// write to it would not be caught. The alternative - halting the whole
|
|
224
|
+
// experiment because an agent put a scratch file where GLOBAL.md told it
|
|
225
|
+
// to - was worse, and happened twice.
|
|
226
|
+
const tmp = realpathSafe(tmpdir())
|
|
227
|
+
return abs === tmp || abs.startsWith(tmp + "/")
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function realpathSafe(p) {
|
|
231
|
+
try {
|
|
232
|
+
return realpathSync(p)
|
|
233
|
+
} catch {
|
|
234
|
+
// A path that does not exist yet cannot be resolved; judge it lexically so
|
|
235
|
+
// a write the agent only planned still counts.
|
|
236
|
+
return p
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Absolute paths baked into the subject that point somewhere real and outside
|
|
242
|
+
* it. A `git archive` snapshot keeps them: five files in the suite subject
|
|
243
|
+
* contain `/Users/<name>/lablab-harness/...`, an agent read one, followed it,
|
|
244
|
+
* and left the sandbox to read the actual checkout. Read-only, but that was
|
|
245
|
+
* luck rather than design.
|
|
246
|
+
*
|
|
247
|
+
* Only paths that both look absolute *and* resolve to something on disk are
|
|
248
|
+
* reported, so a stray "/home/ubuntu" in a fixture is not noise.
|
|
249
|
+
*/
|
|
250
|
+
function findLeakedPaths(dir, depth = 0) {
|
|
251
|
+
if (depth > 6) return []
|
|
252
|
+
const found = []
|
|
253
|
+
const entries = []
|
|
254
|
+
try {
|
|
255
|
+
entries.push(...readdirSync(dir, { withFileTypes: true }))
|
|
256
|
+
} catch {
|
|
257
|
+
return []
|
|
258
|
+
}
|
|
259
|
+
for (const e of entries) {
|
|
260
|
+
if (e.name === ".git" || e.name === ".space" || e.name === "node_modules") continue
|
|
261
|
+
const p = join(dir, e.name)
|
|
262
|
+
if (e.isDirectory()) {
|
|
263
|
+
found.push(...findLeakedPaths(p, depth + 1))
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
if (!/\.(md|py|ts|tsx|js|json|txt|ya?ml|toml|sh)$/.test(e.name)) continue
|
|
267
|
+
let body
|
|
268
|
+
try {
|
|
269
|
+
body = readFileSync(p, "utf8")
|
|
270
|
+
} catch {
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
for (const m of body.matchAll(/\/(?:Users|home)\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*/g)) {
|
|
274
|
+
const abs = m[0].replace(/[.,;:)]+$/, "")
|
|
275
|
+
if (!existsSync(abs)) continue
|
|
276
|
+
if (abs === dir || abs.startsWith(dir + "/")) continue
|
|
277
|
+
found.push({ file: p.slice(dir.length + 1), path: abs })
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// One entry per distinct path; the file it came from is only for the message.
|
|
281
|
+
const seen = new Set()
|
|
282
|
+
return found.filter((f) => (seen.has(f.path) ? false : seen.add(f.path)))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Premises before runs. Seven numbers in eval/RESULTS.md were wrong, and every
|
|
286
|
+
// one was a grader agreeing with the agent about something the subject did not
|
|
287
|
+
// say. A suite whose premises are false cannot be repaired by running it more
|
|
288
|
+
// times, so this refuses to start rather than spending twenty agent runs on one.
|
|
289
|
+
if (suitePath) {
|
|
290
|
+
const premises = checkPremises(target, JSON.parse(readFileSync(suitePath, "utf8")))
|
|
291
|
+
for (const w of premises.warnings) console.error(`warn: ${w.id}: ${w.problem}`)
|
|
292
|
+
if (!premises.ok) {
|
|
293
|
+
console.error(`FAIL: ${premises.fatal.length} premise(s) in ${suitePath} do not hold against ${target}.`)
|
|
294
|
+
for (const f of premises.fatal) console.error(` ${f.id}: ${f.problem}`)
|
|
295
|
+
console.error("")
|
|
296
|
+
console.error("Run `node eval/premises.mjs --target <dir> --suite <file>` for the full report.")
|
|
297
|
+
process.exit(1)
|
|
298
|
+
}
|
|
299
|
+
console.error(`ok: all premises in ${suitePath} hold against the subject`)
|
|
300
|
+
console.error("")
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const leaks = findLeakedPaths(target)
|
|
304
|
+
const sanitize = args.includes("--sanitize")
|
|
305
|
+
if (rescoreDir && !existsSync(rescoreDir)) {
|
|
306
|
+
console.error(`rescore directory not found: ${rescoreDir}`)
|
|
307
|
+
process.exit(1)
|
|
308
|
+
}
|
|
309
|
+
// Rescore spawns nothing, so it cannot walk out of anything.
|
|
310
|
+
if (leaks.length && !sanitize && !rescoreDir && !args.includes("--allow-leaks")) {
|
|
311
|
+
console.error(`FAIL: the subject contains ${leaks.length} absolute path(s) that resolve outside it.`)
|
|
312
|
+
console.error("An agent can follow one and leave the sandbox, which happened for real:")
|
|
313
|
+
console.error("a kit-on run read the actual lablab-harness checkout before this check existed.")
|
|
314
|
+
console.error("")
|
|
315
|
+
for (const l of leaks.slice(0, 10)) console.error(` ${l.path}`)
|
|
316
|
+
console.error(` first seen in ${leaks[0].file}`)
|
|
317
|
+
console.error("")
|
|
318
|
+
console.error("Rerun with --sanitize to scrub each run copy, or --allow-leaks to accept the risk.")
|
|
319
|
+
process.exit(1)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Rewrite leaked absolute paths in a run copy so following one leads nowhere.
|
|
324
|
+
* Only files the leak scan already looked at are touched, so a binary or a
|
|
325
|
+
* vendored blob is never rewritten by accident.
|
|
326
|
+
*/
|
|
327
|
+
function sanitizeCopy(dir) {
|
|
328
|
+
let n = 0
|
|
329
|
+
const walk = (d, depth = 0) => {
|
|
330
|
+
if (depth > 6) return
|
|
331
|
+
let entries = []
|
|
332
|
+
try {
|
|
333
|
+
entries = readdirSync(d, { withFileTypes: true })
|
|
334
|
+
} catch {
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
for (const e of entries) {
|
|
338
|
+
if (e.name === ".git" || e.name === ".space" || e.name === "node_modules") continue
|
|
339
|
+
const p = join(d, e.name)
|
|
340
|
+
if (e.isDirectory()) {
|
|
341
|
+
walk(p, depth + 1)
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
if (!/\.(md|py|ts|tsx|js|json|txt|ya?ml|toml|sh)$/.test(e.name)) continue
|
|
345
|
+
let body
|
|
346
|
+
try {
|
|
347
|
+
body = readFileSync(p, "utf8")
|
|
348
|
+
} catch {
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
let next = body
|
|
352
|
+
for (const l of leaks) next = next.split(l.path).join("<outside-repo>")
|
|
353
|
+
if (next !== body) {
|
|
354
|
+
writeFileSync(p, next)
|
|
355
|
+
n++
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
walk(dir)
|
|
360
|
+
return n
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The model the kit-on arm will actually use, read from the live config.
|
|
365
|
+
*
|
|
366
|
+
* Not the top-level `model`: `use` writes `default_agent` and a per-lane
|
|
367
|
+
* `agent.<lane>.model`, and opencode runs the default agent. The router also
|
|
368
|
+
* moves a lane onto the next rung of its chain when the route is spent, and
|
|
369
|
+
* it rewrites `agent.<lane>.model` - the top-level `model` stays where it
|
|
370
|
+
* was. So the top-level field is the model the kit-on arm is NOT on, in
|
|
371
|
+
* exactly the case the kit exists to handle.
|
|
372
|
+
*
|
|
373
|
+
* The config is read from `configDir()`, which honours
|
|
374
|
+
* `OPENCODE_CONFIG_DIR` - the one env var the kit honours everywhere else.
|
|
375
|
+
* A hard-coded `~/.config/opencode` is a different file on any machine that
|
|
376
|
+
* sets it, and the kit-on arm and the pin would then read two configs.
|
|
377
|
+
*
|
|
378
|
+
* @returns {string | null}
|
|
379
|
+
*/
|
|
380
|
+
function liveModel() {
|
|
381
|
+
const dir = configDir()
|
|
382
|
+
for (const name of ["opencode.jsonc", "opencode.json"]) {
|
|
383
|
+
const p = join(dir, name)
|
|
384
|
+
if (!existsSync(p)) continue
|
|
385
|
+
try {
|
|
386
|
+
const cfg = /** @type {any} */ (parseJsonc(readFileSync(p, "utf8"))) ?? {}
|
|
387
|
+
const lane = cfg.default_agent ?? "build"
|
|
388
|
+
const m = cfg.agent?.[lane]?.model ?? cfg.model
|
|
389
|
+
if (typeof m === "string" && m) return m
|
|
390
|
+
} catch {}
|
|
391
|
+
}
|
|
392
|
+
return null
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// A kit-free home. Config and instructions come from nowhere; auth and the
|
|
396
|
+
// session store are symlinked back so the run is a normal opencode run.
|
|
397
|
+
//
|
|
398
|
+
// The model is PINNED to the one the kit-on arm uses. An empty config left
|
|
399
|
+
// opencode to pick its own default, and that default is not the model the
|
|
400
|
+
// kit-on arm was on: the two arms were then measuring two different models
|
|
401
|
+
// and calling the difference the kit's cost. Worse, the default it picked
|
|
402
|
+
// did not answer at all - four kit-off runs hit the timeout with no output
|
|
403
|
+
// and no tool call, and a timed-out run has no context and no tools, so it
|
|
404
|
+
// enters the medians as a fast, empty, wrong answer.
|
|
405
|
+
//
|
|
406
|
+
// The comparison this file exists to make is "the same model, with the kit
|
|
407
|
+
// and without it". The model is now pinned to make that literally true, and
|
|
408
|
+
// the report prints it so a reader can check.
|
|
409
|
+
function makeNokitHome(model) {
|
|
410
|
+
const home = mkdtempSync(join(tmpdir(), "opencode-ab-"))
|
|
411
|
+
mkdirSync(join(home, ".config/opencode"), { recursive: true })
|
|
412
|
+
mkdirSync(join(home, ".local/share"), { recursive: true })
|
|
413
|
+
// Typed, because the loose script config does not catch a property added
|
|
414
|
+
// to an object literal: `noImplicitAny: false` turns "this property does
|
|
415
|
+
// not exist" off for exactly that case.
|
|
416
|
+
/** @type {Record<string, string>} */
|
|
417
|
+
const config = { $schema: "https://opencode.ai/config.json" }
|
|
418
|
+
if (model) config.model = model
|
|
419
|
+
writeFileSync(join(home, ".config/opencode/opencode.json"), JSON.stringify(config, null, 2))
|
|
420
|
+
const link = join(home, ".local/share/opencode")
|
|
421
|
+
try {
|
|
422
|
+
symlinkSync(join(REAL_HOME, ".local/share/opencode"), link, "dir")
|
|
423
|
+
} catch {}
|
|
424
|
+
return home
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// A cheap fingerprint of the target tree. Proves the task did not mutate the
|
|
428
|
+
// subject under test, which would invalidate the second rep.
|
|
429
|
+
//
|
|
430
|
+
// `.space` and `.opencode` are excluded: the kit's own pruner writes telemetry
|
|
431
|
+
// into the working directory, so the kit-on arm always creates them. Counting
|
|
432
|
+
// those would report a dirty tree on every single run.
|
|
433
|
+
function fingerprint(dir) {
|
|
434
|
+
const SKIP = new Set([".git", ".space", ".opencode"])
|
|
435
|
+
const h = createHash("sha256")
|
|
436
|
+
const walk = (d) => {
|
|
437
|
+
for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
|
|
438
|
+
if (SKIP.has(e.name)) continue
|
|
439
|
+
const p = join(d, e.name)
|
|
440
|
+
if (e.isDirectory()) walk(p)
|
|
441
|
+
else h.update(e.name).update(String(statSync(p).size))
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
walk(dir)
|
|
445
|
+
return h.digest("hex").slice(0, 16)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function parseStream(file, root = TARGET_REAL) {
|
|
449
|
+
const reads = [], searches = [], others = [], escaped = new Set(), readOutside = new Set()
|
|
450
|
+
let steps = 0, inputTokens = 0, reasoning = 0, finalCtx = 0, text = "", first = null, last = null
|
|
451
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
452
|
+
const s = line.trim()
|
|
453
|
+
if (!s) continue
|
|
454
|
+
let d
|
|
455
|
+
try { d = JSON.parse(s) } catch { continue }
|
|
456
|
+
const ts = d.timestamp
|
|
457
|
+
if (ts) { if (first === null) first = ts; last = ts }
|
|
458
|
+
if (d.type === "tool_use") {
|
|
459
|
+
const p = d.part ?? {}
|
|
460
|
+
const input = p.state?.input ?? {}
|
|
461
|
+
// Every path the agent names is recorded, and anything outside the target
|
|
462
|
+
// is kept verbatim. The first pilot ran in the *parent* session's project
|
|
463
|
+
// and wrote to the real repository; --dir should stop that, and this is
|
|
464
|
+
// the check that proves it did rather than assuming it.
|
|
465
|
+
// A WRITE outside the target is what the harness stops for: the first
|
|
466
|
+
// pilot wrote to the real repository while the task said not to modify
|
|
467
|
+
// anything. A READ outside it is wasteful and sometimes a privacy
|
|
468
|
+
// leak, but it damages nothing, and treating it as an escape stopped
|
|
469
|
+
// the whole experiment on an agent that grepped the filesystem root.
|
|
470
|
+
const writes = new Set(["write", "edit", "apply_patch", "patch"])
|
|
471
|
+
const isWrite = writes.has(p.tool)
|
|
472
|
+
for (const k of ["filePath", "path"]) {
|
|
473
|
+
if (typeof input[k] !== "string") continue
|
|
474
|
+
if (isInsideTarget(input[k], root)) continue
|
|
475
|
+
if (isWrite) escaped.add(input[k])
|
|
476
|
+
else readOutside.add(input[k])
|
|
477
|
+
}
|
|
478
|
+
if (p.tool === "read") {
|
|
479
|
+
reads.push(input.offset !== undefined || input.limit !== undefined)
|
|
480
|
+
} else if (p.tool === "grep" || p.tool === "glob") searches.push(p.tool)
|
|
481
|
+
else others.push(p.tool ?? "?")
|
|
482
|
+
} else if (d.type === "step_finish") {
|
|
483
|
+
const t = d.part?.tokens ?? {}
|
|
484
|
+
steps++
|
|
485
|
+
inputTokens += t.input ?? 0
|
|
486
|
+
reasoning += t.reasoning ?? 0
|
|
487
|
+
finalCtx = (t.input ?? 0) + (t.cache?.read ?? 0)
|
|
488
|
+
} else if (d.type === "text") {
|
|
489
|
+
text += d.part?.text ?? ""
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const narrow = reads.filter(Boolean).length
|
|
493
|
+
return {
|
|
494
|
+
steps,
|
|
495
|
+
tools: reads.length + searches.length + others.length,
|
|
496
|
+
reads: reads.length,
|
|
497
|
+
narrow,
|
|
498
|
+
narrow_pct: reads.length ? Math.round((narrow / reads.length) * 1000) / 10 : 0,
|
|
499
|
+
searches: searches.length,
|
|
500
|
+
other: others.reduce((a, t) => (a[t] = (a[t] ?? 0) + 1, a), {}),
|
|
501
|
+
inputTokens,
|
|
502
|
+
reasoning,
|
|
503
|
+
finalCtx,
|
|
504
|
+
seconds: first !== null && last !== null ? elapsedSeconds(first, last) : null,
|
|
505
|
+
text,
|
|
506
|
+
escaped: [...escaped],
|
|
507
|
+
read_outside: [...readOutside],
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function runArmCapture(arm, home, id, idx, outDir, prompt, runDir) {
|
|
512
|
+
const out = join(outDir, `${id}-${arm}-${idx}.json`)
|
|
513
|
+
const env = { ...process.env }
|
|
514
|
+
if (arm === "off") {
|
|
515
|
+
env.HOME = home
|
|
516
|
+
// The kit-free home is only kit-free if the config is not found
|
|
517
|
+
// somewhere else. `OPENCODE_CONFIG_DIR` is what opencode looks at
|
|
518
|
+
// FIRST, so with it set the "off" arm loaded the kit's own config, used
|
|
519
|
+
// its rules, and used its model - and the arm was not an off arm at all.
|
|
520
|
+
delete env.OPENCODE_CONFIG_DIR
|
|
521
|
+
}
|
|
522
|
+
const t0 = Date.now()
|
|
523
|
+
// --dir is not optional. Nested inside an opencode session, `opencode run`
|
|
524
|
+
// otherwise inherits the *parent* session's project directory and reads the
|
|
525
|
+
// wrong repository entirely - which it did on the first pilot.
|
|
526
|
+
const res = spawnSync("opencode", ["run", "--dir", runDir, "--format", "json", prompt], {
|
|
527
|
+
cwd: runDir, env, encoding: "utf8", maxBuffer: 256 * 1024 * 1024,
|
|
528
|
+
timeout: runTimeoutMs, killSignal: "SIGKILL",
|
|
529
|
+
})
|
|
530
|
+
const stdout = res.stdout ?? ""
|
|
531
|
+
writeFileSync(out, stdout)
|
|
532
|
+
// Whether the run was killed by the ceiling.
|
|
533
|
+
//
|
|
534
|
+
// The old check was `stdout === null`, and `spawnSync` NEVER returns null
|
|
535
|
+
// for stdout - it returns the partial output and reports the kill through
|
|
536
|
+
// `error` and `signal`. So the timeout branch could never fire, every run
|
|
537
|
+
// scored as a success, and a run that hung until the ceiling was counted in
|
|
538
|
+
// the correctness ratio and in every median.
|
|
539
|
+
const timedOut =
|
|
540
|
+
/** @type {any} */ (res.error)?.code === "ETIMEDOUT" ||
|
|
541
|
+
res.signal === "SIGKILL" ||
|
|
542
|
+
(res.status === null && Date.now() - t0 >= runTimeoutMs)
|
|
543
|
+
return {
|
|
544
|
+
out,
|
|
545
|
+
code: res.status === 0 ? 0 : 1,
|
|
546
|
+
timedOut,
|
|
547
|
+
wall: elapsedSeconds(t0, Date.now()),
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const armModel = liveModel()
|
|
552
|
+
if (!armModel) {
|
|
553
|
+
console.warn(" warn: no model in the live config - the kit-off arm will use opencode's default,")
|
|
554
|
+
console.warn(" which is not necessarily the model the kit-on arm uses.")
|
|
555
|
+
}
|
|
556
|
+
const nokit = rescoreDir ? null : makeNokitHome(armModel)
|
|
557
|
+
const outDir = rescoreDir ?? mkdtempSync(join(tmpdir(), "opencode-ab-out-"))
|
|
558
|
+
const before = fingerprint(target)
|
|
559
|
+
|
|
560
|
+
console.log(`A/B: ${jobs.length} task(s) x ${reps} rep(s) per arm${rescoreDir ? ", rescored" : ", alternating"}`)
|
|
561
|
+
console.log(` target ${target}`)
|
|
562
|
+
for (const j of jobs) console.log(` task ${j.id}`)
|
|
563
|
+
console.log(` timeout ${runTimeoutMs / 1000}s per run`)
|
|
564
|
+
console.log(` model ${armModel ?? "(opencode's default - NOT pinned)"}`)
|
|
565
|
+
if (nokit) console.log(` kit-off home ${nokit}`)
|
|
566
|
+
console.log("")
|
|
567
|
+
|
|
568
|
+
const runs = []
|
|
569
|
+
const keptCopies = []
|
|
570
|
+
if (rescoreDir) {
|
|
571
|
+
const byId = new Map(jobs.map((j) => [j.id, j]))
|
|
572
|
+
for (const f of readdirSync(rescoreDir).filter((x) => x.endsWith(".json")).sort()) {
|
|
573
|
+
const m = /^(.+)-(on|off)-(\d+)\.json$/.exec(f)
|
|
574
|
+
if (!m) continue
|
|
575
|
+
const job = byId.get(m[1])
|
|
576
|
+
if (!job) {
|
|
577
|
+
console.error(` skipping ${f}: no task named ${m[1]}`)
|
|
578
|
+
continue
|
|
579
|
+
}
|
|
580
|
+
const file = join(rescoreDir, f)
|
|
581
|
+
const s = parseStream(file, TARGET_REAL)
|
|
582
|
+
const invalid = s.steps === 0
|
|
583
|
+
let ok = null
|
|
584
|
+
let why
|
|
585
|
+
if (job.mutate) {
|
|
586
|
+
why = "mutation not rescored"
|
|
587
|
+
} else if (invalid) {
|
|
588
|
+
why = "no output, not scored"
|
|
589
|
+
} else {
|
|
590
|
+
// The same exclusions the live path applies. A re-score that graded
|
|
591
|
+
// the answers differently from the run it is re-scoring would be a
|
|
592
|
+
// measurement of the grader, and the number it produced would not be
|
|
593
|
+
// comparable with the one it is meant to explain.
|
|
594
|
+
const g = gradeAnswer(target, s.text, job.want, { exclude: SUITE_FILES })
|
|
595
|
+
ok = g.ok
|
|
596
|
+
why = g.ok ? `cited ${g.cited}` : g.cited === 0 ? "no citation" : `cited ${g.cited}, none held`
|
|
597
|
+
}
|
|
598
|
+
runs.push({ id: job.id, arm: m[2], i: Number(m[3]), ...s, wall: s.seconds, code: 0, timedOut: false, invalid: invalid || job.mutate, correct: ok, why })
|
|
599
|
+
console.log(` ${job.id} ${m[2]}-${m[3]} ... ${why}`)
|
|
600
|
+
}
|
|
601
|
+
console.log("")
|
|
602
|
+
} else for (const job of jobs) {
|
|
603
|
+
for (let i = 1; i <= reps; i++) {
|
|
604
|
+
for (const arm of ["on", "off"]) {
|
|
605
|
+
// Every run gets its own throwaway copy of the subject. A read-only task
|
|
606
|
+
// does not need one, but sharing a tree between an editing task and
|
|
607
|
+
// anything else is how a run inherits the previous run's work.
|
|
608
|
+
const runDir = mkdtempSync(join(tmpdir(), `opencode-ab-run-${job.id}-`))
|
|
609
|
+
cpSync(target, runDir, { recursive: true })
|
|
610
|
+
if (sanitize && leaks.length) sanitizeCopy(runDir)
|
|
611
|
+
process.stdout.write(` ${job.id} ${arm}-${i} ... `)
|
|
612
|
+
const r = runArmCapture(arm, nokit, job.id, i, outDir, job.task, runDir)
|
|
613
|
+
const runReal = realpathSync(runDir)
|
|
614
|
+
const m = parseStream(r.out, runReal)
|
|
615
|
+
// Grade against the repository, not against a string I typed. The
|
|
616
|
+
// substring grader produced three wrong numbers in RESULTS.md; this
|
|
617
|
+
// resolves every citation the agent made and reads the real line.
|
|
618
|
+
let ok
|
|
619
|
+
let why = ""
|
|
620
|
+
// A run that emitted nothing was not answered, it did not start. One such
|
|
621
|
+
// run was scored as a wrong answer before this check existed, which is
|
|
622
|
+
// the same class of error as the substring grader: measuring the harness
|
|
623
|
+
// instead of the agent. Invalid runs are excluded, not counted.
|
|
624
|
+
const invalid = r.timedOut || m.steps === 0
|
|
625
|
+
if (invalid) {
|
|
626
|
+
ok = null
|
|
627
|
+
why = r.timedOut ? "TIMED OUT, not scored" : "no output, not scored"
|
|
628
|
+
} else if (job.mutate) {
|
|
629
|
+
const g = gradeMutation(runDir, job.mutate)
|
|
630
|
+
ok = g.ok
|
|
631
|
+
why = g.ok ? "edit ok" : `edit MISS (${g.reason})`
|
|
632
|
+
} else {
|
|
633
|
+
// The suite's own file is excluded from the citations: the subject
|
|
634
|
+
// is a copy of this repository, so the answer key is inside it.
|
|
635
|
+
const g = gradeAnswer(runDir, m.text, job.want, { exclude: SUITE_FILES })
|
|
636
|
+
ok = g.ok
|
|
637
|
+
why = g.ok ? `cited ${g.cited}` : g.cited === 0 ? "no citation" : `cited ${g.cited}, none held`
|
|
638
|
+
}
|
|
639
|
+
runs.push({ id: job.id, arm, i, ...m, wall: r.wall, code: r.code, timedOut: r.timedOut, invalid, correct: ok, why })
|
|
640
|
+
// Stop the moment an arm reaches outside the run directory. The first
|
|
641
|
+
// pilot wrote to the real repository while the task said not to modify
|
|
642
|
+
// anything, so a second arm is not worth the risk once this has happened.
|
|
643
|
+
if (m.escaped.length) {
|
|
644
|
+
console.log(`ESCAPED`)
|
|
645
|
+
console.error("")
|
|
646
|
+
console.error(`FAIL: ${job.id} ${arm}-${i} WROTE to ${m.escaped.length} path(s) outside ${runDir}`)
|
|
647
|
+
for (const p of m.escaped.slice(0, 10)) console.error(` ${p}`)
|
|
648
|
+
console.error("")
|
|
649
|
+
console.error("Stopping. Do not run further arms against a target the agent writes outside.")
|
|
650
|
+
console.error(`raw stream kept for inspection: ${r.out}`)
|
|
651
|
+
console.error(`run copy kept for inspection: ${runDir}`)
|
|
652
|
+
process.exit(1)
|
|
653
|
+
}
|
|
654
|
+
console.log(
|
|
655
|
+
`${m.tools} tools, ${m.narrow_pct}% narrow, ${m.finalCtx} ctx, ${r.wall}s` +
|
|
656
|
+
(r.timedOut ? ", TIMED OUT" : `, ${why}`),
|
|
657
|
+
)
|
|
658
|
+
// Read-only tasks never touched the copy. An editing task's copy is kept
|
|
659
|
+
// so a wrong edit can be read, which is the only way to tell a mistake
|
|
660
|
+
// from a mis-scored answer.
|
|
661
|
+
if (!job.mutate) rmSync(runDir, { recursive: true, force: true })
|
|
662
|
+
else keptCopies.push(runDir)
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const after = fingerprint(target)
|
|
668
|
+
// Median, never mean. See median() in ./metrics.js for the run that proves why.
|
|
669
|
+
// Invalid runs are excluded everywhere: a process that emitted nothing has no
|
|
670
|
+
// tools, no tokens and no answer, and including its zeros would drag every
|
|
671
|
+
// median toward looking as though the agent did nothing at all.
|
|
672
|
+
const scored = runs.filter((r) => !r.invalid)
|
|
673
|
+
const stat = (arm, key) => median(scored.filter((r) => r.arm === arm).map((r) => r[key]))
|
|
674
|
+
const scoreOf = (arm) => {
|
|
675
|
+
const rs = scored.filter((r) => r.arm === arm)
|
|
676
|
+
return `${rs.filter((r) => r.correct).length}/${rs.length}`
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const rows = [
|
|
680
|
+
// Correctness first, and it stays first: of every column here, it is the only
|
|
681
|
+
// one that came out the same across all three revisions of this suite. The
|
|
682
|
+
// wall-clock row below changed sign between runs, so it is reported for the
|
|
683
|
+
// record rather than as something to read a verdict from.
|
|
684
|
+
["correct", scoreOf("on"), scoreOf("off")],
|
|
685
|
+
["tool calls", stat("on", "tools"), stat("off", "tools")],
|
|
686
|
+
["read calls", stat("on", "reads"), stat("off", "reads")],
|
|
687
|
+
["narrow reads", stat("on", "narrow"), stat("off", "narrow")],
|
|
688
|
+
["narrow %", stat("on", "narrow_pct"), stat("off", "narrow_pct")],
|
|
689
|
+
["grep/glob", stat("on", "searches"), stat("off", "searches")],
|
|
690
|
+
["steps", stat("on", "steps"), stat("off", "steps")],
|
|
691
|
+
["input tokens", stat("on", "inputTokens"), stat("off", "inputTokens")],
|
|
692
|
+
["final ctx", stat("on", "finalCtx"), stat("off", "finalCtx")],
|
|
693
|
+
["wall seconds", stat("on", "wall"), stat("off", "wall")],
|
|
694
|
+
["not scored", runs.filter((r) => r.arm === "on" && r.invalid).length, runs.filter((r) => r.arm === "off" && r.invalid).length],
|
|
695
|
+
// The escape check compares against a per-run copy that no longer exists when
|
|
696
|
+
// rescoring, so it would report every path as an escape. Report nothing rather
|
|
697
|
+
// than report something wrong.
|
|
698
|
+
...(rescoreDir
|
|
699
|
+
? []
|
|
700
|
+
: [["escaped paths", runs.filter((r) => r.arm === "on").reduce((a, r) => a + r.escaped.length, 0), runs.filter((r) => r.arm === "off").reduce((a, r) => a + r.escaped.length, 0)]]),
|
|
701
|
+
]
|
|
702
|
+
|
|
703
|
+
console.log("")
|
|
704
|
+
console.log(` ${"metric (median)".padEnd(16)} ${"kit on".padStart(10)} ${"kit off".padStart(10)}`)
|
|
705
|
+
console.log(` ${"-".repeat(16)} ${"-".repeat(10)} ${"-".repeat(10)}`)
|
|
706
|
+
for (const [label, a, b] of rows) {
|
|
707
|
+
console.log(` ${label.padEnd(16)} ${String(a).padStart(10)} ${String(b).padStart(10)}`)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (jobs.length > 1) {
|
|
711
|
+
console.log("")
|
|
712
|
+
console.log(` ${"per task".padEnd(16)} ${"kit on".padStart(10)} ${"kit off".padStart(10)}`)
|
|
713
|
+
console.log(` ${"-".repeat(16)} ${"-".repeat(10)} ${"-".repeat(10)}`)
|
|
714
|
+
for (const job of jobs) {
|
|
715
|
+
const score = (arm) => {
|
|
716
|
+
const rs = scored.filter((r) => r.id === job.id && r.arm === arm)
|
|
717
|
+
return `${rs.filter((r) => r.correct).length}/${rs.length}`
|
|
718
|
+
}
|
|
719
|
+
console.log(` ${job.id.padEnd(16)} ${score("on").padStart(10)} ${score("off").padStart(10)}`)
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const bad = runs.filter((r) => r.invalid)
|
|
724
|
+
console.log("")
|
|
725
|
+
if (bad.length) {
|
|
726
|
+
console.log(` NOT SCORED (excluded from every number above):`)
|
|
727
|
+
for (const r of bad) console.log(` ${r.id}/${r.arm}-${r.i} ${r.why}`)
|
|
728
|
+
}
|
|
729
|
+
if (!rescoreDir) {
|
|
730
|
+
console.log(` every path stayed under the target: ${runs.every((r) => r.escaped.length === 0) ? "yes" : "NO"}`)
|
|
731
|
+
} else {
|
|
732
|
+
console.log(" escape check skipped: the per-run copies are gone, so it cannot be re-verified")
|
|
733
|
+
}
|
|
734
|
+
console.log(` target unchanged: ${before === after ? "yes" : `NO (${before} -> ${after})`}`)
|
|
735
|
+
console.log(` raw streams: ${outDir}`)
|
|
736
|
+
console.log(` kit-off home: ${nokit}`)
|
|
737
|
+
console.log(` model: ${armModel ?? "(opencode's default - NOT pinned)"}`)
|
|
738
|
+
|
|
739
|
+
if (before !== after) {
|
|
740
|
+
console.error("")
|
|
741
|
+
console.error("FAIL: the task modified the target, so the arms saw different trees.")
|
|
742
|
+
process.exit(1)
|
|
743
|
+
}
|