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 +1 -1
- package/README.md +90 -10
- package/agents/build.md +2 -2
- package/bin/oc-harness.mjs +148 -42
- package/dist/index.js +197 -64
- package/package.json +8 -6
- package/profiles/index.json +18 -0
- package/profiles/muse-split-free.json +64 -0
- package/profiles/schema.json +29 -0
- package/profiles/space-bunny-free.json +1 -1
- package/skills/space-finish/SKILL.md +4 -14
- package/src/config.js +40 -8
- package/src/driver/run.mjs +237 -0
- package/src/profile.js +269 -14
- package/src/rules.js +3 -18
- package/src/tasks.js +248 -0
package/GLOBAL.md
CHANGED
|
@@ -54,7 +54,7 @@ to avoid this.
|
|
|
54
54
|
|
|
55
55
|
## Never
|
|
56
56
|
|
|
57
|
-
- `git push`, publish, or
|
|
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
|
-
[](https://github.com/pham-canh/space-harness/actions/workflows/ci.yml)
|
|
4
4
|
[](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
|
|
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/
|
|
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,
|
|
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 &&
|
|
239
|
-
caught, while `git log --grep push` and `
|
|
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
|
-
|
|
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/
|
|
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:
|
|
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
|
-
-
|
|
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.
|
package/bin/oc-harness.mjs
CHANGED
|
@@ -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()[
|
|
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
|
-
|
|
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
|
-
|
|
377
|
+
|
|
378
|
+
let isLink = false
|
|
379
|
+
let ours = false
|
|
362
380
|
try {
|
|
363
|
-
|
|
381
|
+
isLink = lstatSync(dest).isSymbolicLink()
|
|
382
|
+
ours = isLink && readlinkSync(dest) === target
|
|
364
383
|
} catch {
|
|
365
|
-
|
|
384
|
+
// Nothing at `dest` yet.
|
|
366
385
|
}
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
625
|
-
|
|
626
|
-
//
|
|
627
|
-
|
|
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
|
-
/**
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
|
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) ?
|
|
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 =
|
|
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)
|