opencode-harness-kit 0.0.0-stage → 0.1.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 +110 -0
- package/LICENSE +21 -0
- package/README.md +421 -2
- package/agents/build.md +66 -0
- package/agents/locate.md +48 -0
- package/agents/plan.md +61 -0
- package/agents/reviewer.md +62 -0
- package/agents/verify.md +54 -0
- package/bin/baseline.mjs +367 -0
- package/bin/git-hooks/pre-commit +30 -0
- package/bin/oc-harness.mjs +977 -0
- package/dist/index.js +976 -0
- package/docs/KNOWN-LIMITS.md +188 -0
- package/docs/RESEARCH.md +210 -0
- package/examples/muse-spark-1.3-contributor-free.json +41 -0
- package/package.json +77 -4
- package/profiles/deepseek-v4-flash-free.json +41 -0
- package/profiles/index.json +60 -0
- package/profiles/longcat-2.5-preview-free.json +41 -0
- package/profiles/schema.json +132 -0
- package/profiles/space-bunny-free.json +42 -0
- package/profiles/x-preview-f-free.json +41 -0
- package/retention.yml +110 -0
- package/skills/space-build/SKILL.md +54 -0
- package/skills/space-finish/SKILL.md +63 -0
- package/skills/space-plan/SKILL.md +101 -0
- package/skills/space-plan/reference/task-template.md +47 -0
- package/skills/space-review/SKILL.md +53 -0
- package/src/config.js +326 -0
- package/src/effort.js +98 -0
- package/src/guard.ts +59 -0
- package/src/index.ts +80 -0
- package/src/profile.js +333 -0
- package/src/prune.ts +258 -0
- package/src/router.ts +192 -0
- package/src/rules.js +496 -0
- package/src/telemetry.ts +98 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Compile a profile into an opencode.jsonc.
|
|
2
|
+
//
|
|
3
|
+
// Config that does not depend on the model - permission allowlist, pruning
|
|
4
|
+
// defaults, experimental flags - is fixed here. Everything model-specific is
|
|
5
|
+
// derived from the profile, so switching models changes only the profile file.
|
|
6
|
+
|
|
7
|
+
import { pruneThreshold, reservedTokens, resolveRoles, resolveDefaultVariant } from "./profile.js"
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path"
|
|
10
|
+
import { homedir } from "node:os"
|
|
11
|
+
|
|
12
|
+
/** @typedef {import("./profile.js").Profile} Profile */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where the global rule set lives, as opencode should be told to look for it.
|
|
16
|
+
*
|
|
17
|
+
* Honours OPENCODE_CONFIG_DIR, because `~/.config/opencode` is only the default
|
|
18
|
+
* and emitting it unconditionally points at the wrong file for anyone using a
|
|
19
|
+
* custom config directory - the installer would write one place and the
|
|
20
|
+
* compiler would name another. Written with a leading `~` when it is the
|
|
21
|
+
* default, which is both correct and readable in the generated file.
|
|
22
|
+
*/
|
|
23
|
+
export function globalInstructionsPath() {
|
|
24
|
+
const dir = process.env.OPENCODE_CONFIG_DIR ?? join(homedir(), ".config", "opencode")
|
|
25
|
+
if (dir === join(homedir(), ".config", "opencode")) return "~/.config/opencode/GLOBAL.md"
|
|
26
|
+
return join(dir, "GLOBAL.md")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The default location, for documentation and tests. Prefer
|
|
31
|
+
* `globalInstructionsPath()` when generating a config.
|
|
32
|
+
*
|
|
33
|
+
* It is written by `oc-harness install`, which symlinks this file rather than
|
|
34
|
+
* copying it, so the repo stays the source of truth. `oc-harness verify` fails if
|
|
35
|
+
* the path it names does not resolve - the failure mode where a config points at
|
|
36
|
+
* an instruction file that was never installed is silent and makes the whole
|
|
37
|
+
* harness a no-op.
|
|
38
|
+
*/
|
|
39
|
+
export const GLOBAL_INSTRUCTIONS = "~/.config/opencode/GLOBAL.md"
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read-only and verification commands.
|
|
43
|
+
*
|
|
44
|
+
* Measured against 5,239 real bash calls from one install: 95.3% would prompt
|
|
45
|
+
* under a narrow allowlist, 23% under this one. The gated remainder is
|
|
46
|
+
* deliberate - ssh, curl, rm, pkill, osascript, cp, mv, kill, docker.
|
|
47
|
+
*
|
|
48
|
+
* `X*` with no space is used on purpose: opencode's `X *` pattern requires a
|
|
49
|
+
* trailing space and therefore does not match a bare `X`.
|
|
50
|
+
*
|
|
51
|
+
* The `gh` verbs below are the read half of the CLI and nothing else. `gh pr
|
|
52
|
+
* create`, `gh pr merge` and `gh api` are NOT here: they write, and `gh api` in
|
|
53
|
+
* particular can POST to any endpoint. Measured cost of gating the whole `gh`
|
|
54
|
+
* surface was 146 prompts; gating only the mutating verbs keeps those 146
|
|
55
|
+
* prompt-free without opening a publish path the guard plugin cannot police.
|
|
56
|
+
*/
|
|
57
|
+
export const BASH_ALLOWLIST = [
|
|
58
|
+
"ls*", "cat*", "head*", "tail*", "wc*", "file*", "stat*", "pwd*", "env*", "printenv*",
|
|
59
|
+
"date*", "whoami*", "which*", "find*", "tree*", "du*", "df*", "lsof*", "sed*", "awk*",
|
|
60
|
+
"sort*", "uniq*", "diff*", "grep*", "rg*", "echo*", "printf*", "sleep*",
|
|
61
|
+
"cd*", "realpath*", "basename*", "dirname*", "readlink*",
|
|
62
|
+
"for*", "set*", "export*", "unset*", "bash*", "sh*", "true*", "false*", "test*",
|
|
63
|
+
"read*", "#*", "xargs*", "seq*", "exit*", ":*",
|
|
64
|
+
"node*", "python3*", "python*", "npm*", "bun*", "uv*", "go*", "cargo*", "rustc*",
|
|
65
|
+
"opencode*", "sqlite3*", "jq*", "fd*",
|
|
66
|
+
"git status*", "git log*", "git diff*", "git show*", "git branch*",
|
|
67
|
+
"git rev-parse*", "git config --get*", "git remote*", "git blame*",
|
|
68
|
+
"git stash list*", "git ls-files*", "git describe*", "git shortlog*", "git --version*",
|
|
69
|
+
// `git -C <dir> <read-verb>` only. A bare `git -C *` would allow `git -C x push`.
|
|
70
|
+
"git -C * status*", "git -C * log*", "git -C * diff*", "git -C * rev-parse*",
|
|
71
|
+
"git -C * ls-files*", "git -C * show*",
|
|
72
|
+
// Staging is local and reversible; publishing is not and stays gated.
|
|
73
|
+
"git add*",
|
|
74
|
+
// `chmod 777` is denied below. `+x` is ordinary build work.
|
|
75
|
+
"chmod +x*", "chmod u+x*",
|
|
76
|
+
"mkdir*", "ps*",
|
|
77
|
+
"gh pr list*", "gh pr view*", "gh pr diff*", "gh pr checks*", "gh pr status*",
|
|
78
|
+
"gh issue list*", "gh issue view*", "gh repo view*", "gh auth status*",
|
|
79
|
+
"PYTEST_DISABLE_PLUGIN_AUTOLOAD=1*", "PYTHONPATH=*", "PYTHONUNBUFFERED=1*",
|
|
80
|
+
"python3 -m pytest*", "python3 -m venv*", "python3 -m compileall*",
|
|
81
|
+
"python3 -c*", "python3 agent/*", "python3 src/*", "python3 -",
|
|
82
|
+
".venv/bin/python*", "pytest*", "ruff*", "mypy*", "black*",
|
|
83
|
+
"npm test*", "npm run *", "npm ci*", "npm ls*", "npm view *",
|
|
84
|
+
"npx*", "uv run *", "uv pip *", "bun run *", "bunx*", "node -e*",
|
|
85
|
+
"opencode debug*", "opencode --version*", "opencode models*",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
/** Hard blocks. The guard plugin enforces the same set in code. */
|
|
89
|
+
export const BASH_DENYLIST = [
|
|
90
|
+
"git push *",
|
|
91
|
+
"git push*",
|
|
92
|
+
"rm -rf *",
|
|
93
|
+
"rm -fr *",
|
|
94
|
+
"curl * | sh",
|
|
95
|
+
"curl * | bash",
|
|
96
|
+
"chmod 777 *",
|
|
97
|
+
"chmod 777*",
|
|
98
|
+
"gh pr create*",
|
|
99
|
+
"gh pr merge*",
|
|
100
|
+
"gh pr close*",
|
|
101
|
+
"gh release*",
|
|
102
|
+
"gh repo delete*",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Iteration cap per role, written as `agent.<role>.steps`.
|
|
107
|
+
*
|
|
108
|
+
* `steps` is a loop breaker rather than a quality knob. On exhaustion OpenCode
|
|
109
|
+
* forces a text-only response, so an agent that has stopped making progress
|
|
110
|
+
* reports what it has instead of silently looping until the context is gone.
|
|
111
|
+
* The caps are sized to the role's real work: build gets room because it runs
|
|
112
|
+
* the edit-verify-fix cycle, verify gets room because a full suite is many
|
|
113
|
+
* commands, and the read-only roles are capped tight because they have one job.
|
|
114
|
+
*/
|
|
115
|
+
export const AGENT_STEPS = /** @type {Record<string, number>} */ ({
|
|
116
|
+
build: 40,
|
|
117
|
+
plan: 25,
|
|
118
|
+
reviewer: 15,
|
|
119
|
+
locate: 12,
|
|
120
|
+
verify: 20,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
/** Roles the kit writes, with the permissions that keep them honest. */
|
|
124
|
+
const AGENT_PERMISSIONS = /** @type {Record<string, Record<string, string>>} */ ({
|
|
125
|
+
plan: { edit: "deny" },
|
|
126
|
+
reviewer: { edit: "deny" },
|
|
127
|
+
locate: { edit: "deny", bash: "deny" },
|
|
128
|
+
verify: { edit: "deny" },
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {Profile} p
|
|
133
|
+
* @param {{ base?: Record<string, any>, comments?: boolean }} [opts]
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
export function generateConfig(p, opts = {}) {
|
|
137
|
+
const base = opts.base ?? {}
|
|
138
|
+
const model = `${p.provider}/${p.model}`
|
|
139
|
+
const roles = resolveRoles(p)
|
|
140
|
+
const instructionsPath = globalInstructionsPath()
|
|
141
|
+
|
|
142
|
+
const agent = { ...(base.agent ?? {}) }
|
|
143
|
+
for (const [role, level] of Object.entries(roles)) {
|
|
144
|
+
if (level === undefined) continue
|
|
145
|
+
const existing = { ...(agent[role] ?? {}) }
|
|
146
|
+
// The model is pinned, never inherited. Preserving a model already written
|
|
147
|
+
// on the agent is how `build` ended up on a `may-train` model while the rest
|
|
148
|
+
// of the file claimed the profile's zero-retention one: the compiler was
|
|
149
|
+
// asked to re-run over a hand-edited config and kept the hand-edit.
|
|
150
|
+
existing.model = model
|
|
151
|
+
if (level === null) delete existing.variant
|
|
152
|
+
else existing.variant = level
|
|
153
|
+
if (AGENT_PERMISSIONS[role]) {
|
|
154
|
+
existing.permission = {
|
|
155
|
+
...(existing.permission ?? {}),
|
|
156
|
+
...AGENT_PERMISSIONS[role],
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (AGENT_STEPS[role]) existing.steps = AGENT_STEPS[role]
|
|
160
|
+
agent[role] = existing
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const bashRules = {
|
|
164
|
+
"*": "ask",
|
|
165
|
+
...Object.fromEntries(BASH_ALLOWLIST.map((rule) => [rule, "allow"])),
|
|
166
|
+
...Object.fromEntries(BASH_DENYLIST.map((rule) => [rule, "deny"])),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const compaction = { ...(base.compaction ?? {}) }
|
|
170
|
+
// `tail_turns` is dropped even if the inherited config had one. It only
|
|
171
|
+
// applies during compaction, and compaction is keyed off the full context
|
|
172
|
+
// ceiling, which a normal session never reaches. On the measured install it
|
|
173
|
+
// had never fired once, so it was inert config.
|
|
174
|
+
delete compaction.tail_turns
|
|
175
|
+
compaction.auto = true
|
|
176
|
+
compaction.prune = true
|
|
177
|
+
compaction.reserved = reservedTokens(p)
|
|
178
|
+
|
|
179
|
+
const base_ = { ...base }
|
|
180
|
+
// Two keys are dropped from the inherited config outright rather than merged.
|
|
181
|
+
// Both are cases where an existing value silently defeats the compiler:
|
|
182
|
+
// `variant` is rejected by the schema and stripped at load, so keeping it
|
|
183
|
+
// makes the file look tiered while doing nothing; and `tool_output` set tight
|
|
184
|
+
// by an earlier version would otherwise survive every regeneration.
|
|
185
|
+
delete base_.variant
|
|
186
|
+
delete base_.tool_output
|
|
187
|
+
|
|
188
|
+
const out = {
|
|
189
|
+
$schema: "https://opencode.ai/config.json",
|
|
190
|
+
...base_,
|
|
191
|
+
|
|
192
|
+
model,
|
|
193
|
+
// Same model, not a cheaper one: swapping models mid-session changes the
|
|
194
|
+
// voice and the tool habits. Lower the effort instead. An inherited
|
|
195
|
+
// small_model pointing at a different model is replaced, not preserved.
|
|
196
|
+
small_model: model,
|
|
197
|
+
// No top-level `variant`. The 1.18.35 Config schema sets
|
|
198
|
+
// `additionalProperties: false` and has no `variant` key, so OpenCode
|
|
199
|
+
// strips it from the resolved config - verified with `opencode debug
|
|
200
|
+
// config`. The per-role tiering is carried by `agent.<role>.variant`,
|
|
201
|
+
// which is a real schema key, and `effort.js` sets the session default at
|
|
202
|
+
// the `config` hook where the runtime does still see it.
|
|
203
|
+
default_agent: base.default_agent ?? "build",
|
|
204
|
+
|
|
205
|
+
// The default is 1, which blocks subagents from spawning subagents and so
|
|
206
|
+
// removes every nested review loop.
|
|
207
|
+
subagent_depth: base.subagent_depth ?? 2,
|
|
208
|
+
// Omitting `lsp` disables it entirely, so this must be set explicitly.
|
|
209
|
+
lsp: base.lsp ?? true,
|
|
210
|
+
instructions: base.instructions ?? [instructionsPath],
|
|
211
|
+
|
|
212
|
+
compaction,
|
|
213
|
+
// OpenCode's own default (2000 lines / 51200 bytes), reached back from an
|
|
214
|
+
// earlier 200 / 8192. The tight setting truncated 415 of 2,959 measured
|
|
215
|
+
// `read` calls, and 418 calls were issued with an `offset` to fetch the
|
|
216
|
+
// rest: the truncation causes the re-read, one for one. At the measured
|
|
217
|
+
// 17.3s median per request that round trip costs far more than the tokens
|
|
218
|
+
// it saves, and prune.ts already bounds old tool output deterministically.
|
|
219
|
+
// Only 14 of 2,959 reads exceed 51,200 bytes, so this ceiling is not the
|
|
220
|
+
// thing that limits context. `oc-harness baseline` re-measures all four
|
|
221
|
+
// numbers if this ever needs arguing again.
|
|
222
|
+
tool_output: { max_lines: 2000, max_bytes: 51200 },
|
|
223
|
+
|
|
224
|
+
skills: base.skills ?? {},
|
|
225
|
+
|
|
226
|
+
experimental: {
|
|
227
|
+
batch_tool: true,
|
|
228
|
+
continue_loop_on_deny: true,
|
|
229
|
+
...(base.experimental ?? {}),
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
permission: {
|
|
233
|
+
...(base.permission ?? {}),
|
|
234
|
+
read: "allow",
|
|
235
|
+
// `allow` here is load-bearing in a way that is easy to get backwards, and
|
|
236
|
+
// it is pinned rather than inherited for the same reason the model is: an
|
|
237
|
+
// inherited `ask` survives regeneration and re-breaks every read-only
|
|
238
|
+
// role.
|
|
239
|
+
//
|
|
240
|
+
// OpenCode appends the global rule AFTER the agent's own, and last match
|
|
241
|
+
// wins, so a global `edit: "ask"` silently overrides every agent's
|
|
242
|
+
// `edit: "deny"` and the read-only roles can edit files after a prompt.
|
|
243
|
+
// `allow` is the implicit default and emits no rule at all, which leaves
|
|
244
|
+
// the per-agent `deny` intact. Writes outside the project are still
|
|
245
|
+
// gated by `external_directory`, which defaults to `ask`.
|
|
246
|
+
edit: "allow",
|
|
247
|
+
// OpenCode ships this as `deny` on every built-in agent, so a genuinely
|
|
248
|
+
// blocked agent has no cheap way to say so and falls back to prose. One
|
|
249
|
+
// structured question is cheaper to answer and easier to act on.
|
|
250
|
+
question: "allow",
|
|
251
|
+
doom_loop: base.permission?.doom_loop ?? "ask",
|
|
252
|
+
bash: bashRules,
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
agent,
|
|
256
|
+
|
|
257
|
+
// The router plugin reads this at startup to know which fallback queue to
|
|
258
|
+
// walk, and it is also how a profile stays identifiable after the config
|
|
259
|
+
// has been hand-edited.
|
|
260
|
+
harness: {
|
|
261
|
+
profile: p.id,
|
|
262
|
+
...(p.fallback?.length ? { fallback: p.fallback[0] } : {}),
|
|
263
|
+
threshold: pruneThreshold(p),
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return render(out, p, { comments: opts.comments ?? true, instructionsPath })
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* @param {Record<string, any>} cfg
|
|
272
|
+
* @param {Profile} p
|
|
273
|
+
* @param {{ comments: boolean, instructionsPath?: string }} opts
|
|
274
|
+
* @returns {string}
|
|
275
|
+
*/
|
|
276
|
+
function render(cfg, p, opts) {
|
|
277
|
+
const c = opts.comments ? "// " : ""
|
|
278
|
+
const instructionsPath = opts.instructionsPath ?? globalInstructionsPath()
|
|
279
|
+
const input = p.limits.input ?? p.limits.context
|
|
280
|
+
const levels = p.effort?.levels ?? []
|
|
281
|
+
const roles = resolveRoles(p)
|
|
282
|
+
|
|
283
|
+
const head = [
|
|
284
|
+
`${c}Generated by opencode-harness-kit. Edit profiles/${p.id}.json instead`,
|
|
285
|
+
`${c}of this file, then re-run: oc-harness use ${p.id}`,
|
|
286
|
+
`${c}`,
|
|
287
|
+
`${c}profile ${p.id}`,
|
|
288
|
+
`${c}model ${p.provider}/${p.model}`,
|
|
289
|
+
`${c}context ${p.limits.context.toLocaleString("en-US")} tokens`,
|
|
290
|
+
`${c}input cap ${input.toLocaleString("en-US")} tokens`,
|
|
291
|
+
`${c}output cap ${p.limits.output.toLocaleString("en-US")} tokens`,
|
|
292
|
+
`${c}reasoning ${levels.length ? levels.join(" < ") : "(not tunable)"}`,
|
|
293
|
+
`${c}retention ${p.retention}${p.retention === "may-train" ? " <-- prompts may train a model" : ""}`,
|
|
294
|
+
`${c}`,
|
|
295
|
+
`${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)`,
|
|
298
|
+
`${c} role effort ${Object.entries(roles).map(([k, v]) => `${k}=${v ?? "unset"}`).join(" ")}`,
|
|
299
|
+
`${c} agent steps ${Object.entries(AGENT_STEPS).filter(([r]) => roles[r]).map(([r, n]) => `${r}=${n}`).join(" ")}`,
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
if (!levels.length) {
|
|
303
|
+
head.push(`${c}`, `${c}This model publishes no tunable reasoning effort, so no variant is written.`)
|
|
304
|
+
} else {
|
|
305
|
+
head.push(
|
|
306
|
+
`${c}`,
|
|
307
|
+
`${c}Effort is per-role above. There is no top-level "variant" key: the 1.18.35`,
|
|
308
|
+
`${c}Config schema rejects it, and the router sets the session default at runtime.`,
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
if (p.supports?.temperature === false) {
|
|
312
|
+
head.push(`${c}This model does not support temperature, so none is set.`)
|
|
313
|
+
}
|
|
314
|
+
if (p.prune?.enabled) {
|
|
315
|
+
head.push(
|
|
316
|
+
`${c} prune keeps ${p.prune.keepRecentToolCalls ?? 6} tool calls, ${p.prune.keepRecentTurns ?? 3} turns verbatim`,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
head.push(
|
|
320
|
+
`${c}`,
|
|
321
|
+
`${c}Run "oc-harness verify" to check this file still matches the profile and`,
|
|
322
|
+
`${c}that the agents and ${instructionsPath} are actually installed.`,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
return `${head.join("\n")}\n${JSON.stringify(cfg, null, 2)}\n`
|
|
326
|
+
}
|
package/src/effort.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Apply a profile's role-based reasoning effort to the OpenCode config.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: no plugin hook can change a live session's model, but the
|
|
4
|
+
// `config` hook runs once at startup, which is early enough to assign each
|
|
5
|
+
// agent its effort level. Effort is the largest latency lever available without
|
|
6
|
+
// changing models: on one measured install, `max` cost 18.7s median per request
|
|
7
|
+
// against 7.8s for `xhigh` on a different model, while 77% of responses ended
|
|
8
|
+
// on a tool call rather than prose.
|
|
9
|
+
//
|
|
10
|
+
// A level is never emitted raw. It is resolved against the profile's own
|
|
11
|
+
// available levels, so a role asking for `max` on a model without `max` gets
|
|
12
|
+
// the nearest level that actually exists.
|
|
13
|
+
|
|
14
|
+
import { loadProfile, resolveRoles, resolveDefaultVariant } from "./profile.js"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {import("./profile.js").Profile} Profile
|
|
18
|
+
* @typedef {import("./profile.js").EffortRequest} EffortRequest
|
|
19
|
+
* @typedef {import("@opencode-ai/plugin").Config} Config
|
|
20
|
+
* @typedef {object} EffortResult
|
|
21
|
+
* @property {string} profileId
|
|
22
|
+
* @property {string} model
|
|
23
|
+
* @property {string|null} defaultVariant
|
|
24
|
+
* @property {Record<string, string|null>} roles
|
|
25
|
+
* @property {string[]} notes
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Roles the kit manages. A role a profile does not mention is left alone. */
|
|
29
|
+
export const MANAGED_ROLES = ["plan", "build", "reviewer", "locate", "verify"]
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} profileId
|
|
33
|
+
* @returns {EffortResult}
|
|
34
|
+
*/
|
|
35
|
+
export function planEffort(profileId) {
|
|
36
|
+
const p = loadProfile(profileId)
|
|
37
|
+
const levels = p.effort?.levels ?? []
|
|
38
|
+
const roles = resolveRoles(p)
|
|
39
|
+
const notes = []
|
|
40
|
+
|
|
41
|
+
if (levels.length === 0) {
|
|
42
|
+
notes.push(
|
|
43
|
+
`${p.model} publishes no tunable reasoning effort; variant will be omitted for every role.`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
for (const [role, want] of Object.entries(p.roles ?? {})) {
|
|
47
|
+
const got = roles[role]
|
|
48
|
+
if (want && got && want !== got) {
|
|
49
|
+
notes.push(`role ${role} asked for ${want}; ${p.model} tops out at ${got}.`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
profileId,
|
|
55
|
+
model: `${p.provider}/${p.model}`,
|
|
56
|
+
defaultVariant: resolveDefaultVariant(p),
|
|
57
|
+
roles,
|
|
58
|
+
notes,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Mutate the resolved OpenCode config so each managed agent declares its own
|
|
64
|
+
* model and effort level.
|
|
65
|
+
*
|
|
66
|
+
* `AgentConfig.variant` only applies to the agent's own configured model, so
|
|
67
|
+
* the model is written alongside the variant instead of relying on the global
|
|
68
|
+
* default. That is also what makes per-role effort work at all.
|
|
69
|
+
*
|
|
70
|
+
* @param {Config} config
|
|
71
|
+
* @param {EffortResult} result
|
|
72
|
+
* @param {{ fallbackProfileId?: string }} [opts]
|
|
73
|
+
* @returns {Config}
|
|
74
|
+
*/
|
|
75
|
+
export function applyEffort(config, result, opts = {}) {
|
|
76
|
+
const cfg = /** @type {Record<string, any>} */ (config)
|
|
77
|
+
|
|
78
|
+
// Always pin the model. When the model has no tunable effort we still set it
|
|
79
|
+
// and simply omit `variant` below.
|
|
80
|
+
cfg.model = result.model
|
|
81
|
+
|
|
82
|
+
cfg.agent = cfg.agent ?? {}
|
|
83
|
+
for (const role of MANAGED_ROLES) {
|
|
84
|
+
const level = result.roles[role]
|
|
85
|
+
if (level === undefined) continue // role not described by this profile
|
|
86
|
+
const existing = cfg.agent[role] ?? {}
|
|
87
|
+
if (!existing.model) existing.model = result.model
|
|
88
|
+
if (level === null) delete existing.variant
|
|
89
|
+
else existing.variant = level
|
|
90
|
+
cfg.agent[role] = existing
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
cfg.harness = opts.fallbackProfileId
|
|
94
|
+
? { profile: result.profileId, fallback: opts.fallbackProfileId }
|
|
95
|
+
: { profile: result.profileId }
|
|
96
|
+
|
|
97
|
+
return config
|
|
98
|
+
}
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Guard hook.
|
|
2
|
+
//
|
|
3
|
+
// The decision logic lives in rules.js so it can be tested without a
|
|
4
|
+
// TypeScript runtime. This file is only the wiring.
|
|
5
|
+
//
|
|
6
|
+
// Fail-closed: if the guard cannot decide, it blocks. A guard that quietly
|
|
7
|
+
// passes when broken is worse than none, because the user stops watching it.
|
|
8
|
+
|
|
9
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
10
|
+
import { checkBash, checkWrite } from "./rules.js"
|
|
11
|
+
|
|
12
|
+
const MARKER = "[opencode-harness-kit] blocked"
|
|
13
|
+
|
|
14
|
+
function deny(why: string, detail: string): never {
|
|
15
|
+
// Throwing is the only way to stop a tool call from a hook.
|
|
16
|
+
throw new Error(
|
|
17
|
+
`${MARKER}: ${why}\n ${detail}\n` +
|
|
18
|
+
` This is enforced in code, not by a prompt. If the command is genuinely ` +
|
|
19
|
+
`safe, run it yourself in a terminal.`,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function truncate(s: string, n = 200): string {
|
|
24
|
+
const one = s.replace(/\s+/g, " ").trim()
|
|
25
|
+
return one.length > n ? `${one.slice(0, n)}...` : one
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const GuardPlugin: Plugin = async ({ worktree }) => {
|
|
29
|
+
return {
|
|
30
|
+
"tool.execute.before": async (input, output) => {
|
|
31
|
+
try {
|
|
32
|
+
if (input.tool === "bash") {
|
|
33
|
+
const hit = checkBash(String(output.args?.command ?? ""))
|
|
34
|
+
if (hit) deny(hit.name, `${hit.why}\n command: ${truncate(hit.segment)}`)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (input.tool === "write" || input.tool === "edit" || input.tool === "patch") {
|
|
39
|
+
const target =
|
|
40
|
+
String(output.args?.filePath ?? output.args?.path ?? "") ||
|
|
41
|
+
String(output.args?.changes?.[0]?.filePath ?? "")
|
|
42
|
+
const hit = checkWrite(target, undefined, worktree)
|
|
43
|
+
if (hit) deny(hit.name, `${hit.why}\n file: ${truncate(hit.segment)}`)
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// Our own denial must not be re-wrapped into a confusing failure.
|
|
47
|
+
if (err instanceof Error && err.message.startsWith(MARKER)) throw err
|
|
48
|
+
// Anything unexpected fails closed. An undecidable guard is not a pass.
|
|
49
|
+
throw new Error(
|
|
50
|
+
`${MARKER} (guard failed closed): ${
|
|
51
|
+
err instanceof Error ? err.message : String(err)
|
|
52
|
+
}`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default GuardPlugin
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Plugin entry point.
|
|
2
|
+
//
|
|
3
|
+
// IMPORTANT: opencode treats every exported function in this module as a plugin
|
|
4
|
+
// and calls it with the plugin input. A library re-exported from here is not a
|
|
5
|
+
// plugin, so calling it produces something like "result.model of undefined" at
|
|
6
|
+
// load time. Only plugin functions belong in this file; consumers import the
|
|
7
|
+
// library modules (profile, config, rules) directly, per the package exports map.
|
|
8
|
+
//
|
|
9
|
+
// Each concern lives in its own module so a project can install only the ones
|
|
10
|
+
// it wants. Nothing here names a model: which model is in use, what effort each
|
|
11
|
+
// role gets, and how much context to reserve all come from a profile.
|
|
12
|
+
|
|
13
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
14
|
+
import { join } from "node:path"
|
|
15
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
16
|
+
import { GuardPlugin } from "./guard.js"
|
|
17
|
+
import { PrunePlugin } from "./prune.js"
|
|
18
|
+
import { RouterPlugin } from "./router.js"
|
|
19
|
+
import { TelemetryPlugin } from "./telemetry.js"
|
|
20
|
+
import { applyEffort, planEffort } from "./effort.js"
|
|
21
|
+
import { profileIdFromConfig } from "./profile.js"
|
|
22
|
+
|
|
23
|
+
export interface HarnessOptions {
|
|
24
|
+
/** Profile id. Defaults to the one recorded in the config by `oc-harness use`. */
|
|
25
|
+
profile?: string
|
|
26
|
+
/** Load a subset. Defaults to all four. */
|
|
27
|
+
only?: Array<"guard" | "prune" | "router" | "telemetry">
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const HarnessPlugin: Plugin = async (ctx, options?: HarnessOptions) => {
|
|
31
|
+
const opts = (options ?? {}) as HarnessOptions
|
|
32
|
+
const want = new Set(opts.only ?? ["guard", "prune", "router", "telemetry"])
|
|
33
|
+
|
|
34
|
+
const plugins: Plugin[] = []
|
|
35
|
+
if (want.has("guard")) plugins.push(GuardPlugin)
|
|
36
|
+
if (want.has("telemetry")) plugins.push(TelemetryPlugin)
|
|
37
|
+
if (want.has("router")) plugins.push(RouterPlugin)
|
|
38
|
+
if (want.has("prune")) plugins.push(PrunePlugin)
|
|
39
|
+
|
|
40
|
+
const merged: Record<string, any> = {}
|
|
41
|
+
for (const p of plugins) {
|
|
42
|
+
const hooks = await p(ctx, {})
|
|
43
|
+
for (const [name, fn] of Object.entries(hooks)) {
|
|
44
|
+
const existing = merged[name]
|
|
45
|
+
merged[name] =
|
|
46
|
+
typeof existing === "function" && typeof fn === "function"
|
|
47
|
+
? async (...a: unknown[]) => {
|
|
48
|
+
await existing(...a)
|
|
49
|
+
await (fn as (...b: unknown[]) => unknown)(...a)
|
|
50
|
+
}
|
|
51
|
+
: fn
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Effort assignment belongs to the config hook, which runs once at startup.
|
|
56
|
+
// Chained after the router so a failover profile wins.
|
|
57
|
+
const routerConfig = merged["config"]
|
|
58
|
+
merged["config"] = async (config: any) => {
|
|
59
|
+
if (routerConfig) await routerConfig(config)
|
|
60
|
+
// config.harness is unreliable here: OpenCode validates the config against a
|
|
61
|
+
// schema with additionalProperties false, so our custom key never reaches
|
|
62
|
+
// the hook. The config file does, so read the id from there.
|
|
63
|
+
const id = opts.profile ?? config?.harness?.profile ?? profileIdFromConfig()
|
|
64
|
+
if (!id) return
|
|
65
|
+
try {
|
|
66
|
+
applyEffort(config, planEffort(id))
|
|
67
|
+
// Record it where the prune hook looks, so pruning picks up this
|
|
68
|
+
// profile's thresholds without access to the resolved config.
|
|
69
|
+
const dir = join(ctx.worktree, ".space")
|
|
70
|
+
mkdirSync(dir, { recursive: true })
|
|
71
|
+
writeFileSync(join(dir, "active"), `${id}\n`)
|
|
72
|
+
} catch {
|
|
73
|
+
// Never block startup on a bad profile.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return merged as any
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default HarnessPlugin
|