opencode-harness-kit 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GLOBAL.md +1 -1
- package/README.md +90 -10
- package/agents/build.md +2 -2
- package/bin/oc-harness.mjs +148 -42
- package/dist/index.js +197 -64
- package/package.json +8 -6
- package/profiles/index.json +18 -0
- package/profiles/muse-split-free.json +64 -0
- package/profiles/schema.json +29 -0
- package/profiles/space-bunny-free.json +1 -1
- package/skills/space-finish/SKILL.md +4 -14
- package/src/config.js +40 -8
- package/src/driver/run.mjs +237 -0
- package/src/profile.js +269 -14
- package/src/rules.js +3 -18
- package/src/tasks.js +248 -0
package/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
|
-
|
|
245
|
-
|
|
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(
|
|
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(
|
|
486
|
+
return Math.max(8e3, Math.round(inputCap(p) * share));
|
|
497
487
|
}
|
|
498
488
|
function pruneThreshold(p) {
|
|
499
|
-
return (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
|
|
512
|
-
const
|
|
513
|
-
const
|
|
514
|
-
|
|
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
|
|
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/
|
|
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
|
}
|
package/profiles/index.json
CHANGED
|
@@ -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
|
+
}
|
package/profiles/schema.json
CHANGED
|
@@ -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,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
|
|
51
|
-
|
|
52
|
-
seems to imply it.
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|