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/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