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 CHANGED
@@ -3,13 +3,26 @@
3
3
  [![CI](https://github.com/pham-canh/space-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/pham-canh/space-harness/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/opencode-harness-kit.svg)](https://www.npmjs.com/package/opencode-harness-kit)
5
5
 
6
+ <p align="center">
7
+ <video src="docs/demo/pitch.mp4" width="720" controls></video>
8
+ </p>
9
+
10
+ *Four minutes forty-seven seconds, recorded live from a real terminal and
11
+ narrated. The take script and the voiceover are in [`video/`](video/), and
12
+ rebuilding it is documented there — including the two mistakes that cost the
13
+ most time, so the next person does not repeat them.*
14
+
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)):
@@ -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
- ![demo: init and verify in one go](docs/demo/demo.gif)
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, four shipped profiles, four different behaviours. The test
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
- Not published to npm yet, or no registry access? The checkout path works
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
@@ -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
@@ -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()[`opencode/${p.provider}/${p.model}`]
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
- let stale = false
377
+
378
+ let isLink = false
379
+ let ours = false
377
380
  try {
378
- stale = readlinkSync(dest) !== target
381
+ isLink = lstatSync(dest).isSymbolicLink()
382
+ ours = isLink && readlinkSync(dest) === target
379
383
  } catch {
380
- stale = true
384
+ // Nothing at `dest` yet.
381
385
  }
382
- if (stale) {
383
- try {
384
- rmSync(dest, { force: true, recursive: false })
385
- } catch {
386
- // 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
387
399
  }
400
+ rmSync(dest, { force: true })
388
401
  }
389
- if (!existsSync(dest)) {
390
- try {
391
- symlinkSync(target, dest)
392
- } catch (err) {
393
- console.log(` ${yellow("!")} ${stamp(`could not link ${dest}: ${err.message}`)}`)
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
- let base = {}
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) && !has("--yes")) {
656
- // The config is the user's file once written. A backup is made either
657
- // way, but overwriting it without asking is how a hand-tuned setup dies.
658
- 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)`))) {
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
- /** opencode.jsonc is JSONC, so strip comments before parsing. */
691
- function stripComments(text) {
692
- const without = text
693
- .replace(/^\s*\/\/.*$/gm, "")
694
- .replace(/\/\*[\s\S]*?\*\//g, "")
695
- try {
696
- return JSON.parse(without)
697
- } catch {
698
- 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
+ )
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 use ${id} --refresh`)}`)
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) ? stripComments(readFileSync(CONFIG_FILE, "utf8")) : null
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 = existsSync(CONFIG_FILE) ? stripComments(readFileSync(CONFIG_FILE, "utf8")) : {}
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 text = readFileSync(file, "utf8");
574
- const json = text.slice(text.indexOf("{"));
575
- const parsed = JSON.parse(json);
576
- const id = parsed?.harness?.profile;
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.0",
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,7 +27,7 @@
27
27
  },
28
28
  "roles": {
29
29
  "plan": "max",
30
- "build": "high",
30
+ "build": "max",
31
31
  "reviewer": "max",
32
32
  "locate": "low",
33
33
  "verify": "low"
@@ -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
- * This exists because the `config` hook cannot be the source. OpenCode's
462
- * Config schema sets `additionalProperties: false`, so a custom `harness` key
463
- * written into the file is stripped from the object handed to the hook. The
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 text = readFileSync(file, "utf8")
477
- const json = text.slice(text.indexOf("{"))
478
- const parsed = JSON.parse(json)
479
- const id = parsed?.harness?.profile
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
+ }
Binary file
Binary file
@@ -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"))