opencode-harness-kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/GLOBAL.md CHANGED
@@ -54,7 +54,7 @@ to avoid this.
54
54
 
55
55
  ## Never
56
56
 
57
- - `git push`, publish, or open a pull request. A human does that.
57
+ - Ask before `git push`, publish, or opening a pull request. Publishing is a human decision.
58
58
  - Commit a secret. If you find one, report it; do not move it.
59
59
  - Read or write `.env`, `.npmrc`, `.netrc`, `.git-credentials`. The guard
60
60
  blocks these and will keep blocking them.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # opencode-harness-kit
2
2
 
3
- [![CI](https://github.com/crytobot459/space-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/crytobot459/space-harness/actions/workflows/ci.yml)
3
+ [![CI](https://github.com/pham-canh/space-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/pham-canh/space-harness/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/opencode-harness-kit.svg)](https://www.npmjs.com/package/opencode-harness-kit)
5
5
 
6
6
  A harness kit for [OpenCode](https://opencode.ai) that is **model-agnostic by
@@ -15,10 +15,15 @@ your side — `npx` only downloads the published kit. Prefer git? The same three
15
15
  steps from a checkout (see [Install](#install)):
16
16
 
17
17
  ```bash
18
- git clone https://github.com/crytobot459/space-harness.git
18
+ git clone https://github.com/pham-canh/space-harness.git
19
19
  ./bin/oc-harness.mjs init
20
20
  ```
21
21
 
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
+
22
27
  The three steps `init` runs, spelled out for when you want to know what
23
28
  happened (prefix with `npx` or `./bin/oc-harness.mjs` to taste):
24
29
 
@@ -114,17 +119,53 @@ on install. It is not hidden, it is labelled.
114
119
  | profile | context | top effort | retention |
115
120
  |---|---|---|---|
116
121
  | `space-bunny-free` (default) | 1,048,576 | `max` | zero-retention |
122
+ | `muse-split-free` | 1,048,576 | `max` | **may train** |
117
123
  | `x-preview-f-free` | 1,000,000 | `max` | zero-retention |
118
124
  | `longcat-2.5-preview-free` | 1,000,000 | — | zero-retention |
119
125
  | `deepseek-v4-flash-free` | 200,000 | `max` | zero-retention |
120
126
  | `muse-spark-1.3-contributor-free` | 1,048,576 | `xhigh` | **may train** |
121
127
 
128
+ ### Split profiles
129
+
130
+ A profile normally puts one model behind every role. `roleModels` is the way to
131
+ split that, for when the best free model and the highest-output model are not the
132
+ same model:
133
+
134
+ ```jsonc
135
+ "model": "space-bunny-free", // base: every role not named below
136
+ "roleModels": {
137
+ "plan": { "model": "muse-spark-1.3-contributor-free", "levels": ["minimal","low","medium","high","xhigh"] },
138
+ "build": { "model": "muse-spark-1.3-contributor-free", "levels": ["minimal","low","medium","high","xhigh"] },
139
+ "locate": { "model": "space-bunny-free", "levels": ["low","medium","high","xhigh","max"] }
140
+ }
141
+ ```
142
+
143
+ Three things fall out of this that are easy to get wrong, so they are enforced:
144
+
145
+ - **`levels` is required when the model differs.** Effort is resolved against the
146
+ levels of the model the role actually runs on. `plan` asking for `max` on a
147
+ model that stops at `xhigh` lands on `xhigh`, instead of writing a `max` variant
148
+ the model has no meaning for.
149
+ - **Retention is the least private model in the file, not the base one.** A base
150
+ of `zero-retention` with a `may-train` role is a `may-train` profile. The
151
+ generated header prints that, with the model responsible, and so do
152
+ `oc-harness use` and `oc-harness show`.
153
+ - **Each role's output cap is its own.** A split role inherits the limits of the
154
+ model it names, so a `build` on a 131,072-token model cannot spend the base's
155
+ 524,288.
156
+
157
+ `muse-split-free` is the shipped example, and it is deliberately not the default:
158
+ three of its five roles run on Muse Spark, a Meta contributor-tier endpoint whose
159
+ provider may train on your prompts. Install it for public or throwaway
160
+ repositories.
161
+
122
162
  ## Commands
123
163
 
124
164
  ```
125
165
  oc-harness list profiles with retention and caveats
126
166
  oc-harness use <id> compile a profile into ~/.config/opencode/opencode.jsonc
127
167
  oc-harness show <id> resolved effort, derived numbers, fallback queue
168
+ oc-harness use <id> --dryrun print what would be written, write nothing
128
169
  oc-harness install symlink the plugin, agents, skills and GLOBAL.md
129
170
  oc-harness verify check the install against the profile; non-zero on drift
130
171
  oc-harness agents report agents the effort tiering does not reach
@@ -235,8 +276,10 @@ Your numbers stay on your machine; only the script is in the repo.
235
276
  it was handed. It splits on shell separators, honours quotes so that a `;`
236
277
  inside a string is an argument rather than a second command, strips `env`,
237
278
  `sudo` and `git -C` before matching, and follows `sh -c "..."` into its payload.
238
- So `cd repo && git push`, `sudo rm -rf /` and `bash -c "git push"` are all
239
- caught, while `git log --grep push` and `git push origin my-feature-f` are not.
279
+ So `cd repo && rm -rf /`, `sudo rm -rf /` and `bash -c "rm -rf /"` are all
280
+ caught, while `git log --grep push` and `echo push` are not. `git push` is
281
+ not guard-blocked; it falls through to the `*` ask permission so opencode
282
+ prompts for human approval before publishing.
240
283
  Writes are scoped to the project directory, with the OS temp directory as the
241
284
  one exception GLOBAL.md allows.
242
285
 
@@ -295,7 +338,7 @@ identically:
295
338
 
296
339
  ```bash
297
340
  pkg install nodejs git
298
- git clone https://github.com/crytobot459/space-harness.git
341
+ git clone https://github.com/pham-canh/space-harness.git
299
342
  ./space-harness/bin/oc-harness.mjs init
300
343
  ```
301
344
 
package/agents/build.md CHANGED
@@ -50,7 +50,7 @@ You implement one task from a plan. One. Not the whole plan.
50
50
  cannot complete the task, say so before writing a partial one.
51
51
  - Never widen the change. A drive-by refactor in the same file is a separate
52
52
  task; do not report it as part of this one.
53
- - Never `git push`, never publish, never open a pull request. Committing is
53
+ - Ask before `git push`, publish, or opening a pull request. Committing is
54
54
  allowed but only when the task asked for it.
55
55
  - Never fabricate a passing result. A failing test reported honestly is worth
56
56
  more than a green one you did not get.
@@ -27,6 +27,8 @@ import {
27
27
  profilePath,
28
28
  pruneThreshold,
29
29
  reservedTokens,
30
+ effectiveRetention,
31
+ roleSplitModels,
30
32
  resolveDefaultVariant,
31
33
  resolveRoles,
32
34
  ProfileError,
@@ -146,7 +148,20 @@ function cmdShow(id) {
146
148
  row("attachment", p.supports?.attachment ? "yes" : stamp("no"))
147
149
  row("reserved", reservedTokens(p).toLocaleString("en-US"))
148
150
  row("prune at", pruneThreshold(p).toLocaleString("en-US"))
149
- row("retention", p.retention === "zero-retention" ? green(p.retention) : yellow(p.retention))
151
+ // Effective, not the base model's: a split profile's base can be
152
+ // zero-retention while the roles that matter are not.
153
+ const eff = effectiveRetention(p)
154
+ row("retention", eff.retention === "zero-retention" ? green(eff.retention) : yellow(eff.retention))
155
+ if (eff.retention !== p.retention) {
156
+ console.log(` ${"".padEnd(20)} ${stamp(`base ${p.model} is ${p.retention}, but ${eff.worst} makes the whole profile ${eff.retention}`)}`)
157
+ }
158
+ if (roleSplitModels(p).length) {
159
+ console.log(`\n ${bold("role models")}`)
160
+ for (const s of roleSplitModels(p)) {
161
+ const cap = s.limits ? ` out ${s.limits.output.toLocaleString("en-US")}` : ""
162
+ console.log(` ${s.role.padEnd(20)} ${s.model}${cap}`)
163
+ }
164
+ }
150
165
 
151
166
  console.log(`\n ${bold("role effort")}`)
152
167
  for (const [role, level] of Object.entries(plan.roles)) {
@@ -563,7 +578,14 @@ function configDrift(p, actual) {
563
578
  out.push(`agent.${role} missing`)
564
579
  continue
565
580
  }
566
- if (agent.model !== want) out.push(`agent.${role}.model ${agent.model} -> ${want}`)
581
+ // A role's expected model is the one roleModels declares for it, falling
582
+ // back to the base. Comparing every agent against `want` would report
583
+ // drift on every split role forever, which trains the user to ignore the
584
+ // one command that exists to catch a moved `build`.
585
+ const wantRole = `${p.provider}/${p.roleModels?.[role]?.model ?? p.model}`
586
+ if (agent.model !== wantRole) {
587
+ out.push(`agent.${role}.model ${agent.model} -> ${wantRole}`)
588
+ }
567
589
  if (agent.variant !== (level ?? undefined)) {
568
590
  out.push(`agent.${role}.variant ${agent.variant ?? "(none)"} -> ${level ?? "(none)"}`)
569
591
  }
@@ -620,6 +642,15 @@ async function cmdUse(id) {
620
642
  return
621
643
  }
622
644
 
645
+ // A flag that is accepted and then ignored is worse than one that errors:
646
+ // `--dryrun` used to write the real config anyway.
647
+ if (has("--dryrun")) {
648
+ console.log(`\n ${stamp("dry run, nothing written")}`)
649
+ console.log(` ${stamp(`would write ${CONFIG_FILE}`)}`)
650
+ summary(p)
651
+ return
652
+ }
653
+
623
654
  mkdirSync(dirname(CONFIG_FILE), { recursive: true })
624
655
  if (existsSync(CONFIG_FILE) && !has("--yes")) {
625
656
  // The config is the user's file once written. A backup is made either
@@ -632,11 +663,28 @@ async function cmdUse(id) {
632
663
  }
633
664
  writeFileSync(CONFIG_FILE, out)
634
665
  console.log(`\n ${green("✓")} wrote ${CONFIG_FILE}`)
666
+ summary(p)
667
+ console.log(`\n ${stamp("verify with: opencode debug config")}\n`)
668
+ }
669
+
670
+ /** What the profile puts behind the user, including a split and real retention. */
671
+ function summary(p) {
672
+ const eff = effectiveRetention(p)
673
+ const splits = roleSplitModels(p)
635
674
  console.log(` ${stamp(`model ${p.provider}/${p.model}`)}`)
636
675
  console.log(` ${stamp(`effort ${resolveDefaultVariant(p) ?? "(not tunable)"}`)}`)
637
676
  console.log(` ${stamp(`roles ${Object.entries(resolveRoles(p)).map(([k, v]) => `${k}=${v ?? "-"}`).join(" ")}`)}`)
677
+ if (splits.length) {
678
+ // Without this the split is invisible until someone opens the file, which
679
+ // is too late to be told which roles send prompts to a provider that trains.
680
+ console.log(` ${stamp(`split ${splits.map((s) => `${s.role}=${s.model}`).join(" ")}`)}`)
681
+ console.log(` ${stamp(` ${splits.map((s) => s.limits ? `${s.role} out ${s.limits.output.toLocaleString("en-US")}` : null).filter(Boolean).join(" ")}`)}`)
682
+ }
683
+ console.log(` ${stamp(`retention ${eff.retention}${eff.retention === "may-train" ? " <-- prompts may train a model" : ""}`)}`)
684
+ if (splits.length && eff.retention !== p.retention) {
685
+ console.log(` ${stamp(` base ${p.model} is ${p.retention}, but ${eff.worst} makes the whole profile ${eff.retention}`)}`)
686
+ }
638
687
  console.log(` ${stamp(`reserved ${reservedTokens(p).toLocaleString("en-US")} tokens`)}`)
639
- console.log(`\n ${stamp("verify with: opencode debug config")}\n`)
640
688
  }
641
689
 
642
690
  /** opencode.jsonc is JSONC, so strip comments before parsing. */
package/dist/index.js CHANGED
@@ -140,26 +140,6 @@ function tokenize(segment) {
140
140
  var ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
141
141
  var GIT_WRAPPERS = /* @__PURE__ */ new Set(["env", "sudo", "doas", "nohup", "time", "command", "exec", "xargs"]);
142
142
  var WRAPPER_VALUE_FLAGS = /* @__PURE__ */ new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"]);
143
- var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
144
- "-C",
145
- "-c",
146
- "--git-dir",
147
- "--work-tree",
148
- "--namespace",
149
- "--super-prefix",
150
- "--exec-path",
151
- "--config-env"
152
- ]);
153
- var GIT_GLOBAL_FLAGS = /* @__PURE__ */ new Set([
154
- "--bare",
155
- "--no-pager",
156
- "--paginate",
157
- "--no-replace-objects",
158
- "--no-optional-locks",
159
- "--literal-pathspecs",
160
- "--glob-pathspecs",
161
- "--icase-pathspecs"
162
- ]);
163
143
  function skipPrefixes(tokens) {
164
144
  let i = 0;
165
145
  for (; ; ) {
@@ -190,26 +170,6 @@ function stripPrefixes(segment) {
190
170
  if (slash !== -1) rest[0] = rest[0].slice(slash + 1);
191
171
  return rest.join(" ");
192
172
  }
193
- function gitSubcommand(segment) {
194
- const tokens = tokenize(segment);
195
- let i = skipPrefixes(tokens);
196
- if (i >= tokens.length) return null;
197
- if (!/(^|[\\/])git(\.exe)?$/i.test(tokens[i])) return null;
198
- i++;
199
- while (i < tokens.length) {
200
- const flag = tokens[i];
201
- if (GIT_VALUE_FLAGS.has(flag)) {
202
- i += 2;
203
- continue;
204
- }
205
- if (/^--[A-Za-z][\w-]*=/.test(flag) || GIT_GLOBAL_FLAGS.has(flag)) {
206
- i++;
207
- continue;
208
- }
209
- break;
210
- }
211
- return tokens[i] ?? null;
212
- }
213
173
  var SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
214
174
  var MAX_SHELL_DEPTH = 2;
215
175
  function shellPayload(segment) {
@@ -240,21 +200,9 @@ var BASH_RULES = [
240
200
  test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
241
201
  why: "downloads and executes a script in one step"
242
202
  },
243
- {
244
- name: "git push with force",
245
- scope: "segment",
246
- // A dry run publishes nothing, so it is not the thing being gated.
247
- check: (seg) => gitSubcommand(seg) === "push" && tokenize(seg).some((w) => w === "--force" || w === "-f" || w.startsWith("--force")),
248
- why: "overwrites published history"
249
- },
250
- {
251
- name: "git push",
252
- scope: "segment",
253
- // Flags are matched as whole words: a `-f` in `my-feature-f` is a branch
254
- // name, not a force flag.
255
- check: (seg) => gitSubcommand(seg) === "push" && !tokenize(seg).some((w) => w === "--dry-run" || w === "-n"),
256
- why: "publishing is a human decision"
257
- },
203
+ // `git push` is ask-gated via permissions (`*` ask in the generated
204
+ // config), not guard-blocked. Publishing needs human approval, but the
205
+ // guard lets it through so opencode can prompt first.
258
206
  {
259
207
  name: "recursive delete",
260
208
  scope: "segment",
@@ -453,6 +401,31 @@ function validate(p, path = "<memory>") {
453
401
  `${where}: effort.reserveShare must be between 0 and 0.5`
454
402
  );
455
403
  }
404
+ for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
405
+ if (!SPLITTABLE_ROLES.includes(role)) {
406
+ throw new ProfileError(
407
+ `${where}: roleModels.${role} is not one of ${SPLITTABLE_ROLES.join(", ")}`
408
+ );
409
+ }
410
+ if (!(role in (p.roles ?? {}))) {
411
+ throw new ProfileError(
412
+ `${where}: roleModels.${role} has no matching entry in roles, so it would never apply`
413
+ );
414
+ }
415
+ if (!rm?.model || typeof rm.model !== "string") {
416
+ throw new ProfileError(`${where}: roleModels.${role} needs a model`);
417
+ }
418
+ if (rm.levels !== void 0 && !Array.isArray(rm.levels)) {
419
+ throw new ProfileError(
420
+ `${where}: roleModels.${role}.levels must be an array`
421
+ );
422
+ }
423
+ if (!retentionFor(rm.model)) {
424
+ throw new ProfileError(
425
+ `${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`
426
+ );
427
+ }
428
+ }
456
429
  }
457
430
  var CANON = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
458
431
  function resolveEffort(levels, requested) {
@@ -476,27 +449,116 @@ function resolveEffort(levels, requested) {
476
449
  }
477
450
  return best ?? levels[levels.length - 1] ?? null;
478
451
  }
452
+ function resolveRoleModel(p, role) {
453
+ const rm = p.roleModels?.[role];
454
+ if (!rm) return null;
455
+ return { model: rm.model, levels: rm.levels ?? [] };
456
+ }
457
+ function levelsForRole(p, role) {
458
+ const rm = resolveRoleModel(p, role);
459
+ if (rm) return rm.levels;
460
+ return p.effort?.levels ?? [];
461
+ }
479
462
  function resolveRoles(p) {
480
- const levels = p.effort?.levels ?? [];
481
463
  const out = (
482
464
  /** @type {Record<string, string | null>} */
483
465
  {}
484
466
  );
485
467
  for (const [role, want] of Object.entries(p.roles ?? {})) {
486
- out[role] = resolveEffort(levels, want);
468
+ out[role] = resolveEffort(levelsForRole(p, role), want);
487
469
  }
488
470
  return out;
489
471
  }
490
472
  function resolveDefaultVariant(p) {
491
473
  return resolveEffort(p.effort?.levels ?? [], p.effort?.default ?? null);
492
474
  }
475
+ var SPLITTABLE_ROLES = ["plan", "build", "reviewer", "locate", "verify"];
476
+ function inputCap(p) {
477
+ let cap = p.limits.input ?? p.limits.context;
478
+ for (const s of roleSplitModels(p)) {
479
+ const other = s.limits?.input ?? s.limits?.context;
480
+ if (typeof other === "number" && other > cap) cap = other;
481
+ }
482
+ return cap;
483
+ }
493
484
  function reservedTokens(p) {
494
- const input = p.limits.input ?? p.limits.context;
495
485
  const share = p.effort?.reserveShare ?? 0.12;
496
- return Math.max(8e3, Math.round(input * share));
486
+ return Math.max(8e3, Math.round(inputCap(p) * share));
497
487
  }
498
488
  function pruneThreshold(p) {
499
- return (p.limits.input ?? p.limits.context) - reservedTokens(p);
489
+ return inputCap(p) - reservedTokens(p);
490
+ }
491
+ function loadRetention() {
492
+ const file = join(ROOT, "retention.yml");
493
+ if (!existsSync(file)) return {};
494
+ const out = (
495
+ /** @type {Record<string, any>} */
496
+ {}
497
+ );
498
+ const lines = readFileSync(file, "utf8").split("\n");
499
+ let current = null;
500
+ let i = 0;
501
+ while (i < lines.length) {
502
+ const raw = lines[i];
503
+ const line = raw.trim();
504
+ const indent = raw.length - raw.trimStart().length;
505
+ if (!line || line.startsWith("#")) {
506
+ i++;
507
+ continue;
508
+ }
509
+ if (indent === 2 && line.endsWith(":")) {
510
+ const key = line.slice(0, -1).trim();
511
+ current = key.includes("/") ? key : `opencode/${key}`;
512
+ out[current] = { retention: "unknown" };
513
+ i++;
514
+ continue;
515
+ }
516
+ if (!current) {
517
+ i++;
518
+ continue;
519
+ }
520
+ const field = line.match(/^(retention|verified|source|note):\s*(.*)$/);
521
+ if (field) {
522
+ const name = field[1];
523
+ const rest = field[2].trim();
524
+ if (/^[>|][-+]?$/.test(rest)) {
525
+ const parts = [];
526
+ let j = i + 1;
527
+ for (; j < lines.length; j++) {
528
+ const l = lines[j];
529
+ if (!l.trim()) {
530
+ parts.push("");
531
+ continue;
532
+ }
533
+ if (l.length - l.trimStart().length <= indent) break;
534
+ parts.push(l.trim());
535
+ }
536
+ out[current][name] = parts.join(" ").replace(/\s+/g, " ").trim();
537
+ i = j;
538
+ continue;
539
+ }
540
+ out[current][name] = rest.replace(/^["']|["']$/g, "");
541
+ i++;
542
+ continue;
543
+ }
544
+ i++;
545
+ }
546
+ return out;
547
+ }
548
+ function retentionFor(id) {
549
+ const table = loadRetention();
550
+ return table[id] ?? table[id.includes("/") ? id : `opencode/${id}`];
551
+ }
552
+ function roleSplitModels(p) {
553
+ return Object.entries(p.roleModels ?? {}).map(([role, rm]) => {
554
+ let limits = null;
555
+ try {
556
+ limits = loadProfile(rm.model)?.limits ?? null;
557
+ } catch {
558
+ limits = null;
559
+ }
560
+ return { role, model: rm.model, levels: rm.levels ?? [], limits };
561
+ });
500
562
  }
501
563
  function configFilePath() {
502
564
  const dir = process.env.OPENCODE_CONFIG_DIR ?? join(process.env.HOME ?? "", ".config", "opencode");
Binary file
Binary file
@@ -0,0 +1,146 @@
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"))
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "opencode-harness-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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
+ "homepage": "https://github.com/pham-canh/space-harness#readme",
6
7
  "repository": {
7
8
  "type": "git",
8
- "url": "https://github.com/crytobot459/space-harness.git"
9
+ "url": "git+https://github.com/pham-canh/space-harness.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/pham-canh/space-harness/issues"
9
13
  },
10
14
  "type": "module",
11
15
  "main": "./dist/index.js",
@@ -2,6 +2,24 @@
2
2
  "$comment": "Profiles verified against live provider metadata. `retention` must come from retention.yml; a model missing there cannot enter this list.",
3
3
  "default": "space-bunny-free",
4
4
  "profiles": [
5
+ {
6
+ "id": "muse-split-free",
7
+ "file": "profiles/muse-split-free.json",
8
+ "title": "Muse Spark 1.3 (heavy) + Space Bunny Free (bulk output)",
9
+ "retention": "may-train",
10
+ "free": true,
11
+ "topEffort": "max",
12
+ "context": 1048576,
13
+ "default": false,
14
+ "split": {
15
+ "plan": "muse-spark-1.3-contributor-free",
16
+ "build": "muse-spark-1.3-contributor-free",
17
+ "reviewer": "muse-spark-1.3-contributor-free",
18
+ "locate": "space-bunny-free",
19
+ "verify": "space-bunny-free"
20
+ },
21
+ "note": "Retention here is the effective one: three roles run on Muse Spark, so the file is may-train even though the base model is zero-retention."
22
+ },
5
23
  {
6
24
  "id": "space-bunny-free",
7
25
  "file": "profiles/space-bunny-free.json",
@@ -0,0 +1,64 @@
1
+ {
2
+ "$comment": "Split profile. The heavy reasoning roles run on Muse Spark 1.3 because it is the strongest free model on Zen by a wide margin (Terminal-Bench 2.1 88.8% vs Nemotron 3 Ultra's 56.4%, DeepSWE v1.1 75.4%). The bulk-output roles stay on Space Bunny because its 524,288-token output cap is 4x Muse Spark's 131,072, and scaffolding or migrating many files is where that ceiling is actually felt. `retention` below describes the BASE model only; the config header prints the effective retention, which is may-train because three roles run on Muse Spark.",
3
+ "id": "muse-split-free",
4
+ "title": "Muse Spark 1.3 (heavy) + Space Bunny Free (bulk output)",
5
+ "provider": "opencode",
6
+ "model": "space-bunny-free",
7
+ "family": null,
8
+ "verified": "2026-10-07",
9
+ "retention": "zero-retention",
10
+ "notes": "Opt-in split, not a default. Muse Spark is a Meta contributor-tier free endpoint: its provider may train on your prompts and completions. Three of the five roles are therefore may-train even though the base model is zero-retention. Install it only for public or throwaway repositories. Effort requests below are resolved against each role's own model, so plan and reviewer ask for `max` and land on `xhigh`, which is the top Muse Spark offers.",
11
+ "limits": {
12
+ "context": 1048576,
13
+ "input": 524288,
14
+ "output": 524288
15
+ },
16
+ "supports": {
17
+ "reasoning": true,
18
+ "temperature": false,
19
+ "toolCall": true,
20
+ "structuredOutput": false,
21
+ "attachment": true
22
+ },
23
+ "effort": {
24
+ "levels": ["low", "medium", "high", "xhigh", "max"],
25
+ "default": "max",
26
+ "reserveShare": 0.12
27
+ },
28
+ "roles": {
29
+ "plan": "max",
30
+ "build": "high",
31
+ "reviewer": "max",
32
+ "locate": "low",
33
+ "verify": "low"
34
+ },
35
+ "roleModels": {
36
+ "plan": {
37
+ "model": "muse-spark-1.3-contributor-free",
38
+ "levels": ["minimal", "low", "medium", "high", "xhigh"]
39
+ },
40
+ "build": {
41
+ "model": "muse-spark-1.3-contributor-free",
42
+ "levels": ["minimal", "low", "medium", "high", "xhigh"]
43
+ },
44
+ "reviewer": {
45
+ "model": "muse-spark-1.3-contributor-free",
46
+ "levels": ["minimal", "low", "medium", "high", "xhigh"]
47
+ },
48
+ "locate": {
49
+ "model": "space-bunny-free",
50
+ "levels": ["low", "medium", "high", "xhigh", "max"]
51
+ },
52
+ "verify": {
53
+ "model": "space-bunny-free",
54
+ "levels": ["low", "medium", "high", "xhigh", "max"]
55
+ }
56
+ },
57
+ "prune": {
58
+ "enabled": true,
59
+ "keepRecentToolCalls": 6,
60
+ "keepRecentTurns": 3,
61
+ "keepUserMessages": true
62
+ },
63
+ "fallback": ["x-preview-f-free", "longcat-2.5-preview-free", "deepseek-v4-flash-free"]
64
+ }
@@ -90,6 +90,18 @@
90
90
  "verify": { "$ref": "#/definitions/effortRequest" }
91
91
  }
92
92
  },
93
+ "roleModels": {
94
+ "type": "object",
95
+ "description": "Model per agent role, for a profile that splits the heavy reasoning roles onto a stronger model while the bulk-output roles stay on a high-output one. `model` remains the base: roles not named here, the top-level `model`, and `small_model` all resolve to it.",
96
+ "additionalProperties": false,
97
+ "properties": {
98
+ "plan": { "$ref": "#/definitions/roleModel" },
99
+ "build": { "$ref": "#/definitions/roleModel" },
100
+ "reviewer": { "$ref": "#/definitions/roleModel" },
101
+ "locate": { "$ref": "#/definitions/roleModel" },
102
+ "verify": { "$ref": "#/definitions/roleModel" }
103
+ }
104
+ },
93
105
  "prune": {
94
106
  "type": "object",
95
107
  "additionalProperties": false,
@@ -122,6 +134,23 @@
122
134
  }
123
135
  },
124
136
  "definitions": {
137
+ "roleModel": {
138
+ "type": "object",
139
+ "description": "A model for one role, plus the effort levels that model actually offers. Required when the model differs from the base, because a level the model does not have must never be written onto the agent.",
140
+ "additionalProperties": false,
141
+ "required": ["model"],
142
+ "properties": {
143
+ "model": {
144
+ "type": "string",
145
+ "description": "Model id without the provider prefix. Must resolve to a profile in this directory, so its limits and retention are known."
146
+ },
147
+ "levels": {
148
+ "type": "array",
149
+ "items": { "type": "string" },
150
+ "description": "Effort levels this role's model offers. Omitted means the role emits no variant at all, which is the right reading for a model with no tunable effort."
151
+ }
152
+ }
153
+ },
125
154
  "effortRequest": {
126
155
  "oneOf": [
127
156
  { "type": "string", "description": "Desired level, resolved to the nearest available one." },
@@ -47,10 +47,10 @@ over an unreviewed or blocked change is the failure this skill exists to stop.
47
47
 
48
48
  ## The publish boundary
49
49
 
50
- Publishing is a human action. Do not `git push`, do not open a pull request, do
51
- not publish a package, do not change repository visibility, even if the task
52
- seems to imply it. The guard will block `git push` in code, but the boundary
53
- is yours to hold, not only the hook's.
50
+ Publishing needs human approval. Ask before `git push`, opening a pull request,
51
+ publishing a package, or changing repository visibility, even if the task
52
+ seems to imply it. Permissions default to ask so opencode prompts first, but
53
+ the boundary is yours to hold, not only the hook's.
54
54
 
55
55
  Leave the tree in a state a human can review: on a branch, with the change
56
56
  visible in `git diff`.
package/src/config.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // defaults, experimental flags - is fixed here. Everything model-specific is
5
5
  // derived from the profile, so switching models changes only the profile file.
6
6
 
7
- import { pruneThreshold, reservedTokens, resolveRoles, resolveDefaultVariant } from "./profile.js"
7
+ import { pruneThreshold, reservedTokens, inputCap, effectiveRetention, resolveRoleModel, resolveRoles, resolveDefaultVariant, roleSplitModels } from "./profile.js"
8
8
 
9
9
  import { join } from "node:path"
10
10
  import { homedir } from "node:os"
@@ -87,8 +87,8 @@ export const BASH_ALLOWLIST = [
87
87
 
88
88
  /** Hard blocks. The guard plugin enforces the same set in code. */
89
89
  export const BASH_DENYLIST = [
90
- "git push *",
91
- "git push*",
90
+ // `git push` is intentionally absent: it falls through to the `*` ask rule
91
+ // so opencode prompts for human approval before publishing.
92
92
  "rm -rf *",
93
93
  "rm -fr *",
94
94
  "curl * | sh",
@@ -143,11 +143,16 @@ export function generateConfig(p, opts = {}) {
143
143
  for (const [role, level] of Object.entries(roles)) {
144
144
  if (level === undefined) continue
145
145
  const existing = { ...(agent[role] ?? {}) }
146
+ const rm = resolveRoleModel(p, role)
146
147
  // The model is pinned, never inherited. Preserving a model already written
147
148
  // on the agent is how `build` ended up on a `may-train` model while the rest
148
149
  // of the file claimed the profile's zero-retention one: the compiler was
149
150
  // asked to re-run over a hand-edited config and kept the hand-edit.
150
- existing.model = model
151
+ //
152
+ // `roleModels` is the one sanctioned way a role differs, because it is
153
+ // declared in the profile next to the model it points at and validated
154
+ // against retention.yml. Still an assignment, never a preserved value.
155
+ existing.model = rm ? `${p.provider}/${rm.model}` : model
151
156
  if (level === null) delete existing.variant
152
157
  else existing.variant = level
153
158
  if (AGENT_PERMISSIONS[role]) {
@@ -280,22 +285,49 @@ function render(cfg, p, opts) {
280
285
  const levels = p.effort?.levels ?? []
281
286
  const roles = resolveRoles(p)
282
287
 
288
+ const effective = effectiveRetention(p)
289
+ // The input cap that actually governs context management, which on a split
290
+ // profile is the widest role model rather than the base. The header must name
291
+ // it, or `reserved` and `prune` read as arithmetic against a number that is
292
+ // not the one they used.
293
+ const effectiveInput = inputCap(p)
294
+ const splits = Object.keys(p.roleModels ?? {})
283
295
  const head = [
284
296
  `${c}Generated by opencode-harness-kit. Edit profiles/${p.id}.json instead`,
285
297
  `${c}of this file, then re-run: oc-harness use ${p.id}`,
286
298
  `${c}`,
287
299
  `${c}profile ${p.id}`,
288
300
  `${c}model ${p.provider}/${p.model}`,
301
+ ...(splits.length
302
+ ? [
303
+ `${c}split ${splits.map((r) => `${r}=${p.roleModels?.[r]?.model}`).join(" ")}`,
304
+ `${c} (roles not listed above run on the base model above)`,
305
+ ]
306
+ : []),
289
307
  `${c}context ${p.limits.context.toLocaleString("en-US")} tokens`,
290
- `${c}input cap ${input.toLocaleString("en-US")} tokens`,
308
+ `${c}input cap ${input.toLocaleString("en-US")} tokens${effectiveInput !== input ? ` (base model; ${effectiveInput.toLocaleString("en-US")} governs split roles)` : ""}`,
291
309
  `${c}output cap ${p.limits.output.toLocaleString("en-US")} tokens`,
292
310
  `${c}reasoning ${levels.length ? levels.join(" < ") : "(not tunable)"}`,
293
- `${c}retention ${p.retention}${p.retention === "may-train" ? " <-- prompts may train a model" : ""}`,
311
+ // The header must name the retention that actually applies, which with a
312
+ // split is the least private model in the file, not the base one. Printing
313
+ // `p.retention` here is the same class of bug as the inherited `build`
314
+ // model: the file claims a privacy property the agents do not have.
315
+ `${c}retention ${effective.retention}${effective.retention === "may-train" ? " <-- prompts may train a model" : ""}`,
316
+ ...(effective.retention !== p.retention
317
+ ? [
318
+ `${c} base ${p.provider}/${p.model} is ${p.retention}, but ${effective.worst} makes the whole file ${effective.retention}`,
319
+ ]
320
+ : []),
294
321
  `${c}`,
295
322
  `${c}Derived numbers, do not hand-edit:`,
296
- `${c} compaction.reserved ${reservedTokens(p).toLocaleString("en-US")} (${Math.round((p.effort?.reserveShare ?? 0.12) * 100)}% of the input cap, held back for reasoning growth)`,
297
- `${c} prune threshold ${pruneThreshold(p).toLocaleString("en-US")} (input cap minus reserve)`,
323
+ `${c} compaction.reserved ${reservedTokens(p).toLocaleString("en-US")} (${Math.round((p.effort?.reserveShare ?? 0.12) * 100)}% of the governing input cap${effectiveInput !== input ? `, ${effectiveInput.toLocaleString("en-US")} for the widest split role` : ""}, held back for reasoning growth)`,
324
+ `${c} prune threshold ${pruneThreshold(p).toLocaleString("en-US")} (governing input cap minus reserve)`,
298
325
  `${c} role effort ${Object.entries(roles).map(([k, v]) => `${k}=${v ?? "unset"}`).join(" ")}`,
326
+ ...(roleSplitModels(p).length
327
+ ? [
328
+ `${c} role models ${roleSplitModels(p).map((r) => `${r.role}=${r.model}${r.limits ? ` (out ${r.limits.output.toLocaleString("en-US")})` : ""}`).join(" ")}`,
329
+ ]
330
+ : []),
299
331
  `${c} agent steps ${Object.entries(AGENT_STEPS).filter(([r]) => roles[r]).map(([r, n]) => `${r}=${n}`).join(" ")}`,
300
332
  ]
301
333
 
package/src/profile.js CHANGED
@@ -17,6 +17,7 @@ export const ROOT = resolve(HERE, "..")
17
17
  /**
18
18
  * @typedef {"zero-retention" | "may-train" | "unknown"} Retention
19
19
  * @typedef {string | null} EffortRequest
20
+ * @typedef {{ model: string, levels?: string[] }} RoleModel
20
21
  * @typedef {object} Profile
21
22
  * @property {string} id
22
23
  * @property {string} [title]
@@ -31,6 +32,7 @@ export const ROOT = resolve(HERE, "..")
31
32
  * structuredOutput?: boolean, attachment?: boolean }} [supports]
32
33
  * @property {{ levels?: string[], default?: string | null, reserveShare?: number }} [effort]
33
34
  * @property {Record<string, EffortRequest>} [roles]
35
+ * @property {Record<string, RoleModel>} [roleModels]
34
36
  * @property {{ enabled?: boolean, keepRecentToolCalls?: number,
35
37
  * keepRecentTurns?: number, keepUserMessages?: boolean }} [prune]
36
38
  * @property {string[]} [fallback]
@@ -135,6 +137,35 @@ export function validate(p, path = "<memory>") {
135
137
  `${where}: effort.reserveShare must be between 0 and 0.5`,
136
138
  )
137
139
  }
140
+ for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
141
+ if (!SPLITTABLE_ROLES.includes(role)) {
142
+ throw new ProfileError(
143
+ `${where}: roleModels.${role} is not one of ${SPLITTABLE_ROLES.join(", ")}`,
144
+ )
145
+ }
146
+ // resolveRoles walks `roles`, so a split for a role nobody asks for never
147
+ // reaches an agent. That reads as "split installed" while doing nothing.
148
+ if (!(role in (p.roles ?? {}))) {
149
+ throw new ProfileError(
150
+ `${where}: roleModels.${role} has no matching entry in roles, so it would never apply`,
151
+ )
152
+ }
153
+ if (!rm?.model || typeof rm.model !== "string") {
154
+ throw new ProfileError(`${where}: roleModels.${role} needs a model`)
155
+ }
156
+ if (rm.levels !== undefined && !Array.isArray(rm.levels)) {
157
+ throw new ProfileError(
158
+ `${where}: roleModels.${role}.levels must be an array`,
159
+ )
160
+ }
161
+ // retention.yml is the only place retention is decided, so a role model
162
+ // missing from it is a profile we cannot honestly write a header for.
163
+ if (!retentionFor(rm.model)) {
164
+ throw new ProfileError(
165
+ `${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`,
166
+ )
167
+ }
168
+ }
138
169
  }
139
170
 
140
171
  // Canonical ordering for reasoning effort, used to snap a requested level onto
@@ -178,15 +209,45 @@ export function resolveEffort(levels, requested) {
178
209
  return best ?? levels[levels.length - 1] ?? null
179
210
  }
180
211
 
212
+ /**
213
+ * Model and effort levels for one role, or null when the role stays on the
214
+ * profile's base model.
215
+ *
216
+ * @param {Profile} p
217
+ * @param {string} role
218
+ * @returns {{ model: string, levels: string[] } | null}
219
+ */
220
+ export function resolveRoleModel(p, role) {
221
+ const rm = p.roleModels?.[role]
222
+ if (!rm) return null
223
+ return { model: rm.model, levels: rm.levels ?? [] }
224
+ }
225
+
226
+ /**
227
+ * Effort levels a role's own model offers. Falls back to the profile's when the
228
+ * role is not split out, so an unsplit profile behaves exactly as before.
229
+ *
230
+ * @param {Profile} p
231
+ * @param {string} role
232
+ * @returns {string[]}
233
+ */
234
+ function levelsForRole(p, role) {
235
+ const rm = resolveRoleModel(p, role)
236
+ if (rm) return rm.levels
237
+ return p.effort?.levels ?? []
238
+ }
239
+
181
240
  /**
182
241
  * @param {Profile} p
183
242
  * @returns {Record<string, string | null>}
184
243
  */
185
244
  export function resolveRoles(p) {
186
- const levels = p.effort?.levels ?? []
187
245
  const out = /** @type {Record<string, string | null>} */ ({})
188
246
  for (const [role, want] of Object.entries(p.roles ?? {})) {
189
- out[role] = resolveEffort(levels, want)
247
+ // Resolve against the levels of the model this role actually runs on. A
248
+ // role asking for `max` on a model topping out at `xhigh` gets `xhigh`,
249
+ // which is only correct if the profile knows the role's real model.
250
+ out[role] = resolveEffort(levelsForRole(p, role), want)
190
251
  }
191
252
  return out
192
253
  }
@@ -201,10 +262,39 @@ export function resolveDefaultVariant(p) {
201
262
  * tool output. Scaled from the model, never hardcoded.
202
263
  * @param {Profile} p @returns {number}
203
264
  */
265
+ // The roles a profile may split onto another model. Kept in step with
266
+ // `roleModels` in profiles/schema.json, which nothing validates at runtime.
267
+ const SPLITTABLE_ROLES = ["plan", "build", "reviewer", "locate", "verify"]
268
+
269
+ /**
270
+ * The model whose limits govern context management: the one with the widest
271
+ * input cap, not necessarily the base model.
272
+ *
273
+ * A split profile derives `reserved` and `prune` from a single `limits`, but
274
+ * the roles do not share it. Sizing for the narrowest input cap silently
275
+ * throttles every wider role to under half its real window, which is the whole
276
+ * point of running them on a bigger model. Sizing for the widest means no role
277
+ * can be under-reserved, which is the direction that has to be safe.
278
+ *
279
+ * @param {Profile} p
280
+ * @returns {number}
281
+ */
282
+ export function inputCap(p) {
283
+ let cap = p.limits.input ?? p.limits.context
284
+ for (const s of roleSplitModels(p)) {
285
+ const other = s.limits?.input ?? s.limits?.context
286
+ if (typeof other === "number" && other > cap) cap = other
287
+ }
288
+ return cap
289
+ }
290
+
291
+ /**
292
+ * @param {Profile} p
293
+ * @returns {number}
294
+ */
204
295
  export function reservedTokens(p) {
205
- const input = p.limits.input ?? p.limits.context
206
296
  const share = p.effort?.reserveShare ?? 0.12
207
- return Math.max(8000, Math.round(input * share))
297
+ return Math.max(8000, Math.round(inputCap(p) * share))
208
298
  }
209
299
 
210
300
  /**
@@ -212,7 +302,7 @@ export function reservedTokens(p) {
212
302
  * @param {Profile} p @returns {number}
213
303
  */
214
304
  export function pruneThreshold(p) {
215
- return (p.limits.input ?? p.limits.context) - reservedTokens(p)
305
+ return inputCap(p) - reservedTokens(p)
216
306
  }
217
307
 
218
308
  /**
@@ -296,6 +386,67 @@ export function retentionFor(id) {
296
386
  return table[id] ?? table[id.includes("/") ? id : `opencode/${id}`]
297
387
  }
298
388
 
389
+ /**
390
+ * Every model a profile can put behind the user, base plus any split roles.
391
+ *
392
+ * @param {Profile} p
393
+ * @returns {string[]}
394
+ */
395
+ export function modelsInPlay(p) {
396
+ const out = [p.model]
397
+ for (const rm of Object.values(p.roleModels ?? {})) {
398
+ if (!out.includes(rm.model)) out.push(rm.model)
399
+ }
400
+ return out
401
+ }
402
+
403
+ /**
404
+ * The split roles, each with the limits of the model that role actually runs
405
+ * on. A role on a different model has a different output cap, and the header
406
+ * has to say so: reading "output cap 524,288" and assuming `build` can spend
407
+ * all of it is wrong whenever build is split onto a smaller model.
408
+ *
409
+ * @param {Profile} p
410
+ * @returns {{ role: string, model: string, levels: string[], limits: Profile["limits"] | null }[]}
411
+ */
412
+ export function roleSplitModels(p) {
413
+ return Object.entries(p.roleModels ?? {}).map(([role, rm]) => {
414
+ let limits = null
415
+ try {
416
+ limits = loadProfile(rm.model)?.limits ?? null
417
+ } catch {
418
+ limits = null
419
+ }
420
+ return { role, model: rm.model, levels: rm.levels ?? [], limits }
421
+ })
422
+ }
423
+
424
+ // Ordered safest-first: a profile is only as private as its least private model.
425
+ const RETENTION_SEVERITY = ["zero-retention", "unknown", "may-train"]
426
+
427
+ /**
428
+ * The retention that actually applies once every role is considered, which is
429
+ * what a config header has to print. `retention.yml` is the source of truth.
430
+ *
431
+ * @param {Profile} p
432
+ * @returns {{ retention: string, worst: string | null }}
433
+ */
434
+ export function effectiveRetention(p) {
435
+ let worst = null
436
+ let retention = "zero-retention"
437
+ for (const id of modelsInPlay(p)) {
438
+ const declared = retentionFor(id)?.retention ?? "unknown"
439
+ if (declared === "may-train") {
440
+ return { retention: "may-train", worst: id }
441
+ }
442
+ if (!worst || RETENTION_SEVERITY.indexOf(declared) > RETENTION_SEVERITY.indexOf(worst)) {
443
+ worst = declared
444
+ }
445
+ retention = worst
446
+ }
447
+ return { retention, worst }
448
+ }
449
+
299
450
  /** Where opencode keeps its config, honouring the same env var it does. */
300
451
  export function configFilePath() {
301
452
  const dir =
package/src/rules.js CHANGED
@@ -349,24 +349,9 @@ export const BASH_RULES = [
349
349
  test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
350
350
  why: "downloads and executes a script in one step",
351
351
  },
352
- {
353
- name: "git push with force",
354
- scope: "segment",
355
- // A dry run publishes nothing, so it is not the thing being gated.
356
- check: (seg) =>
357
- gitSubcommand(seg) === "push" &&
358
- tokenize(seg).some((w) => w === "--force" || w === "-f" || w.startsWith("--force")),
359
- why: "overwrites published history",
360
- },
361
- {
362
- name: "git push",
363
- scope: "segment",
364
- // Flags are matched as whole words: a `-f` in `my-feature-f` is a branch
365
- // name, not a force flag.
366
- check: (seg) =>
367
- gitSubcommand(seg) === "push" && !tokenize(seg).some((w) => w === "--dry-run" || w === "-n"),
368
- why: "publishing is a human decision",
369
- },
352
+ // `git push` is ask-gated via permissions (`*` ask in the generated
353
+ // config), not guard-blocked. Publishing needs human approval, but the
354
+ // guard lets it through so opencode can prompt first.
370
355
  {
371
356
  name: "recursive delete",
372
357
  scope: "segment",