opencode-harness-kit 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +527 -18
  2. package/CONTRIBUTING.md +242 -0
  3. package/GLOBAL.md +34 -5
  4. package/MIGRATION.md +151 -0
  5. package/README.md +307 -37
  6. package/SECURITY.md +30 -0
  7. package/bin/baseline.mjs +15 -7
  8. package/bin/git-hooks/pre-commit +41 -9
  9. package/bin/oc-harness.mjs +1265 -82
  10. package/dist/config.d.ts +127 -0
  11. package/dist/denylist.d.ts +33 -0
  12. package/dist/effort.d.ts +64 -0
  13. package/dist/guard.d.ts +23 -0
  14. package/dist/health.d.ts +43 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +532 -149
  17. package/dist/plugin.d.ts +15 -0
  18. package/dist/profile.d.ts +399 -0
  19. package/dist/project.d.ts +8 -0
  20. package/dist/prune.d.ts +6 -0
  21. package/dist/router.d.ts +3 -0
  22. package/dist/rules.d.ts +109 -0
  23. package/dist/safe.d.ts +7 -0
  24. package/dist/telemetry-store.d.ts +12 -0
  25. package/dist/telemetry.d.ts +3 -0
  26. package/docs/EXIT-CODES.md +71 -0
  27. package/docs/KNOWN-LIMITS.md +45 -21
  28. package/docs/RESEARCH.md +9 -4
  29. package/docs/ROUTING.md +53 -8
  30. package/eval/RESULTS.md +443 -0
  31. package/eval/ab.mjs +743 -0
  32. package/eval/attribute.mjs +170 -0
  33. package/eval/flags.mjs +88 -0
  34. package/eval/measure-install.mjs +323 -0
  35. package/eval/measure-prune.mjs +339 -0
  36. package/eval/metrics.js +79 -0
  37. package/eval/premises.mjs +200 -0
  38. package/eval/prepare.mjs +233 -0
  39. package/eval/run.mjs +376 -0
  40. package/eval/tasks-self.json +81 -0
  41. package/eval/tasks-ts.json +50 -0
  42. package/eval/tasks.json +83 -0
  43. package/eval/thresholds.mjs +134 -0
  44. package/eval/verify.mjs +287 -0
  45. package/package.json +44 -17
  46. package/profiles/fleet-all.json +6 -1
  47. package/profiles/fleet-safe.json +7 -3
  48. package/profiles/schema.json +182 -51
  49. package/prompts/build.md +4 -2
  50. package/skills/space-build/SKILL.md +4 -2
  51. package/skills/space-finish/SKILL.md +6 -2
  52. package/skills/space-plan/reference/task-template.md +13 -5
  53. package/src/config.js +182 -32
  54. package/src/denylist.js +95 -0
  55. package/src/driver/run.mjs +81 -16
  56. package/src/effort.js +15 -4
  57. package/src/guard.ts +165 -13
  58. package/src/health.js +80 -0
  59. package/src/index.ts +15 -4
  60. package/src/lane-usage.js +72 -18
  61. package/src/plugin-type-check.ts +38 -0
  62. package/src/plugin.ts +39 -0
  63. package/src/profile.js +225 -26
  64. package/src/project.js +47 -0
  65. package/src/prune.ts +110 -31
  66. package/src/router.ts +32 -63
  67. package/src/rules.js +99 -7
  68. package/src/safe.ts +27 -0
  69. package/src/sqlite.js +144 -0
  70. package/src/tasks.js +112 -29
  71. package/src/telemetry-store.ts +62 -0
  72. package/src/telemetry.ts +60 -57
  73. package/src/trust.js +70 -9
package/README.md CHANGED
@@ -3,6 +3,18 @@
3
3
  [![CI](https://github.com/pham-canh/space-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/pham-canh/space-harness/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/opencode-harness-kit.svg)](https://www.npmjs.com/package/opencode-harness-kit)
5
5
 
6
+ <p align="center">
7
+ <video src="docs/demo/pitch.mp4" width="720" controls></video>
8
+ </p>
9
+
10
+ *Four minutes forty-seven seconds, recorded live from a real terminal and
11
+ narrated. The take script and the voiceover are in [`video/`](video/), and
12
+ rebuilding it is documented there — including the two mistakes that cost the
13
+ most time, so the next person does not repeat them.*
14
+
15
+ *It was recorded against an earlier version of the kit: the numbers it names
16
+ are the ones from then, and the ones below are the ones now.*
17
+
6
18
  A harness kit for [OpenCode](https://opencode.ai) that is **model-agnostic by
7
19
  construction**. Switching models is a data edit, not a code change.
8
20
 
@@ -10,6 +22,10 @@ construction**. Switching models is a data edit, not a code change.
10
22
  npx -y opencode-harness-kit init # pick a free model, then install + verify
11
23
  ```
12
24
 
25
+ <p align="center">
26
+ <img src="docs/demo-loop.gif" width="760" alt="oc-harness list showing the model-agnostic profiles, then oc-harness verify reporting a consistent install">
27
+ </p>
28
+
13
29
  then restart opencode. That is the whole quickstart. No npm account needed on
14
30
  your side — `npx` only downloads the published kit. Prefer git? The same three
15
31
  steps from a checkout (see [Install](#install)):
@@ -55,6 +71,13 @@ test suite asserts that rather than taking it on faith.
55
71
 
56
72
  The default profile is a **fleet**: ten jobs, ten free models, one session.
57
73
 
74
+ The table below is the **unrestricted** fleet (`fleet-all`), where every lane
75
+ gets the model built for it. The default is the **safe** fleet
76
+ (`fleet-safe`), where every lane resolves to a provider with a zero-retention
77
+ policy and six of the ten run on a second-choice model to stay there. Both
78
+ cover the same ten jobs; they differ in what they are willing to promise
79
+ about your prompts.
80
+
58
81
  | lane | job | model | measured |
59
82
  |---|---|---|---|
60
83
  | `build` | write and verify the change | MiniMax M3.1 Flash | 3.1 s |
@@ -86,11 +109,6 @@ Super VL is the only model on the tier that publishes a `none` effort level,
86
109
  which makes "reasoning off" a job of its own. Putting each on the job it is
87
110
  good at is worth more than any single "best" model.
88
111
 
89
- The default is the **safe** fleet, and it is safe by construction rather than
90
- by label: every lane resolves to a provider with a zero-retention policy, and
91
- six of the ten lanes run on a second-choice model to stay there. The
92
- unrestricted fleet gives every lane the model built for it.
93
-
94
112
  The full table, with the measurements behind every choice and the three
95
113
  capabilities the tier turns out not to have, is in
96
114
  [`docs/ROUTING.md`](docs/ROUTING.md).
@@ -115,7 +133,7 @@ $ oc-harness use fleet-safe --print
115
133
  // Derived numbers, do not hand-edit:
116
134
  // compaction.reserved 24,000 (12% of the narrowest input cap in play)
117
135
  // prune threshold 176,000 (narrowest input cap minus reserve)
118
- // role effort plan=max build=max reviewer=max locate=unset ...
136
+ // role effort plan=max build=max reviewer=max locate=low ...
119
137
  // lane models build=minimax-m3.1-flash-free (out 512,000) plan=...
120
138
  ```
121
139
 
@@ -147,7 +165,7 @@ $ oc-harness show fleet-safe
147
165
  lane effort
148
166
  plan max
149
167
  locate low
150
- sweep (unset) (requested none)
168
+ sweep low
151
169
  ```
152
170
 
153
171
  A model with no tunable effort at all gets **no** `variant` written, rather
@@ -199,16 +217,118 @@ it. One file covers ten jobs either way.
199
217
  The five-lane profiles run everything on one model, which is the right shape
200
218
  when you want one route and want to know exactly which one.
201
219
 
220
+ ## Free models are perishable, so check them
221
+
222
+ Every model in the table above is a **free tier slot**, and free tier slots
223
+ turn over in days. Zen states free models are available "for a limited time"
224
+ with no date attached, so a profile is a snapshot of a moment rather than a
225
+ stable thing.
226
+
227
+ Observed lifetimes, from one install's 20-day history:
228
+
229
+ | model | observed lifetime |
230
+ |---|---|
231
+ | `mimo-v2.5-free` | 1 day |
232
+ | `big-pickle` | 1 day |
233
+ | `nemotron-3-ultra-free` | 3 days |
234
+ | `muse-spark-1.3-contributor-free` | still running at 20 days |
235
+
236
+ The tier turned over again since. Five of this kit's own profiles are already
237
+ withdrawn, and one of them was the **default** at the time:
238
+
239
+ | withdrawn | why it matters |
240
+ |---|---|
241
+ | `space-bunny-free` | It was the default profile. A config left pointing at it kept working and kept saying it was zero-retention. |
242
+ | `x-preview-f-free` | Withdrawn within a month of shipping. |
243
+ | `deepseek-v4-flash-free` | Withdrawn within a month. Replaced by `deepseek-v4.1-flash-free`. |
244
+ | `muse-spark-1.3-contributor-free` | A Meta contributor route whose provider was permitted to train on prompts. The paid route is still in the catalogue. |
245
+ | `mimo-v2.5-free` | Observed alive for one day. Replaced by `mimo-v2.6-flash-free`. |
246
+
247
+ The full list, with a reason for each, is in
248
+ [`profiles/index.json`](profiles/index.json).
249
+
250
+ ### The four commands, and when each one runs
251
+
252
+ | command | what it checks | when |
253
+ |---|---|---|
254
+ | `oc-harness list` | the profiles this kit ships, with retention and caveats | offline, any time |
255
+ | `oc-harness doctor` | every profile against the live model catalogue, and the installed config against the active profile | **before every release**, and whenever a lane starts failing with a 429 or a "model not found" |
256
+ | `oc-harness doctor --check-models` | one run, non-zero when a shipped model is no longer in the catalogue | from cron: the shape a scheduler wants |
257
+ | `oc-harness doctor --watch` | keeps watching, and reports only what **changed** | leave it in a terminal for a week |
258
+ | `oc-harness refresh <id>` | re-derives a profile's machine-derived fields from live metadata | when `doctor` reports drift |
259
+ | `oc-harness verify` | the installed config still matches the profile, and the kit's own files are still installed | after every `use` / `install` |
260
+
261
+ A cron line that tells you the moment a route disappears, rather than the
262
+ moment a lane starts failing:
263
+
264
+ ```cron
265
+ 0 9 * * * cd ~/.config/opencode && oc-harness doctor --check-models
266
+ ```
267
+
268
+ `doctor` separates the two kinds of finding, because they are not the same
269
+ kind of finding:
270
+
271
+ - **A broken install** — the config no longer matches the profile, or the
272
+ active profile no longer loads. This always exits non-zero.
273
+ - **Perishable metadata** — a profile has drifted from models.dev because a
274
+ provider changed a context window or a capability flag. This is reported and
275
+ exits zero, unless you pass `--strict`, which is for a release.
276
+
277
+ A CI job that failed on the second kind would go red on a Tuesday because
278
+ somebody else changed a provider's limits, and a job that goes red for a
279
+ reason you cannot act on is a job people stop reading.
280
+
281
+ It is not a hypothetical. The day this was written, four of the twelve
282
+ shipped profiles had already drifted from the live catalogue — the
283
+ `temperature` flag had flipped on every one of them.
284
+
285
+ ### What `refresh` will and will not touch
286
+
287
+ `refresh` rewrites only the fields a machine can derive: context, output and
288
+ input ceilings, the effort levels a model publishes, and the capability flags.
289
+ It never touches `retention`, `note`, `roles` or the fallback queue, because
290
+ those are human judgements and a script overwriting a privacy verdict is worse
291
+ than drift.
292
+
293
+ **If a provider changes its terms, a human has to say so.** `retention.yml` is
294
+ hand-curated and dated (`verified:` on every entry). `doctor` will catch the
295
+ one case it can check mechanically — a profile that says `zero-retention`
296
+ while `retention.yml` now says `may-train` — and nothing else.
297
+
298
+ ### What happens when a model is withdrawn
299
+
300
+ No plugin hook can change the model of a running session, so the failover is
301
+ not mid-flight:
302
+
303
+ 1. The router marks the route spent when a request fails with a free-tier
304
+ limit or a withdrawal.
305
+ 2. At the **next** session start, the lane that used it moves to the next rung
306
+ of its own chain, and the session's own model moves to the next profile in
307
+ the fallback queue.
308
+ 3. A fleet keeps its lanes. One spent route costs one lane, not ten — that is
309
+ the whole reason every lane carries a chain of models instead of a single
310
+ name.
311
+
312
+ If a model is withdrawn and the lane's whole chain has gone with it, the lane
313
+ falls back to the profile's base model. If even that is spent, the lane still
314
+ gets a model and the config header says what the effective retention turned
315
+ out to be. A lane is never left without one.
316
+
317
+ See [docs/KNOWN-LIMITS.md](docs/KNOWN-LIMITS.md) for the rest of what this
318
+ tier cannot do.
319
+
202
320
  ## Requirements
203
321
 
204
322
  | | |
205
323
  |---|---|
206
324
  | Node | **22 or newer** — `engines` in package.json, and the CLI uses `node:` prefixed builtins. Node 20 fails at import time, not with a friendly message. |
207
325
  | npm | 10 or newer, for `npm ci` when installing from a checkout |
208
- | OS | macOS, Linux and Windows. No platform checks in the code; the tests run on `ubuntu-latest`. |
209
- | Shell | none. The CLI never shells out, so no `bash`, `zsh` or `fish` setup is needed. |
210
- | Network | `list` and `doctor` read models.dev, which is the point of both. Everything else, including `install`, `use` and `verify`, works offline. |
211
- | Disk | it writes into `~/.config/opencode/` and nothing else. |
326
+ | OS | macOS, Linux and Windows. The tests run on `ubuntu-latest`. |
327
+ | Shell | none needed to *use* the kit — no `bash`, `zsh` or `fish` setup. Two commands shell out to external binaries: `doctor` and `refresh` call `curl` to read models.dev, and `baseline` and the measurement scripts call `sqlite3`. Everything else, including `install`, `use` and `verify`, works offline. |
328
+ | Network | only `doctor` and `refresh` need it, and only to read the live model catalogue. `list` is offline: it reads the shipped profiles. Everything else works offline. |
329
+ | Disk | `use` and `install` write into `~/.config/opencode/` and nothing else. `doctor` writes nothing. `refresh` rewrites a profile in the kit's own
330
+ directory - `node_modules` for an npm install - and keeps a backup of the
331
+ one it replaced. |
212
332
 
213
333
  `~/.config/opencode/` is your live agent config. `use` and `install` write there,
214
334
  `verify` only reads, and `use --dryrun` writes nothing at all. Every write keeps
@@ -218,20 +338,40 @@ a `.bak` next to the file, including under `--yes`.
218
338
 
219
339
  ```
220
340
  oc-harness list profiles with retention and caveats
221
- oc-harness show <id> resolved lanes, derived numbers, fallback chains
341
+ oc-harness show <id> resolved lanes, derived numbers, fallback chains, guard rules
222
342
  oc-harness init pick a free profile, then install + use + verify
223
343
  oc-harness use <id> compile a profile into ~/.config/opencode/opencode.jsonc
224
- oc-harness use <id> --dryrun print what would be written, write nothing
344
+ oc-harness use <id> --dryrun print a summary of what would be written, write nothing
225
345
  oc-harness refresh <id> re-derive a profile against the live model list
226
346
  oc-harness install symlink the plugin, prompts, skills and GLOBAL.md
227
- oc-harness link just the plugin and instruction file, no prompts or skills
347
+ oc-harness link just the plugin, no prompts or skills
228
348
  oc-harness verify check the install against the profile; non-zero on drift
229
349
  oc-harness agents report agents the effort tiering does not reach
230
350
  oc-harness lanes report which lanes the fleet actually used
351
+ oc-harness lanes --json the same report, for a script
352
+ oc-harness trust set up npm trusted publishing for this package
231
353
  oc-harness doctor re-check profiles against models.dev and the live config
354
+ oc-harness doctor --watch poll the catalogue, and report only what changed
355
+ oc-harness refresh --all re-derive every shipped profile against the live catalogue
356
+ oc-harness upgrade check for a newer kit, refresh the profiles, rewrite the config
357
+ oc-harness backups the configs that were replaced, newest first
358
+ oc-harness uninstall remove the plugin, prompts, skills and GLOBAL.md again
359
+ oc-harness cost what the kit is doing on this project, in numbers
360
+ oc-harness restore <file> put one back
232
361
  oc-harness baseline measure the current install
362
+ oc-harness run drive .space/tasks.md to done with the build agent
233
363
  ```
234
364
 
365
+ Every flag accepts both spellings: `--profile fleet-safe` and
366
+ `--profile=fleet-safe` are the same flag.
367
+
368
+ Every command that can fail says so in its exit code, so a script can tell
369
+ "the kit says no" from "the kit broke".
370
+ [`docs/EXIT-CODES.md`](docs/EXIT-CODES.md) lists them.
371
+
372
+ `doctor`, `show` and `lanes` take `--json` and print nothing else, so a
373
+ script gets the answer without parsing prose.
374
+
235
375
  `oc-harness <anything-else>` now exits non-zero with the list of real commands,
236
376
  instead of printing help and reporting success.
237
377
 
@@ -312,6 +452,12 @@ A lane that is never used is a description the model reads on every turn and
312
452
  a free model doing nothing. Cut it from the profile's `roles` and the lane
313
453
  leaves the config with it.
314
454
 
455
+ The `build` row is not comparable with the others: it includes the turns
456
+ that did the delegating, so it counts the work of the other nine lanes. A
457
+ one-line `build` reply on its own is about a third of that number, and
458
+ rather more on a cold start. [`docs/ROUTING.md`](docs/ROUTING.md) has the
459
+ per-profile tables and what each row measures.
460
+
315
461
  One request is one assistant message, which is one round trip to the provider:
316
462
  a lane that makes three tool calls makes three requests. The counters come
317
463
  from `.space/telemetry.jsonl`, written by the plugin, never sent anywhere, and
@@ -326,12 +472,12 @@ From one real install:
326
472
 
327
473
  ```
328
474
  CONTEXT PER REQUEST
329
- p50 100,942 p90 256,940 p99 481,687 max 547,986
330
- fresh input p50 528
475
+ p50 99,648 p90 246,536 p99 482,779 max 547,986
476
+ fresh input p50 534
331
477
  STORED MESSAGE BYTES
332
- tool 116.58 MB 77.9%
333
- reasoning 23.10 MB 15.4%
334
- text 3.12 MB 2.1%
478
+ tool 114.78 MB 77.9%
479
+ reasoning 22.94 MB 15.6%
480
+ text 3.04 MB 2.1%
335
481
  READ PRESSURE
336
482
  read calls 2,966
337
483
  grep/glob/list 249
@@ -340,15 +486,15 @@ READ PRESSURE
340
486
  cut by the 200-line cap 441
341
487
  re-reads with an offset 423
342
488
  PERMISSION PRESSURE
343
- prompt with kit allowlist 1,002 (18.5%)
489
+ prompt with kit allowlist 1,002 (19.1%)
344
490
  compaction events 0 (never fired)
345
491
  ```
346
492
 
347
493
  Four numbers in that report changed how this kit is configured, and two of them
348
494
  reversed an earlier decision:
349
495
 
350
- **Tool output is 78% of stored context**, and sessions re-read ~101k tokens per
351
- request to add ~528 new ones. That is why pruning here is deterministic and
496
+ **Tool output is 78% of stored context**, and sessions re-read ~100k tokens per
497
+ request to add ~534 new ones. That is why pruning here is deterministic and
352
498
  unconditional.
353
499
 
354
500
  **Read pressure is 11.9 whole-file reads per search.** Every production harness
@@ -364,6 +510,45 @@ reads exceed 51,200 bytes, so that ceiling is not what limits context.
364
510
 
365
511
  Your numbers stay on your machine; only the script is in the repo.
366
512
 
513
+ `npm run measure` is an **observation** about one install: it compares the
514
+ work before the kit landed with the work after, and the work is never the
515
+ same. It says so when the two windows are not comparable, and the gate uses
516
+ the number that is.
517
+
518
+ `npm run measure:prune` is the attributable one. opencode stores the message
519
+ parts unpruned — the pruner rewrites the outgoing request, not the history —
520
+ so the stored parts of a real session are exactly what the pruner was given,
521
+ and replaying the real pruner over them measures exactly what it removed, on
522
+ the same messages with and without. On one install: **80.5% of the context
523
+ across 60 real sessions**.
524
+
525
+ ## Memory
526
+
527
+ The kit does not implement memory. A local vector store with embeddings, a
528
+ web UI, auto-capture and a user profile is a large thing to build badly, and
529
+ [`opencode-mem`](https://github.com/tickernelz/opencode-mem) already is one.
530
+
531
+ What the kit does is make it one flag away and keep the config honest about
532
+ it:
533
+
534
+ ```bash
535
+ oc-harness use fleet-safe --memory # adds opencode-mem to the plugin list
536
+ oc-harness use fleet-safe --memory=opencode-mem@2.32.0 # a pinned version
537
+ ```
538
+
539
+ Your own plugins are never touched — the entry is **added**, so a config that
540
+ already has it does not grow a second copy, and a config that already has
541
+ other plugins keeps them.
542
+
543
+ The memory plugin keeps its own configuration in
544
+ `~/.config/opencode/opencode-mem.jsonc`. It stores and searches without one,
545
+ but **auto-capture needs a provider** to summarise a session into a memory, so
546
+ give it one:
547
+
548
+ ```jsonc
549
+ { "opencodeProvider": "openai", "opencodeModel": "inherit" }
550
+ ```
551
+
367
552
  ## The plugins
368
553
 
369
554
  | module | hook | job |
@@ -371,7 +556,21 @@ Your numbers stay on your machine; only the script is in the repo.
371
556
  | `guard.ts` | `tool.execute.before` | Blocks destructive commands in code. Fail-closed. |
372
557
  | `prune.ts` | `experimental.chat.messages.transform` | Replaces old tool output with a one-line stub. |
373
558
  | `router.ts` | `config`, `event`, `chat.message` | Moves a lane to the next rung of its chain when a route is spent. |
374
- | `telemetry.ts` | `event`, `chat.message`, `tool.execute.after` | Local JSONL, gitignored, never a network call. |
559
+ | `telemetry.ts` | `event`, `tool.execute.after` | Local JSONL, gitignored, never a network call. |
560
+
561
+ Both `.space/telemetry.jsonl` and `.space/prune.jsonl` are written in the
562
+ **project**, not wherever opencode was started from. The prune log used to
563
+ land in the process's working directory, which is often your home directory.
564
+
565
+ **The utility route.** `small_model` is what opencode uses for its short
566
+ auxiliary calls — a title, a one-line summary, a question rewritten. It used
567
+ to be the session model, so every one of those calls paid the session model's
568
+ *reasoning*, and reasoning, not output, is what a short call costs. Measured
569
+ on this tier, a title costs **49 reasoning tokens on DeepSeek and 332 on
570
+ Nemotron 3.5 Super VL**, and DeepSeek answers in 7.2 s against 22.7 s for
571
+ GLM. A fleet now names a cheaper route; a single-model profile keeps its one
572
+ route, because "one route, and it is the one you asked for" is a promise those
573
+ profiles make. The generated file says which route it chose.
375
574
 
376
575
  `guard.ts` matches against the command a shell would actually run, not the text
377
576
  it was handed. It splits on shell separators, honours quotes so that a `;`
@@ -383,11 +582,19 @@ not guard-blocked; it falls through to the `*` ask permission so opencode
383
582
  prompts for human approval before publishing.
384
583
 
385
584
  Writes are scoped to the project directory, with the OS temp directory as the
386
- one exception GLOBAL.md allows.
585
+ one exception GLOBAL.md allows. That covers every tool that writes: `write`
586
+ and `edit` by their path, `apply_patch` by the paths named in the patch, and
587
+ a bash command by its **working directory** — a command run from `/etc` is
588
+ writing outside the project, whatever the command is.
589
+
590
+ A write tool whose arguments name no file is blocked rather than allowed
591
+ through: a guard that cannot see what a write touches cannot scope it. And
592
+ if the guard itself cannot decide, it blocks — a guard that quietly passes
593
+ when broken is worse than none.
387
594
 
388
595
  Pruning keeps user messages, assistant prose, diffs, and the last few turns
389
- verbatim, and never touches the in-flight turn. Old reasoning is dropped, since
390
- stale reasoning is worse than none.
596
+ verbatim, and never touches the in-flight turn. Old reasoning is dropped,
597
+ since stale reasoning is worse than none.
391
598
 
392
599
  `router.ts` fails over **per lane**. A fleet has ten routes, so one spent
393
600
  route must not take the other nine down with it: the lane that used the dead
@@ -496,17 +703,37 @@ Writes outside the project still ask, via `external_directory`.
496
703
 
497
704
  | lane | mode | job | effort |
498
705
  |---|---|---|---|
499
- | `build` | primary | writes and verifies code | middle |
500
- | `plan` | primary | turns a request into an executable plan | highest |
501
- | `reviewer` | subagent | two passes, never edits | highest |
502
- | `locate` | subagent | finds code, reports `file:line`, never designs | lowest |
503
- | `verify` | subagent | runs checks, never fixes them | lowest |
706
+ | `build` | primary | writes and verifies code | `max` |
707
+ | `plan` | primary | turns a request into an executable plan | `max` |
708
+ | `reviewer` | subagent | two passes, never edits | `max` |
709
+ | `locate` | subagent | finds code, reports `file:line`, never designs | `low` |
710
+ | `verify` | subagent | runs checks, never fixes them | `low` |
504
711
  | `vision` | subagent | reads an image or a screenshot | model's |
505
712
  | `doc` | subagent | reads a long document or a spec | model's |
506
- | `schema` | subagent | produces JSON a program can parse | highest |
507
- | `sweep` | subagent | bulk mechanical sweep, no judgement | lowest |
713
+ | `schema` | subagent | produces JSON a program can parse | `max` |
714
+ | `sweep` | subagent | bulk mechanical sweep, no judgement | `low` / `none` |
508
715
  | `longform` | subagent | very long context, or several answers | model's |
509
716
 
717
+ The effort column is the request in the profile, resolved against the levels
718
+ of the model the lane actually runs on — so `max` is `max` where the model
719
+ publishes it and the nearest level that does exist where it does not, and
720
+ `none` is no variant at all on a model that cannot turn reasoning off. Run
721
+ `oc-harness show <profile>` for the resolved values and the note explaining
722
+ any difference.
723
+
724
+ The tier is worth protecting: measured on one install, DeepSeek spends **320
725
+ reasoning tokens a message at `max` and 19 at `low`**, so the lever is a
726
+ factor of 17. A lane that asks for a tier and lands on a model that publishes
727
+ none runs at that model's natural rate instead, and the generated config says
728
+ so in the form `locate=unset(asked low)`.
729
+
730
+ Three lanes in the unrestricted fleet cannot get their tier, and the
731
+ measurement says they are the three cheapest reasoners on the tier — 116, 59
732
+ and 18 reasoning tokens a message, against 289 for `build` and 198 for
733
+ `plan`, which keep their tier on purpose. The one lane that was genuinely
734
+ expensive was `sweep`, running on a model that reasons 420 tokens a message
735
+ for what is supposed to be a mechanical grep. It does not any more.
736
+
510
737
  `locate` is deliberately *not* called `scout`: OpenCode ships a built-in `scout`
511
738
  subagent for dependency and external-docs research, and shipping our own file
512
739
  under that name shadowed it with `bash: deny` so it could not do the one thing
@@ -516,6 +743,11 @@ Each lane carries a `steps` cap. It is a loop breaker, not a quality knob: on
516
743
  exhaustion OpenCode forces a text-only response, so an agent that has stopped
517
744
  making progress reports rather than looping until the context is gone.
518
745
 
746
+ Each lane also gets its own `permission.task` rule — `deny` for everything
747
+ except the lanes it may hand work to. That is the mechanism behind "hand the
748
+ job to the lane that has it": a lane that is not allowed to spawn anything
749
+ cannot quietly become a general-purpose agent on the most expensive tier.
750
+
519
751
  ## The skill loop
520
752
 
521
753
  The lanes above are the *who*. The skills are the *order*, and they load by
@@ -537,6 +769,43 @@ the conversation instead of the requirement.
537
769
  Publishing is never a loop step. `space-finish` prepares the change; a human
538
770
  decides it ships.
539
771
 
772
+ ## What it costs, measured
773
+
774
+ The kit's own A/B - the same task with and without it, ten reps per arm, 120
775
+ agent runs, both arms on the same model, on this repository at a pinned commit
776
+ so anyone can repeat it:
777
+
778
+ | metric (median) | kit on | kit off |
779
+ |---|---|---|
780
+ | **correct** | **54/60** | **55/60** |
781
+ | tool calls | 12.5 | 8 |
782
+ | input tokens | 56,205 | 10,540 |
783
+ | final context | 39,928 | 12,718 |
784
+ | wall seconds | 43 | 21 |
785
+ | paths written outside the target | 0 | 0 |
786
+
787
+ **On a task that fits in one read, the kit is a cost.** It spends over five
788
+ times the input tokens and takes twice as long, and the answers are a tie -
789
+ 54 against 55, and a second run of the same 120 runs gave 55 against 54.
790
+ The two edit tasks are a tie at 10/10, and the guard stopped no writes in
791
+ either arm.
792
+
793
+ The earlier version of this table said 52/60 against 59/59 - the kit looked
794
+ clearly worse - and that was the grader's fault: it could not read a
795
+ citation written as `**File:** x` on one line and `**Line:** 105` on the
796
+ next, which is the shape some of the kit's answers took. Re-scoring the
797
+ same 120 runs with the fixed grader gives 34/40 against 30/40 on the lookup
798
+ tasks - the kit ahead, where the old grader had it behind. The full account is in [eval/RESULTS.md](eval/RESULTS.md).
799
+
800
+ What the kit buys is in the two places that suite cannot see: **long
801
+ sessions**, where the pruner removes 80.5% of the context, and **process** -
802
+ a reviewer that reviews and does not edit, a verify that runs the checks and
803
+ does not fix them, a publish decision that belongs to a human.
804
+
805
+ Whether it is worth it depends on whether the work is long. A repository of
806
+ one-file lookups should not install this. The full table is in
807
+ [eval/RESULTS.md](eval/RESULTS.md).
808
+
540
809
  ## Known limits
541
810
 
542
811
  Read [docs/KNOWN-LIMITS.md](docs/KNOWN-LIMITS.md) before relying on failover.
@@ -549,7 +818,7 @@ session starts.
549
818
 
550
819
  ```bash
551
820
  npm install
552
- npm run check # everything: build, typecheck, tests, live install, verify
821
+ npm run check # everything offline: build, typecheck, tests
553
822
  ```
554
823
 
555
824
  Or one layer at a time:
@@ -557,6 +826,7 @@ Or one layer at a time:
557
826
  ```bash
558
827
  npm test # model-agnostic assertions, no network, no model calls
559
828
  npm run typecheck
829
+ npm run check:install # the above, plus the live install measurement and verify
560
830
  npm run eval # synthetic: pruning, prompt pressure, effort projection
561
831
  npm run measure # is this install behaving? narrow reads, context
562
832
  npm run attribute # where the context went: fixed overhead vs history
@@ -565,8 +835,8 @@ npm run baseline # your own install, from scratch
565
835
  ```
566
836
 
567
837
  `eval/ab.mjs` runs the same tasks with and without the kit, to separate the kit
568
- from the work. It spawns real agents, so it is not part of `npm run check`, and
569
- it wants a subject it can hand each run a private copy of:
838
+ from the work. It spawns real agents, so it is not part of any check, and it
839
+ wants a subject it can hand each run a private copy of:
570
840
 
571
841
  ```bash
572
842
  node eval/prepare.mjs --repo ~/work/lablab-harness --ref 8a674f5
@@ -576,7 +846,7 @@ node eval/ab.mjs --rescore <saved stream dir> # re-grade without spawning agen
576
846
 
577
847
  `npm test` and `npm run typecheck` need nothing but the repo, so CI runs those.
578
848
  The rest read `~/.local/share/opencode/opencode.db` and `~/.config/opencode`,
579
- which only exist on a machine that has run OpenCode with the kit installed -
849
+ which only exist on a machine that has run OpenCode with the kit installed —
580
850
  `npm run check:online` additionally hits the model catalog over the network.
581
851
 
582
852
  Measured results, and the limits they do not cross, are in
package/SECURITY.md ADDED
@@ -0,0 +1,30 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ | Version | Supported |
6
+ |---|---|
7
+ | 0.4.x | yes |
8
+ | 0.3.x | yes |
9
+ | 0.2.x | security fixes only |
10
+ | 0.1.x | security fixes only |
11
+
12
+ ## Reporting a vulnerability
13
+
14
+ Please report privately through GitHub's
15
+ [private vulnerability reporting](https://github.com/pham-canh/space-harness/security/advisories/new)
16
+ rather than opening a public issue.
17
+
18
+ Include the affected version, a reproduction, and the expected behaviour.
19
+ You can expect an acknowledgement within 72 hours and a fix or mitigation
20
+ plan within 7 days.
21
+
22
+ ## Scope
23
+
24
+ `space-harness` runs agent subprocesses and reads harness config. Reports
25
+ that are in scope include path traversal when resolving a capability
26
+ profile, command injection through harness config values, and any way a
27
+ harness writes outside its configured working directory.
28
+
29
+ Out of scope: anything requiring you to hand over a credential you were
30
+ never given, and issues in the model provider itself.
package/bin/baseline.mjs CHANGED
@@ -19,18 +19,24 @@ const wantJson = args.includes("--json")
19
19
  const dbArg = args.indexOf("--db")
20
20
  const DB = dbArg >= 0 ? args[dbArg + 1] : join(homedir(), ".local/share/opencode/opencode.db")
21
21
 
22
+ const { canQuerySqlite, sqliteMissingMessage, query: queryDb } = await import("../src/sqlite.js")
23
+
22
24
  if (!existsSync(DB)) {
23
25
  console.error(`opencode database not found: ${DB}`)
24
26
  console.error("pass --db <path> or run opencode at least once first")
25
27
  process.exit(1)
26
28
  }
27
29
 
30
+ // The sqlite3 CLI, not the library. A missing tool is a thing a person can
31
+ // fix, and the failure used to be a raw `spawnSync sqlite3 ENOENT` stack
32
+ // trace.
33
+ if (!canQuerySqlite()) {
34
+ console.error(sqliteMissingMessage())
35
+ process.exit(1)
36
+ }
37
+
28
38
  function query(sql) {
29
- const out = execFileSync("sqlite3", ["-json", `file:${DB}?mode=ro`, sql], {
30
- encoding: "utf8",
31
- maxBuffer: 256 * 1024 * 1024,
32
- }).trim()
33
- return out ? JSON.parse(out) : []
39
+ return queryDb(DB, sql)
34
40
  }
35
41
 
36
42
  const ASSISTANT = `json_extract(data,'$.role')='assistant'`
@@ -163,7 +169,9 @@ const topAskers = () => {
163
169
  // allowlist, which meant the number it reported was whatever the list looked
164
170
  // like when the copy was last edited. Every tuning decision in this project is
165
171
  // supposed to rest on this measurement, so it has to measure the real list.
166
- const { BASH_ALLOWLIST } = await import(new URL("../src/config.js", import.meta.url))
172
+ const { BASH_ALLOWLIST } = await import(
173
+ new URL("../src/config.js", import.meta.url).href,
174
+ )
167
175
  const kitAllowlist = BASH_ALLOWLIST
168
176
  const currentAllow = [
169
177
  "git status *", "git log *", "git diff *", "grep *",
@@ -184,7 +192,7 @@ const paidEquivalent =
184
192
  //
185
193
  // This exists because the kit once shipped max_lines 200 / max_bytes 8192 on
186
194
  // the reasoning that tight truncation "saves context", and a later install
187
- // measured 415 of 2,959 reads exceeding 8 KB against 418 reads issued with an
195
+ // measured 415 of 2,966 reads exceeding 8 KB against 423 reads issued with an
188
196
  // `offset`. Truncation causes the re-read roughly one for one, and at the
189
197
  // measured ~17s median per request that round trip is more expensive than the
190
198
  // tokens it saves. Re-run this before changing these numbers again.
@@ -1,12 +1,23 @@
1
1
  #!/bin/sh
2
2
  # Installed by `oc-harness install`. Remove with: rm .git/hooks/pre-commit
3
3
  #
4
- # Runs the offline gate on every commit. The live-install half is deliberately
5
- # left out: measure and attribute read ~/.local/share/opencode, which is slow,
6
- # machine-specific, and irrelevant to whether a commit broke the kit.
4
+ # It runs the PROJECT's own checks, and only if the project has any.
7
5
  #
8
- # On a machine that has never run OpenCode, test:install cannot run at all, so
9
- # this hook uses the subset that needs nothing but the repository.
6
+ # The previous version ran `npm run test:all`, which is a script of THIS KIT,
7
+ # not of the project the hook was installed into. So in every project that
8
+ # installed the kit, every commit failed with:
9
+ #
10
+ # npm error Missing script: "test:all"
11
+ #
12
+ # until the author reached for --no-verify. A gate that blocks every commit
13
+ # is not a gate.
14
+ #
15
+ # The kit's own repository is the exception, and it is handled by the script
16
+ # list below: `test:all` is there, so the kit gates itself.
17
+ #
18
+ # The live-install half is deliberately left out: measure and attribute read
19
+ # ~/.local/share/opencode, which is slow, machine-specific, and irrelevant to
20
+ # whether a commit broke anything.
10
21
 
11
22
  set -e
12
23
 
@@ -18,13 +29,34 @@ if [ -n "$SKIP_SPACE_HARNESS_HOOK" ]; then
18
29
  exit 0
19
30
  fi
20
31
 
21
- echo "pre-commit: npm run test:all"
22
- if ! npm run test:all >/tmp/space-harness-hook.log 2>&1; then
32
+ # No package.json, no JavaScript project, no gate.
33
+ if [ ! -f package.json ]; then
34
+ exit 0
35
+ fi
36
+
37
+ # The gate is the project's own test script, if it has one. The kit's own
38
+ # repository has `test:all`, which is the offline half of its gate.
39
+ gate=""
40
+ if node -e "process.exit(require('./package.json').scripts?.['test:all'] ? 0 : 1)" 2>/dev/null; then
41
+ gate="test:all"
42
+ elif node -e "process.exit(require('./package.json').scripts?.test ? 0 : 1)" 2>/dev/null; then
43
+ gate="test"
44
+ fi
45
+
46
+ if [ -z "$gate" ]; then
47
+ # Nothing to run. A hook that blocks every commit because the project has
48
+ # no test script is worse than no hook.
49
+ exit 0
50
+ fi
51
+
52
+ log="${TMPDIR:-/tmp}/space-harness-hook.log"
53
+ echo "pre-commit: npm run $gate"
54
+ if ! npm run "$gate" >"$log" 2>&1; then
23
55
  echo "pre-commit: FAILED. Last lines:"
24
- tail -30 /tmp/space-harness-hook.log
56
+ tail -30 "$log"
25
57
  echo ""
26
58
  echo "Fix it, or commit with --no-verify if you are certain it is not yours."
27
59
  exit 1
28
60
  fi
29
61
 
30
- exit 0
62
+ exit 0