opencode-harness-kit 0.4.3 → 0.4.5

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 (74) hide show
  1. package/CHANGELOG.md +569 -18
  2. package/CONTRIBUTING.md +249 -0
  3. package/GLOBAL.md +34 -5
  4. package/MIGRATION.md +151 -0
  5. package/README.md +354 -66
  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 +1268 -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 +83 -0
  27. package/docs/KNOWN-LIMITS.md +45 -21
  28. package/docs/RESEARCH.md +20 -5
  29. package/docs/ROUTING.md +191 -31
  30. package/eval/RESULTS.md +457 -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 +331 -0
  35. package/eval/measure-prune.mjs +339 -0
  36. package/eval/metrics.js +79 -0
  37. package/eval/models.mjs +302 -0
  38. package/eval/premises.mjs +200 -0
  39. package/eval/prepare.mjs +233 -0
  40. package/eval/run.mjs +376 -0
  41. package/eval/tasks-self.json +81 -0
  42. package/eval/tasks-ts.json +50 -0
  43. package/eval/tasks.json +83 -0
  44. package/eval/thresholds.mjs +134 -0
  45. package/eval/verify.mjs +287 -0
  46. package/package.json +44 -17
  47. package/profiles/fleet-all.json +6 -1
  48. package/profiles/fleet-safe.json +7 -3
  49. package/profiles/schema.json +182 -51
  50. package/prompts/build.md +4 -2
  51. package/skills/space-build/SKILL.md +4 -2
  52. package/skills/space-finish/SKILL.md +6 -2
  53. package/skills/space-plan/reference/task-template.md +13 -5
  54. package/src/config.js +182 -32
  55. package/src/denylist.js +95 -0
  56. package/src/driver/run.mjs +81 -16
  57. package/src/effort.js +15 -4
  58. package/src/guard.ts +165 -13
  59. package/src/health.js +80 -0
  60. package/src/index.ts +15 -4
  61. package/src/lane-usage.js +72 -18
  62. package/src/plugin-type-check.ts +38 -0
  63. package/src/plugin.ts +39 -0
  64. package/src/profile.js +225 -26
  65. package/src/project.js +47 -0
  66. package/src/prune.ts +110 -31
  67. package/src/router.ts +32 -63
  68. package/src/rules.js +99 -7
  69. package/src/safe.ts +27 -0
  70. package/src/sqlite.js +144 -0
  71. package/src/tasks.js +112 -29
  72. package/src/telemetry-store.ts +62 -0
  73. package/src/telemetry.ts +60 -57
  74. package/src/trust.js +70 -9
package/CHANGELOG.md CHANGED
@@ -12,7 +12,565 @@ is not happening.
12
12
 
13
13
  ---
14
14
 
15
- ## [Unreleased]
15
+ ## [0.4.5] — 2026-10-21
16
+
17
+ Published. The tag is what cut it: `git tag v0.4.5 && git push origin main
18
+ v0.4.5`, and the publish job watches for exactly that.
19
+
20
+ This release is the measurement side of the kit: the numbers in the routing
21
+ table were taken once, by hand, and could not be taken again. Now they can
22
+ be, and the page that carries them says which measurement it is holding.
23
+
24
+ ### Added
25
+
26
+ - **`eval/models.mjs`** - measures every model the shipped profiles use, one
27
+ line each, the same prompt for all of them, with the `low` effort variant
28
+ as well. It is what the routing table in `docs/ROUTING.md` is measured
29
+ with, and it did not exist: the numbers in that table had been taken once,
30
+ by hand, and could not be taken again.
31
+
32
+ ### Changed
33
+
34
+ - **`docs/ROUTING.md` is measured again**, on 2026-10-21, and the two sets
35
+ of numbers are now labelled for what they measure. The install tables are
36
+ the telemetry of a working session; the new one is a direct call, from the
37
+ process start, which is the wait a lane would actually cost. Beam is still
38
+ the fastest of the ten, but by under a second over the next three, so the
39
+ reason to pick it is the 1M context as much as the speed. GLM is level
40
+ with Nemotron 3 Ultra for review, so the safe ceiling costs nothing
41
+ there. LongCat is twice Beam on the same one-line question.
42
+
43
+ - **The 0.4.4 A/B table above was corrected.** It was written from the run
44
+ the grader mis-scored, and it said the kit was worse. The shipped
45
+ measurement is a tie.
46
+
47
+ ### Fixed
48
+
49
+ - **`eval/models.mjs` accepted a mistyped `--reps`** and measured nothing
50
+ while reporting success - the same class of bug this kit keeps finding in
51
+ other places. It also ignored `--reps=5`, did not say why a model failed,
52
+ and left a scratch directory behind when it was killed.
53
+
54
+ ## [0.4.4] — 2026-10-21
55
+
56
+ Published. The tag is what cut it: `git tag v0.4.4 && git push origin main
57
+ v0.4.4`, and the publish job watches for exactly that.
58
+
59
+ This release is mostly about things that were quietly wrong. The guard let a
60
+ wrapper through, the grader scored correct answers wrong, the A/B compared
61
+ two different models, and the published type declarations did not resolve for
62
+ a consumer. None of them announced itself: they were quiet, and a quiet
63
+ failure in a harness is the kind that gets believed.
64
+
65
+ ### Fixed
66
+
67
+ **The denylist was matched against the raw command string, so a wrapper got
68
+ past it.**
69
+
70
+ `cd /tmp && npm publish`, `sudo npm publish` and `sh -c "npm publish"` all
71
+ went through. The patterns are `npm publish*`, and the command does not
72
+ start with them. The kit already had the machinery for exactly this - the
73
+ rules are matched per segment, with the wrappers stripped and the payload of
74
+ a `bash -c` followed - and the denylist now uses it. One list, read one way.
75
+
76
+ **The grader paired a path with a line number that belonged to something
77
+ else.**
78
+
79
+ A gap that could cross a sentence turned "the answer is in src/a.js.
80
+ Unrelated note: see line 2 of the report" into a claim about line 2. The
81
+ bare path was then dropped, because the file had been cited with a line, and
82
+ a right file was graded against a line the agent never claimed. The gap may
83
+ no longer contain a full stop, and a number on the next line has to be
84
+ introduced by a label - which is the shape `**File:**` / `**Line:**` takes.
85
+
86
+ **A citation to the build output was graded as evidence about the source.**
87
+
88
+ `dist/`, `eval/` and `test/` are in the exclusion list, and the test was
89
+ `endsWith` - which never matches a directory, because a file path does not
90
+ end with a directory name. The exclusion now matches a directory prefix and
91
+ a file name, wherever the file is.
92
+
93
+ **The A/B compared two different models and called the difference the kit's
94
+ cost.**
95
+
96
+ The kit-off arm ran with an empty config, so opencode picked its own default
97
+ model; the kit-on arm ran on the model in the installed kit's config. The
98
+ default it picked did not answer either - four kit-off runs hit the
99
+ 600-second ceiling with no output at all, and a timed-out run has zero tools
100
+ and zero context, so it entered the medians as a fast, empty, wrong answer.
101
+
102
+ The model is now pinned in the kit-off home, read from the config the same
103
+ way the kit reads it: the lane model of the default agent, which is the
104
+ model opencode actually runs and the one the router moves when a route is
105
+ spent. The report prints it, so a reader can check that both arms ran on
106
+ the same one.
107
+
108
+ **The published type declarations did not resolve for a consumer.**
109
+
110
+ They import a type from `@opencode-ai/plugin`, which was a devDependency, so
111
+ a consumer that installed the package type-checked against a type it did
112
+ not have. The subpath exports - `opencode-harness-kit/profile` and the rest
113
+ - declared no types at all, even though the declarations were built and
114
+ shipped.
115
+
116
+ The plugin package depends on `effect`, `zod` and `@opencode-ai/sdk`, and
117
+ has three peer dependencies of its own, so making it a peer is not an
118
+ answer either: an optional peer is never installed by npm, and a required
119
+ one drags all of that onto a consumer that only wanted to compile a config.
120
+
121
+ So the published declarations no longer import it. The plugin type is
122
+ declared structurally, in `src/plugin.ts`, and the real type is checked
123
+ against it in `src/plugin-type-check.ts` - in CI, where the package is
124
+ installed. A drift between the two is a failed build, not a broken
125
+ consumer.
126
+
127
+ **`sudo bash -c "rm -rf /"` was not blocked.**
128
+
129
+ The guard unwrapped a shell's payload only when the shell was the first
130
+ word of the segment, so a wrapper in front of it - `sudo`, `env`, `xargs`,
131
+ `npx` - hid the whole command line. The same shape let `npx npm publish`
132
+ through. The wrappers come off first now.
133
+
134
+ **A failed catalogue fetch ended the watch instead of continuing it.**
135
+
136
+ `fetchCatalog` called `fail()`, which exits. The catch around it could
137
+ never run, so `doctor --watch` died on the first failed poll - a transient
138
+ network error turned a monitor into a dead one. The fetch throws now, and
139
+ the callers decide: the watch carries on, and `doctor --json` prints a
140
+ JSON object with the error in it rather than nothing at all.
141
+
142
+ **`uninstall` left the kit's own broken links behind.**
143
+
144
+ It checked whether a link pointed inside the kit by resolving it, and
145
+ resolving a DANGLING link throws - which is exactly the case the command
146
+ exists for, because `npm uninstall` leaves four dangling links behind. A
147
+ link is now resolved without requiring its target to exist, and the same
148
+ test is applied to `GLOBAL.md` and the plugin link, which were removed
149
+ whatever they pointed at.
150
+
151
+ **`oc-harness cost` reported 0 for a project that had pruned anything.**
152
+
153
+ The field in the log was renamed from `savedBytes` to `savedChars`, and
154
+ the log is project-local and persistent: every project that upgraded had
155
+ the old records and read them as zero. The old name is still read.
156
+
157
+ **`oc-harness cost` reported characters as bytes.**
158
+
159
+ The pruner counts characters, and the number it printed said "bytes", which
160
+ is wrong by a factor on any text that is not ASCII.
161
+
162
+ **`refresh --all` wrote its backups into `profiles/`.**
163
+
164
+ That directory is published to npm, so every refresh added a file to the
165
+ tarball, and nothing ever pruned them. The backups go to the config
166
+ directory now, with the other backups.
167
+
168
+ **`uninstall` deleted the user's own symlinks.**
169
+
170
+ It removed every symlink in `prompts/` and `skills/`, on the grounds that a
171
+ directory of symlinks is the kit's. A user with their own symlink in that
172
+ directory lost it. A link is the kit's when it points inside the kit.
173
+
174
+ **`doctor --json` put colour codes inside the messages**, and the exit code
175
+ for a missing config or a missing profile changed without the change being
176
+ said out loud. The codes are out now, and an error in JSON mode is a JSON
177
+ object.
178
+
179
+ **In a project that is not a git repository, the kit did nothing at all.**
180
+
181
+ `ctx.worktree` is opencode's worktree: the git root of the project. For a
182
+ directory that is not a git repository it is the FILESYSTEM ROOT, and the kit
183
+ wrote its files under it. So in every non-git project the config hook's write
184
+ to `.space/active` failed with a permission error that the plugin swallowed,
185
+ and then:
186
+
187
+ - the pruner found no active profile and did nothing at all,
188
+ - the router never recorded a spent route, so no lane ever failed over,
189
+ - the telemetry went nowhere,
190
+ - and the guard's scope check was against "/", which contains every path, so
191
+ it was not a scope check.
192
+
193
+ None of it was visible from the code, because the kit had only ever been run
194
+ in git repositories. It was found by running a session in a directory that is
195
+ not one and noticing that no `.space/` appeared. The A/B had been running
196
+ against the broken kit: its subject is a `git archive`, so it has no `.git`,
197
+ and the pruner was inert for every kit-on run. The measurement this
198
+ repository exists to make was measuring a kit that did not prune.
199
+
200
+ The project root is now resolved from the session's own DIRECTORY, walking up
201
+ to the git root when there is one. A subdirectory of a monorepo is still that
202
+ repository, and a directory that is not a repository is its own project.
203
+
204
+ **The grader scored correct answers wrong.** An agent that answers in a
205
+ markdown field writes the file on one line and the number on the next:
206
+
207
+ **File:** `src/profile.js`
208
+ **Line:** 105
209
+
210
+ and the citation pattern stopped at the newline, so the pair was never made
211
+ and the answer was scored wrong. It is the shape most of the answers took:
212
+ on one measured run, four correct answers graded as one. The gap may cross a
213
+ newline now, and the file/line field form is paired explicitly rather than by
214
+ proximity. The numbers in `eval/RESULTS.md` were computed with this grader,
215
+ so they are being re-measured rather than patched.
216
+
217
+ **`npm publish` was ALLOWED.** The allowlist had a blanket `npm*`, so
218
+ publishing to a registry needed no prompt and no question, while GLOBAL.md
219
+ says to ask before publishing. The mechanical layer contradicted the
220
+ judgement layer, which is the exact failure this kit is built to catch.
221
+
222
+ The list now lives in `src/denylist.js` because it is enforced in TWO places:
223
+ by opencode's permission map, and by the guard plugin. The two used to be
224
+ different lists - the config denied `gh pr create` and `npm publish` and the
225
+ guard blocked neither - so the config's own comment, "the guard plugin
226
+ enforces the same set in code", was false. A permission map can be overridden
227
+ by a plugin or lost to a hand-edit; the guard does not care.
228
+
229
+ `npm install` and `npm ci` are no longer allowed without a question. They
230
+ run code the agent did not read, and they change the tree the agent was
231
+ asked to change.
232
+
233
+ **The subject of the A/B held the grader and the answer key.** The subject of
234
+ the self suite is this repository, so it held `eval/verify.mjs` - the grader -
235
+ and `eval/tasks-self.json`, the answer key. An agent that found the grader
236
+ used it to check its own work, and one that found the suite read the answer.
237
+ Both were measured: an agent that read the grader made 48 tool calls to a
238
+ task that needed 5. `eval/` and `test/` are excluded from the subject now.
239
+ The answer lives in `src/`, and so does the question.
240
+
241
+ **The kit's own cost, measured and cut.** The A/B said the kit was a net loss
242
+ on a short task, and the commands showed why: the agent was running the
243
+ project's whole test suite, and installing the dependencies, to rename one
244
+ constant. The rule that told it to do that was absolute.
245
+
246
+ It is scoped now: run the check that covers what you changed, run the one the
247
+ task named, and do not re-run a check that already passed. Do not install
248
+ anything the task did not ask for. And "locate before you read" is
249
+ conditional - when the question already names the file, read it.
250
+
251
+ Measured over 20 runs after the change, against 60 before:
252
+
253
+ | per run | before | after |
254
+ |---|---|---|
255
+ | `npm test` calls | 2.2 | **0.9** |
256
+ | `npm install` calls | 0.4 | **0** |
257
+
258
+ ### Fixed, again
259
+
260
+ A review of this release found six more, and fixing them turned up two of the
261
+ same class.
262
+
263
+ **The project-root fix was incomplete.** Two call sites still read the
264
+ worktree: the effort hook looked for the spent-route file under it, and the
265
+ router wrote the spent-route file under it. So the router recorded a spent
266
+ route where the effort hook was not looking, and a lane the router had
267
+ already given up on was put back onto it.
268
+
269
+ **The documented profile override had never worked.**
270
+ `OPENCODE_HARNESS_PROFILE` is a profile ID, and it was being checked with
271
+ `existsSync`, like the marker file beside it. It never matched a file, so
272
+ the override that the docs and the measurement scripts both rely on was
273
+ inert - and the measurement it gates reported 0% removed and failed.
274
+
275
+ **The measurement depended on the developer's local state.** The pruner
276
+ finds its profile from `.space/active` in the project, and that file is
277
+ gitignored, so on a fresh clone there is none, the pruner does nothing, and
278
+ the gate says the kit removes nothing. The environment override is set for
279
+ the measurement now, and the measurement runs against a scratch directory
280
+ rather than writing into the repository it is measuring.
281
+
282
+ **A gate that could be switched off by a typo.** `--min-saved abc` became
283
+ NaN, and `NaN` compares false against everything, so the gate never failed.
284
+ The script uses the shared flag parser, which throws.
285
+
286
+ **The A/B stopped the whole experiment twice, both times wrongly.** Once on a
287
+ scratch file in the temp directory, which is the one place GLOBAL.md tells an
288
+ agent to put scratch files and the one place the guard's scope rule allows;
289
+ and once on an agent that grepped the filesystem root. A READ outside the
290
+ subject is wasteful and sometimes a privacy leak, but it damages nothing - the
291
+ pilot that mattered WROTE to the real repository. A write outside the target
292
+ stops the experiment; a read outside it is counted and reported.
293
+
294
+ **A citation to the suite's own file was accepted as evidence.** The self
295
+ suite's subject is a copy of this repository, so the answer key is inside
296
+ it, and an agent that greps for the symbol could cite the suite and pass
297
+ without reading the source. The suite files are excluded from the citations.
298
+
299
+ **The failover threw away the utility route.** `applySessionModel` set
300
+ `small_model` to the fallback profile's session model, so the fleet's cheap
301
+ route was replaced exactly when the fleet failed over - and `verify` then
302
+ called the difference between the file on disk and the running session a
303
+ drift.
304
+
305
+ Also: the python fallback ran every query twice, the CLI check spawned a
306
+ process per query, the database path was not URI-escaped, `--since` was
307
+ ignored as the last argument, `--interval abc` became a 1ms loop, a failed
308
+ catalogue fetch in watch mode announced every missing model as "is back", a
309
+ non-list `plugin` value was replaced instead of kept, the `why` in
310
+ fleet-all's utility route claimed the model was in every chain when it is in
311
+ seven of ten, and the install-wide measurement could report a 100% context
312
+ improvement from a NULL average.
313
+
314
+ ### Changed
315
+
316
+ - **The install-wide measurement is an observation, and there is a controlled
317
+ one.** `measure:check` compared the work before the kit landed with the work
318
+ after, and gated on the context per request. On this install it said the
319
+ context per request went UP by 16%, with twice as many reads and twice as
320
+ many messages in the second window - a number about the jobs, not about the
321
+ kit. The after-window was also "everything since the install", so the
322
+ comparison was a different one every time the script ran.
323
+
324
+ The windows are fixed and equal around the split now, and the report says
325
+ how long they were. The context drop is still reported and still gated, but
326
+ only when the two windows are comparable: when one side did twice the work
327
+ of the other, the number is skipped with a note that says why, because a gate
328
+ that fails for reasons nobody can act on is a gate people switch off.
329
+
330
+ The gate uses the number that IS comparable. opencode stores the message
331
+ parts UNPRUNED - the pruner rewrites the outgoing request, not the history
332
+ - so the stored parts of a real session are exactly what the pruner was
333
+ given, and replaying the real pruner over them measures exactly what it
334
+ removed, on the same messages with and without. On this install, measured on
335
+ 2026-10-20: **80.5% of the context across 60 real sessions**, 6,608 parts
336
+ stubbed. The number moves with the sessions, so it is a measurement of one
337
+ install at one moment - run `npm run measure:prune` for the number on
338
+ yours.
339
+
340
+ The measurement scripts also run without the sqlite3 CLI now. python3's
341
+ built-in sqlite3 module does the same job, and python3 is on more machines
342
+ than the CLI is.
343
+
344
+ - **`small_model` is a cheaper route.** It is what opencode uses for its short
345
+ auxiliary calls - a title, a one-line summary - and it was the session model,
346
+ so every one of those calls paid the session model's REASONING. Measured on
347
+ this tier, a title costs 49 reasoning tokens on deepseek and 332 on
348
+ nemotron-3.5-super-vl, and deepseek answers in 7.2s against 22.7s for glm. A
349
+ fleet names a cheaper route now and the generated file says which one; a
350
+ single-model profile keeps its one route, because "one route, and it is the
351
+ one you asked for" is a promise those profiles make.
352
+
353
+ - **The safe fleet honours every tier request it makes.** The tier is worth
354
+ 17x on reasoning tokens - deepseek spends 320 a message at `max` and 19 at
355
+ `low` - so a lane that asks for a tier and lands on a model that publishes
356
+ none runs at that model's natural rate instead. One lane was genuinely
357
+ expensive: `sweep` is a bulk mechanical search, so reasoning on it is pure
358
+ waste, and it ran on a model that reasons 420 tokens a message at 16.6s.
359
+ No zero-retention model publishes a `none` level, so the lane could not even
360
+ turn reasoning off when it asked to. It runs on deepseek now: 19 reasoning
361
+ tokens at 3.4s.
362
+
363
+ The three lanes in the unrestricted fleet that cannot get their tier turn
364
+ out to be the three CHEAPEST reasoners on the tier - 116, 59 and 18 tokens a
365
+ message, against 289 for `build` and 198 for `plan`, which keep their tier
366
+ on purpose. The tier is the lever, and it is not where the tokens are. The
367
+ generated config says so where it happens, in the form
368
+ `locate=unset(asked low)`.
369
+
370
+ - `npm run check` is the offline gate — build, typecheck, tests — and is green
371
+ on a checkout that has never seen an OpenCode login. The live half moved to
372
+ `npm run check:install`, which needs a machine that has run OpenCode. The
373
+ old claim that `npm run check` had to pass on an untouched checkout was
374
+ false, and it was the reason the whole gate was skipped.
375
+ - `profiles/schema.json` no longer accepts `roleModels.<lane>.levels`. The
376
+ field was in the schema, validated as an array, and then read by nothing, so
377
+ a profile author could fill it with a lie and get no error.
378
+ - `oc-harness show` now prints what the guard blocks and why. It was the one
379
+ part of the kit a user could not inspect at all.
380
+ - The config directory path uses `homedir()` in all three places that build
381
+ it. One of them used `process.env.HOME`, which with HOME unset produced a
382
+ RELATIVE path, so `use` wrote to one place and the reader read another.
383
+ - `retention.yml` is cached like the lane table next to it. It was re-read and
384
+ re-parsed for every candidate of every lane.
385
+ - The severity table ranked `unknown` as milder than `may-train`, while
386
+ `retentionAllowed` refuses `unknown` outright. The two contradicted each
387
+ other, and the table is the one that decides what a profile is labelled.
388
+ - The `lanes` header printed the raw record count and called it "lane
389
+ request(s)". A request is one assistant message, and a message is written
390
+ twice, so the headline said twice what the table under it said.
391
+ - The router checked the health file for a profile ID where the file is keyed
392
+ by MODEL. For the ten single-model profiles the two are the same string and
393
+ the check worked by accident; for the two fleets it could never fire.
394
+ - `harness.fallback` in the generated config was a copy of the first id of a
395
+ queue the router reads straight from the profile file, in a key nothing
396
+ reads. `fallbackProfileId` was a parameter no caller ever supplied.
397
+ - The README quoted two different sets of numbers for the same measurement.
398
+ There is one set now, and it is the one in `docs/RESEARCH.md` and
399
+ `eval/RESULTS.md`.
400
+ - Both `.space/telemetry.jsonl` and `.space/prune.jsonl` are written in the
401
+ project. The prune log used to land in the process's working directory,
402
+ which is often your home directory.
403
+ ### Added
404
+
405
+ - **`show --json` names the profile's own notes `notes`** and the compiler's
406
+ adjustments `adjustments`, because the human report calls them different
407
+ things and the JSON should not make one name mean two.
408
+
409
+ - **`doctor --json` and `show --json`.** "Is the install healthy" and "what
410
+ does this profile resolve to" are questions a CI job and a dashboard ask,
411
+ and the answers were tables of padded columns with colour codes in them.
412
+ Both now print the machine-readable form and nothing else - not the human
413
+ report first and the JSON after it, which is not machine-readable. An
414
+ error in JSON mode is a JSON object, so a script that pipes the output
415
+ into a parser gets something it can read either way. `lanes --json` used
416
+ the key `lanes` for the USAGE of each lane, and `show --json` uses it for
417
+ the lane TABLE; the usage one is now `usage`, so one name does not mean
418
+ two shapes.
419
+
420
+ - **`docs/EXIT-CODES.md`**, because a command that can fail should say how,
421
+ and "non-zero" does not tell a script whether the work landed or there was
422
+ no task file. Four codes for the loop, two for the doctor, and the rule
423
+ that a measurement which could not be compared is reported and NOT gated.
424
+
425
+ - **`MIGRATION.md` covers 0.3 to 0.4.** The upgrade asks something of the
426
+ user - the guard now refuses in code what it used to only suggest, and the
427
+ pre-commit hook runs the project's own tests instead of the kit's - so it
428
+ is written down, with the command to check it and the command to undo it.
429
+
430
+ - **`oc-harness cost`** — what the kit is doing on THIS project, in numbers.
431
+ The kit's claim is a trade: a fixed overhead on every request, and the
432
+ context it prunes back. Both numbers were already being written — the prune
433
+ log records what each pass removed, and the telemetry records what each lane
434
+ cost — and nothing read them together. On the one measured here: 83.7
435
+ million characters removed over 272 prune passes, 48,122 tool outputs
436
+ stubbed, and the lanes sorted by what they cost.
437
+
438
+ - **`oc-harness uninstall`.** The install SYMLINKS the plugin, the prompts,
439
+ the skills and GLOBAL.md out of the kit's directory, so `npm uninstall` left
440
+ four dangling symlinks and opencode then loaded neither the rules nor the
441
+ guard — and said nothing about it.
442
+
443
+ - **`oc-harness refresh --all`**, which re-derives every shipped profile
444
+ against the live catalogue, and updates `verified`, which is the field that
445
+ says when a profile was last checked. Leaving it stale made it a lie.
446
+
447
+ - **`oc-harness upgrade`**, which checks the registry for a newer kit,
448
+ refreshes the profiles, and writes the config again. Three things go stale
449
+ on their own and all three were invisible.
450
+
451
+ - **`oc-harness backups` and `oc-harness restore`.** The single `.bak` was
452
+ overwritten on the next `use` and carried no profile id, so "go back to
453
+ what I had" was not something a person could do at 2am. The config also
454
+ records which kit wrote it.
455
+
456
+ - **Type declarations.** The package's `types` pointed at `src/index.ts` — a
457
+ TypeScript SOURCE file — and the tsconfig had `noEmit`, so no declarations
458
+ were ever produced. A consumer type-checked the kit's source out of
459
+ node_modules.
460
+
461
+ - **Tests for the pieces the suite never reached**: the python fallback, the
462
+ project root for a project that is not a git repository, the escape rule,
463
+ the flag parser, and the measurement scripts themselves — the gate that
464
+ `npm run measure:check` depends on had no test at all.
465
+
466
+ - **The demo is back on the front page.** `docs/demo/pitch.mp4` and
467
+ `docs/demo-loop.gif` are in the README again, and `video/` still holds the
468
+ take script, the voiceover and the recording driver. The film was recorded
469
+ against an earlier version of the kit, and the README now says so next to
470
+ it rather than leaving a front page that quietly misstates the project's own
471
+ numbers - which is the reason 0.4.3 removed it in the first place, and the
472
+ reason it should not have been removed without putting the film back.
473
+
474
+ - **`OPENCODE_HARNESS_CATALOG_URL` overrides where the live model catalogue
475
+ comes from.** A mirror is one use; a local fixture is another, and a test
476
+ that reached models.dev would fail on a machine with no network. The `file://`
477
+ spelling is read directly, so a fixture needs no external binary.
478
+
479
+ - **`OC_HARNESS_NO_HOOK` skips the pre-commit hook.** A test that writes an
480
+ executable git hook into the repository it is testing is a test that changes
481
+ the repository.
482
+
483
+ - **`oc-harness use --memory` turns on a memory plugin.** The kit does not
484
+ implement memory, and it should not: a local vector store with embeddings, a
485
+ web UI, auto-capture and a user profile is a large thing to build badly, and
486
+ `opencode-mem` already is one. The entry is ADDED to the plugin list, so the
487
+ user's own plugins survive and a config that already has it does not grow a
488
+ second copy.
489
+
490
+ - **`oc-harness doctor --check-models` and `--watch`.** The question a
491
+ free-tier user actually has to ask is "is one of my routes gone", and it is
492
+ not the same question as "has a profile drifted". `--check-models` answers it
493
+ in one run with a non-zero exit, which is the shape a cron wants; `--watch`
494
+ keeps running and reports only what CHANGED.
495
+
496
+ - **`eval/measure-prune.mjs`.** The attributable measurement of the pruner,
497
+ described above.
498
+
499
+ - **The A/B suite is reproducible, and it has been run.** The old one ran
500
+ against `lablab-harness@8a674f5`, which is not public. A result nobody can
501
+ check is not a result. The new suite runs against the kit's own tree at a
502
+ pinned commit, so `git clone && git checkout <sha>` reproduces it exactly.
503
+
504
+ Ten reps per arm, 120 agent runs, on the same model in both arms:
505
+
506
+ | metric (median) | kit on | kit off |
507
+ |---|---|---|
508
+ | **correct** | **54/60** | **55/60** |
509
+ | tool calls | 12.5 | 8 |
510
+ | input tokens | 56,205 | 10,540 |
511
+ | final context | 39,928 | 12,718 |
512
+ | wall seconds | 43 | 21 |
513
+ | paths written outside the target | 0 | 0 |
514
+
515
+ **On a task that fits in one read, the kit is a cost.** Its instructions
516
+ cost over five times the input tokens and half again as many tool calls,
517
+ and the run takes twice as long. The answers are a tie - 54 against 55,
518
+ and a second run of the same 120 runs gave 55 against 54, the other way.
519
+ The rule that says *locate before you read* is a waste on a one-file
520
+ question, and the tool counts show it. The two edit tasks are a tie at
521
+ 10/10, and the guard stopped no writes in either arm.
522
+
523
+ **The first version of this table said the kit was worse - 52/60 against
524
+ 59/59 - and the grader was why.** It could not read a citation written as
525
+ `**File:** x` on one line and `**Line:** 105` on the next, which is the
526
+ shape the kit's own instructions produce, while the kit-off arm answered
527
+ in prose and was graded by a pattern that handled prose. Re-scoring the
528
+ same 120 answers with the fixed grader puts the kit ahead on the lookup
529
+ tasks. A harness that measures the harness is worse than one that measures
530
+ nothing.
531
+
532
+ The value the kit does have is in long sessions - the pruner removes 80.5%
533
+ of the context across real ones - and in process, which a grader that
534
+ counts correct answers cannot see. Whether it is worth it depends on
535
+ whether the work is long. **A repo of one-file lookups should not install
536
+ this.**
537
+
538
+ The full table, and what it does not establish, is in
539
+ `eval/RESULTS.md`.
540
+
541
+ - **`oc-harness refresh --all`** re-derives every shipped profile against the
542
+ live catalogue, and reports which models are gone.
543
+ - **`oc-harness doctor --check-models` and `--watch`** record a withdrawal in
544
+ `~/.config/opencode/withdrawn.jsonl`, with the profiles that used it. A model
545
+ is withdrawn without a rename and without a notice, so the only way to know
546
+ WHEN a lane started failing is to have been looking.
547
+ - **`oc-harness upgrade`** checks the registry for a newer kit, refreshes the
548
+ profiles, and writes the config again. Three things go stale on their own -
549
+ the kit, the profiles and the config - and all three were invisible.
550
+ - **A scheduled workflow** (`.github/workflows/measure.yml`), so the numbers
551
+ are kept rather than measured once. The offline half runs on every push;
552
+ the A/B runs nightly, because it costs model quota and takes about an hour.
553
+ - **`OPENCODE_HARNESS_PROFILES`** points the kit at a different profile
554
+ directory. A test that refreshes a profile would otherwise rewrite the kit's
555
+ own data - which is what happened the first time this was tried.
556
+ - **A flag appended to an npm script now overrides the script's own**:
557
+ `npm run measure:prune -- --sessions 1` means one session, not sixty.
558
+
559
+ - **Tests for the pieces the suite never reached**: the python fallback, the
560
+ project root for a project that is not a git repository, the escape rule,
561
+ and the flag parser.
562
+
563
+ ### Security
564
+
565
+ - `eval/prepare.mjs` could delete a directory tree. `--name` was joined onto
566
+ the scratch root without being checked and the result removed with
567
+ `rmSync(..., { recursive: true })`, so `--name ../victim` deleted the
568
+ victim's tree. The label is a name and not a path, and the resolved path is
569
+ checked a second time against the scratch root.
570
+ - `eval/prepare.mjs` could run a command. The archive step was one `sh -c`
571
+ string and the paths were quoted with `JSON.stringify`, which is not shell
572
+ quoting — a path containing `$(...)` was executed by the shell. `git` and
573
+ `tar` are called with argument arrays now, and the tar goes through a pipe.
16
574
 
17
575
  ## [0.4.3] — 2026-10-20
18
576
 
@@ -57,20 +615,11 @@ is not happening.
57
615
  that can be opened in the JSON body. The list asked for JSON and worked; the
58
616
  create did not, and failed with a message that said nothing about why. Every
59
617
  npm trust call asks for JSON now.
60
- - **Each npm command gets its own two-factor challenge.** The code a browser
61
- flow returns is valid for the challenge that asked for it and for nothing
62
- else, so carrying one from one command to the next made the second command
63
- send a code that was already spent - and the registry answered that with a
64
- different challenge, one that wants an authenticator code rather than a
65
- browser. The failure looked like "npm wants something else now" and nothing
66
- like the cause.
67
- - **The URLs come from the response body, not from a guess.** The done URL
68
- was rebuilt from the id, which sent a real request to the registry with a
69
- made-up id; the registry answered "forbidden", which is not a token, so the
70
- wait ran its full length and then reported that nobody had answered.
71
- - **`OC_TRUST_NO_BROWSER` turns the browser off.** A test that opens a
72
- browser on the machine it runs on is a test that breaks whatever the
73
- person at the keyboard was doing.
618
+ - The per-command two-factor challenge, the done-URL guess and
619
+ `OC_TRUST_NO_BROWSER` were all fixed in 0.3.9 and are listed there. They
620
+ appear here once more because this release was cut from a branch that
621
+ predated 0.3.9's changelog entry, and a reader of the two releases was
622
+ left with the impression that the same three fixes happened twice.
74
623
 
75
624
  ### The release path, end to end
76
625
 
@@ -182,8 +731,9 @@ The publish is now automatic and holds no secret:
182
731
  short-lived publish token, scoped to the workflow that asked for it. There
183
732
  is no long-lived token in the repository to leak, rotate or expire - and npm
184
733
  is revoking the legacy automation tokens anyway.
185
- - The job installs npm 11.5.1 or later, which is what OIDC needs and what
186
- node 22 does not ship.
734
+ - The job runs on node 24, whose bundled npm already speaks trusted
735
+ publishing. An `npm install -g npm@latest` self-upgrade also works, and is
736
+ documented elsewhere as intermittently corrupting the install.
187
737
  - The registry is configured by hand instead of through `setup-node`'s
188
738
  `registry-url`, because that helper always writes an auth line, and an
189
739
  empty auth line makes npm believe it is authenticated and skip the OIDC
@@ -396,7 +946,7 @@ the tool-output caps, and the `scout` → `locate` agent rename.
396
946
  `git log --grep push` and `echo push` are not. A heredoc body is data, not
397
947
  commands.
398
948
  - **`tool_output` was pinned at 200 lines / 8 KB**, about 6× tighter than
399
- OpenCode's default. It truncated 415 of 2,966 `read` calls and 418 calls
949
+ OpenCode's default. It truncated 415 of 2,966 `read` calls and 423 calls
400
950
  were issued with an `offset` to fetch the rest: the truncation *causes* the
401
951
  re-read, one for one, at a measured 17.3 s median per request. The cap is
402
952
  now the default, and pruning rather than truncation bounds the context.
@@ -449,6 +999,7 @@ silent in the worst way:
449
999
  ---
450
1000
 
451
1001
  [Unreleased]: https://github.com/pham-canh/space-harness/compare/v0.4.3...HEAD
1002
+ [0.4.4]: https://github.com/pham-canh/space-harness/compare/v0.4.3...v0.4.4
452
1003
  [0.4.3]: https://github.com/pham-canh/space-harness/compare/v0.4.2...v0.4.3
453
1004
  [0.4.2]: https://github.com/pham-canh/space-harness/compare/v0.4.1...v0.4.2
454
1005
  [0.4.1]: https://github.com/pham-canh/space-harness/compare/v0.4.0...v0.4.1