opencode-harness-kit 0.6.3 → 0.7.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/dist/profile.d.ts CHANGED
@@ -12,6 +12,7 @@ export type Lane = {
12
12
  steps?: number;
13
13
  permission?: Record<string, any>;
14
14
  prompt: string;
15
+ skills?: boolean;
15
16
  };
16
17
  export type Profile = {
17
18
  id: string;
@@ -69,7 +70,7 @@ export type RetentionRecord = {
69
70
  * @typedef {"zero-retention" | "retained" | "may-train" | "unknown"} Retention
70
71
  * @typedef {string | null} EffortRequest
71
72
  * @typedef {{ model: string, levels?: string[], chain?: string[] }} RoleModel
72
- * @typedef {{ description: string, mode?: string, steps?: number, permission?: Record<string, any>, prompt: string }} Lane
73
+ * @typedef {{ description: string, mode?: string, steps?: number, permission?: Record<string, any>, prompt: string, skills?: boolean }} Lane
73
74
  * @typedef {object} Profile
74
75
  * @property {string} id
75
76
  * @property {string} [title]
package/dist/prune.d.ts CHANGED
@@ -1,5 +1,27 @@
1
1
  import type { Plugin } from "./plugin.js";
2
+ interface StubStats {
3
+ stubbed: number;
4
+ kept: number;
5
+ bytesBefore: number;
6
+ bytesAfter: number;
7
+ }
2
8
  export declare const PrunePlugin: Plugin;
9
+ /**
10
+ * Append one line to the project's prune log, and rotate it when the file
11
+ * outgrew `maxBytes`.
12
+ *
13
+ * Exported so the rotation can be driven directly in the tests: reaching it
14
+ * through the hook needs a 4MB fixture, which is not a fixture.
15
+ *
16
+ * @param {string} worktree the project the log belongs to
17
+ * @param {string} profileId the profile the prune ran under
18
+ * @param {StubStats & { saved: number, threshold: number }} stats
19
+ * @param {number} [maxBytes] rotate above this; the telemetry default
20
+ */
21
+ export declare function logPrune(worktree: string, profileId: string, stats: StubStats & {
22
+ saved: number;
23
+ threshold: number;
24
+ }, maxBytes?: number): Promise<void>;
3
25
  export declare const pruneMeta: {
4
26
  note: string;
5
27
  };
package/dist/rules.d.ts CHANGED
@@ -75,8 +75,12 @@ export declare function shellPayload(segment: string): string | null;
75
75
  export declare const ASK_GATED: RegExp[];
76
76
  /** @type {Rule[]} */
77
77
  export declare const BASH_RULES: Rule[];
78
- /** @type {Rule[]} */
79
- export declare const WRITE_RULES: Rule[];
78
+ export declare const WRITE_RULES: {
79
+ name: string;
80
+ test: RegExp;
81
+ unless: RegExp;
82
+ why: string;
83
+ }[];
80
84
  /**
81
85
  * The shared denylist, checked the way the rules are checked.
82
86
  *
@@ -9,18 +9,32 @@ stdout gets the answer and nothing else.
9
9
  ## `oc-harness run`
10
10
 
11
11
  The unattended loop. It walks `.space/tasks.md`, asks the agent for one task
12
- at a time, and re-runs the task's own verify command before believing it.
12
+ at a time, and re-runs the task's own verify command before believing it. It
13
+ builds and verifies; it does not plan, commit or push. With `--review` it also
14
+ asks the reviewer lane for a schema'd verdict and stops unless that verdict
15
+ passes.
13
16
 
14
17
  | Code | Meaning |
15
18
  |---|---|
16
19
  | 0 | Every task is done. |
17
20
  | 1 | There is no task file. The loop needs a plan to walk. |
18
- | 2 | Stopped early and a person is needed: a task is blocked, a task has no verify command, the round cap was reached, or the time budget ran out. |
19
- | 3 | The agent itself failed. The reason is the agent's own last words, and the task is marked blocked with them. |
21
+ | 2 | Stopped early and a person is needed: a task is blocked, a task has no verify command or one that cannot fail (`true`, `echo ok`), a task file with no tasks in it, the agent changed files the task did not name, another run holds the lock, the round cap was reached, or the time budget ran out. |
22
+ | 3 | The agent itself failed. The reason is the agent's own last words, and the task is marked blocked with them. This includes the agent exiting 0 with an empty transcript: that is a spent or unreachable model route, not a change worth verifying, so no verify runs. |
20
23
  | 4 | The verify command failed. The task is marked blocked and the real output is in `.space/tasks.md` - not in the exit code, which is no place for a stack trace. |
24
+ | 5 | The review did not pass: the reviewer failed, returned prose instead of a schema'd verdict, or returned `fail`. The task is marked blocked with the blockers and the verdict file is in `.space/review/`. |
21
25
 
22
26
  Code 2 is the "asked for something impossible" code: nothing about it is a
23
- bug, and the reason is printed. Codes 3 and 4 mean the work did not land.
27
+ bug, and the reason is printed. Codes 3, 4 and 5 mean the work did not land.
28
+
29
+ A task's `files:` line is a scope, not a hint. When the agent changes a file
30
+ the line does not name, the loop marks the task blocked and stops rather than
31
+ letting the next task build on an unplanned edit. `--allow-extra-files` is
32
+ the deliberate way to say the task may go wider; it is also the only way to
33
+ run outside a git work tree, where the check has nothing to read.
34
+
35
+ A live lock is not cleared automatically. `--force-unlock` breaks one
36
+ explicitly, for the case where the recorded pid now belongs to an unrelated
37
+ process. Do not use it to start a second run alongside a real one.
24
38
 
25
39
  ## `oc-harness doctor`
26
40
 
@@ -0,0 +1,73 @@
1
+ # The review verdict
2
+
3
+ `oc-harness run --review` asks the `reviewer` lane for a machine-readable
4
+ verdict. A verdict is accepted only when it matches this schema; anything else
5
+ is a failed review, never a pass.
6
+
7
+ The contract is deliberately narrow. A reviewer that writes prose, wraps the
8
+ object in a sentence, or omits a field is rejected, because a loop that parses
9
+ whatever it is given will eventually parse a compliment as a pass.
10
+
11
+ ## Schema
12
+
13
+ ```json
14
+ {
15
+ "schema": "oc-harness/review@1",
16
+ "verdict": "pass",
17
+ "blockers": [],
18
+ "notes": ""
19
+ }
20
+ ```
21
+
22
+ | Field | Required | Rule |
23
+ |---|---|---|
24
+ | `schema` | yes | Exactly `oc-harness/review@1`. Bump the suffix to change the shape. |
25
+ | `verdict` | yes | `pass` or `fail`. |
26
+ | `blockers` | yes | An array. `pass` requires it empty; `fail` requires at least one. |
27
+ | `blockers[].file` | yes | A path from the repository root. |
28
+ | `blockers[].line` | yes | A non-negative integer. `0` is allowed for a file-level finding. |
29
+ | `blockers[].issue` | yes | What is wrong. |
30
+ | `blockers[].consequence` | yes | What breaks because of it. A finding with no consequence is a preference. |
31
+ | `notes` | no | A short string. |
32
+
33
+ The reviewer may output the object bare or inside one ` ```json ` fence. It
34
+ may not put a sentence before or after it.
35
+
36
+ ## Where it is kept
37
+
38
+ The orchestrator validates the verdict and writes it to
39
+ `.space/review/<round>-<task-slug>.json`, atomically. A review that leaves no
40
+ file is a review nobody can audit, and the next run cannot tell it happened.
41
+
42
+ ```json
43
+ {
44
+ "schema": "oc-harness/review@1",
45
+ "at": "2026-10-24T12:00:00.000Z",
46
+ "task": "Add the retry budget",
47
+ "round": 3,
48
+ "reviewer": "reviewer",
49
+ "verify": { "command": "npm test", "exit": 0, "seconds": 12 },
50
+ "verdict": "fail",
51
+ "blockers": [
52
+ {
53
+ "file": "src/retry.js",
54
+ "line": 41,
55
+ "issue": "the budget is decremented after the sleep",
56
+ "consequence": "a timed-out attempt is refunded, so the loop can run forever"
57
+ }
58
+ ],
59
+ "notes": ""
60
+ }
61
+ ```
62
+
63
+ ## What the loop does with it
64
+
65
+ | Verdict | Outcome |
66
+ |---|---|
67
+ | `pass` | The task is marked `done`, and the task's `output:` records the verdict path. |
68
+ | `fail` | The task is marked `blocked` with the blockers, and the run stops with exit 5. |
69
+ | malformed | The task is marked `blocked`, and the run stops with exit 5. |
70
+ | reviewer crashed | The task is marked `blocked`, and the run stops with exit 5. |
71
+
72
+ Review runs after the task's own `verify:` has passed. A failed verify never
73
+ reaches the reviewer, because there is nothing yet worth reviewing.
package/eval/RESULTS.md CHANGED
@@ -18,6 +18,16 @@ bottom runs the same task with and without the kit, ten times each, on both
18
18
  arms at once - 120 agent runs, which is enough to be worth reading and not
19
19
  enough to be worth trusting.
20
20
 
21
+ **One definition changed, and the tables are marked.** Until commit `8614d75`
22
+ the A/B added up `tokens.input` - the part of each request that was not in the
23
+ provider's prompt cache - and called it "input tokens". That is not what a
24
+ request costs: a cached step reports a few hundred tokens there against tens of
25
+ thousands in `cache.read`. The column is now called **uncached input**, and a
26
+ **context tokens** row was added where the streams still exist to re-read it.
27
+ Both are medians over the same runs; the context row is the one to compare
28
+ against the guard's budget, and the uncached row is the one to compare against
29
+ any table written before this note.
30
+
21
31
  ## Two measurements, and the difference between them
22
32
 
23
33
  **`npm run measure` is an OBSERVATION about one install.** It compares the work
@@ -175,7 +185,7 @@ so it entered the medians as a fast, empty, wrong answer.
175
185
  | narrow reads | 66.7% | 66.7% |
176
186
  | grep/glob calls | 3 | 2 |
177
187
  | steps | 9 | 6 |
178
- | input tokens | 56,205 | 10,540 |
188
+ | uncached input | 56,205 | 10,540 |
179
189
  | final context | 39,928 | 12,718 |
180
190
  | wall seconds | 43 | 21 |
181
191
  | paths written outside the target | 0 | 0 |
@@ -199,8 +209,10 @@ traffic - gave **55/60 against 54/60**, the other way. Two measurements
199
209
  put the tie on either side of zero, which is what a tie looks like from
200
210
  both ends.
201
211
 
202
- **On everything else, the kit is a cost.** It spends **5.3x the input
203
- tokens** and takes **twice as long**, and it makes **more** tool calls.
212
+ **On everything else, the kit is a cost.** It spends **5.3x the uncached input
213
+ tokens** (the streams for this run are gone, so the context total cannot be
214
+ re-derived; on the free route, where they survive, the same ratio is 6.4x) and
215
+ takes **twice as long**, and it makes **more** tool calls.
204
216
  Those are the honest numbers.
205
217
 
206
218
  **The previous conclusion was the grader's fault, and it is worth writing
@@ -255,6 +267,117 @@ one-file question following it is waste either way. A fleet that routes
255
267
  each job to the model built for it is the other half of what the kit
256
268
  offers, and this suite uses one model on both sides, so it cannot see that.
257
269
 
270
+ ### The same suite on the free route, 3 reps per arm
271
+
272
+ node eval/ab.mjs --target <scratch>/self --suite eval/tasks-self.json \
273
+ --reps 3 --timeout 180 --out <scratch>/ab-self-02 --budget 2500000
274
+
275
+ Run after the budget work, on the route this install actually uses,
276
+ `opencode/deepseek-v4.1-flash-free`, with `--out` so a quota stop would
277
+ resume rather than restart. 36 runs, 33 scored: three hit the 180-second
278
+ ceiling (one on, two off) and are excluded rather than counted as wrong.
279
+
280
+ **The token rows were re-read after the counter was fixed.** Until then the
281
+ suite summed `tokens.input` - the part of the prompt that was not in the
282
+ provider's cache - and the table called it "input tokens". The cost of a
283
+ request is the whole context it reads. Re-running the same streams through the
284
+ corrected reader puts the medians at 252,054 and 39,180 - **6.4x**, not 4.7x:
285
+
286
+ node eval/ab.mjs --target <scratch>/self --suite eval/tasks-self.json \
287
+ --rescore <scratch>/ab-self-02
288
+
289
+ The context row is over the 12 runs per arm that can still be re-scored: the
290
+ six edit runs per arm cannot, because the per-run copy of the subject they
291
+ edited is gone and re-grading them would grade nothing. The uncached row is
292
+ over the 17 and 16 runs the live suite scored, because that is the number it
293
+ printed; it is kept because every table above it was written with that
294
+ definition. Neither row is comparable to the other's count, and both are
295
+ comparable between the arms.
296
+
297
+ | metric (median) | kit on | kit off |
298
+ | --- | --- | --- |
299
+ | **correct** | **15/17** | **16/16** |
300
+ | tool calls | 9 | 4 |
301
+ | steps | 7 | 4 |
302
+ | narrow reads | 60% | 83.4% |
303
+ | context tokens (12 runs/arm) | 252,054 | 39,180 |
304
+ | uncached input (17/16 runs) | 44,444 | 9,527 |
305
+ | final context | 43,823 | 11,236 |
306
+ | wall seconds | 53.8 | 18.6 |
307
+
308
+ Same shape as the 10-rep run, and a little worse for the kit: 6.4x the context
309
+ tokens, 2.9x the wall time, twice the tool calls, and the one task the kit
310
+ lost outright was `read-control` (1/3 against 3/3) - the smallest task in the
311
+ suite, where the kit's fixed overhead is the whole cost. Three reps per arm
312
+ is not enough to call a correctness difference; it is enough to say the cost
313
+ reproduces.
314
+
315
+ ### What the skill list cost
316
+
317
+ The first request of a run is the clean place to measure fixed overhead: the
318
+ prompt, the tool schemas, and the task, before any conversation. It is also
319
+ nearly constant run to run, which makes a median over 20 runs meaningful to the
320
+ token.
321
+
322
+ | first request | kit on | kit off |
323
+ | --- | --- | --- |
324
+ | skills on (3-rep run) | 36,016 | 7,856 |
325
+ | skills off (10-rep run) | 10,865 | 7,867 |
326
+
327
+ `oc-harness use fleet-safe --no-skills` removed **25,151 tokens** from every
328
+ kit-on request - 70% of its fixed cost, and more than the whole off arm. The
329
+ spread across runs is ±20 tokens, so this is not sampling noise; the off arm
330
+ did not move because the flag only changes the lanes' prompt.
331
+
332
+ node -e 'for (const f of <dir>/*-on-*.json) { first step_finish }'
333
+
334
+ The residual 10,865 is the lane prompt, `GLOBAL.md` and the tool schemas. The
335
+ skill list was the single largest line item in the kit's overhead, and it was
336
+ paying for 219 skills that a lane with `tools.skill: false` could not open.
337
+
338
+ ### The 10-rep run: 47 of 120, stopped by the window
339
+
340
+ node eval/ab.mjs --target <scratch>/self --suite eval/tasks-self.json \
341
+ --reps 10 --timeout 180 --out <scratch>/ab-self-10 --budget 12000000
342
+
343
+ It started 47 runs and then the account's free window ran out, which looks
344
+ like `opencode run` hanging rather than like an error - see the preflight note
345
+ in the README. 46 of the 47 produced a result: 40 were scored and six are
346
+ listed as not scored. The 47th was cut off mid-run and is in no number below.
347
+ The other 73 were never started. Three of the six tasks have runs at all, so
348
+ this is a partial set and **not** the headline number. It is here because the
349
+ direction is worth having and because the files it came from will not survive
350
+ forever:
351
+
352
+ node eval/ab.mjs --target <scratch>/self --suite eval/tasks-self.json \
353
+ --rescore <scratch>/ab-self-10
354
+
355
+ | metric | kit on | kit off |
356
+ | --- | --- | --- |
357
+ | correct, all three tasks | 18/19 | 22/22 |
358
+ | `read-control` | 9/10 | 10/10 |
359
+ | `read-multi-denylist` | 6/6 | 10/10 |
360
+ | `read-multi-gate` | 3/3 | 2/2 |
361
+ | context tokens (median) | 62,990 | 49,374 |
362
+ | wall seconds (median) | 30.6 | 29.3 |
363
+ | not scored | 5 | 1 |
364
+
365
+ `wall seconds` is the harness's own timer, not the last event in the stream:
366
+ four of the on runs were killed at 180s and their streams stop earlier, so a
367
+ stream-derived column made the killed arm look faster.
368
+
369
+ Five runs hit the 180-second ceiling: four of them are `read-multi-denylist`
370
+ on the kit, at 12, 12, 15 and 20 steps, so on that task the kit did not merely
371
+ answer worse, it did not finish four times in ten. The fifth is `read-multi-gate`
372
+ off. A sixth run, `read-multi-gate` on, produced no output at all and is not
373
+ scored either. The context ratio here (1.3x) is much smaller than the 3-rep
374
+ run's 6.4x and should not be read as a contradiction: the task mix is
375
+ different, and the unfinished runs are excluded from the median.
376
+
377
+ Resuming is `--out` plus the same command; a finished run is read from its
378
+ sidecar and not paid for again, and the sidecar's token count is re-read from
379
+ the stream so the cap counts the corrected number.
380
+
258
381
  ## The A/B on TypeScript, against a PUBLIC subject
259
382
 
260
383
  node eval/prepare.mjs --clone https://github.com/typesense/typesense-js \
@@ -284,7 +407,7 @@ is the check that stops an ambiguous task.
284
407
  | tool calls | 12 | 12 |
285
408
  | read calls | 2 | 2 |
286
409
  | narrow reads | 50% | 58.4% |
287
- | input tokens | 46,025 | 17,324 |
410
+ | uncached input | 46,025 | 17,324 |
288
411
  | final context | 45,455 | 18,962 |
289
412
  | wall seconds | **31.4** | 34.4 |
290
413
  | paths outside the target | 0 | 0 |
@@ -332,7 +455,7 @@ pristine subject file by file, and the answer is graded by citation.
332
455
  | **correct** | **2/2** | **2/2** |
333
456
  | tool calls | 47 | 46 |
334
457
  | read calls | 12 | 18 |
335
- | input tokens | 78,280 | 37,171 |
458
+ | uncached input | 78,280 | 37,171 |
336
459
  | final context | 68,794 | 42,732 |
337
460
  | wall seconds | 121.4 | 104.7 |
338
461
 
@@ -360,7 +483,7 @@ off and on:
360
483
  | --- | --- | --- | --- |
361
484
  | tool calls | 67.5 | 51 | **-24%** |
362
485
  | read calls | 22.5 | 18.5 | -18% |
363
- | input tokens | 268,834 | 84,325 | **-69%** |
486
+ | uncached input | 268,834 | 84,325 | **-69%** |
364
487
  | final context | 48,637 | 75,705 | +56% |
365
488
  | wall seconds | 307.5 | 123.5 | **-60%** |
366
489
 
@@ -401,7 +524,7 @@ task actually says, and the difference between the arms is the lane.
401
524
  | **correct** | **2/2** | **2/2** |
402
525
  | tool calls | 36 | 20 |
403
526
  | read calls | 5.5 | 7 |
404
- | input tokens | 85,499 | 38,934 |
527
+ | uncached input | 85,499 | 38,934 |
405
528
  | wall seconds | 161.5 | 80.8 |
406
529
 
407
530
  ### What this says, and what it does not