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.
Files changed (41) hide show
  1. package/GLOBAL.md +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +464 -2
  4. package/agents/build.md +66 -0
  5. package/agents/locate.md +48 -0
  6. package/agents/plan.md +61 -0
  7. package/agents/reviewer.md +62 -0
  8. package/agents/verify.md +54 -0
  9. package/bin/baseline.mjs +367 -0
  10. package/bin/git-hooks/pre-commit +30 -0
  11. package/bin/oc-harness.mjs +1025 -0
  12. package/dist/index.js +1038 -0
  13. package/docs/KNOWN-LIMITS.md +188 -0
  14. package/docs/RESEARCH.md +210 -0
  15. package/docs/demo/demo.gif +0 -0
  16. package/docs/demo/demo.mp4 +0 -0
  17. package/docs/demo/render.py +146 -0
  18. package/examples/muse-spark-1.3-contributor-free.json +41 -0
  19. package/package.json +81 -4
  20. package/profiles/deepseek-v4-flash-free.json +41 -0
  21. package/profiles/index.json +78 -0
  22. package/profiles/longcat-2.5-preview-free.json +41 -0
  23. package/profiles/muse-split-free.json +64 -0
  24. package/profiles/schema.json +161 -0
  25. package/profiles/space-bunny-free.json +42 -0
  26. package/profiles/x-preview-f-free.json +41 -0
  27. package/retention.yml +110 -0
  28. package/skills/space-build/SKILL.md +54 -0
  29. package/skills/space-finish/SKILL.md +63 -0
  30. package/skills/space-plan/SKILL.md +101 -0
  31. package/skills/space-plan/reference/task-template.md +47 -0
  32. package/skills/space-review/SKILL.md +53 -0
  33. package/src/config.js +358 -0
  34. package/src/effort.js +98 -0
  35. package/src/guard.ts +59 -0
  36. package/src/index.ts +80 -0
  37. package/src/profile.js +484 -0
  38. package/src/prune.ts +258 -0
  39. package/src/router.ts +192 -0
  40. package/src/rules.js +481 -0
  41. 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