opencode-harness-kit 0.2.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/README.md +47 -10
- package/agents/build.md +1 -1
- package/bin/oc-harness.mjs +97 -39
- package/dist/index.js +75 -4
- package/package.json +3 -5
- package/profiles/space-bunny-free.json +1 -1
- package/skills/space-finish/SKILL.md +0 -10
- package/src/driver/run.mjs +237 -0
- package/src/profile.js +113 -9
- package/src/tasks.js +248 -0
- package/docs/demo/demo.gif +0 -0
- package/docs/demo/demo.mp4 +0 -0
- package/docs/demo/render.py +0 -146
package/README.md
CHANGED
|
@@ -3,13 +3,26 @@
|
|
|
3
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)):
|
|
@@ -19,11 +32,6 @@ git clone https://github.com/pham-canh/space-harness.git
|
|
|
19
32
|
./bin/oc-harness.mjs init
|
|
20
33
|
```
|
|
21
34
|
|
|
22
|
-

|
|
23
|
-
|
|
24
|
-
Regenerate it any time with `python3 docs/demo/render.py` (needs ffmpeg); the
|
|
25
|
-
frames are drawn from real CLI output, not typed by hand.
|
|
26
|
-
|
|
27
35
|
The three steps `init` runs, spelled out for when you want to know what
|
|
28
36
|
happened (prefix with `npx` or `./bin/oc-harness.mjs` to taste):
|
|
29
37
|
|
|
@@ -52,7 +60,7 @@ profiles/space-bunny-free.json ← the model
|
|
|
52
60
|
src/ ← no model name anywhere in here
|
|
53
61
|
```
|
|
54
62
|
|
|
55
|
-
Same plugin source,
|
|
63
|
+
Same plugin source, five shipped profiles, five different behaviours. The test
|
|
56
64
|
suite asserts that rather than taking it on faith.
|
|
57
65
|
|
|
58
66
|
## What the compiler derives
|
|
@@ -159,20 +167,41 @@ three of its five roles run on Muse Spark, a Meta contributor-tier endpoint whos
|
|
|
159
167
|
provider may train on your prompts. Install it for public or throwaway
|
|
160
168
|
repositories.
|
|
161
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
|
+
|
|
162
185
|
## Commands
|
|
163
186
|
|
|
164
187
|
```
|
|
165
188
|
oc-harness list profiles with retention and caveats
|
|
166
|
-
oc-harness use <id> compile a profile into ~/.config/opencode/opencode.jsonc
|
|
167
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
|
|
168
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
|
|
169
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
|
|
170
196
|
oc-harness verify check the install against the profile; non-zero on drift
|
|
171
197
|
oc-harness agents report agents the effort tiering does not reach
|
|
172
198
|
oc-harness doctor re-check profiles against models.dev and the live config
|
|
173
199
|
oc-harness baseline measure the current install
|
|
174
200
|
```
|
|
175
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
|
+
|
|
176
205
|
### `verify` is the command that matters
|
|
177
206
|
|
|
178
207
|
A generated config is only half an install. The other half is the instruction
|
|
@@ -333,8 +362,7 @@ pkg install nodejs git
|
|
|
333
362
|
npx -y opencode-harness-kit init
|
|
334
363
|
```
|
|
335
364
|
|
|
336
|
-
|
|
337
|
-
identically:
|
|
365
|
+
Without registry access, the checkout path works identically:
|
|
338
366
|
|
|
339
367
|
```bash
|
|
340
368
|
pkg install nodejs git
|
|
@@ -460,6 +488,15 @@ Measured results, and the limits they do not cross, are in
|
|
|
460
488
|
`docs/RESEARCH.md` records where the numbers came from and what was tried and
|
|
461
489
|
rejected.
|
|
462
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
|
+
|
|
463
500
|
## License
|
|
464
501
|
|
|
465
502
|
MIT
|
package/agents/build.md
CHANGED
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"
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
resolveDefaultVariant,
|
|
33
33
|
resolveRoles,
|
|
34
34
|
ProfileError,
|
|
35
|
+
parseJsonc,
|
|
35
36
|
} from "../src/profile.js"
|
|
36
37
|
import { generateConfig, globalInstructionsPath } from "../src/config.js"
|
|
37
38
|
import { planEffort } from "../src/effort.js"
|
|
@@ -134,7 +135,7 @@ function cmdShow(id) {
|
|
|
134
135
|
if (!id) fail("usage: oc-harness show <profile>")
|
|
135
136
|
const p = loadProfile(id)
|
|
136
137
|
const plan = planEffort(id)
|
|
137
|
-
const retention = loadRetention()[
|
|
138
|
+
const retention = loadRetention()[`${p.provider}/${p.model}`]
|
|
138
139
|
|
|
139
140
|
console.log(`\n${bold(p.id)} ${stamp(p.title ?? "")}\n`)
|
|
140
141
|
const row = (k, v) => console.log(` ${k.padEnd(20)} ${v}`)
|
|
@@ -373,25 +374,36 @@ async function askChoice(question) {
|
|
|
373
374
|
function linkOne(src, dest) {
|
|
374
375
|
const parent = realpathSync(dirname(dest))
|
|
375
376
|
const target = relative(parent, realpathSync(src))
|
|
376
|
-
|
|
377
|
+
|
|
378
|
+
let isLink = false
|
|
379
|
+
let ours = false
|
|
377
380
|
try {
|
|
378
|
-
|
|
381
|
+
isLink = lstatSync(dest).isSymbolicLink()
|
|
382
|
+
ours = isLink && readlinkSync(dest) === target
|
|
379
383
|
} catch {
|
|
380
|
-
|
|
384
|
+
// Nothing at `dest` yet.
|
|
381
385
|
}
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
387
399
|
}
|
|
400
|
+
rmSync(dest, { force: true })
|
|
388
401
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
symlinkSync(target, dest)
|
|
405
|
+
} catch (err) {
|
|
406
|
+
console.log(` ${yellow("!")} ${stamp(`could not link ${dest}: ${err.message}`)}`)
|
|
395
407
|
}
|
|
396
408
|
}
|
|
397
409
|
|
|
@@ -420,9 +432,7 @@ function cmdVerify() {
|
|
|
420
432
|
// hand-edit anywhere in the model/variant/permission surface shows up
|
|
421
433
|
// here, which is the check that would have caught `build` being moved onto
|
|
422
434
|
// a may-train model.
|
|
423
|
-
const actual = existsSync(CONFIG_FILE)
|
|
424
|
-
? stripComments(readFileSync(CONFIG_FILE, "utf8"))
|
|
425
|
-
: null
|
|
435
|
+
const actual = existsSync(CONFIG_FILE) ? readConfigJson() : null
|
|
426
436
|
if (!actual) {
|
|
427
437
|
problems.push(`${CONFIG_FILE} does not exist. Run: oc-harness use ${p.id}`)
|
|
428
438
|
} else {
|
|
@@ -631,10 +641,7 @@ async function cmdUse(id) {
|
|
|
631
641
|
console.error(msg)
|
|
632
642
|
}
|
|
633
643
|
|
|
634
|
-
|
|
635
|
-
if (existsSync(CONFIG_FILE)) {
|
|
636
|
-
base = stripComments(readFileSync(CONFIG_FILE, "utf8"))
|
|
637
|
-
}
|
|
644
|
+
const base = readConfigJson()
|
|
638
645
|
|
|
639
646
|
const out = generateConfig(p, { base, comments: true })
|
|
640
647
|
if (has("--print")) {
|
|
@@ -652,10 +659,12 @@ async function cmdUse(id) {
|
|
|
652
659
|
}
|
|
653
660
|
|
|
654
661
|
mkdirSync(dirname(CONFIG_FILE), { recursive: true })
|
|
655
|
-
if (existsSync(CONFIG_FILE)
|
|
656
|
-
|
|
657
|
-
//
|
|
658
|
-
|
|
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)`))) {
|
|
659
668
|
fail("left the existing config alone")
|
|
660
669
|
}
|
|
661
670
|
writeFileSync(`${CONFIG_FILE}.bak`, readFileSync(CONFIG_FILE))
|
|
@@ -687,16 +696,24 @@ function summary(p) {
|
|
|
687
696
|
console.log(` ${stamp(`reserved ${reservedTokens(p).toLocaleString("en-US")} tokens`)}`)
|
|
688
697
|
}
|
|
689
698
|
|
|
690
|
-
/**
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
+
)
|
|
699
715
|
}
|
|
716
|
+
return parsed
|
|
700
717
|
}
|
|
701
718
|
|
|
702
719
|
/** Re-fetch models.dev and report which profiles have drifted from it. */
|
|
@@ -838,7 +855,7 @@ async function cmdDoctor() {
|
|
|
838
855
|
problems++
|
|
839
856
|
console.log(` ${yellow("!")} ${id.padEnd(32)} ${yellow("drift")}`)
|
|
840
857
|
for (const i of issues) console.log(` ${i}`)
|
|
841
|
-
console.log(` ${stamp(`fix: oc-harness
|
|
858
|
+
console.log(` ${stamp(`fix: oc-harness refresh ${id}`)}`)
|
|
842
859
|
}
|
|
843
860
|
}
|
|
844
861
|
console.log(
|
|
@@ -858,7 +875,7 @@ async function cmdDoctor() {
|
|
|
858
875
|
console.log(` ${yellow("!")} ${stamp("no profile recorded in the config. Run: oc-harness use <profile>")}\n`)
|
|
859
876
|
return
|
|
860
877
|
}
|
|
861
|
-
const installed = existsSync(CONFIG_FILE) ?
|
|
878
|
+
const installed = existsSync(CONFIG_FILE) ? readConfigJson() : null
|
|
862
879
|
if (!installed) {
|
|
863
880
|
console.log(` ${yellow("!")} ${stamp(`${CONFIG_FILE} missing. Run: oc-harness use ${activeId}`)}\n`)
|
|
864
881
|
return
|
|
@@ -908,7 +925,7 @@ function cmdAgents() {
|
|
|
908
925
|
fail("no profile is installed. Run: oc-harness use <profile>")
|
|
909
926
|
}
|
|
910
927
|
|
|
911
|
-
const config =
|
|
928
|
+
const config = readConfigJson()
|
|
912
929
|
const configured = config.model ?? "(none)"
|
|
913
930
|
const wantModel = `${current.provider}/${current.model}`
|
|
914
931
|
|
|
@@ -960,6 +977,32 @@ function fail(msg) {
|
|
|
960
977
|
process.exit(1)
|
|
961
978
|
}
|
|
962
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
|
+
|
|
963
1006
|
try {
|
|
964
1007
|
switch (cmd) {
|
|
965
1008
|
case "list":
|
|
@@ -995,6 +1038,9 @@ try {
|
|
|
995
1038
|
case "baseline":
|
|
996
1039
|
await import("./baseline.mjs")
|
|
997
1040
|
break
|
|
1041
|
+
case "run":
|
|
1042
|
+
await cmdRun()
|
|
1043
|
+
break
|
|
998
1044
|
case "help":
|
|
999
1045
|
default:
|
|
1000
1046
|
console.log(`
|
|
@@ -1011,13 +1057,25 @@ try {
|
|
|
1011
1057
|
agents report agents the effort tiering does not reach
|
|
1012
1058
|
doctor re-check profiles against models.dev and the installed config
|
|
1013
1059
|
baseline measure the current opencode install
|
|
1060
|
+
run drive .space/tasks.md to done with the build agent
|
|
1014
1061
|
|
|
1015
1062
|
options
|
|
1016
1063
|
--print write the config to stdout instead of disk
|
|
1017
1064
|
--yes accept a retention warning, back up the old config
|
|
1018
1065
|
--retention filter list by retention class
|
|
1019
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)
|
|
1020
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
|
+
}
|
|
1021
1079
|
}
|
|
1022
1080
|
} catch (err) {
|
|
1023
1081
|
if (err instanceof ProfileError) fail(err.message)
|
package/dist/index.js
CHANGED
|
@@ -564,16 +564,87 @@ function configFilePath() {
|
|
|
564
564
|
const dir = process.env.OPENCODE_CONFIG_DIR ?? join(process.env.HOME ?? "", ".config", "opencode");
|
|
565
565
|
return join(dir, "opencode.jsonc");
|
|
566
566
|
}
|
|
567
|
+
function parseJsonc(text) {
|
|
568
|
+
let out = "";
|
|
569
|
+
let i = 0;
|
|
570
|
+
let inString = false;
|
|
571
|
+
let escaped = false;
|
|
572
|
+
let pendingComma = false;
|
|
573
|
+
const n = text.length;
|
|
574
|
+
while (i < n) {
|
|
575
|
+
const ch = text[i];
|
|
576
|
+
if (inString) {
|
|
577
|
+
out += ch;
|
|
578
|
+
if (escaped) escaped = false;
|
|
579
|
+
else if (ch === "\\") escaped = true;
|
|
580
|
+
else if (ch === '"') inString = false;
|
|
581
|
+
i++;
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (ch === '"') {
|
|
585
|
+
if (pendingComma) {
|
|
586
|
+
out += ",";
|
|
587
|
+
pendingComma = false;
|
|
588
|
+
}
|
|
589
|
+
out += ch;
|
|
590
|
+
inString = true;
|
|
591
|
+
i++;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
595
|
+
while (i < n && text[i] !== "\n") i++;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
599
|
+
i += 2;
|
|
600
|
+
while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
|
601
|
+
i += 2;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (ch === ",") {
|
|
605
|
+
if (pendingComma) return null;
|
|
606
|
+
pendingComma = true;
|
|
607
|
+
i++;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
|
|
611
|
+
out += ch;
|
|
612
|
+
i++;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
if (ch === "}" || ch === "]") {
|
|
616
|
+
pendingComma = false;
|
|
617
|
+
out += ch;
|
|
618
|
+
i++;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (pendingComma) {
|
|
622
|
+
out += ",";
|
|
623
|
+
pendingComma = false;
|
|
624
|
+
}
|
|
625
|
+
out += ch;
|
|
626
|
+
i++;
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
const parsed = JSON.parse(out);
|
|
630
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
631
|
+
} catch {
|
|
632
|
+
if (!out.trim()) return {};
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
567
636
|
function profileIdFromConfig() {
|
|
568
637
|
const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
|
|
569
638
|
if (fromEnv) return fromEnv.trim() || null;
|
|
570
639
|
const file = configFilePath();
|
|
571
640
|
if (!existsSync(file)) return null;
|
|
572
641
|
try {
|
|
573
|
-
const
|
|
574
|
-
const
|
|
575
|
-
const
|
|
576
|
-
|
|
642
|
+
const parsed = parseJsonc(readFileSync(file, "utf8"));
|
|
643
|
+
const harness = parsed?.harness;
|
|
644
|
+
const id = harness && typeof harness === "object" ? (
|
|
645
|
+
/** @type {{profile?: unknown}} */
|
|
646
|
+
harness.profile
|
|
647
|
+
) : null;
|
|
577
648
|
return typeof id === "string" && id ? id : null;
|
|
578
649
|
} catch {
|
|
579
650
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-harness-kit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Model-agnostic harness kit for OpenCode: capability profiles, role-based reasoning effort, context pruning, and measured baselines.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/pham-canh/space-harness#readme",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"GLOBAL.md",
|
|
38
38
|
"agents/",
|
|
39
39
|
"skills/",
|
|
40
|
-
"docs
|
|
40
|
+
"docs/*.md",
|
|
41
41
|
"README.md",
|
|
42
42
|
"LICENSE"
|
|
43
43
|
],
|
|
@@ -75,9 +75,7 @@
|
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"@opencode-ai/plugin": "^1.18.31",
|
|
77
77
|
"@types/node": "^22.0.0",
|
|
78
|
+
"esbuild": "^0.28.2",
|
|
78
79
|
"typescript": "^5.6.0"
|
|
79
|
-
},
|
|
80
|
-
"dependencies": {
|
|
81
|
-
"esbuild": "^0.28.2"
|
|
82
80
|
}
|
|
83
81
|
}
|
|
@@ -27,16 +27,6 @@ over an unreviewed or blocked change is the failure this skill exists to stop.
|
|
|
27
27
|
config or profile, run its verify command. A hand-edit survives a regeneration
|
|
28
28
|
silently, and that is how a zero-retention claim ends up running on a
|
|
29
29
|
may-train model.
|
|
30
|
-
2. **Read the diff once more as a whole.** Reviewers read task by task; you
|
|
31
|
-
read the change as someone who has never seen it will.
|
|
32
|
-
3. **Check the documentation.** Did this change make a document describe
|
|
33
|
-
something that no longer exists? Fix the document, do not note it for later.
|
|
34
|
-
4. **Write the summary** a person needs in order to trust the change without
|
|
35
|
-
reading the whole diff.
|
|
36
|
-
5. **Check the install is still whole.** If this project is driven by a generated
|
|
37
|
-
config or profile, run its verify command. A hand-edit survives a regeneration
|
|
38
|
-
silently, and that is how a zero-retention claim ends up running on a
|
|
39
|
-
may-train model.
|
|
40
30
|
|
|
41
31
|
## Summary shape
|
|
42
32
|
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `oc-harness run` -- drive the space-plan -> build -> review -> finish loop
|
|
3
|
+
// without a human at the keyboard.
|
|
4
|
+
//
|
|
5
|
+
// The four skills already know how to do their own job. What was missing was
|
|
6
|
+
// the thing that decides *which* one runs next and *when to stop*. That is
|
|
7
|
+
// here, and it is deliberately small: it reads `.space/tasks.md`, hands one
|
|
8
|
+
// task at a time to `opencode run --agent <role>`, records the real verify
|
|
9
|
+
// output, and stops.
|
|
10
|
+
//
|
|
11
|
+
// What it will not do, ever:
|
|
12
|
+
// - push, open a PR, or cut a release. Publishing is a human decision, and an
|
|
13
|
+
// unattended loop that can do it is an unattended loop that will.
|
|
14
|
+
// - retry a task it already blocked, or start a task with no verify command.
|
|
15
|
+
// A task it cannot check is a task it cannot claim is done.
|
|
16
|
+
// - loop forever. Every stop condition is explicit and every one of them is
|
|
17
|
+
// reported, so a run that ends is never a mystery.
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from "node:child_process"
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
21
|
+
import { join } from "node:path"
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
parseTasks,
|
|
25
|
+
nextPending,
|
|
26
|
+
markTask,
|
|
27
|
+
taskPrompt,
|
|
28
|
+
summarize,
|
|
29
|
+
isBlocked,
|
|
30
|
+
} from "../tasks.js"
|
|
31
|
+
|
|
32
|
+
const TASKS_PATH = join(".space", "tasks.md")
|
|
33
|
+
|
|
34
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`
|
|
35
|
+
const stamp = (s) => `\x1b[2m${s}\x1b[0m`
|
|
36
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`
|
|
37
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`
|
|
38
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run one command to completion and report what happened.
|
|
42
|
+
*
|
|
43
|
+
* Never throws: the caller has to be able to write the failure into the task
|
|
44
|
+
* file, which is the only record anyone gets of why a task did not land.
|
|
45
|
+
*/
|
|
46
|
+
export function sh(command, { cwd, timeoutMs = 900_000 } = {}) {
|
|
47
|
+
const started = Date.now()
|
|
48
|
+
const res = spawnSync(command, {
|
|
49
|
+
shell: true,
|
|
50
|
+
cwd,
|
|
51
|
+
timeout: timeoutMs,
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
54
|
+
env: { ...process.env, NO_COLOR: process.env.NO_COLOR ?? "" },
|
|
55
|
+
})
|
|
56
|
+
const stdout = `${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd()
|
|
57
|
+
return {
|
|
58
|
+
ok: res.status === 0,
|
|
59
|
+
status: res.status,
|
|
60
|
+
// A spawn that never started has no stderr; say so instead of implying the
|
|
61
|
+
// command ran and said nothing.
|
|
62
|
+
output: stdout || (res.error ? String(res.error.message) : ""),
|
|
63
|
+
seconds: Math.round((Date.now() - started) / 1000),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The tail of a verify run -- the last few lines are the verdict. */
|
|
68
|
+
export function tail(text, n = 12) {
|
|
69
|
+
const lines = String(text).split("\n").filter((l) => l.trim())
|
|
70
|
+
return lines.slice(-n).join("\n")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Ask `opencode` to do the task. Returns the transcript so a human can read what
|
|
75
|
+
* the agent actually did rather than being told about it.
|
|
76
|
+
*/
|
|
77
|
+
export function invokeAgent(role, prompt, { cwd, timeoutMs }) {
|
|
78
|
+
const res = spawnSync(
|
|
79
|
+
"opencode",
|
|
80
|
+
["run", "--agent", role, "--format", "json", prompt],
|
|
81
|
+
{
|
|
82
|
+
cwd,
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
timeout: timeoutMs,
|
|
85
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
return {
|
|
89
|
+
ok: res.status === 0,
|
|
90
|
+
status: res.status,
|
|
91
|
+
output: `${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd(),
|
|
92
|
+
error: res.error ? String(res.error.message) : "",
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The agent's own instructions for one task, plus what the loop expects back. */
|
|
97
|
+
export function buildPrompt(task) {
|
|
98
|
+
return [
|
|
99
|
+
`You are the space-build agent. Do exactly this one task and stop.`,
|
|
100
|
+
``,
|
|
101
|
+
taskPrompt(task),
|
|
102
|
+
``,
|
|
103
|
+
task.verify
|
|
104
|
+
? `When the change is done, run this command yourself: ${task.verify}`
|
|
105
|
+
: `There is no verify command for this task. Do the work, say what you changed, and stop.`,
|
|
106
|
+
``,
|
|
107
|
+
`Rules for this run:`,
|
|
108
|
+
` - Change only the files named above. If the task is wrong or blocked, say so and stop.`,
|
|
109
|
+
` - Never run git push, never create a PR, never publish. A human does that.`,
|
|
110
|
+
` - Report the real output of the verify command. Never invent a passing result.`,
|
|
111
|
+
].join("\n")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run the loop.
|
|
116
|
+
*
|
|
117
|
+
* Stops at the first of: nothing pending, nothing verifiable, a blocked task, a
|
|
118
|
+
* failing verify, the round cap, or the time budget. Whichever it was gets
|
|
119
|
+
* printed, so a run that stops early always says why.
|
|
120
|
+
*/
|
|
121
|
+
export function runLoop({
|
|
122
|
+
cwd = process.cwd(),
|
|
123
|
+
role = "build",
|
|
124
|
+
maxRounds = 20,
|
|
125
|
+
budgetMinutes = 120,
|
|
126
|
+
runAgent = invokeAgent,
|
|
127
|
+
runVerify = sh,
|
|
128
|
+
} = {}) {
|
|
129
|
+
const started = Date.now()
|
|
130
|
+
const budgetMs = budgetMinutes * 60_000
|
|
131
|
+
const log = []
|
|
132
|
+
|
|
133
|
+
if (!existsSync(join(cwd, TASKS_PATH))) {
|
|
134
|
+
return stop(log, {
|
|
135
|
+
reason: `no task file at ${TASKS_PATH}. Run space-plan first -- this loop needs a plan to walk.`,
|
|
136
|
+
code: 1,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let text = readFileSync(join(cwd, TASKS_PATH), "utf8")
|
|
141
|
+
let rounds = 0
|
|
142
|
+
|
|
143
|
+
for (;;) {
|
|
144
|
+
const parsed = parseTasks(text)
|
|
145
|
+
const task = nextPending(parsed)
|
|
146
|
+
|
|
147
|
+
if (!task) {
|
|
148
|
+
const s = summarize(parsed)
|
|
149
|
+
return stop(log, {
|
|
150
|
+
reason:
|
|
151
|
+
s.blocked > 0
|
|
152
|
+
? `nothing left to run: ${s.done} done, ${s.blocked} blocked.`
|
|
153
|
+
: `every task is done (${s.done}/${s.total}).`,
|
|
154
|
+
code: s.blocked > 0 ? 2 : 0,
|
|
155
|
+
summary: s,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (isBlocked(task)) {
|
|
160
|
+
return stop(log, { reason: `task is blocked and needs a person: ${task.title}`, code: 2 })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!task.verify) {
|
|
164
|
+
text = markTask(text, task, {
|
|
165
|
+
status: "blocked",
|
|
166
|
+
verifyOutput: "blocked by the loop: this task has no verify command, so nothing could prove it finished.",
|
|
167
|
+
})
|
|
168
|
+
writeFileSync(join(cwd, TASKS_PATH), text)
|
|
169
|
+
return stop(log, {
|
|
170
|
+
reason: `"${task.title}" has no verify command. It cannot be checked, so it cannot be run unattended. Add one, or do it yourself.`,
|
|
171
|
+
code: 2,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
rounds++
|
|
176
|
+
if (rounds > maxRounds) {
|
|
177
|
+
return stop(log, { reason: `stopped at the round cap (${maxRounds}). Raise it with --max-rounds, or run fewer tasks.`, code: 2 })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const elapsed = Date.now() - started
|
|
181
|
+
if (elapsed >= budgetMs) {
|
|
182
|
+
return stop(log, { reason: `stopped at the time budget (${budgetMinutes} min, ${Math.round(elapsed / 60000)} min used).`, code: 2 })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
console.log(`\n ${bold(`[${rounds}]`)} ${task.title}`)
|
|
186
|
+
if (task.files) console.log(` ${stamp(task.files)}`)
|
|
187
|
+
|
|
188
|
+
const agent = runAgent(role, buildPrompt(task), {
|
|
189
|
+
cwd,
|
|
190
|
+
timeoutMs: Math.max(60_000, budgetMs - (Date.now() - started)),
|
|
191
|
+
})
|
|
192
|
+
if (!agent.ok) {
|
|
193
|
+
const why = agent.error || agent.output || `exit ${agent.status}`
|
|
194
|
+
text = markTask(text, task, {
|
|
195
|
+
status: "blocked",
|
|
196
|
+
verifyOutput: `blocked by the loop: the ${role} agent failed.\n${tail(why)}`,
|
|
197
|
+
})
|
|
198
|
+
writeFileSync(join(cwd, TASKS_PATH), text)
|
|
199
|
+
return stop(log, {
|
|
200
|
+
reason: `the ${role} agent failed on "${task.title}": ${tail(why, 3).split("\n")[0]}`,
|
|
201
|
+
code: 3,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The agent's claim is not the evidence. Re-run the verify here, because a
|
|
206
|
+
// loop that trusts the agent's own word about whether it passed is a loop
|
|
207
|
+
// that reports success it never checked.
|
|
208
|
+
console.log(` ${stamp(`verify: ${task.verify}`)}`)
|
|
209
|
+
const check = runVerify(task.verify, { cwd, timeoutMs: 1800_000 })
|
|
210
|
+
log.push({ round: rounds, task: task.title, ok: check.ok, seconds: check.seconds })
|
|
211
|
+
|
|
212
|
+
if (!check.ok) {
|
|
213
|
+
text = markTask(text, task, {
|
|
214
|
+
status: "blocked",
|
|
215
|
+
verifyOutput: `verify failed (exit ${check.status}, ${check.seconds}s)\n${tail(check.output)}`,
|
|
216
|
+
})
|
|
217
|
+
writeFileSync(join(cwd, TASKS_PATH), text)
|
|
218
|
+
return stop(log, {
|
|
219
|
+
reason: `verify failed on "${task.title}". Marked blocked -- the real output is in ${TASKS_PATH}.`,
|
|
220
|
+
code: 4,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
text = markTask(text, task, {
|
|
225
|
+
status: "done",
|
|
226
|
+
verifyOutput: `exit ${check.status} in ${check.seconds}s\n${tail(check.output)}`,
|
|
227
|
+
})
|
|
228
|
+
writeFileSync(join(cwd, TASKS_PATH), text)
|
|
229
|
+
console.log(` ${green("done")} ${stamp(`${check.seconds}s`)}`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function stop(log, { reason, code, summary }) {
|
|
234
|
+
return { reason, code: code ?? 0, rounds: log.length, log, summary }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export { TASKS_PATH }
|
package/src/profile.js
CHANGED
|
@@ -455,14 +455,118 @@ export function configFilePath() {
|
|
|
455
455
|
return join(dir, "opencode.jsonc")
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
/**
|
|
459
|
+
* Parse JSONC the way OpenCode does: comments and trailing commas are legal.
|
|
460
|
+
*
|
|
461
|
+
* A regex that strips `//` cannot be used here. The generated config carries
|
|
462
|
+
* `"$schema": "https://opencode.ai/config.json"`, so a naive strip corrupts the
|
|
463
|
+
* URL; and a trailing comma -- ordinary in a hand-edited `.jsonc` -- makes
|
|
464
|
+
* `JSON.parse` throw, which used to surface as an empty object and silently
|
|
465
|
+
* dropped every hand-tuned key in `oc-harness use`. This walks the text once,
|
|
466
|
+
* tracking string state, so both are handled correctly.
|
|
467
|
+
*
|
|
468
|
+
* @param {string} text
|
|
469
|
+
* @returns {Record<string, unknown> | null} null when the text is not valid JSONC
|
|
470
|
+
*/
|
|
471
|
+
export function parseJsonc(text) {
|
|
472
|
+
let out = ""
|
|
473
|
+
let i = 0
|
|
474
|
+
let inString = false
|
|
475
|
+
let escaped = false
|
|
476
|
+
/** True once a `,` has been emitted that a `}` or `]` may turn out to cancel. */
|
|
477
|
+
let pendingComma = false
|
|
478
|
+
|
|
479
|
+
const n = text.length
|
|
480
|
+
while (i < n) {
|
|
481
|
+
const ch = text[i]
|
|
482
|
+
|
|
483
|
+
if (inString) {
|
|
484
|
+
out += ch
|
|
485
|
+
if (escaped) escaped = false
|
|
486
|
+
else if (ch === "\\") escaped = true
|
|
487
|
+
else if (ch === '"') inString = false
|
|
488
|
+
i++
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (ch === '"') {
|
|
493
|
+
if (pendingComma) {
|
|
494
|
+
out += ","
|
|
495
|
+
pendingComma = false
|
|
496
|
+
}
|
|
497
|
+
out += ch
|
|
498
|
+
inString = true
|
|
499
|
+
i++
|
|
500
|
+
continue
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
504
|
+
while (i < n && text[i] !== "\n") i++
|
|
505
|
+
continue
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
509
|
+
i += 2
|
|
510
|
+
while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++
|
|
511
|
+
i += 2
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (ch === ",") {
|
|
516
|
+
// A second comma before any value is malformed. Repairing `,,,` into one
|
|
517
|
+
// separator would mean guessing at a file we are about to rewrite, which
|
|
518
|
+
// is the exact failure this parser exists to prevent.
|
|
519
|
+
if (pendingComma) return null
|
|
520
|
+
// Hold the comma back: if the next significant character closes an
|
|
521
|
+
// object or array, this comma was illegal trailing punctuation.
|
|
522
|
+
pendingComma = true
|
|
523
|
+
i++
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
|
|
528
|
+
out += ch
|
|
529
|
+
i++
|
|
530
|
+
continue
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (ch === "}" || ch === "]") {
|
|
534
|
+
// A closer cancels any comma held back before it.
|
|
535
|
+
pendingComma = false
|
|
536
|
+
out += ch
|
|
537
|
+
i++
|
|
538
|
+
continue
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Every other significant character settles a comma held back across
|
|
542
|
+
// whitespace or a comment. Miss this on the string branch below and a
|
|
543
|
+
// comment after `1, // note` swallows the separator entirely.
|
|
544
|
+
if (pendingComma) {
|
|
545
|
+
out += ","
|
|
546
|
+
pendingComma = false
|
|
547
|
+
}
|
|
548
|
+
out += ch
|
|
549
|
+
i++
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
const parsed = JSON.parse(out)
|
|
554
|
+
return parsed && typeof parsed === "object" ? parsed : null
|
|
555
|
+
} catch {
|
|
556
|
+
// An empty or comments-only file is not malformed JSONC, it is an empty
|
|
557
|
+
// config, and `oc-harness use` has to be able to fill one in. Only report
|
|
558
|
+
// a parse failure when there was actually something to parse.
|
|
559
|
+
if (!out.trim()) return {}
|
|
560
|
+
return null
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
458
564
|
/**
|
|
459
565
|
* Read the active profile id from the config file on disk.
|
|
460
566
|
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
* file survives; the in-memory config does not. Verified against the 1.18.35
|
|
465
|
-
* schema.
|
|
567
|
+
* Returns null when the file is absent, unreadable, or not valid JSONC. Callers
|
|
568
|
+
* fall back to the profile named in the `config` hook, so a null here degrades
|
|
569
|
+
* to the default profile rather than failing.
|
|
466
570
|
*
|
|
467
571
|
* @returns {string | null}
|
|
468
572
|
*/
|
|
@@ -473,10 +577,10 @@ export function profileIdFromConfig() {
|
|
|
473
577
|
const file = configFilePath()
|
|
474
578
|
if (!existsSync(file)) return null
|
|
475
579
|
try {
|
|
476
|
-
const
|
|
477
|
-
const
|
|
478
|
-
const
|
|
479
|
-
|
|
580
|
+
const parsed = parseJsonc(readFileSync(file, "utf8"))
|
|
581
|
+
const harness = parsed?.harness
|
|
582
|
+
const id =
|
|
583
|
+
harness && typeof harness === "object" ? /** @type {{profile?: unknown}} */ (harness).profile : null
|
|
480
584
|
return typeof id === "string" && id ? id : null
|
|
481
585
|
} catch {
|
|
482
586
|
return null
|
package/src/tasks.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Parse and rewrite `.space/tasks.md`, the one artifact the whole loop reads.
|
|
2
|
+
//
|
|
3
|
+
// The driver reads it to decide what to do next, so a mistake here is a mistake
|
|
4
|
+
// in the loop itself rather than in a report about the loop. It is kept pure and
|
|
5
|
+
// line-based on purpose: every byte of the file that the loop does not own --
|
|
6
|
+
// the goal, the constraints, the decisions, any prose a human added -- survives
|
|
7
|
+
// a rewrite untouched.
|
|
8
|
+
|
|
9
|
+
export const TASK_FIELDS = ["files", "change", "verify", "status"]
|
|
10
|
+
|
|
11
|
+
const SECTIONS = ["Constraints", "Decisions", "Risks", "Tasks"]
|
|
12
|
+
|
|
13
|
+
const headingRe = /^##\s+(.+?)\s*$/
|
|
14
|
+
const taskRe = /^\s*-\s*\[([ xX])\]\s*(.*)$/
|
|
15
|
+
const fieldRe = new RegExp(`^\\s+-\\s+(${TASK_FIELDS.join("|")})\\s*:\\s*(.*)$`)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} Task
|
|
19
|
+
* @property {number} index position in the file, zero based
|
|
20
|
+
* @property {boolean} checked the checkbox was ticked
|
|
21
|
+
* @property {string} title the task on one line
|
|
22
|
+
* @property {string} files which files it is allowed to touch
|
|
23
|
+
* @property {string} change what it is supposed to change
|
|
24
|
+
* @property {string} verify the command that proves it
|
|
25
|
+
* @property {string} status pending | done | blocked
|
|
26
|
+
* @property {number} start first line of the task block
|
|
27
|
+
* @property {number} end last line of the task block
|
|
28
|
+
* @property {string} [key] indexed by field name when a block is written
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {object} TaskFile
|
|
33
|
+
* @property {string} goal
|
|
34
|
+
* @property {string} constraints
|
|
35
|
+
* @property {string} decisions
|
|
36
|
+
* @property {string} risks
|
|
37
|
+
* @property {Task[]} tasks
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read a task file into its sections and tasks.
|
|
42
|
+
*
|
|
43
|
+
* Only the Tasks section is parsed structurally. Everything else is kept as raw
|
|
44
|
+
* lines so that rendering a document back never reformats a decision someone
|
|
45
|
+
* wrote in prose.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} text
|
|
48
|
+
* @returns {TaskFile}
|
|
49
|
+
*/
|
|
50
|
+
export function parseTasks(text) {
|
|
51
|
+
const lines = String(text ?? "").split("\n")
|
|
52
|
+
|
|
53
|
+
const goal = (() => {
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (!line.trim()) continue
|
|
56
|
+
if (line.startsWith("# ")) return line.slice(2).trim()
|
|
57
|
+
return line.trim() // a file with no heading still has a goal on line 1
|
|
58
|
+
}
|
|
59
|
+
return ""
|
|
60
|
+
})()
|
|
61
|
+
|
|
62
|
+
/** @type {Record<string, string[]>} */
|
|
63
|
+
const sections = {}
|
|
64
|
+
let current = ""
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
const h = line.match(headingRe)
|
|
67
|
+
if (h) {
|
|
68
|
+
current = h[1]
|
|
69
|
+
if (!sections[current]) sections[current] = []
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (current) sections[current].push(line)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @type {Task[]} */
|
|
76
|
+
const tasks = []
|
|
77
|
+
for (let i = 0; i < lines.length; i++) {
|
|
78
|
+
const m = lines[i].match(taskRe)
|
|
79
|
+
if (!m) continue
|
|
80
|
+
// A checkbox outside the Tasks section is prose, not a task.
|
|
81
|
+
if (!inSection(lines, i, "Tasks")) continue
|
|
82
|
+
|
|
83
|
+
/** @type {Record<string, any> & Task} */
|
|
84
|
+
const t = {
|
|
85
|
+
index: tasks.length,
|
|
86
|
+
checked: m[1].toLowerCase() === "x",
|
|
87
|
+
title: m[2].trim(),
|
|
88
|
+
files: "",
|
|
89
|
+
change: "",
|
|
90
|
+
verify: "",
|
|
91
|
+
status: "",
|
|
92
|
+
start: i,
|
|
93
|
+
end: i,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let last = null
|
|
97
|
+
let j = i + 1
|
|
98
|
+
for (; j < lines.length; j++) {
|
|
99
|
+
if (taskRe.test(lines[j]) || headingRe.test(lines[j])) break
|
|
100
|
+
const f = lines[j].match(fieldRe)
|
|
101
|
+
if (f) {
|
|
102
|
+
t[f[1]] = f[2].trim()
|
|
103
|
+
last = f[1]
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
// A wrapped value continues the field above it, indented deeper.
|
|
107
|
+
if (last && lines[j].trim() && /^\s{4,}\S/.test(lines[j])) {
|
|
108
|
+
t[last] = `${t[last]} ${lines[j].trim()}`.trim()
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (!lines[j].trim()) continue
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
t.end = j - 1
|
|
115
|
+
if (!t.status) t.status = t.checked ? "done" : "pending"
|
|
116
|
+
tasks.push(t)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
goal,
|
|
121
|
+
constraints: raw(sections, "Constraints"),
|
|
122
|
+
decisions: raw(sections, "Decisions"),
|
|
123
|
+
risks: raw(sections, "Risks"),
|
|
124
|
+
tasks,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string[]} lines
|
|
130
|
+
* @param {number} idx
|
|
131
|
+
* @param {string} name
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
function inSection(lines, idx, name) {
|
|
135
|
+
for (let i = idx; i >= 0; i--) {
|
|
136
|
+
const h = lines[i].match(headingRe)
|
|
137
|
+
if (h) return h[1] === name
|
|
138
|
+
}
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {Record<string, string[]>} sections
|
|
144
|
+
* @param {string} name
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
function raw(sections, name) {
|
|
148
|
+
return (sections[name] ?? []).join("\n").trim()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The next task the loop is allowed to touch. Blocked work is never retried.
|
|
153
|
+
*
|
|
154
|
+
* @param {TaskFile} parsed
|
|
155
|
+
* @returns {Task | null}
|
|
156
|
+
*/
|
|
157
|
+
export function nextPending(parsed) {
|
|
158
|
+
return parsed.tasks.find((t) => !isDone(t) && !isBlocked(t)) ?? null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @param {Task} t */
|
|
162
|
+
export const isDone = (t) => t.checked || t.status === "done"
|
|
163
|
+
|
|
164
|
+
/** @param {Task} t */
|
|
165
|
+
export const isBlocked = (t) => t.status === "blocked"
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Record the outcome of a task: flip the checkbox, set the status, and paste the
|
|
169
|
+
* verify output that was actually observed.
|
|
170
|
+
*
|
|
171
|
+
* The verify text is written under the `verify:` line rather than replacing it,
|
|
172
|
+
* because the contract asks for the command and the real output together.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} text
|
|
175
|
+
* @param {Task} task
|
|
176
|
+
* @param {{ status: string, verifyOutput?: string }} outcome
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
export function markTask(text, task, { status, verifyOutput }) {
|
|
180
|
+
const lines = String(text).split("\n")
|
|
181
|
+
const boxLine = lines[task.start] ?? ""
|
|
182
|
+
lines[task.start] = boxLine.replace(
|
|
183
|
+
/^\s*-\s*\[[ xX]\]/,
|
|
184
|
+
status === "done" ? "- [x]" : "- [ ]",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
let statusLine = -1
|
|
188
|
+
for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
|
|
189
|
+
const f = lines[i]?.match(fieldRe)
|
|
190
|
+
if (f && f[1] === "status") {
|
|
191
|
+
statusLine = i
|
|
192
|
+
break
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (statusLine >= 0) lines[statusLine] = lines[statusLine].replace(
|
|
196
|
+
/(\s-\s+status\s*:).*$/,
|
|
197
|
+
`$1 ${status}`,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if (verifyOutput) {
|
|
201
|
+
let verifyLine = -1
|
|
202
|
+
for (let i = task.start; i <= task.end + 1 && i < lines.length; i++) {
|
|
203
|
+
const f = lines[i]?.match(fieldRe)
|
|
204
|
+
if (f && f[1] === "verify") {
|
|
205
|
+
verifyLine = i
|
|
206
|
+
break
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const verifySource = verifyLine >= 0 ? lines[verifyLine] : undefined
|
|
210
|
+
if (verifySource) {
|
|
211
|
+
const indent = (verifySource.match(/^\s*/) ?? [""])[0] + " "
|
|
212
|
+
const quoted = String(verifyOutput)
|
|
213
|
+
.trimEnd()
|
|
214
|
+
.split("\n")
|
|
215
|
+
.map((l) => `${indent}${l}`)
|
|
216
|
+
lines.splice(verifyLine + 1, 0, ...quoted)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return lines.join("\n")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* One task rendered as the prompt handed to the agent that will do it.
|
|
225
|
+
*
|
|
226
|
+
* @param {Task} task
|
|
227
|
+
* @returns {string}
|
|
228
|
+
*/
|
|
229
|
+
export function taskPrompt(task) {
|
|
230
|
+
const out = [task.title]
|
|
231
|
+
if (task.files) out.push("", `Files: ${task.files}`)
|
|
232
|
+
if (task.change) out.push("", `Change: ${task.change}`)
|
|
233
|
+
if (task.verify) out.push("", `Verify: ${task.verify}`)
|
|
234
|
+
return out.join("\n")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The summary the driver prints when it stops, whatever stopped it.
|
|
239
|
+
*
|
|
240
|
+
* @param {TaskFile} parsed
|
|
241
|
+
* @returns {{ done: number, blocked: number, pending: number, total: number }}
|
|
242
|
+
*/
|
|
243
|
+
export function summarize(parsed) {
|
|
244
|
+
const done = parsed.tasks.filter(isDone).length
|
|
245
|
+
const blocked = parsed.tasks.filter(isBlocked).length
|
|
246
|
+
const pending = parsed.tasks.filter((t) => !isDone(t) && !isBlocked(t)).length
|
|
247
|
+
return { done, blocked, pending, total: parsed.tasks.length }
|
|
248
|
+
}
|
package/docs/demo/demo.gif
DELETED
|
Binary file
|
package/docs/demo/demo.mp4
DELETED
|
Binary file
|
package/docs/demo/render.py
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Render the space-harness demo video from REAL CLI output.
|
|
3
|
-
|
|
4
|
-
Nothing here is typed by hand: the script runs `init` and `verify` against a
|
|
5
|
-
throwaway config dir, captures what the CLI actually prints, and draws that.
|
|
6
|
-
The only cosmetic edit is replacing the random tmp path with
|
|
7
|
-
~/.config/opencode so the frames stay readable.
|
|
8
|
-
"""
|
|
9
|
-
import os
|
|
10
|
-
import re
|
|
11
|
-
import subprocess
|
|
12
|
-
import sys
|
|
13
|
-
import tempfile
|
|
14
|
-
|
|
15
|
-
from PIL import Image, ImageDraw, ImageFont
|
|
16
|
-
|
|
17
|
-
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # docs/demo/render.py -> repo root
|
|
18
|
-
ROOT = REPO
|
|
19
|
-
BIN = ["node", os.path.join(ROOT, "bin", "oc-harness.mjs")]
|
|
20
|
-
W, H = 1280, 720
|
|
21
|
-
FPS = 15
|
|
22
|
-
FONT = ImageFont.truetype("/System/Library/Fonts/Menlo.ttc", 22)
|
|
23
|
-
LINE_H = 34
|
|
24
|
-
TOP = 40
|
|
25
|
-
LEFT = 48
|
|
26
|
-
BG = (13, 17, 23)
|
|
27
|
-
WHITE = (230, 237, 243)
|
|
28
|
-
GREEN = (63, 185, 80)
|
|
29
|
-
DIM = (139, 148, 158)
|
|
30
|
-
YELLOW = (210, 153, 34)
|
|
31
|
-
RED = (248, 81, 73)
|
|
32
|
-
|
|
33
|
-
ANSI = re.compile(r"\x1b\[(\d+)m")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def run_clip(args, cfg):
|
|
37
|
-
env = dict(os.environ, OPENCODE_CONFIG_DIR=cfg)
|
|
38
|
-
p = subprocess.run(BIN + args, capture_output=True, text=True, env=env, cwd=ROOT)
|
|
39
|
-
assert p.returncode == 0, p.stderr[-2000:]
|
|
40
|
-
out = (p.stdout + p.stderr).replace(cfg, "~/.config/opencode")
|
|
41
|
-
return [ln.rstrip() for ln in out.splitlines()]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def spans(line):
|
|
45
|
-
"""Split an ANSI-coloured line into (text, colour) runs."""
|
|
46
|
-
runs, cur, buf = [], WHITE, ""
|
|
47
|
-
pos = 0
|
|
48
|
-
for m in ANSI.finditer(line):
|
|
49
|
-
if m.start() > pos:
|
|
50
|
-
buf += line[pos : m.start()]
|
|
51
|
-
code = m.group(1)
|
|
52
|
-
if buf:
|
|
53
|
-
runs.append((buf, cur))
|
|
54
|
-
buf = ""
|
|
55
|
-
cur = {"0": WHITE, "1": WHITE, "2": DIM, "31": RED, "32": GREEN, "33": YELLOW}.get(code, WHITE)
|
|
56
|
-
pos = m.end()
|
|
57
|
-
if pos < len(line):
|
|
58
|
-
buf += line[pos:]
|
|
59
|
-
if buf:
|
|
60
|
-
runs.append((buf, cur))
|
|
61
|
-
return runs or [("", WHITE)]
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def draw_terminal(draw, lines, prompt="$ "):
|
|
65
|
-
draw.rectangle([0, 0, W, H], fill=BG)
|
|
66
|
-
maxl = (H - TOP) // LINE_H
|
|
67
|
-
if len(lines) > maxl:
|
|
68
|
-
head = [lines[0]] if lines[0][0] == "cmd" else []
|
|
69
|
-
lines = head + [ln for ln in lines if ln[0] != "cmd"][-(maxl - len(head)) :]
|
|
70
|
-
y = TOP
|
|
71
|
-
for kind, payload in lines:
|
|
72
|
-
if y > H - LINE_H:
|
|
73
|
-
break
|
|
74
|
-
if kind == "cmd":
|
|
75
|
-
draw.text((LEFT, y), prompt, font=FONT, fill=GREEN)
|
|
76
|
-
x = LEFT + draw.textlength(prompt, font=FONT)
|
|
77
|
-
for text, colour in spans(payload):
|
|
78
|
-
draw.text((x, y), text, font=FONT, fill=colour)
|
|
79
|
-
x += draw.textlength(text, font=FONT)
|
|
80
|
-
elif kind == "card":
|
|
81
|
-
text, colour = payload
|
|
82
|
-
draw.text((LEFT, y), text, font=FONT, fill=colour)
|
|
83
|
-
else:
|
|
84
|
-
x = LEFT
|
|
85
|
-
for text, colour in spans(payload):
|
|
86
|
-
draw.text((x, y), text, font=FONT, fill=colour)
|
|
87
|
-
x += draw.textlength(text, font=FONT)
|
|
88
|
-
y += LINE_H
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def frames_for_command(cmd, output):
|
|
92
|
-
frames = []
|
|
93
|
-
typed = ""
|
|
94
|
-
for ch in cmd:
|
|
95
|
-
typed += ch
|
|
96
|
-
frames.append([("cmd", typed)])
|
|
97
|
-
for i in range(0, len(output), 2):
|
|
98
|
-
frames.append([("cmd", cmd)] + [("out", ln) for ln in output[: i + 2]])
|
|
99
|
-
full = [("cmd", cmd)] + [("out", ln) for ln in output]
|
|
100
|
-
frames.extend([full] * (2 * FPS))
|
|
101
|
-
return frames
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def card(lines, hold=2):
|
|
105
|
-
return [[("card", ln) for ln in lines]] * (hold * FPS)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def main(out_dir):
|
|
109
|
-
cfg = tempfile.mkdtemp(prefix="demo-")
|
|
110
|
-
init_out = run_clip(["init", "--profile", "space-bunny-free", "--yes"], cfg)
|
|
111
|
-
verify_out = run_clip(["verify"], cfg)
|
|
112
|
-
seq = []
|
|
113
|
-
seq += card([(("space-harness", WHITE)), (("one command from zero to working", DIM))], hold=2)
|
|
114
|
-
seq += frames_for_command("npx -y opencode-harness-kit init", [l for l in init_out if l.strip()])
|
|
115
|
-
seq += frames_for_command("oc-harness verify", [l for l in verify_out if l.strip()])
|
|
116
|
-
seq += card(
|
|
117
|
-
[
|
|
118
|
-
(("done. restart opencode, then ask it something", GREEN)),
|
|
119
|
-
(("github.com/crytobot459/space-harness", WHITE)),
|
|
120
|
-
(("free models. verified setup.", DIM)),
|
|
121
|
-
],
|
|
122
|
-
hold=3,
|
|
123
|
-
)
|
|
124
|
-
os.makedirs(out_dir, exist_ok=True)
|
|
125
|
-
frame_dir = tempfile.mkdtemp(prefix="frames-")
|
|
126
|
-
for i, lines in enumerate(seq):
|
|
127
|
-
flat = []
|
|
128
|
-
for kind, payload in lines:
|
|
129
|
-
flat.append((kind, payload))
|
|
130
|
-
img = Image.new("RGB", (W, H), BG)
|
|
131
|
-
draw_terminal(ImageDraw.Draw(img), flat)
|
|
132
|
-
img.save(os.path.join(frame_dir, f"f{i:04d}.png"))
|
|
133
|
-
mp4 = os.path.join(out_dir, "demo.mp4")
|
|
134
|
-
subprocess.run(
|
|
135
|
-
["ffmpeg", "-y", "-v", "error", "-framerate", str(FPS), "-i", os.path.join(frame_dir, "f%04d.png"),
|
|
136
|
-
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23", mp4], check=True)
|
|
137
|
-
gif = os.path.join(out_dir, "demo.gif")
|
|
138
|
-
subprocess.run(
|
|
139
|
-
["ffmpeg", "-y", "-v", "error", "-framerate", "10", "-i", os.path.join(frame_dir, "f%04d.png"),
|
|
140
|
-
"-vf", "scale=640:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=64[p];[s1][p]paletteuse",
|
|
141
|
-
gif], check=True)
|
|
142
|
-
print(f"wrote {mp4} ({os.path.getsize(mp4)//1024}KB) and {gif} ({os.path.getsize(gif)//1024}KB)")
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if __name__ == "__main__":
|
|
146
|
-
main(sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, "docs", "demo"))
|