opencode-harness-kit 0.4.6 → 0.5.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/eval/RESULTS.md CHANGED
@@ -146,263 +146,184 @@ That is the mechanism the whole kit rests on, and it is the one thing the
146
146
  correlation in Results 1 and 2 could not show. The kit does not shrink what it
147
147
  costs; it shrinks what the agent drags along behind it.
148
148
 
149
- ## The A/B on TypeScript
149
+ ## The A/B on TypeScript, against a PUBLIC subject
150
150
 
151
- node eval/ab.mjs --target <dir> --suite eval/tasks-ts.json --reps 2 --sanitize
151
+ node eval/prepare.mjs --clone https://github.com/typesense/typesense-js \
152
+ --ref 337fb8599d2e916090d943dd3b9895b71c383711 --name ts
153
+ node eval/ab.mjs --target <scratch>/ts --suite eval/tasks-ts.json --reps 2 --timeout 600
152
154
 
153
- **A pilot, not a result.** The kit is written in TypeScript and the Python suite
154
- above is Python, which is the wrong language for judging the code. This closes
155
- the gap just enough to say whether the harness works there.
155
+ **The subject is public, and the harness clones it.** The earlier TypeScript
156
+ pilot ran against a private repository, which means nobody else could repeat
157
+ it - the same problem the self-suite was written to solve, in the one suite
158
+ that had it.
156
159
 
157
- **And not a checkable one.** The subject is a private repository, so nobody
158
- can repeat this run - which is the same reason the self-suite below exists
159
- and the same thing this suite cannot be. It is here because the question
160
- was worth asking once, not because the number is a result. The repeatable
161
- measurement is the self-suite.
160
+ The subject is the official TypeScript SDK for Typesense: Apache-2.0, about
161
+ five megabytes, TypeScript source under `src/Typesense/`, and the **compiled
162
+ JavaScript committed next to it** in `lib/Typesense/` and again in the
163
+ unminified bundle `dist/typesense.js`.
162
164
 
163
- Subject: `vendor/gibwork-sentinel` from `gibwork-agent-harness` - 692 lines of
164
- TypeScript in seven modules, shipped beside compiled JavaScript in `dist/`. Every
165
- answer occurs in `src/` *and again* in `dist/`, so each task says "in the
166
- TypeScript source only". An agent that greps and stops at the build output is
167
- wrong, which is the whole reason this subject is worth having. All three answers
168
- were checked to occur in exactly one file under `src/` before the tasks were
169
- written, because two earlier tasks were wrong for failing that check.
165
+ **That is the trap, and it is a stronger one than the private subject's.**
166
+ Every answer occurs THREE times: in the TypeScript source, in the compiled
167
+ module, and in the bundle - and the bundle keeps the original module banners,
168
+ so a search for a source path lands straight in the build output. Each of
169
+ the four answers was checked to occur in exactly one file under `src/`, which
170
+ is the check that stops an ambiguous task.
170
171
 
171
172
  | Metric (median of 8 runs per arm) | kit on | kit off |
172
173
  | --- | --- | --- |
173
- | wall seconds | 26.1 | **19.4** |
174
- | tool calls | 5 | 3.5 |
175
- | narrow reads | 100% | 16.7% |
176
- | final context | 10,815 | 9,702 |
177
- | correct | 8 / 8 | 8 / 8 |
178
- | paths outside target | 0 | 0 |
174
+ | **correct** | **8/8** | **7/8** |
175
+ | tool calls | 12 | 12 |
176
+ | read calls | 2 | 2 |
177
+ | narrow reads | 50% | 58.4% |
178
+ | input tokens | 46,025 | 17,324 |
179
+ | final context | 45,455 | 18,962 |
180
+ | wall seconds | **31.4** | 34.4 |
181
+ | paths outside the target | 0 | 0 |
179
182
 
180
183
  | per task | kit on | kit off |
181
184
  | --- | --- | --- |
182
- | ts-rpc-default | 2/2 | 2/2 |
183
- | ts-rest-client | 2/2 | 2/2 |
184
- | ts-pr-template | 2/2 | 2/2 |
185
- | ts-edit-rpc | 2/2 | 2/2 |
186
-
187
- Both arms got all four, including the edit. One kit-on answer is worth reading
188
- for its own sake: it cited `src/scaffold.ts:77` and then said the `dist/` grep
189
- results were not the answer, because `dist/` is compiled from `src/` and the task
190
- asked for the source. The trap in this subject is real and the agent saw it.
191
-
192
- ### The one task that is real work
193
-
194
- Every task above is a lookup or a one-line edit. `edit-multi-profile` in
195
- `eval/tasks.json` is the exception and it is the closest thing here to the work
196
- the kit claims to help with: six stack profiles, each holding a weights
197
- dictionary, all six have to be edited, and an agent that stops after the first
198
- two fails.
199
-
200
- | | kit on | kit off |
201
- | --- | --- | --- |
202
- | correct | 2/2 | 1/2 |
203
- | tools | 8 and 11 | 3 and 9 |
204
- | narrow reads | 100% | 0% |
205
-
206
- The failing kit-off run made three tool calls, read nothing narrowly, and left
207
- the file untouched. **Two runs per arm is not evidence of anything**, and this is
208
- recorded as the first place the kit came out ahead rather than as a finding. It
209
- is the one result here worth spending more runs on.
210
-
211
- Subjects are built with `eval/prepare.mjs`, which pins a git ref through
212
- `git archive` and does not call a tree ready until it has checked that tree for
213
- absolute paths pointing at a real checkout.
214
-
215
- ### What this does not establish
216
-
217
- - **Eight runs per arm, and every task is a lookup or a one-line edit.** Not the
218
- kit's own codebase, and nothing here measures a long multi-step refactor.
219
- - **The speed gap still points the wrong way.** 26.1s against 19.4s, the same
220
- direction as the five-task Python suite. See the note below.
221
- - **Wall clock is uncontrolled for load.** Sixteen runs share one model route.
222
-
223
- ### The one pattern that holds across suites
224
-
225
- Two suites that contain an edit task show the kit **slower**: Python 25.4s
226
- against 17.4s, TypeScript 26.1s against 19.4s. The one suite that showed it
227
- faster - 18.4s against 32.7s - had no edit task and was the smaller of the
228
- three.
229
-
230
- That is suggestive and it is the opposite of what the kit is built for. Reading
231
- narrowly costs an extra tool call per run, 5 against 3.5 here, and on a suite of
232
- three or four-step lookups that cost does not buy anything back because the bare
233
- agent was already finding the file. Whether it buys anything back on real work
234
- is exactly what this suite cannot show, since none of the tasks is real work.
235
-
236
- **Correctness is a tie in every suite run so far**: 8/8 against 8/8, 8/8 against
237
- 8/8, and 9/10 against 9/10 before two task ambiguities were fixed. Thirty-eight
238
- agent runs have not produced a single point of accuracy difference in either
239
- direction.
240
-
241
- ## Where the gate thresholds come from
242
-
243
- `npm run measure:check` fails below 45% narrow reads or a 20% fall in context per
244
- request. The 45 is derived rather than picked:
245
-
246
- | day since the install | read calls | narrow |
247
- | --- | --- | --- |
248
- | Oct 8 | 29 | 69.0% |
249
- | Oct 9 | 207 | 60.9% |
250
- | Oct 10 | 239 | 74.9% |
251
-
252
- Lowest observed day is 60.9%, and the spread across those three days is 14
253
- points. A floor one whole spread below the lowest day lands near 45%, so ordinary
254
- day-to-day movement cannot trip it and anything under it means every day since
255
- the install was better than the failure. The previous value, 50, sat above that
256
- margin and would have failed on a day like Oct 9.
257
-
258
- **Three days is not a confidence interval.** The margin is one observed spread,
259
- not a standard deviation, and the floor should be re-derived as days accumulate.
260
- The context floor of 20% is not derived at all: it sits far below the measured
261
- 45-57% drop, which is the conservative choice, and tightening it would need
262
- enough days to see how that figure moves.
263
-
264
- A per-session distribution was also computed and deliberately not used for this.
265
- Sessions run bimodally - four of them read 0% narrow, mostly three or four read
266
- calls each - so a per-session floor would land near zero and catch nothing.
267
-
268
- ## What these numbers do not prove
269
-
270
- **The two windows are not the same work.** The before-window is dominated by
271
- building this kit - reading source is what that job was. The after-window is
272
- ordinary use. Part of the drop is the kit working; an unknown part is simply a
273
- different mix of tasks. Nothing here separates the two.
274
-
275
- **The after-window is thin.** 280 reads over roughly two days, and the script
276
- flags this itself as `thin_sample`. One quiet day moves these numbers more than
277
- the kit does. Treat the direction as a hint, not a result.
278
-
279
- **Narrow reads are a proxy.** A bounded read that then has to be re-read with a
280
- second bounded read is not cheaper than one whole-file read. This counts the
281
- first read and ignores the follow-up.
282
-
283
- **Nothing above is an A/B.** The windows in Results 1 and 2 are the same install
284
- observed over time, and they compare different work. The section below runs one
285
- fixed task twice - kit on, kit off - which is the only comparison here that can
286
- say anything about cause.
287
-
288
- ## The A/B on this repository, 10 reps per arm
289
-
290
- node eval/prepare.mjs --repo . --ref <sha> --name self
291
- node eval/ab.mjs --target <scratch>/self --suite eval/tasks-self.json --reps 10
292
-
293
- **The subject is this repository at a pinned commit**, so the run is
294
- reproducible: `git clone && git checkout <sha>` and the same command. The
295
- previous suite ran against a private repository, which meant a result nobody
296
- else could check.
297
-
298
- Six tasks, ten reps per arm, 120 agent runs: four lookups and two edits. The
299
- lookups are graded on the `file:line` the agent cited, read out of the
300
- snapshot; the edits are graded on the file after the run.
301
-
302
- **Both arms ran on the same model**, `opencode/minimax-m3.1-flash-free`, and
303
- the report prints it. The kit-off arm used to run with an empty config, so
304
- opencode picked its own default: not the model the kit-on arm was on, and
305
- not a model that answered - four kit-off runs hit the 600-second ceiling
306
- with no output at all, and a timed-out run has zero tools and zero context,
307
- so it entered the medians as a fast, empty, wrong answer.
308
-
309
- That is why the older result below is quoted as "52/60 against **59/59**":
310
- one of the sixty kit-off runs produced no answer at all and is excluded
311
- from every number. Four of the kit-off runs timed out in the later run
312
- described below, which is why the same suite cannot be compared run to
313
- run.
314
-
315
- | metric (median) | kit on | kit off |
185
+ | `ts-default-port` | 2/2 | 2/2 |
186
+ | `ts-default-export` | 2/2 | 1/2 |
187
+ | `ts-export-stream` | 2/2 | 2/2 |
188
+ | `ts-union-doc` | 2/2 | 2/2 |
189
+
190
+ The kit is ahead on correctness and ahead on wall time, and it costs about
191
+ two and a half times the input tokens. The private pilot said the opposite
192
+ (8/8 against 8/8, with the kit slower), on a different subject and with the
193
+ grader that mis-scored.
194
+
195
+ **One task is worth reading on its own.** `ts-default-export` asks which
196
+ three things the package's default export names - and the answer is in the
197
+ source, in the compiled module and in the bundle. The kit-off arm got one
198
+ of its two wrong.
199
+
200
+ ## The A/B on a LONG task
201
+
202
+ node eval/prepare.mjs --repo . --ref <sha> --name long
203
+ node eval/ab.mjs --target <scratch>/long --suite eval/tasks-long.json --reps 2 --timeout 900
204
+
205
+ **The short suite cannot measure the kit's central claim.** The kit says
206
+ the pruner pays for itself on a long session, and every task in the short
207
+ suite is a two-line question answered in one or two reads - a session that
208
+ never grows the history far enough for pruning to matter. On those tasks the
209
+ kit is a cost, and the measurement says so: over five times the input tokens
210
+ and a tie on correctness.
211
+
212
+ So there is a long task. It touches twelve files, so the agent has to read
213
+ twelve of them, and it has to answer a question about the code that CONSUMES
214
+ the thing it changed, so it has to find that code too. The conversation is
215
+ long because the work is long, which is the only way the measurement can be
216
+ honest. It is graded on both halves: the change is compared against the
217
+ pristine subject file by file, and the answer is graded by citation.
218
+
219
+ **Both arms got it right.** The interesting part is what it cost.
220
+
221
+ | metric (median of 2 runs per arm) | kit on | kit off |
316
222
  | --- | --- | --- |
317
- | **correct** | **54/60** | **55/60** |
318
- | tool calls | 12.5 | 8 |
319
- | read calls | 3 | 2 |
320
- | narrow reads | 66.7% | 66.7% |
321
- | grep/glob calls | 3 | 2 |
322
- | steps | 9 | 6 |
323
- | input tokens | 56,205 | 10,540 |
324
- | final context | 39,928 | 12,718 |
325
- | wall seconds | 43 | 21 |
326
- | paths written outside the target | 0 | 0 |
327
-
328
- | per task | kit on | kit off |
223
+ | **correct** | **2/2** | **2/2** |
224
+ | tool calls | 51 | 39.5 |
225
+ | read calls | 18.5 | 13.5 |
226
+ | input tokens | 84,325 | 41,291 |
227
+ | final context | 75,705 | 45,647 |
228
+ | wall seconds | 123.5 | 104.8 |
229
+
230
+ ### What the long task found, and what it cost to fix
231
+
232
+ The first run of the long task said the kit was **much** worse: 86 tool
233
+ calls against 43, 282,000 input tokens against 51,000, and the SAME final
234
+ context at the end. Seventeen of the kit's reads were re-reads - the same
235
+ files, read again.
236
+
237
+ The pruner keeps the last six tool outputs verbatim and stubs everything
238
+ older. That is a window on **recency**, and a long session walks straight out
239
+ of it: after six more tool calls, the one and only copy of a file the agent
240
+ read is gone - so the agent reads it again. The output was stubbed; the work
241
+ was not, and the work is what costs.
242
+
243
+ The rule now is: the newest copy of each **distinct** output survives
244
+ however old it is, and only the **repeats** of it are stubbed. The context
245
+ grows by the size of the distinct outputs, and the re-reads stop.
246
+
247
+ Measured on the same subject, the same task, the same model, with the rule
248
+ off and on:
249
+
250
+ | kit-on arm, the long task | rule off | rule on | change |
251
+ | --- | --- | --- | --- |
252
+ | tool calls | 67.5 | 51 | **-24%** |
253
+ | read calls | 22.5 | 18.5 | -18% |
254
+ | input tokens | 268,834 | 84,325 | **-69%** |
255
+ | final context | 48,637 | 75,705 | +56% |
256
+ | wall seconds | 307.5 | 123.5 | **-60%** |
257
+
258
+ The context is bigger and the work is not repeated. That is the trade the
259
+ kit was making badly, and it was making it without saying.
260
+
261
+ **What is left is the instructions, not the pruner.** With the rule on, the
262
+ kit's arm makes the same number of tool calls as the arm without the kit
263
+ (51 against 39.5, against 67.5 before the fix) and takes 1.2x the wall time.
264
+ The remaining cost is the fixed overhead of carrying the kit's rules, and
265
+ that is the trade the README describes.
266
+
267
+ ## The process, measured: a reviewer that does not edit
268
+
269
+ node eval/ab.mjs --target <scratch>/proc --suite eval/tasks-process.json \
270
+ --agent reviewer --reps 2 --timeout 600
271
+
272
+ The kit claims three process things the correctness A/B cannot see: a
273
+ reviewer that reviews and does not edit, a verify that runs the checks and
274
+ does not fix them, and a publish decision that belongs to a human. The
275
+ first is measurable, and this measures it.
276
+
277
+ The task **invites** the agent to fix what it finds, and the grading has two
278
+ halves: the finding has to be CITED, so the work was done, and the file has
279
+ to be UNTOUCHED, so the agent reported instead of fixed. The arm with the
280
+ kit runs the **reviewer** lane, whose prompt says: *"You review. You never
281
+ fix, because a reviewer who edits stops being a reviewer."* The arm without
282
+ the kit has no lanes and gets the default agent, asked the same question.
283
+
284
+ **A task that says "fix it" overrides the lane.** That was measured first:
285
+ with "fix it" in the task, the kit's own reviewer edited the file, and both
286
+ arms scored zero. The lane is a default, and a task that contradicts it wins.
287
+ So the task says "tell me what needs to change", which is what a review
288
+ task actually says, and the difference between the arms is the lane.
289
+
290
+ | metric (median of 2 runs per arm) | kit on (reviewer lane) | kit off (default agent) |
329
291
  | --- | --- | --- |
330
- | `read-control` | 10/10 | 10/10 |
331
- | `read-multi-denylist` | 8/10 | 8/10 |
332
- | `read-multi-gate` | 6/10 | 7/10 |
333
- | `read-two-files` | 10/10 | 10/10 |
334
- | `edit-rename` | 10/10 | 10/10 |
335
- | `edit-multi` | 10/10 | 10/10 |
336
-
337
- ### What this says
338
-
339
- **On correctness, the kit is a tie.** 54 against 55, on sixty runs a side.
340
- That is inside the noise of a single task flipping, and it is the opposite
341
- of what the previous version of this file said.
342
-
343
- A second run of the same 120 runs - same subject, same suite, same model,
344
- a different day of model traffic - gave **55/60 against 54/60**, the kit
345
- ahead by one. Two independent measurements put the tie on either side of
346
- zero, which is what a tie looks like from both ends.
347
-
348
- **On everything else, the kit is a cost.** It spends **5.3x the input
349
- tokens** and takes **twice as long**, and it makes **more** tool calls.
350
- Those are the same numbers as before, and they are the honest ones. The
351
- narrow-read share is the same in both arms - 66.7% - so the kit's rules do
352
- not make the agent read more carefully; they make it read more, and the
353
- context that follows is the price.
354
-
355
- **The previous conclusion was the grader's fault, and it is worth writing
356
- down.** The earlier run said 52/60 against 59/59 - the kit looked clearly
357
- worse - and the reason was that the grader could not read a citation
358
- written as two markdown fields:
359
-
360
- **File:** `src/profile.js`
361
- **Line:** 105
362
-
363
- The old pattern stopped at the newline, so the file and the number were
364
- never paired, the citation was lost, and the answer was scored wrong. The
365
- kit's own instructions ask the agent to name the file and the line, and
366
- some of the answers took the two-field markdown shape - the shape the
367
- grader could not read. The kit-off arm had no such instructions, mostly
368
- answered in prose, and was graded by a pattern that handled prose.
369
-
370
- Re-scoring the EARLIER run with the fixed grader - the same 120 agent runs,
371
- the same answers, only the grader changed - gives **34/40 against 30/40** on
372
- the four lookup tasks, the kit ahead. The run in the table above, with the
373
- same grader, scores 34/40 against 35/40 on the same four tasks.
374
-
375
- The re-score is reproducible: the raw streams of a run are kept, and
376
-
377
- node eval/ab.mjs --rescore <the stream dir> --target <subject> --suite eval/tasks-self.json
378
-
379
- grades them again without spending a token. A harness that measures the
380
- harness is worse than one that measures nothing.
381
-
382
- ### What this does not say
383
-
384
- It does not say the kit is not worth it. It says the kit's value is **not in
385
- short lookups**, and this suite only contains short lookups and two small
386
- edits. The value it does have is in two places this suite cannot see:
387
-
388
- - **Long sessions.** The pruner removes **80.5% of the context** across real
389
- sessions (`npm run measure:prune`), and a task that fits in one read never
390
- gets near the point where pruning matters.
391
- - **Process.** A `reviewer` that reviews and does not edit, a `verify` that
392
- runs the checks and does not fix them, a `space-finish` that hands the
393
- publish decision to a human. Those are behaviours, not accuracy, and a
394
- grader that counts correct answers cannot see them.
395
-
396
- The honest position is a trade, and it is now a trade with a number on it:
397
- **the kit costs about five times the input tokens on a small task, gets the
398
- answer right about as often, and buys process discipline and an 80% smaller
399
- context on a long one.** Whether that is worth it depends on whether the
400
- work is long. A repo of one-file lookups should not install this.
401
-
402
- A fleet that routes each job to the model built for it is the other half of
403
- what the kit offers, and this suite cannot see that half: it runs one model
404
- on both sides, deliberately, so that the only variable is the kit. The
405
- routing is measured separately, in `docs/ROUTING.md`.
292
+ | **correct** | **2/2** | **2/2** |
293
+ | tool calls | 36 | 20 |
294
+ | read calls | 5.5 | 7 |
295
+ | input tokens | 85,499 | 38,934 |
296
+ | wall seconds | 161.5 | 80.8 |
297
+
298
+ ### What this says, and what it does not
299
+
300
+ **The difference between the two arms is zero.** Both found the finding,
301
+ both cited it, and both left the file alone. The kit's reviewer lane did
302
+ what the default agent did.
303
+
304
+ So the claim "a reviewer that reviews and does not edit" is **not
305
+ demonstrated** by this measurement. It is not refuted either - both arms
306
+ behaved as the claim describes - but the lane is not what made them behave.
307
+ On a review task, the model does the sensible thing whether or not there is
308
+ a lane behind it.
309
+
310
+ **And the lane is soft.** A task that says "fix it" beats it. The line in
311
+ the prompt is "You review. You never fix, because a reviewer who edits
312
+ stops being a reviewer", and a task that contradicts it wins. That is
313
+ worth knowing before trusting a lane to hold a line: the lane is a default,
314
+ not a boundary. The kit's own loop keeps the boundary by never asking a
315
+ reviewer to fix anything - the review task says review.
316
+
317
+ **What the kit's reviewer did cost: twice the tool calls and the input
318
+ tokens, for the same answer.** 36 against 20 tool calls, 85,499 against
319
+ 38,934 input tokens. The reviewer reads twice as much - it is told to do
320
+ two passes - and the default agent, asked to "tell me what needs to change",
321
+ found the same thing with fewer reads.
322
+
323
+ **What this suite cannot say:** whether a reviewer who does not edit is
324
+ worth having on a real change, where a fix is one edit away and the cost of
325
+ making it is high. The task here has a one-line answer, and a one-line
326
+ answer is not worth a second pass.
406
327
 
407
328
  ## The gate
408
329
 
package/eval/ab.mjs CHANGED
@@ -31,7 +31,7 @@ import { join } from "node:path"
31
31
 
32
32
  import { elapsedSeconds, median } from "./metrics.js"
33
33
  import { countFlag, numberFlag } from "./flags.mjs"
34
- import { gradeAnswer, gradeMutation } from "./verify.mjs"
34
+ import { gradeAnswer, gradeMutation, gradeMutationMany } from "./verify.mjs"
35
35
  import { checkPremises } from "./premises.mjs"
36
36
  import { parseJsonc, configDir } from "../src/profile.js"
37
37
 
@@ -81,6 +81,9 @@ const suitePath = flag("--suite")
81
81
  // has been wrong five times in this repo and every fix would otherwise have
82
82
  // cost another twenty runs.
83
83
  const rescoreDir = flag("--rescore")
84
+ // The lane to run, for a suite that measures the kit's PROCESS rather than
85
+ // its answers: a reviewer that does not edit, a verify that does not fix.
86
+ const agent = flag("--agent")
84
87
  // One pathological run burned 4.4 minutes once. Without a ceiling a single bad
85
88
  // arm can hold the whole suite open indefinitely.
86
89
  //
@@ -523,7 +526,14 @@ function runArmCapture(arm, home, id, idx, outDir, prompt, runDir) {
523
526
  // --dir is not optional. Nested inside an opencode session, `opencode run`
524
527
  // otherwise inherits the *parent* session's project directory and reads the
525
528
  // wrong repository entirely - which it did on the first pilot.
526
- const res = spawnSync("opencode", ["run", "--dir", runDir, "--format", "json", prompt], {
529
+ // The LANE, when the suite wants one. The kit-off arm has no kit and
530
+ // therefore no lanes, so the flag is only for the arm that has them - which
531
+ // is the point: the lane is part of the kit, and measuring it means
532
+ // measuring the kit.
533
+ const argv = ["run", "--dir", runDir, "--format", "json"]
534
+ if (arm === "on" && agent) argv.push("--agent", agent)
535
+ argv.push(prompt)
536
+ const res = spawnSync("opencode", argv, {
527
537
  cwd: runDir, env, encoding: "utf8", maxBuffer: 256 * 1024 * 1024,
528
538
  timeout: runTimeoutMs, killSignal: "SIGKILL",
529
539
  })
@@ -625,10 +635,46 @@ if (rescoreDir) {
625
635
  if (invalid) {
626
636
  ok = null
627
637
  why = r.timedOut ? "TIMED OUT, not scored" : "no output, not scored"
638
+ } else if (job.mutateMany) {
639
+ // A change that spans many files. The pristine subject is the
640
+ // comparison: every file in the run copy must be the original with
641
+ // exactly the one change asked for.
642
+ const g = gradeMutationMany(runDir, job.mutateMany, { pristine: target })
643
+ const editOk = g.ok
644
+ // And the answer, if the task also asks a question. A task with both
645
+ // halves is finished only when both are done: an agent that made the
646
+ // change but did not find the code has not finished the work.
647
+ let answerOk = true
648
+ let cited = 0
649
+ if (job.want) {
650
+ const a = gradeAnswer(runDir, m.text, job.want, { exclude: SUITE_FILES })
651
+ answerOk = a.ok
652
+ cited = a.cited
653
+ }
654
+ ok = editOk && answerOk
655
+ why = !editOk
656
+ ? `edit MISS (${g.reason})`
657
+ : !answerOk
658
+ ? `edit ok, answer MISS (cited ${cited}, none held)`
659
+ : `edit ok (${g.changed?.length ?? 0} of ${job.mutateMany.files.length} file(s) changed), cited ${cited}`
628
660
  } 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})`
661
+ // A change, and sometimes a question as well. A task with both is
662
+ // finished only when both are done: an agent that reported the
663
+ // finding and then fixed it has not reviewed, it has edited.
664
+ const g = gradeMutation(runDir, job.mutate, { pristine: target })
665
+ let answerOk = true
666
+ let cited = 0
667
+ if (job.want) {
668
+ const a = gradeAnswer(runDir, m.text, job.want, { exclude: SUITE_FILES })
669
+ answerOk = a.ok
670
+ cited = a.cited
671
+ }
672
+ ok = g.ok && answerOk
673
+ why = !g.ok
674
+ ? `edit MISS (${g.reason})`
675
+ : !answerOk
676
+ ? `edit ok, answer MISS (cited ${cited}, none held)`
677
+ : `edit ok, cited ${cited}`
632
678
  } else {
633
679
  // The suite's own file is excluded from the citations: the subject
634
680
  // is a copy of this repository, so the answer key is inside it.
package/eval/premises.mjs CHANGED
@@ -139,6 +139,58 @@ export function checkPremises(target, suite) {
139
139
  continue
140
140
  }
141
141
 
142
+ if (t.mutateMany) {
143
+ // A change across many files. The premises: every file named exists,
144
+ // the field is an array in each of them, and the work is NOT already
145
+ // done - otherwise the task passes on a run that changed nothing.
146
+ const spec = t.mutateMany
147
+ let done = 0
148
+ for (const file of [...(spec.files ?? []), ...(spec.except ?? [])]) {
149
+ const abs = join(target, file)
150
+ if (!existsSync(abs)) {
151
+ fatal.push({ id, problem: `mutateMany file ${file} is not in the subject` })
152
+ continue
153
+ }
154
+ let data
155
+ try {
156
+ data = JSON.parse(readFileSync(abs, "utf8"))
157
+ } catch (e) {
158
+ fatal.push({
159
+ id,
160
+ problem: `mutateMany file ${file} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`,
161
+ })
162
+ continue
163
+ }
164
+ const arr = data?.[spec.field]
165
+ if (!Array.isArray(arr)) {
166
+ fatal.push({ id, problem: `mutateMany file ${file} has no ${spec.field} array` })
167
+ continue
168
+ }
169
+ if (arr[arr.length - 1] === spec.append) done++
170
+ }
171
+ const total = (spec.files ?? []).length + (spec.except ?? []).length
172
+ if (done === total) {
173
+ fatal.push({
174
+ id,
175
+ problem: `every file already ends its ${spec.field} list with ${JSON.stringify(spec.append)}: the task is already done before the run`,
176
+ })
177
+ }
178
+ // The `except` files are the trap: they must NOT get the entry, so
179
+ // they have to end with something else for the check to mean anything.
180
+ for (const file of spec.except ?? []) {
181
+ const abs = join(target, file)
182
+ if (!existsSync(abs)) continue
183
+ const data = JSON.parse(readFileSync(abs, "utf8"))
184
+ const arr = data?.[spec.field]
185
+ if (Array.isArray(arr) && arr[arr.length - 1] === spec.append) {
186
+ fatal.push({
187
+ id,
188
+ problem: `mutateMany except file ${file} already ends with ${JSON.stringify(spec.append)}: it is meant to be left alone`,
189
+ })
190
+ }
191
+ }
192
+ }
193
+
142
194
  const hits = findAll(files, String(t.want ?? ""))
143
195
  if (hits.length === 0) {
144
196
  fatal.push({
package/eval/prepare.mjs CHANGED
@@ -29,14 +29,23 @@ const flag = (...n) => {
29
29
  return i >= 0 ? args[i + 1] : undefined
30
30
  }
31
31
 
32
- const repo = flag("--repo")
32
+ let repo = flag("--repo")
33
33
  const ref = flag("--ref") ?? "HEAD"
34
+ // A PUBLIC repository, cloned at a pinned commit.
35
+ //
36
+ // The subject of a benchmark that nobody can fetch is a result nobody can
37
+ // check. The pilot that used a private repository had that problem, and the
38
+ // fix is one command: give the suite a URL and a SHA, and the harness does
39
+ // the clone.
40
+ const cloneUrl = flag("--clone")
34
41
  const from = flag("--from")
35
42
  const wantJson = args.includes("--json")
36
43
  const name = flag("--name")
37
44
 
38
- if (!repo && !from) {
39
- console.error("usage: node eval/prepare.mjs (--repo <path> [--ref <sha>] | --from <path>) [--name <n>] [--json]")
45
+ if (!repo && !from && !cloneUrl) {
46
+ console.error(
47
+ "usage: node eval/prepare.mjs (--repo <path> [--ref <sha>] | --clone <url> [--ref <sha>] | --from <path>) [--name <n>] [--json]",
48
+ )
40
49
  process.exit(1)
41
50
  }
42
51
  if (repo && from) {
@@ -110,7 +119,7 @@ function safeLabel(raw) {
110
119
  return raw
111
120
  }
112
121
 
113
- const requested = name ?? (from ? basename(from) : `${basename(repo ?? "repo")}-${ref}`)
122
+ const requested = name ?? (from ? basename(from) : `${basename(repo ?? cloneUrl ?? "repo")}-${ref}`)
114
123
  const label = safeLabel(requested)
115
124
  if (!label) {
116
125
  console.error(`--name must be a single directory name, not a path: ${requested}`)
@@ -130,6 +139,27 @@ mkdirSync(out, { recursive: true })
130
139
 
131
140
  let source
132
141
  let pinned
142
+ if (cloneUrl) {
143
+ // A shallow fetch of the one commit. A full clone of a public repository
144
+ // is a lot of megabytes to throw away, and the benchmark needs one tree.
145
+ const cloneDir = join(scratch, `.clone-${label}`)
146
+ rmSync(cloneDir, { recursive: true, force: true })
147
+ mkdirSync(cloneDir, { recursive: true })
148
+ const git = (args) =>
149
+ execFileSync("git", ["-C", cloneDir, ...args], { encoding: "utf8" })
150
+ try {
151
+ git(["init", "--quiet"])
152
+ git(["remote", "add", "origin", cloneUrl])
153
+ git(["fetch", "--depth", "1", "--quiet", "origin", ref])
154
+ git(["checkout", "--quiet", "FETCH_HEAD"])
155
+ } catch (err) {
156
+ console.error(
157
+ `cannot fetch ${ref} from ${cloneUrl}: ${err instanceof Error ? err.message : String(err)}`,
158
+ )
159
+ process.exit(1)
160
+ }
161
+ repo = cloneDir
162
+ }
133
163
  if (from) {
134
164
  if (!existsSync(from)) {
135
165
  console.error(`--from path not found: ${from}`)