opencode-harness-kit 0.0.0-stage → 0.2.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 +464 -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 +1025 -0
- package/dist/index.js +1038 -0
- package/docs/KNOWN-LIMITS.md +188 -0
- package/docs/RESEARCH.md +210 -0
- package/docs/demo/demo.gif +0 -0
- package/docs/demo/demo.mp4 +0 -0
- package/docs/demo/render.py +146 -0
- package/examples/muse-spark-1.3-contributor-free.json +41 -0
- package/package.json +81 -4
- package/profiles/deepseek-v4-flash-free.json +41 -0
- package/profiles/index.json +78 -0
- package/profiles/longcat-2.5-preview-free.json +41 -0
- package/profiles/muse-split-free.json +64 -0
- package/profiles/schema.json +161 -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 +358 -0
- package/src/effort.js +98 -0
- package/src/guard.ts +59 -0
- package/src/index.ts +80 -0
- package/src/profile.js +484 -0
- package/src/prune.ts +258 -0
- package/src/router.ts +192 -0
- package/src/rules.js +481 -0
- package/src/telemetry.ts +98 -0
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
|