Skip to content

Commit 0f799ea

Browse files
committed
feat(iso-harness): emit opencode-model-fallback.json from iso/config.json
Add optional top-level `opencodeModelFallback` on `iso/config.json`. When present, the OpenCode emitter writes `.opencode/opencode-model-fallback.json` verbatim so `@razroo/opencode-model-fallback` picks up global `fallback_models`, custom `retryable_error_patterns`, cooldowns, etc. without merging unknown keys into `opencode.json`. Validates the field is a plain object. Documented in README + CHANGELOG. Tests cover happy path and schema rejection. Bumps @razroo/iso-harness to 0.6.1. Made-with: Cursor
1 parent 0a0f7fb commit 0f799ea

7 files changed

Lines changed: 85 additions & 6 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/iso-harness/CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# @razroo/iso-harness
22

3+
## 0.6.1
4+
5+
### Patch Changes
6+
7+
- Emit `.opencode/opencode-model-fallback.json` when `iso/config.json`
8+
defines a top-level `opencodeModelFallback` object.
9+
10+
This is the on-disk config shape consumed by
11+
`@razroo/opencode-model-fallback` (retryable error patterns, global
12+
`fallback_models`, cooldowns, etc.). Keeping it in `iso/` means
13+
`iso-harness build` fans it out with the rest of the OpenCode target
14+
instead of hand-maintaining a generated path outside the harness
15+
source tree.
16+
317
## 0.6.0
418

519
### Minor Changes

packages/iso-harness/README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ iso/ → CLAUDE.md (Claude Code
1313
├── agents/ .mcp.json
1414
│ └── researcher.md → AGENTS.md (Codex + OpenCode)
1515
└── commands/ .codex/config.toml
16-
└── review.md .opencode/agents/*.md
16+
└── review.md .opencode/agents/*.md
1717
.opencode/skills/*.md
18+
.opencode/opencode-model-fallback.json (optional; from `opencodeModelFallback` in iso/config.json)
1819
opencode.json
1920
→ .cursor/rules/*.mdc (Cursor)
2021
.cursor/mcp.json
@@ -50,6 +51,7 @@ iso-harness build --watch # rebuild on every change under iso/
5051
```
5152
iso/
5253
├── instructions.md # root prompt → CLAUDE.md / AGENTS.md / .cursor/rules/main.mdc
54+
├── config.json # optional — targets.* merges + opencodeModelFallback file emit
5355
├── mcp.json # shared MCP server definitions
5456
├── agents/ # subagents
5557
│ └── <slug>.md # YAML frontmatter + body
@@ -138,6 +140,11 @@ explicit hatches keep harness-specific features possible:
138140
harness config (not per-item). Keys under `targets.opencode` are
139141
merged into the generated `opencode.json` — use this for OpenCode's
140142
top-level `instructions: [...]` array, for example.
143+
4. **`iso/config.json` top-level `opencodeModelFallback`** — JSON object
144+
written verbatim to `.opencode/opencode-model-fallback.json` for the
145+
[`@razroo/opencode-model-fallback`](https://www.npmjs.com/package/@razroo/opencode-model-fallback)
146+
plugin (`retryable_error_patterns`, global `fallback_models`, etc.).
147+
OpenCode-only; other harnesses ignore it.
141148

142149
```json
143150
// iso/config.json
@@ -146,13 +153,18 @@ explicit hatches keep harness-specific features possible:
146153
"opencode": {
147154
"instructions": ["templates/states.yml"]
148155
}
156+
},
157+
"opencodeModelFallback": {
158+
"cooldown_seconds": 60,
159+
"retryable_error_patterns": ["(?i)venice.*insufficient"],
160+
"fallback_models": ["openrouter/openai/gpt-oss-120b:free"]
149161
}
150162
}
151163
```
152164

153-
For features with no cross-harness analogue (Claude Code hooks, OpenCode
154-
`fallback_models`), edit the generated file or add a separate post-build
155-
step — don't force them into the neutral source.
165+
Per-agent OpenCode `fallback_models` still belong in agent frontmatter
166+
under `targets.opencode`. Use `opencodeModelFallback` only for the
167+
**global** plugin file OpenCode loads from `.opencode/`.
156168

157169
## Composition with `@razroo/iso-route`
158170

packages/iso-harness/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@razroo/iso-harness",
3-
"version": "0.6.0",
3+
"version": "0.6.1",
44
"description": "One config for every coding agent — Cursor, Claude Code, Codex, OpenCode.",
55
"type": "module",
66
"bin": {

packages/iso-harness/src/targets/opencode.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,5 +125,14 @@ export async function emitOpenCode(src, outDir, opts = {}) {
125125
await push(opencodeJsonPath, output, writeJson);
126126
}
127127

128+
// Optional @razroo/opencode-model-fallback plugin config — emitted as its
129+
// own file so OpenCode picks it up without polluting opencode.json. Source
130+
// of truth lives next to other harness config in iso/config.json.
131+
const modelFallback = src.config?.opencodeModelFallback;
132+
if (modelFallback != null) {
133+
const p = path.join(outDir, '.opencode', 'opencode-model-fallback.json');
134+
await push(p, JSON.stringify(modelFallback, null, 2) + '\n');
135+
}
136+
128137
return written;
129138
}

packages/iso-harness/src/validate.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,16 @@ export function validateConfig(config, file = 'iso/config.json') {
157157
}
158158
}
159159
}
160+
if (config.opencodeModelFallback !== undefined) {
161+
if (!isPlainObject(config.opencodeModelFallback)) {
162+
diags.push({
163+
severity: 'error',
164+
file,
165+
field: 'opencodeModelFallback',
166+
message: `"opencodeModelFallback" must be a JSON object (plugin config for .opencode/opencode-model-fallback.json)`,
167+
});
168+
}
169+
}
160170
return diags;
161171
}
162172

packages/iso-harness/tests/build.test.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,40 @@ test('loadSource: throws a clear error when mcp.json has no servers object', asy
117117
await assert.rejects(() => loadSource(iso), /top-level "servers" object/);
118118
});
119119

120+
test('build: opencode target writes opencode-model-fallback.json from opencodeModelFallback', async () => {
121+
const { iso, out } = mkIso();
122+
writeFileSync(
123+
join(iso, 'config.json'),
124+
JSON.stringify(
125+
{
126+
opencodeModelFallback: {
127+
cooldown_seconds: 1,
128+
retryable_error_patterns: ['(?i)venice'],
129+
fallback_models: ['openrouter/openai/gpt-oss-20b:free'],
130+
},
131+
},
132+
null,
133+
2,
134+
),
135+
);
136+
await build({ source: iso, out, targets: ['opencode'] });
137+
const p = join(out, '.opencode', 'opencode-model-fallback.json');
138+
assert.ok(existsSync(p));
139+
const got = JSON.parse(readFileSync(p, 'utf8'));
140+
assert.equal(got.cooldown_seconds, 1);
141+
assert.deepEqual(got.retryable_error_patterns, ['(?i)venice']);
142+
assert.deepEqual(got.fallback_models, ['openrouter/openai/gpt-oss-20b:free']);
143+
});
144+
145+
test('build: rejects opencodeModelFallback when not a plain object', async () => {
146+
const { iso, out } = mkIso();
147+
writeFileSync(join(iso, 'config.json'), JSON.stringify({ opencodeModelFallback: [] }));
148+
await assert.rejects(
149+
() => build({ source: iso, out, targets: ['opencode'] }),
150+
/opencodeModelFallback/,
151+
);
152+
});
153+
120154
test('build: rendered CLAUDE.md preserves the instructions file verbatim (plus trailing newline)', async () => {
121155
const { iso, out } = mkIso({ instructions: '# My project\n\nRule 1.\nRule 2.' });
122156
await build({ source: iso, out, targets: ['claude'] });

0 commit comments

Comments
 (0)