opencode-harness-kit 0.4.2 → 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.
Files changed (73) hide show
  1. package/CHANGELOG.md +544 -19
  2. package/CONTRIBUTING.md +242 -0
  3. package/GLOBAL.md +34 -5
  4. package/MIGRATION.md +151 -0
  5. package/README.md +294 -37
  6. package/SECURITY.md +30 -0
  7. package/bin/baseline.mjs +15 -7
  8. package/bin/git-hooks/pre-commit +41 -9
  9. package/bin/oc-harness.mjs +1265 -82
  10. package/dist/config.d.ts +127 -0
  11. package/dist/denylist.d.ts +33 -0
  12. package/dist/effort.d.ts +64 -0
  13. package/dist/guard.d.ts +23 -0
  14. package/dist/health.d.ts +43 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +532 -149
  17. package/dist/plugin.d.ts +15 -0
  18. package/dist/profile.d.ts +399 -0
  19. package/dist/project.d.ts +8 -0
  20. package/dist/prune.d.ts +6 -0
  21. package/dist/router.d.ts +3 -0
  22. package/dist/rules.d.ts +109 -0
  23. package/dist/safe.d.ts +7 -0
  24. package/dist/telemetry-store.d.ts +12 -0
  25. package/dist/telemetry.d.ts +3 -0
  26. package/docs/EXIT-CODES.md +71 -0
  27. package/docs/KNOWN-LIMITS.md +45 -21
  28. package/docs/RESEARCH.md +9 -4
  29. package/docs/ROUTING.md +53 -8
  30. package/eval/RESULTS.md +443 -0
  31. package/eval/ab.mjs +743 -0
  32. package/eval/attribute.mjs +170 -0
  33. package/eval/flags.mjs +88 -0
  34. package/eval/measure-install.mjs +323 -0
  35. package/eval/measure-prune.mjs +339 -0
  36. package/eval/metrics.js +79 -0
  37. package/eval/premises.mjs +200 -0
  38. package/eval/prepare.mjs +233 -0
  39. package/eval/run.mjs +376 -0
  40. package/eval/tasks-self.json +81 -0
  41. package/eval/tasks-ts.json +50 -0
  42. package/eval/tasks.json +83 -0
  43. package/eval/thresholds.mjs +134 -0
  44. package/eval/verify.mjs +287 -0
  45. package/package.json +44 -17
  46. package/profiles/fleet-all.json +6 -1
  47. package/profiles/fleet-safe.json +7 -3
  48. package/profiles/schema.json +182 -51
  49. package/prompts/build.md +4 -2
  50. package/skills/space-build/SKILL.md +4 -2
  51. package/skills/space-finish/SKILL.md +6 -2
  52. package/skills/space-plan/reference/task-template.md +13 -5
  53. package/src/config.js +182 -32
  54. package/src/denylist.js +95 -0
  55. package/src/driver/run.mjs +81 -16
  56. package/src/effort.js +15 -4
  57. package/src/guard.ts +165 -13
  58. package/src/health.js +80 -0
  59. package/src/index.ts +15 -4
  60. package/src/lane-usage.js +72 -18
  61. package/src/plugin-type-check.ts +38 -0
  62. package/src/plugin.ts +39 -0
  63. package/src/profile.js +225 -26
  64. package/src/project.js +47 -0
  65. package/src/prune.ts +110 -31
  66. package/src/router.ts +32 -63
  67. package/src/rules.js +99 -7
  68. package/src/safe.ts +27 -0
  69. package/src/sqlite.js +144 -0
  70. package/src/tasks.js +112 -29
  71. package/src/telemetry-store.ts +62 -0
  72. package/src/telemetry.ts +60 -57
  73. 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
+ }