opencode-harness-kit 0.4.6 → 0.5.1

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 CHANGED
@@ -12,6 +12,126 @@ is not happening.
12
12
 
13
13
  ---
14
14
 
15
+ ## [0.5.1] — 2026-10-21
16
+
17
+ Published. The tag is what cut it: `git tag v0.5.1 && git push origin main
18
+ v0.5.1`, and the publish job watches for exactly that.
19
+
20
+ A patch, and it is a patch because nothing a user does differently is in
21
+ here: the grader was stricter than it said, and five tests were reporting
22
+ green with the behaviour they name broken.
23
+
24
+ ### Fixed
25
+
26
+ - **The grader accepted a field the agent added.** The comparison with the
27
+ pristine subject looked at the keys the original had, so an agent that made
28
+ the right change and also added a field of its own was graded correct. The
29
+ added key is a change like any other, and the grader says so.
30
+
31
+ - **`isExcluded` did not normalise the path.** The same file is written
32
+ several ways - "./dist/index.js", "dist/index.js", "src/../dist/index.js" -
33
+ and the check only caught one spelling. The grader was saved by also
34
+ checking the resolved path; the exported helper was not, and it is the
35
+ helper a caller would reach for.
36
+
37
+ ### Test suite
38
+
39
+ Five tests could not fail, and each was found by breaking the code on
40
+ purpose and watching the test stay green:
41
+
42
+ - **"a many-file change that reordered the list" did not test reordering.**
43
+ The fixture put the appended entry first, so a different check caught it
44
+ and the assertion matched both reasons. The comparison with the original -
45
+ the check that matters - was never exercised. There are two tests now.
46
+ - **"the pruner changes nothing when there is no profile" could not fail.**
47
+ The prunable window was empty, and ten DISTINCT outputs all survive
48
+ anyway. It uses repeated outputs now, with a control case.
49
+ - **"a pruner that throws does not break the turn" never reached the
50
+ catch.** A string is iterable in JavaScript, so it yielded characters and
51
+ never threw.
52
+ - **The measurement test did not assert that the measurement measured
53
+ anything.** It ran behind a gate that can never fail and only matched the
54
+ header, so a pruner that measured nothing passed.
55
+ - **The python-fallback test never removed the sqlite3 CLI**, so the
56
+ fallback it was about never ran on a runner that has it.
57
+
58
+ Also fixed: a lane loop that could iterate zero times; two escape hatches
59
+ that made a test assert nothing at all; a git-root test that used the
60
+ checkout and so failed in a source tarball; seventeen temporary directories
61
+ leaked a run; and a profile index read with a fallback that made a missing
62
+ key mean "no entries" rather than a failure.
63
+
64
+ ## [0.5.0] — 2026-10-21
65
+
66
+ A MINOR release, and the reason is the pruner: the rule that decides what
67
+ survives changed, so the default behaviour of the kit changed. A patch
68
+ would have been a lie about what is in the box.
69
+
70
+ Published. The tag is what cut it: `git tag v0.5.0 && git push origin main
71
+ v0.5.0`, and the publish job watches for exactly that.
72
+
73
+ This release is the one where the kit measured itself properly, and what it
74
+ found was a defect in the kit: the pruner was making the agent read the
75
+ same files again.
76
+
77
+ ### Added
78
+
79
+ - **A long task for the A/B**, and a grader that can check one. The short
80
+ suite is two-line questions answered in one or two reads, and on those the
81
+ kit is a cost - over five times the input tokens and a tie on correctness.
82
+ That is the wrong task for the kit's central claim, which is that the
83
+ pruner pays for itself on a LONG session: a short session never grows the
84
+ history far enough for pruning to matter. The new task touches twelve files
85
+ and asks a question about the code that consumes what it changed, so the
86
+ conversation is long because the work is long.
87
+
88
+ Grading it needed a new kind of check. A grader that looks at one file
89
+ cannot say whether the other eleven were touched, so the check is a
90
+ COMPARISON with the pristine subject: every file in the run copy must be
91
+ the original with exactly one entry appended, and the file for the model
92
+ being appended must be untouched - a model cannot fall back to itself.
93
+
94
+ - **The process, measured.** The kit claims a reviewer that reviews and does
95
+ not edit. A task that invites the agent to fix what it finds, graded on
96
+ the finding being cited AND the file being untouched, gives 2/2 against
97
+ 2/2: the kit's reviewer lane and the default agent behaved the same. The
98
+ lane is not what made them behave, and it is soft - a task that says "fix
99
+ it" overrides it. Both are written down, because a claim that is not
100
+ measured is a claim.
101
+
102
+ - **A public TypeScript subject.** The pilot ran against a private
103
+ repository, which means nobody else could repeat it. The subject is now
104
+ `typesense/typesense-js` at a pinned commit: Apache-2.0, TypeScript source
105
+ under `src/Typesense/` and the COMPILED JavaScript committed next to it in
106
+ `lib/` and again in the unminified bundle. Every answer occurs three times,
107
+ and the bundle keeps the original module banners, so a search for a source
108
+ path lands in the build output. The harness clones it itself:
109
+ `eval/prepare.mjs --clone <url> --ref <sha>`.
110
+
111
+ ### Changed
112
+
113
+ - **The pruner keeps the newest copy of each DISTINCT output.** It used to
114
+ keep the last six tool outputs and stub everything older, which is a window
115
+ on RECENCY: after six more calls, the only copy of a file the agent had
116
+ read was gone, and the agent read it again. The long task measured it -
117
+ seventeen re-reads, five times the input tokens, and the same final
118
+ context. The newest copy of each distinct output now survives however old
119
+ it is, and only the repeats are stubbed. Same subject, same task, same
120
+ model: **-69% input tokens and -60% wall time**, at the cost of a bigger
121
+ context. It is a setting, `prune.keepDistinctOutputs`, so the old
122
+ behaviour is one profile edit away.
123
+
124
+ ### Fixed
125
+
126
+ - **The nightly A/B failed every night** because the repository has no
127
+ provider secret. The job is skipped when the key is missing, because a
128
+ schedule that is always red is a schedule nobody reads.
129
+
130
+ - **`oc-harness uninstall` and the `restore` command** now keep the dated
131
+ backups in one place, and the prompt that asks "overwrite the config?"
132
+ says where the backup goes. It said ".bak", and the only thing that wrote
133
+ a `.bak` was `restore`.
134
+
15
135
  ## [0.4.6] — 2026-10-21
16
136
 
17
137
  Published. The tag is what cut it: `git tag v0.4.6 && git push origin main
package/CONTRIBUTING.md CHANGED
@@ -69,6 +69,13 @@ config, run the layers individually and say so in the PR.
69
69
  not part of any check, and a PR does not need it. `npm run measure` does not
70
70
  spawn an agent either — it queries the install database read-only.
71
71
 
72
+ The nightly workflow needs a provider to measure with, and the provider key
73
+ is a **secret** — which a job-level `if` cannot read. The flag is therefore
74
+ a repository **variable**: set `HAS_OPENCODE_KEY` to `true` when
75
+ `OPENCODE_API_KEY` is there, and the job runs. Without the variable the job
76
+ is skipped, not failed, because a schedule that is always red is a schedule
77
+ nobody reads.
78
+
72
79
  `eval/models.mjs` spawns real agents too, one line each, against the live
73
80
  tier: it is what the routing table in `docs/ROUTING.md` is measured with,
74
81
  and it costs a request per model per rep. It is not part of any check
package/README.md CHANGED
@@ -539,6 +539,16 @@ the same messages with and without. On one install, on 2026-10-20:
539
539
  sessions, because it is measured over the ones this machine happened to
540
540
  have - so run the command for the number on yours.
541
541
 
542
+ **What is kept is the newest copy of each DISTINCT output.** The window on
543
+ recent calls was a window on *recency*, and a long session walks out of it:
544
+ after six more calls, the only copy of a file the agent had read was gone,
545
+ and the agent read it again. On the long-task A/B that was seventeen
546
+ re-reads and five times the input tokens. The newest copy of each distinct
547
+ output now survives however old it is, and only the repeats are stubbed -
548
+ **-69% input tokens and -60% wall time** on that task, at the cost of a
549
+ bigger context. It is a setting: `prune.keepDistinctOutputs` in the profile,
550
+ `false` to get the old behaviour back.
551
+
542
552
  ## Memory
543
553
 
544
554
  The kit does not implement memory. A local vector store with embeddings, a
@@ -814,11 +824,32 @@ next, which is the shape some of the kit's answers took. Re-scoring the
814
824
  same 120 runs with the fixed grader gives 34/40 against 30/40 on the lookup
815
825
  tasks - the kit ahead, where the old grader had it behind. The full account is in [eval/RESULTS.md](eval/RESULTS.md).
816
826
 
817
- What the kit buys is in the two places that suite cannot see: **long
818
- sessions**, where the pruner removes 65-80% of the context depending on the
819
- sessions it is measured over, and **process** -
820
- a reviewer that reviews and does not edit, a verify that runs the checks and
821
- does not fix them, a publish decision that belongs to a human.
827
+ **On a long task the pruner is where the kit pays.** The suite above is
828
+ made of two-line questions answered in one or two reads, and a session that
829
+ short never grows the history far enough for pruning to matter. So there is
830
+ a second suite with one task that touches twelve files and asks a question
831
+ about the code that consumes what it changed — and on it, with the pruner's
832
+ rule fixed (see below), the kit's arm makes the same number of tool calls
833
+ as the arm without the kit (47 against 46) and takes 1.2x the wall time.
834
+
835
+ **The pruner used to make the agent re-read.** The first run of that task
836
+ said the kit was much worse: 86 tool calls against 43 and 282,000 input
837
+ tokens against 51,000, for the same final context. Seventeen of the reads
838
+ were re-reads. The pruner keeps the last six tool outputs and stubs
839
+ everything older — a window on *recency* — so after six more calls the only
840
+ copy of a file the agent had read was gone, and the agent read it again. The
841
+ newest copy of each **distinct** output now survives however old it is, and
842
+ only the repeats are stubbed. Same subject, same task, same model: **-69%
843
+ input tokens and -60% wall time**, at the cost of a bigger context.
844
+
845
+ **The process is measured too, and the result is not flattering.** The kit
846
+ claims a reviewer that reviews and does not edit. A task that invites the
847
+ agent to fix what it finds, graded on the finding being cited AND the file
848
+ being untouched, gives **2/2 against 2/2**: the kit's reviewer lane and the
849
+ default agent behaved the same. The lane is not what made them behave, and
850
+ it is soft — a task that says "fix it" overrides it. Both are in
851
+ [eval/RESULTS.md](eval/RESULTS.md), because a claim that is not measured is
852
+ a claim.
822
853
 
823
854
  Whether it is worth it depends on whether the work is long. A repository of
824
855
  one-file lookups should not install this. The full table is in
package/dist/index.js CHANGED
@@ -670,7 +670,8 @@ function normalize(p) {
670
670
  enabled: p.prune?.enabled ?? false,
671
671
  keepRecentToolCalls: p.prune?.keepRecentToolCalls ?? 6,
672
672
  keepRecentTurns: p.prune?.keepRecentTurns ?? 3,
673
- keepUserMessages: p.prune?.keepUserMessages ?? true
673
+ keepUserMessages: p.prune?.keepUserMessages ?? true,
674
+ keepDistinctOutputs: p.prune?.keepDistinctOutputs ?? true
674
675
  },
675
676
  fallback: p.fallback ?? []
676
677
  };
@@ -942,6 +943,9 @@ function validate(p, path = "<memory>") {
942
943
  if (prune.keepUserMessages !== void 0 && typeof prune.keepUserMessages !== "boolean") {
943
944
  throw new ProfileError(`${where}: prune.keepUserMessages must be a boolean`);
944
945
  }
946
+ if (prune.keepDistinctOutputs !== void 0 && typeof prune.keepDistinctOutputs !== "boolean") {
947
+ throw new ProfileError(`${where}: prune.keepDistinctOutputs must be a boolean`);
948
+ }
945
949
  if (prune.enabled !== void 0 && typeof prune.enabled !== "boolean") {
946
950
  throw new ProfileError(`${where}: prune.enabled must be a boolean`);
947
951
  }
@@ -1166,6 +1170,17 @@ function activeProfileId(worktree) {
1166
1170
  return null;
1167
1171
  }
1168
1172
  var REASONING_STUB = "[reasoning] dropped from context - it was from an earlier turn and is stale.";
1173
+ function toolSignature(part) {
1174
+ const tool = part?.state?.tool ?? part?.tool;
1175
+ if (!tool) return null;
1176
+ let input;
1177
+ try {
1178
+ input = JSON.stringify(part?.state?.input ?? {});
1179
+ } catch {
1180
+ return null;
1181
+ }
1182
+ return `${tool} ${input}`;
1183
+ }
1169
1184
  function sizeOf(part) {
1170
1185
  if (typeof part?.text === "string") return part.text.length;
1171
1186
  if (typeof part?.state?.output === "string") return part.state.output.length;
@@ -1197,6 +1212,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
1197
1212
  keepCalls: 0,
1198
1213
  keepTurns: 0,
1199
1214
  keepUsers: true,
1215
+ keepDistinct: false,
1200
1216
  threshold: 0
1201
1217
  };
1202
1218
  let cached = null;
@@ -1215,6 +1231,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
1215
1231
  keepCalls: profile.prune.keepRecentToolCalls ?? 6,
1216
1232
  keepTurns: profile.prune.keepRecentTurns ?? 3,
1217
1233
  keepUsers: profile.prune.keepUserMessages ?? true,
1234
+ keepDistinct: profile.prune.keepDistinctOutputs ?? true,
1218
1235
  threshold: pruneThreshold(profile)
1219
1236
  };
1220
1237
  return cached;
@@ -1222,7 +1239,7 @@ var PrunePlugin = async ({ worktree, directory }) => {
1222
1239
  return {
1223
1240
  "experimental.chat.messages.transform": async (_input, output) => {
1224
1241
  try {
1225
- const { profile, keepCalls, keepTurns, keepUsers, threshold } = resolve4();
1242
+ const { profile, keepCalls, keepTurns, keepUsers, keepDistinct, threshold } = resolve4();
1226
1243
  if (!profile) return;
1227
1244
  const messages = output.messages;
1228
1245
  if (!Array.isArray(messages) || messages.length === 0) return;
@@ -1239,6 +1256,21 @@ var PrunePlugin = async ({ worktree, directory }) => {
1239
1256
  seen++;
1240
1257
  }
1241
1258
  }
1259
+ if (keepDistinct) {
1260
+ const seenSignatures = /* @__PURE__ */ new Set();
1261
+ for (let i = messages.length - 1; i >= 0; i--) {
1262
+ const msg = messages[i];
1263
+ if (!msg?.parts) continue;
1264
+ for (const part of msg.parts) {
1265
+ if (part?.type !== "tool") continue;
1266
+ if (part.state?.status !== "completed") continue;
1267
+ const sig = toolSignature(part);
1268
+ if (!sig || seenSignatures.has(sig)) continue;
1269
+ seenSignatures.add(sig);
1270
+ keepToolMessageIds.add(msg.info?.id ?? String(i));
1271
+ }
1272
+ }
1273
+ }
1242
1274
  const stats = {
1243
1275
  stubbed: 0,
1244
1276
  kept: 0,
package/dist/profile.d.ts CHANGED
@@ -54,6 +54,7 @@ export type Profile = {
54
54
  keepRecentToolCalls?: number;
55
55
  keepRecentTurns?: number;
56
56
  keepUserMessages?: boolean;
57
+ keepDistinctOutputs?: boolean;
57
58
  };
58
59
  fallback?: string[];
59
60
  };
@@ -86,7 +87,8 @@ export type RetentionRecord = {
86
87
  * @property {Record<string, EffortRequest>} [roles]
87
88
  * @property {Record<string, RoleModel>} [roleModels]
88
89
  * @property {{ enabled?: boolean, keepRecentToolCalls?: number,
89
- * keepRecentTurns?: number, keepUserMessages?: boolean }} [prune]
90
+ * keepRecentTurns?: number, keepUserMessages?: boolean,
91
+ * keepDistinctOutputs?: boolean }} [prune]
90
92
  * @property {string[]} [fallback]
91
93
  * @typedef {{ retention: Retention, note?: string, verified?: string, source?: string }} RetentionRecord
92
94
  */
@@ -175,6 +175,11 @@ writes it to the project root, and reading `process.cwd()` alone reported
175
175
 
176
176
  ## The free tier cannot read a PDF, an audio file or a video
177
177
 
178
+ **Re-checked 2026-10-21, against the live tier, and both still hold.** A
179
+ PDF attached to a prompt is refused by the provider with the message below,
180
+ and an `.m4a` is refused by opencode with `Cannot read binary file`. The
181
+ model then answers as if it had heard nothing.
182
+
178
183
  Three capabilities the catalogue advertises are not reachable, and no lane
179
184
  claims them:
180
185
 
@@ -342,9 +347,19 @@ does; if it does not, `prune.ts` is doing the work anyway.
342
347
 
343
348
  `prune.ts` keeps pointers rather than bodies. Full tool output is not deleted
344
349
  from the session record, and OpenCode already writes over-cap output to its own
345
- truncation directory, so re-reading is possible. But "re-run the command to see
346
- it again" is more expensive than "scroll up". The stub is one line precisely so
347
- an agent can decide whether it is worth it, and the stub says so.
350
+ truncation directory, so re-reading is possible. The stub is one line precisely
351
+ so an agent can decide whether it is worth it, and the stub says so.
352
+
353
+ **But the newest copy of each DISTINCT output survives.** The window on
354
+ RECENT calls was a window on recency, and a long session walks out of it: after
355
+ six more tool calls, the only copy of a file the agent had read was gone, and
356
+ the agent read it again. The long-task A/B measured seventeen re-reads, five
357
+ times the input tokens and the same final context. The rule now keeps the
358
+ newest copy of each distinct output however old it is, and stubs only the
359
+ REPEATS of it. The context grows by the size of the distinct outputs, and the
360
+ re-reads stop: **-69% input tokens and -60% wall time** on that task, at the
361
+ cost of a bigger context. It is a setting - `prune.keepDistinctOutputs` - and
362
+ turning it off brings the old behaviour back.
348
363
 
349
364
  A `savedChars` of 0 in `prune.jsonl` is not a second, redundant pass. It means
350
365
  `bytesAfter` did not come out below `bytesBefore` for that turn, which happens
@@ -361,6 +376,22 @@ possible. Treat the ratio as a guide, not an exact count. It does import the
361
376
  real allowlist rather than keeping a copy, so the list cannot drift from what is
362
377
  actually shipped.
363
378
 
379
+ ## A lane is a default, not a boundary
380
+
381
+ The kit's reviewer prompt says: *"You review. You never fix, because a
382
+ reviewer who edits stops being a reviewer."* That is an instruction, and an
383
+ instruction is only as strong as the task it sits next to.
384
+
385
+ It was measured. A task that says "review this and FIX it if it is wrong"
386
+ makes the reviewer lane edit the file, and makes the default agent edit it
387
+ too - both arms scored zero on "the finding was reported AND the file was
388
+ untouched". A task that says "tell me what needs to change" makes both arms
389
+ report and neither edit, so the lane made no difference there either.
390
+
391
+ So the lane holds a line when the task does not push against it, and the
392
+ kit's own loop keeps that line by never asking a reviewer to fix anything.
393
+ If you write a review task that says "fix it", the reviewer will fix it.
394
+
364
395
  ## What this kit does not do
365
396
 
366
397
  It does not implement memory persistence, worktree orchestration, a context