opencode-harness-kit 0.1.0 → 0.2.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/GLOBAL.md CHANGED
@@ -54,7 +54,7 @@ to avoid this.
54
54
 
55
55
  ## Never
56
56
 
57
- - `git push`, publish, or open a pull request. A human does that.
57
+ - Ask before `git push`, publish, or opening a pull request. Publishing is a human decision.
58
58
  - Commit a secret. If you find one, report it; do not move it.
59
59
  - Read or write `.env`, `.npmrc`, `.netrc`, `.git-credentials`. The guard
60
60
  blocks these and will keep blocking them.
package/README.md CHANGED
@@ -1,21 +1,34 @@
1
1
  # opencode-harness-kit
2
2
 
3
- [![CI](https://github.com/crytobot459/space-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/crytobot459/space-harness/actions/workflows/ci.yml)
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
+
6
15
  A harness kit for [OpenCode](https://opencode.ai) that is **model-agnostic by
7
16
  construction**. Switching models is a data edit, not a code change.
8
17
 
9
18
  ```bash
10
- npx oc-harness init # pick a free model, then install + verify
19
+ npx -y opencode-harness-kit init # pick a free model, then install + verify
11
20
  ```
12
21
 
22
+ <p align="center">
23
+ <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">
24
+ </p>
25
+
13
26
  then restart opencode. That is the whole quickstart. No npm account needed on
14
27
  your side — `npx` only downloads the published kit. Prefer git? The same three
15
28
  steps from a checkout (see [Install](#install)):
16
29
 
17
30
  ```bash
18
- git clone https://github.com/crytobot459/space-harness.git
31
+ git clone https://github.com/pham-canh/space-harness.git
19
32
  ./bin/oc-harness.mjs init
20
33
  ```
21
34
 
@@ -47,7 +60,7 @@ profiles/space-bunny-free.json ← the model
47
60
  src/ ← no model name anywhere in here
48
61
  ```
49
62
 
50
- Same plugin source, four shipped profiles, four different behaviours. The test
63
+ Same plugin source, five shipped profiles, five different behaviours. The test
51
64
  suite asserts that rather than taking it on faith.
52
65
 
53
66
  ## What the compiler derives
@@ -114,24 +127,81 @@ on install. It is not hidden, it is labelled.
114
127
  | profile | context | top effort | retention |
115
128
  |---|---|---|---|
116
129
  | `space-bunny-free` (default) | 1,048,576 | `max` | zero-retention |
130
+ | `muse-split-free` | 1,048,576 | `max` | **may train** |
117
131
  | `x-preview-f-free` | 1,000,000 | `max` | zero-retention |
118
132
  | `longcat-2.5-preview-free` | 1,000,000 | — | zero-retention |
119
133
  | `deepseek-v4-flash-free` | 200,000 | `max` | zero-retention |
120
134
  | `muse-spark-1.3-contributor-free` | 1,048,576 | `xhigh` | **may train** |
121
135
 
136
+ ### Split profiles
137
+
138
+ A profile normally puts one model behind every role. `roleModels` is the way to
139
+ split that, for when the best free model and the highest-output model are not the
140
+ same model:
141
+
142
+ ```jsonc
143
+ "model": "space-bunny-free", // base: every role not named below
144
+ "roleModels": {
145
+ "plan": { "model": "muse-spark-1.3-contributor-free", "levels": ["minimal","low","medium","high","xhigh"] },
146
+ "build": { "model": "muse-spark-1.3-contributor-free", "levels": ["minimal","low","medium","high","xhigh"] },
147
+ "locate": { "model": "space-bunny-free", "levels": ["low","medium","high","xhigh","max"] }
148
+ }
149
+ ```
150
+
151
+ Three things fall out of this that are easy to get wrong, so they are enforced:
152
+
153
+ - **`levels` is required when the model differs.** Effort is resolved against the
154
+ levels of the model the role actually runs on. `plan` asking for `max` on a
155
+ model that stops at `xhigh` lands on `xhigh`, instead of writing a `max` variant
156
+ the model has no meaning for.
157
+ - **Retention is the least private model in the file, not the base one.** A base
158
+ of `zero-retention` with a `may-train` role is a `may-train` profile. The
159
+ generated header prints that, with the model responsible, and so do
160
+ `oc-harness use` and `oc-harness show`.
161
+ - **Each role's output cap is its own.** A split role inherits the limits of the
162
+ model it names, so a `build` on a 131,072-token model cannot spend the base's
163
+ 524,288.
164
+
165
+ `muse-split-free` is the shipped example, and it is deliberately not the default:
166
+ three of its five roles run on Muse Spark, a Meta contributor-tier endpoint whose
167
+ provider may train on your prompts. Install it for public or throwaway
168
+ repositories.
169
+
170
+ ## Requirements
171
+
172
+ | | |
173
+ |---|---|
174
+ | 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. |
175
+ | npm | 10 or newer, for `npm ci` when installing from a checkout |
176
+ | OS | macOS, Linux and Windows. No platform checks in the code; the tests run on `ubuntu-latest`. |
177
+ | Shell | none. The CLI never shells out, so no `bash`, `zsh` or `fish` setup is needed. |
178
+ | Network | `list` and `doctor` read models.dev, which is the point of both. Everything else, including `install`, `use` and `verify`, works offline. |
179
+ | Disk | it writes into `~/.config/opencode/` and nothing else. |
180
+
181
+ `~/.config/opencode/` is your live agent config. `use` and `install` write there,
182
+ `verify` only reads, and `use --dryrun` writes nothing at all. Every write keeps
183
+ a `.bak` next to the file, including under `--yes`.
184
+
122
185
  ## Commands
123
186
 
124
187
  ```
125
188
  oc-harness list profiles with retention and caveats
126
- oc-harness use <id> compile a profile into ~/.config/opencode/opencode.jsonc
127
189
  oc-harness show <id> resolved effort, derived numbers, fallback queue
190
+ oc-harness init pick a free profile, then install + verify
191
+ oc-harness use <id> compile a profile into ~/.config/opencode/opencode.jsonc
192
+ oc-harness use <id> --dryrun print what would be written, write nothing
193
+ oc-harness refresh <id> re-derive a profile against the live model list
128
194
  oc-harness install symlink the plugin, agents, skills and GLOBAL.md
195
+ oc-harness link just the plugin and instruction file, no agents or skills
129
196
  oc-harness verify check the install against the profile; non-zero on drift
130
197
  oc-harness agents report agents the effort tiering does not reach
131
198
  oc-harness doctor re-check profiles against models.dev and the live config
132
199
  oc-harness baseline measure the current install
133
200
  ```
134
201
 
202
+ `oc-harness <anything-else>` now exits non-zero with the list of real commands,
203
+ instead of printing help and reporting success.
204
+
135
205
  ### `verify` is the command that matters
136
206
 
137
207
  A generated config is only half an install. The other half is the instruction
@@ -235,8 +305,10 @@ Your numbers stay on your machine; only the script is in the repo.
235
305
  it was handed. It splits on shell separators, honours quotes so that a `;`
236
306
  inside a string is an argument rather than a second command, strips `env`,
237
307
  `sudo` and `git -C` before matching, and follows `sh -c "..."` into its payload.
238
- So `cd repo && git push`, `sudo rm -rf /` and `bash -c "git push"` are all
239
- caught, while `git log --grep push` and `git push origin my-feature-f` are not.
308
+ So `cd repo && rm -rf /`, `sudo rm -rf /` and `bash -c "rm -rf /"` are all
309
+ caught, while `git log --grep push` and `echo push` are not. `git push` is
310
+ not guard-blocked; it falls through to the `*` ask permission so opencode
311
+ prompts for human approval before publishing.
240
312
  Writes are scoped to the project directory, with the OS temp directory as the
241
313
  one exception GLOBAL.md allows.
242
314
 
@@ -290,12 +362,11 @@ pkg install nodejs git
290
362
  npx -y opencode-harness-kit init
291
363
  ```
292
364
 
293
- Not published to npm yet, or no registry access? The checkout path works
294
- identically:
365
+ Without registry access, the checkout path works identically:
295
366
 
296
367
  ```bash
297
368
  pkg install nodejs git
298
- git clone https://github.com/crytobot459/space-harness.git
369
+ git clone https://github.com/pham-canh/space-harness.git
299
370
  ./space-harness/bin/oc-harness.mjs init
300
371
  ```
301
372
 
@@ -417,6 +488,15 @@ Measured results, and the limits they do not cross, are in
417
488
  `docs/RESEARCH.md` records where the numbers came from and what was tried and
418
489
  rejected.
419
490
 
491
+ ## Contributing
492
+
493
+ [`CONTRIBUTING.md`](CONTRIBUTING.md) — the bar is that every change is
494
+ verifiable. Offline tests stay offline, anything that writes to disk needs a
495
+ `--dryrun` and a backup, and a number that moves updates the README in the same
496
+ commit. Bug reports go to [SECURITY.md](SECURITY.md) if they involve a key or
497
+ someone else's config; otherwise open an issue with the command you ran and what
498
+ it printed.
499
+
420
500
  ## License
421
501
 
422
502
  MIT
package/agents/build.md CHANGED
@@ -2,7 +2,7 @@
2
2
  description: Writes code and verifies it. Use when the plan is settled and files need to change.
3
3
  mode: primary
4
4
  model: opencode/space-bunny-free
5
- variant: high
5
+ variant: max
6
6
  steps: 40
7
7
  permission:
8
8
  edit: allow
@@ -50,7 +50,7 @@ You implement one task from a plan. One. Not the whole plan.
50
50
  cannot complete the task, say so before writing a partial one.
51
51
  - Never widen the change. A drive-by refactor in the same file is a separate
52
52
  task; do not report it as part of this one.
53
- - Never `git push`, never publish, never open a pull request. Committing is
53
+ - Ask before `git push`, publish, or opening a pull request. Committing is
54
54
  allowed but only when the task asked for it.
55
55
  - Never fabricate a passing result. A failing test reported honestly is worth
56
56
  more than a green one you did not get.
@@ -12,7 +12,7 @@
12
12
  // The point of the kit: switching models is a data edit, not a code change.
13
13
  // Everything model-specific lives in profiles/<id>.json.
14
14
 
15
- import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, symlinkSync, readdirSync, readlinkSync, realpathSync } from "node:fs"
15
+ import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, symlinkSync, readdirSync, readlinkSync, realpathSync, lstatSync } from "node:fs"
16
16
  import { homedir } from "node:os"
17
17
  import { dirname, join, relative } from "node:path"
18
18
  import { execFileSync } from "node:child_process"
@@ -27,9 +27,12 @@ import {
27
27
  profilePath,
28
28
  pruneThreshold,
29
29
  reservedTokens,
30
+ effectiveRetention,
31
+ roleSplitModels,
30
32
  resolveDefaultVariant,
31
33
  resolveRoles,
32
34
  ProfileError,
35
+ parseJsonc,
33
36
  } from "../src/profile.js"
34
37
  import { generateConfig, globalInstructionsPath } from "../src/config.js"
35
38
  import { planEffort } from "../src/effort.js"
@@ -132,7 +135,7 @@ function cmdShow(id) {
132
135
  if (!id) fail("usage: oc-harness show <profile>")
133
136
  const p = loadProfile(id)
134
137
  const plan = planEffort(id)
135
- const retention = loadRetention()[`opencode/${p.provider}/${p.model}`]
138
+ const retention = loadRetention()[`${p.provider}/${p.model}`]
136
139
 
137
140
  console.log(`\n${bold(p.id)} ${stamp(p.title ?? "")}\n`)
138
141
  const row = (k, v) => console.log(` ${k.padEnd(20)} ${v}`)
@@ -146,7 +149,20 @@ function cmdShow(id) {
146
149
  row("attachment", p.supports?.attachment ? "yes" : stamp("no"))
147
150
  row("reserved", reservedTokens(p).toLocaleString("en-US"))
148
151
  row("prune at", pruneThreshold(p).toLocaleString("en-US"))
149
- row("retention", p.retention === "zero-retention" ? green(p.retention) : yellow(p.retention))
152
+ // Effective, not the base model's: a split profile's base can be
153
+ // zero-retention while the roles that matter are not.
154
+ const eff = effectiveRetention(p)
155
+ row("retention", eff.retention === "zero-retention" ? green(eff.retention) : yellow(eff.retention))
156
+ if (eff.retention !== p.retention) {
157
+ console.log(` ${"".padEnd(20)} ${stamp(`base ${p.model} is ${p.retention}, but ${eff.worst} makes the whole profile ${eff.retention}`)}`)
158
+ }
159
+ if (roleSplitModels(p).length) {
160
+ console.log(`\n ${bold("role models")}`)
161
+ for (const s of roleSplitModels(p)) {
162
+ const cap = s.limits ? ` out ${s.limits.output.toLocaleString("en-US")}` : ""
163
+ console.log(` ${s.role.padEnd(20)} ${s.model}${cap}`)
164
+ }
165
+ }
150
166
 
151
167
  console.log(`\n ${bold("role effort")}`)
152
168
  for (const [role, level] of Object.entries(plan.roles)) {
@@ -358,25 +374,36 @@ async function askChoice(question) {
358
374
  function linkOne(src, dest) {
359
375
  const parent = realpathSync(dirname(dest))
360
376
  const target = relative(parent, realpathSync(src))
361
- let stale = false
377
+
378
+ let isLink = false
379
+ let ours = false
362
380
  try {
363
- stale = readlinkSync(dest) !== target
381
+ isLink = lstatSync(dest).isSymbolicLink()
382
+ ours = isLink && readlinkSync(dest) === target
364
383
  } catch {
365
- stale = true
384
+ // Nothing at `dest` yet.
366
385
  }
367
- if (stale) {
368
- try {
369
- rmSync(dest, { force: true, recursive: false })
370
- } catch {
371
- // Nothing to remove, or it is a real directory we must not delete.
386
+ if (ours) return
387
+
388
+ if (existsSync(dest)) {
389
+ // Replace only what this installer put there. The old code treated "not
390
+ // already our link" as "safe to delete" and called `rmSync` with
391
+ // `recursive: false`, which succeeds on a plain file: a hand-written
392
+ // agent was deleted and replaced by a symlink without a word. A real
393
+ // directory is left alone too -- unlinking it is not this installer's
394
+ // call to make.
395
+ if (!isLink) {
396
+ const kind = lstatSync(dest).isDirectory() ? "directory" : "file"
397
+ console.log(` ${yellow("!")} ${stamp(`left alone: ${dest} is a real ${kind} and was not ours to replace`)}`)
398
+ return
372
399
  }
400
+ rmSync(dest, { force: true })
373
401
  }
374
- if (!existsSync(dest)) {
375
- try {
376
- symlinkSync(target, dest)
377
- } catch (err) {
378
- console.log(` ${yellow("!")} ${stamp(`could not link ${dest}: ${err.message}`)}`)
379
- }
402
+
403
+ try {
404
+ symlinkSync(target, dest)
405
+ } catch (err) {
406
+ console.log(` ${yellow("!")} ${stamp(`could not link ${dest}: ${err.message}`)}`)
380
407
  }
381
408
  }
382
409
 
@@ -405,9 +432,7 @@ function cmdVerify() {
405
432
  // hand-edit anywhere in the model/variant/permission surface shows up
406
433
  // here, which is the check that would have caught `build` being moved onto
407
434
  // a may-train model.
408
- const actual = existsSync(CONFIG_FILE)
409
- ? stripComments(readFileSync(CONFIG_FILE, "utf8"))
410
- : null
435
+ const actual = existsSync(CONFIG_FILE) ? readConfigJson() : null
411
436
  if (!actual) {
412
437
  problems.push(`${CONFIG_FILE} does not exist. Run: oc-harness use ${p.id}`)
413
438
  } else {
@@ -563,7 +588,14 @@ function configDrift(p, actual) {
563
588
  out.push(`agent.${role} missing`)
564
589
  continue
565
590
  }
566
- if (agent.model !== want) out.push(`agent.${role}.model ${agent.model} -> ${want}`)
591
+ // A role's expected model is the one roleModels declares for it, falling
592
+ // back to the base. Comparing every agent against `want` would report
593
+ // drift on every split role forever, which trains the user to ignore the
594
+ // one command that exists to catch a moved `build`.
595
+ const wantRole = `${p.provider}/${p.roleModels?.[role]?.model ?? p.model}`
596
+ if (agent.model !== wantRole) {
597
+ out.push(`agent.${role}.model ${agent.model} -> ${wantRole}`)
598
+ }
567
599
  if (agent.variant !== (level ?? undefined)) {
568
600
  out.push(`agent.${role}.variant ${agent.variant ?? "(none)"} -> ${level ?? "(none)"}`)
569
601
  }
@@ -609,10 +641,7 @@ async function cmdUse(id) {
609
641
  console.error(msg)
610
642
  }
611
643
 
612
- let base = {}
613
- if (existsSync(CONFIG_FILE)) {
614
- base = stripComments(readFileSync(CONFIG_FILE, "utf8"))
615
- }
644
+ const base = readConfigJson()
616
645
 
617
646
  const out = generateConfig(p, { base, comments: true })
618
647
  if (has("--print")) {
@@ -620,11 +649,22 @@ async function cmdUse(id) {
620
649
  return
621
650
  }
622
651
 
652
+ // A flag that is accepted and then ignored is worse than one that errors:
653
+ // `--dryrun` used to write the real config anyway.
654
+ if (has("--dryrun")) {
655
+ console.log(`\n ${stamp("dry run, nothing written")}`)
656
+ console.log(` ${stamp(`would write ${CONFIG_FILE}`)}`)
657
+ summary(p)
658
+ return
659
+ }
660
+
623
661
  mkdirSync(dirname(CONFIG_FILE), { recursive: true })
624
- if (existsSync(CONFIG_FILE) && !has("--yes")) {
625
- // The config is the user's file once written. A backup is made either
626
- // way, but overwriting it without asking is how a hand-tuned setup dies.
627
- if (interactive() && !(await confirm(`overwrite ${CONFIG_FILE}? (a .bak backup is kept)`))) {
662
+ if (existsSync(CONFIG_FILE)) {
663
+ // The config is the user's file once written. The backup is unconditional:
664
+ // `--yes` skips the question, it does not skip the safety net. Gating the
665
+ // backup on `--yes` meant the one path that overwrites without asking was
666
+ // also the one path with nothing to fall back to.
667
+ if (!has("--yes") && interactive() && !(await confirm(`overwrite ${CONFIG_FILE}? (a .bak backup is kept)`))) {
628
668
  fail("left the existing config alone")
629
669
  }
630
670
  writeFileSync(`${CONFIG_FILE}.bak`, readFileSync(CONFIG_FILE))
@@ -632,23 +672,48 @@ async function cmdUse(id) {
632
672
  }
633
673
  writeFileSync(CONFIG_FILE, out)
634
674
  console.log(`\n ${green("✓")} wrote ${CONFIG_FILE}`)
675
+ summary(p)
676
+ console.log(`\n ${stamp("verify with: opencode debug config")}\n`)
677
+ }
678
+
679
+ /** What the profile puts behind the user, including a split and real retention. */
680
+ function summary(p) {
681
+ const eff = effectiveRetention(p)
682
+ const splits = roleSplitModels(p)
635
683
  console.log(` ${stamp(`model ${p.provider}/${p.model}`)}`)
636
684
  console.log(` ${stamp(`effort ${resolveDefaultVariant(p) ?? "(not tunable)"}`)}`)
637
685
  console.log(` ${stamp(`roles ${Object.entries(resolveRoles(p)).map(([k, v]) => `${k}=${v ?? "-"}`).join(" ")}`)}`)
686
+ if (splits.length) {
687
+ // Without this the split is invisible until someone opens the file, which
688
+ // is too late to be told which roles send prompts to a provider that trains.
689
+ console.log(` ${stamp(`split ${splits.map((s) => `${s.role}=${s.model}`).join(" ")}`)}`)
690
+ console.log(` ${stamp(` ${splits.map((s) => s.limits ? `${s.role} out ${s.limits.output.toLocaleString("en-US")}` : null).filter(Boolean).join(" ")}`)}`)
691
+ }
692
+ console.log(` ${stamp(`retention ${eff.retention}${eff.retention === "may-train" ? " <-- prompts may train a model" : ""}`)}`)
693
+ if (splits.length && eff.retention !== p.retention) {
694
+ console.log(` ${stamp(` base ${p.model} is ${p.retention}, but ${eff.worst} makes the whole profile ${eff.retention}`)}`)
695
+ }
638
696
  console.log(` ${stamp(`reserved ${reservedTokens(p).toLocaleString("en-US")} tokens`)}`)
639
- console.log(`\n ${stamp("verify with: opencode debug config")}\n`)
640
697
  }
641
698
 
642
- /** opencode.jsonc is JSONC, so strip comments before parsing. */
643
- function stripComments(text) {
644
- const without = text
645
- .replace(/^\s*\/\/.*$/gm, "")
646
- .replace(/\/\*[\s\S]*?\*\//g, "")
647
- try {
648
- return JSON.parse(without)
649
- } catch {
650
- return {}
699
+ /**
700
+ * Read the installed config as JSONC.
701
+ *
702
+ * Strict on purpose. Returning `{}` for a config we could not parse made
703
+ * `oc-harness use` merge onto an empty base and write the user's own settings
704
+ * back out deleted, with no message. Refusing is the only safe answer here.
705
+ */
706
+ function readConfigJson() {
707
+ if (!existsSync(CONFIG_FILE)) return {}
708
+ const parsed = parseJsonc(readFileSync(CONFIG_FILE, "utf8"))
709
+ if (!parsed) {
710
+ fail(
711
+ `${CONFIG_FILE} is not valid JSONC and was left untouched.\n` +
712
+ ` Refusing to regenerate: the existing settings cannot be read, so merging\n` +
713
+ ` onto them would silently drop them. Fix the file by hand, then re-run.`,
714
+ )
651
715
  }
716
+ return parsed
652
717
  }
653
718
 
654
719
  /** Re-fetch models.dev and report which profiles have drifted from it. */
@@ -790,7 +855,7 @@ async function cmdDoctor() {
790
855
  problems++
791
856
  console.log(` ${yellow("!")} ${id.padEnd(32)} ${yellow("drift")}`)
792
857
  for (const i of issues) console.log(` ${i}`)
793
- console.log(` ${stamp(`fix: oc-harness use ${id} --refresh`)}`)
858
+ console.log(` ${stamp(`fix: oc-harness refresh ${id}`)}`)
794
859
  }
795
860
  }
796
861
  console.log(
@@ -810,7 +875,7 @@ async function cmdDoctor() {
810
875
  console.log(` ${yellow("!")} ${stamp("no profile recorded in the config. Run: oc-harness use <profile>")}\n`)
811
876
  return
812
877
  }
813
- const installed = existsSync(CONFIG_FILE) ? stripComments(readFileSync(CONFIG_FILE, "utf8")) : null
878
+ const installed = existsSync(CONFIG_FILE) ? readConfigJson() : null
814
879
  if (!installed) {
815
880
  console.log(` ${yellow("!")} ${stamp(`${CONFIG_FILE} missing. Run: oc-harness use ${activeId}`)}\n`)
816
881
  return
@@ -860,7 +925,7 @@ function cmdAgents() {
860
925
  fail("no profile is installed. Run: oc-harness use <profile>")
861
926
  }
862
927
 
863
- const config = existsSync(CONFIG_FILE) ? stripComments(readFileSync(CONFIG_FILE, "utf8")) : {}
928
+ const config = readConfigJson()
864
929
  const configured = config.model ?? "(none)"
865
930
  const wantModel = `${current.provider}/${current.model}`
866
931
 
@@ -912,6 +977,32 @@ function fail(msg) {
912
977
  process.exit(1)
913
978
  }
914
979
 
980
+ /**
981
+ * Drive the task file to done without a human at the keyboard.
982
+ *
983
+ * Every stop condition is printed, and the exit code says which kind of stop it
984
+ * was: 0 finished, 1 nothing to plan, 2 needs a person, 3 the agent died,
985
+ * 4 verify failed. A caller can therefore tell "done" from "gave up" without
986
+ * reading the transcript.
987
+ */
988
+ async function cmdRun() {
989
+ const { runLoop } = await import("../src/driver/run.mjs")
990
+
991
+ const result = runLoop({
992
+ cwd: process.cwd(),
993
+ role: opt("--role") ?? "build",
994
+ maxRounds: Number(opt("--max-rounds") ?? 20),
995
+ budgetMinutes: Number(opt("--budget") ?? 120),
996
+ })
997
+
998
+ const s = result.summary
999
+ console.log(`\n ${bold(result.code === 0 ? "done" : "stopped")} ${result.reason}`)
1000
+ if (s) {
1001
+ console.log(` ${stamp(`${s.done} done, ${s.blocked} blocked, ${s.pending} pending`)}`)
1002
+ }
1003
+ if (result.code !== 0) process.exitCode = result.code
1004
+ }
1005
+
915
1006
  try {
916
1007
  switch (cmd) {
917
1008
  case "list":
@@ -947,6 +1038,9 @@ try {
947
1038
  case "baseline":
948
1039
  await import("./baseline.mjs")
949
1040
  break
1041
+ case "run":
1042
+ await cmdRun()
1043
+ break
950
1044
  case "help":
951
1045
  default:
952
1046
  console.log(`
@@ -963,13 +1057,25 @@ try {
963
1057
  agents report agents the effort tiering does not reach
964
1058
  doctor re-check profiles against models.dev and the installed config
965
1059
  baseline measure the current opencode install
1060
+ run drive .space/tasks.md to done with the build agent
966
1061
 
967
1062
  options
968
1063
  --print write the config to stdout instead of disk
969
1064
  --yes accept a retention warning, back up the old config
970
1065
  --retention filter list by retention class
971
1066
  --free list free profiles only
1067
+
1068
+ run options
1069
+ --role <agent> which agent does the work (default: build)
1070
+ --max-rounds N stop after N tasks (default: 20)
1071
+ --budget MIN stop after MIN minutes (default: 120)
972
1072
  `)
1073
+ // An unknown command is a typo, not a request for help. Without this
1074
+ // `oc-harness tsl` prints help and exits 0, so a script calling it never
1075
+ // finds out that nothing ran.
1076
+ if (cmd !== "help" && cmd !== "--help" && cmd !== "-h") {
1077
+ fail(`unknown command: ${cmd}\n run 'oc-harness help' for the list`)
1078
+ }
973
1079
  }
974
1080
  } catch (err) {
975
1081
  if (err instanceof ProfileError) fail(err.message)