opencode-harness-kit 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,42 +449,202 @@ 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");
503
565
  return join(dir, "opencode.jsonc");
504
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
+ }
505
636
  function profileIdFromConfig() {
506
637
  const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
507
638
  if (fromEnv) return fromEnv.trim() || null;
508
639
  const file = configFilePath();
509
640
  if (!existsSync(file)) return null;
510
641
  try {
511
- const text = readFileSync(file, "utf8");
512
- const json = text.slice(text.indexOf("{"));
513
- const parsed = JSON.parse(json);
514
- 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;
515
648
  return typeof id === "string" && id ? id : null;
516
649
  } catch {
517
650
  return null;
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.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
+ "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",
@@ -33,7 +37,7 @@
33
37
  "GLOBAL.md",
34
38
  "agents/",
35
39
  "skills/",
36
- "docs/",
40
+ "docs/*.md",
37
41
  "README.md",
38
42
  "LICENSE"
39
43
  ],
@@ -71,9 +75,7 @@
71
75
  "devDependencies": {
72
76
  "@opencode-ai/plugin": "^1.18.31",
73
77
  "@types/node": "^22.0.0",
78
+ "esbuild": "^0.28.2",
74
79
  "typescript": "^5.6.0"
75
- },
76
- "dependencies": {
77
- "esbuild": "^0.28.2"
78
80
  }
79
81
  }
@@ -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." },
@@ -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
 
@@ -47,10 +37,10 @@ over an unreviewed or blocked change is the failure this skill exists to stop.
47
37
 
48
38
  ## The publish boundary
49
39
 
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.
40
+ Publishing needs human approval. Ask before `git push`, opening a pull request,
41
+ publishing a package, or changing repository visibility, even if the task
42
+ seems to imply it. Permissions default to ask so opencode prompts first, but
43
+ the boundary is yours to hold, not only the hook's.
54
44
 
55
45
  Leave the tree in a state a human can review: on a branch, with the change
56
46
  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