opencode-harness-kit 0.4.3 → 0.4.4

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 (73) hide show
  1. package/CHANGELOG.md +527 -18
  2. package/CONTRIBUTING.md +242 -0
  3. package/GLOBAL.md +34 -5
  4. package/MIGRATION.md +151 -0
  5. package/README.md +307 -37
  6. package/SECURITY.md +30 -0
  7. package/bin/baseline.mjs +15 -7
  8. package/bin/git-hooks/pre-commit +41 -9
  9. package/bin/oc-harness.mjs +1265 -82
  10. package/dist/config.d.ts +127 -0
  11. package/dist/denylist.d.ts +33 -0
  12. package/dist/effort.d.ts +64 -0
  13. package/dist/guard.d.ts +23 -0
  14. package/dist/health.d.ts +43 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +532 -149
  17. package/dist/plugin.d.ts +15 -0
  18. package/dist/profile.d.ts +399 -0
  19. package/dist/project.d.ts +8 -0
  20. package/dist/prune.d.ts +6 -0
  21. package/dist/router.d.ts +3 -0
  22. package/dist/rules.d.ts +109 -0
  23. package/dist/safe.d.ts +7 -0
  24. package/dist/telemetry-store.d.ts +12 -0
  25. package/dist/telemetry.d.ts +3 -0
  26. package/docs/EXIT-CODES.md +71 -0
  27. package/docs/KNOWN-LIMITS.md +45 -21
  28. package/docs/RESEARCH.md +9 -4
  29. package/docs/ROUTING.md +53 -8
  30. package/eval/RESULTS.md +443 -0
  31. package/eval/ab.mjs +743 -0
  32. package/eval/attribute.mjs +170 -0
  33. package/eval/flags.mjs +88 -0
  34. package/eval/measure-install.mjs +323 -0
  35. package/eval/measure-prune.mjs +339 -0
  36. package/eval/metrics.js +79 -0
  37. package/eval/premises.mjs +200 -0
  38. package/eval/prepare.mjs +233 -0
  39. package/eval/run.mjs +376 -0
  40. package/eval/tasks-self.json +81 -0
  41. package/eval/tasks-ts.json +50 -0
  42. package/eval/tasks.json +83 -0
  43. package/eval/thresholds.mjs +134 -0
  44. package/eval/verify.mjs +287 -0
  45. package/package.json +44 -17
  46. package/profiles/fleet-all.json +6 -1
  47. package/profiles/fleet-safe.json +7 -3
  48. package/profiles/schema.json +182 -51
  49. package/prompts/build.md +4 -2
  50. package/skills/space-build/SKILL.md +4 -2
  51. package/skills/space-finish/SKILL.md +6 -2
  52. package/skills/space-plan/reference/task-template.md +13 -5
  53. package/src/config.js +182 -32
  54. package/src/denylist.js +95 -0
  55. package/src/driver/run.mjs +81 -16
  56. package/src/effort.js +15 -4
  57. package/src/guard.ts +165 -13
  58. package/src/health.js +80 -0
  59. package/src/index.ts +15 -4
  60. package/src/lane-usage.js +72 -18
  61. package/src/plugin-type-check.ts +38 -0
  62. package/src/plugin.ts +39 -0
  63. package/src/profile.js +225 -26
  64. package/src/project.js +47 -0
  65. package/src/prune.ts +110 -31
  66. package/src/router.ts +32 -63
  67. package/src/rules.js +99 -7
  68. package/src/safe.ts +27 -0
  69. package/src/sqlite.js +144 -0
  70. package/src/tasks.js +112 -29
  71. package/src/telemetry-store.ts +62 -0
  72. package/src/telemetry.ts +60 -57
  73. package/src/trust.js +70 -9
package/src/guard.ts CHANGED
@@ -6,10 +6,82 @@
6
6
  // Fail-closed: if the guard cannot decide, it blocks. A guard that quietly
7
7
  // passes when broken is worse than none, because the user stops watching it.
8
8
 
9
- import type { Plugin } from "@opencode-ai/plugin"
10
- import { checkBash, checkWrite } from "./rules.js"
9
+ import type { Plugin } from "./plugin.js"
10
+ import { homedir, tmpdir } from "node:os"
11
+ import { join, resolve } from "node:path"
12
+ import { checkBash, checkDenied, checkWrite, insideRoot } from "./rules.js"
11
13
 
12
- const MARKER = "[opencode-harness-kit] blocked"
14
+ import { write } from "./telemetry-store.js"
15
+ import { projectRoot } from "./project.js"
16
+
17
+ export const MARKER = "[opencode-harness-kit] blocked"
18
+
19
+ /**
20
+ * The tools that write to a file, and where the path to write lives in each.
21
+ *
22
+ * OpenCode 1.19 has `write` and `edit` (both take `filePath`) and
23
+ * `apply_patch` (which takes a whole patch as `patchText`). On a GPT-5-class
24
+ * model `write` and `edit` are REMOVED and replaced by `apply_patch`, so a
25
+ * guard that only knew about `write` and `edit` enforced nothing at all on
26
+ * those models - the tool the agent actually had was not one it looked at.
27
+ *
28
+ * `patch` and `changes[].filePath` are not OpenCode 1.19 shapes. They are
29
+ * kept because a third-party plugin may register such a tool, and a tool the
30
+ * guard cannot read is a tool the guard cannot protect.
31
+ */
32
+ const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "patch"])
33
+
34
+ /**
35
+ * The files a patch touches.
36
+ *
37
+ * `apply_patch` sends a patch, not a path, so the paths have to be read out
38
+ * of it. Two formats are in use: opencode's own `*** Update File: path`
39
+ * markers, and a plain unified diff with `+++ b/path` headers.
40
+ *
41
+ * @param {string} text
42
+ * @returns {string[]}
43
+ */
44
+ export function pathsInPatch(text: string): string[] {
45
+ const out: string[] = []
46
+ const push = (p: string) => {
47
+ const v = p.trim()
48
+ if (!v) return
49
+ if (v === "/dev/null") return
50
+ if (!out.includes(v)) out.push(v)
51
+ }
52
+ for (const m of text.matchAll(/^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/gm)) {
53
+ push(m[1])
54
+ }
55
+ for (const m of text.matchAll(/^\+\+\+\s+(?:b\/)?(.+)$/gm)) {
56
+ push(m[1])
57
+ }
58
+ return out
59
+ }
60
+
61
+ /**
62
+ * The files a write tool is about to touch.
63
+ *
64
+ * @param {string} tool
65
+ * @param {any} args
66
+ * @returns {string[]} empty when the arguments name no file
67
+ */
68
+ export function writeTargets(tool: string, args: any): string[] {
69
+ if (!args || typeof args !== "object") return []
70
+ if (tool === "apply_patch") {
71
+ return pathsInPatch(String(args.patchText ?? args.patch ?? ""))
72
+ }
73
+ const out: string[] = []
74
+ const one = (v: any) => {
75
+ if (typeof v === "string" && v) out.push(v)
76
+ }
77
+ one(args.filePath)
78
+ one(args.path)
79
+ for (const c of Array.isArray(args.changes) ? args.changes : []) {
80
+ one(c?.filePath)
81
+ one(c?.path)
82
+ }
83
+ return out
84
+ }
13
85
 
14
86
  function deny(why: string, detail: string): never {
15
87
  // Throwing is the only way to stop a tool call from a hook.
@@ -19,33 +91,113 @@ function deny(why: string, detail: string): never {
19
91
  `safe, run it yourself in a terminal.`,
20
92
  )
21
93
  }
22
-
23
94
  function truncate(s: string, n = 200): string {
24
95
  const one = s.replace(/\s+/g, " ").trim()
25
96
  return one.length > n ? `${one.slice(0, n)}...` : one
26
97
  }
27
98
 
28
- export const GuardPlugin: Plugin = async ({ worktree }) => {
99
+ export const GuardPlugin: Plugin = async ({ worktree, directory }) => {
100
+ // The project root, not the worktree: the worktree is the filesystem root
101
+ // in a project that is not a git repository, so the scope check was against
102
+ // "/" - which contains every path - and the denial log went nowhere.
103
+ const project = projectRoot(directory, worktree)
104
+ // Where a denial is recorded.
105
+ //
106
+ // It has to be recorded HERE. A denial throws in `tool.execute.before`, and
107
+ // opencode calls `tool.execute.after` only after the tool has run - so a
108
+ // blocked command produces no `after` event at all, and the telemetry
109
+ // plugin never sees it. The file's own header promised to record "tool
110
+ // denials (to see the guard being useful rather than annoying)", and it
111
+ // could not.
112
+ const telemetryDir = join(project, ".space")
113
+
114
+ /** Record the denial, then stop the call. */
115
+ const refuse = (tool: string, rule: string, why: string, detail: string): never => {
116
+ write(telemetryDir, { kind: "guard", tool, rule })
117
+ deny(why, detail)
118
+ }
119
+
29
120
  return {
30
- "tool.execute.before": async (input, output) => {
121
+ "tool.execute.before": async (input: any, output: any) => {
31
122
  try {
32
123
  if (input.tool === "bash") {
124
+ // The bash tool takes a `workdir`, and it becomes the command's
125
+ // working directory. A command run from outside the project is
126
+ // writing outside the project, which is the one thing the scope
127
+ // rule exists to stop - and the guard used to ignore the argument
128
+ // entirely, so `workdir: "/etc"` was a way around it.
129
+ const raw = output.args?.workdir
130
+ if (typeof raw === "string" && raw.trim()) {
131
+ const target = raw.startsWith("~")
132
+ ? join(homedir(), raw.slice(1))
133
+ : resolve(project, raw)
134
+ if (!insideRoot(target, project) && !insideRoot(target, tmpdir())) {
135
+ refuse(
136
+ input.tool,
137
+ "command outside the project",
138
+ "command runs outside the project",
139
+ `the working directory is ${truncate(target)}, and this project's files are the ones you were asked to change`,
140
+ )
141
+ }
142
+ }
33
143
  const hit = checkBash(String(output.args?.command ?? ""))
34
- if (hit) deny(hit.name, `${hit.why}\n command: ${truncate(hit.segment)}`)
144
+ if (hit) {
145
+ refuse(input.tool, hit.name, hit.name, `${hit.why}\n command: ${truncate(hit.segment)}`)
146
+ }
147
+ // The shared denylist, enforced in code.
148
+ //
149
+ // The config denies these through opencode's permission map, and the
150
+ // guard used to enforce a DIFFERENT set - it blocked neither
151
+ // `gh pr create` nor `npm publish`. The config's own comment said
152
+ // the two were the same, and they were not. One list, two
153
+ // enforcers: the permission map can be overridden by a plugin or
154
+ // lost to a hand-edit, and the guard does not care.
155
+ const command = String(output.args?.command ?? "")
156
+ // Checked per segment, with the wrappers stripped and the payload of
157
+ // a `bash -c` followed: `cd /tmp && npm publish`, `sudo npm
158
+ // publish` and `sh -c "npm publish"` are all the same command, and
159
+ // matching the raw string let every one of them through.
160
+ const denied = checkDenied(command)
161
+ if (denied) {
162
+ refuse(
163
+ input.tool,
164
+ `denied command (${denied.pattern})`,
165
+ "denied command",
166
+ `this command matches \`${denied.pattern}\`, which the kit never allows.\n command: ${truncate(denied.segment)}`,
167
+ )
168
+ }
35
169
  return
36
170
  }
37
171
 
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)}`)
172
+ if (WRITE_TOOLS.has(input.tool)) {
173
+ const targets = writeTargets(input.tool, output.args)
174
+ if (!targets.length) {
175
+ // A write tool whose arguments name no file. Failing open here
176
+ // would mean a tool this guard does not understand writes
177
+ // wherever it likes.
178
+ refuse(
179
+ input.tool,
180
+ "unknown write target",
181
+ "cannot tell what this write touches",
182
+ `the ${input.tool} tool's arguments name no file, so the scope of the write is unknown`,
183
+ )
184
+ }
185
+ for (const target of targets) {
186
+ const hit = checkWrite(target, undefined, project)
187
+ if (hit) {
188
+ refuse(input.tool, hit.name, hit.name, `${hit.why}\n file: ${truncate(hit.segment)}`)
189
+ }
190
+ }
44
191
  }
45
192
  } catch (err) {
46
193
  // Our own denial must not be re-wrapped into a confusing failure.
47
194
  if (err instanceof Error && err.message.startsWith(MARKER)) throw err
48
195
  // Anything unexpected fails closed. An undecidable guard is not a pass.
196
+ write(telemetryDir, {
197
+ kind: "guard",
198
+ tool: input.tool,
199
+ rule: "guard failed closed",
200
+ })
49
201
  throw new Error(
50
202
  `${MARKER} (guard failed closed): ${
51
203
  err instanceof Error ? err.message : String(err)
package/src/health.js ADDED
@@ -0,0 +1,80 @@
1
+ // The spent-route file.
2
+ //
3
+ // A route is "spent" when the provider says the free tier is exhausted. The
4
+ // router records that, and the effort hook reads it, so the two have to agree
5
+ // on WHERE the file is - and that is the project root, not the worktree.
6
+ //
7
+ // The worktree is opencode's git root, and for a directory that is not a git
8
+ // repository it is the FILESYSTEM ROOT. The router wrote the file under the
9
+ // worktree while the effort hook read it from the worktree, and in a
10
+ // non-git project the two were not even looking at the same place.
11
+ //
12
+ // Plain JS with JSDoc types, like the rest of src/: the CLI and the tests run
13
+ // under plain node, and the plugin loads .ts.
14
+
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
16
+ import { join } from "node:path"
17
+
18
+ /**
19
+ * @typedef {object} Health
20
+ * @property {Record<string, { at: string, reason: string }>} unhealthy
21
+ * @property {string} [active]
22
+ */
23
+
24
+ /**
25
+ * @param {string} dir the project root
26
+ * @returns {string}
27
+ */
28
+ export function healthPath(dir) {
29
+ return join(dir, ".space", "health.json")
30
+ }
31
+
32
+ /**
33
+ * @param {string} dir the project root
34
+ * @returns {Health}
35
+ */
36
+ export function readHealth(dir) {
37
+ try {
38
+ return JSON.parse(readFileSync(healthPath(dir), "utf8"))
39
+ } catch {
40
+ return { unhealthy: {} }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * @param {string} dir the project root
46
+ * @param {Health} health
47
+ */
48
+ export function writeHealth(dir, health) {
49
+ try {
50
+ if (!existsSync(join(dir, ".space"))) mkdirSync(join(dir, ".space"), { recursive: true })
51
+ writeFileSync(healthPath(dir), JSON.stringify(health, null, 2))
52
+ } catch {
53
+ // Best effort. A health file that cannot be written means no failover,
54
+ // and failing to write it must not break the session.
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The models a previous session found spent.
60
+ *
61
+ * Exported so the effort hook resolves a lane's variant against the same set
62
+ * of spent routes the router used. A lane that failed over to a model with
63
+ * fewer effort levels must lose its variant, or the config asks for an effort
64
+ * the new model does not publish.
65
+ *
66
+ * The health file is keyed by the qualified route, `opencode/<model>`. The
67
+ * lane chains list bare model ids, so the prefix is dropped here. Comparing
68
+ * the two directly would never match, and a spent route would look healthy.
69
+ *
70
+ * @param {string} dir the project root
71
+ * @returns {Set<string>}
72
+ */
73
+ export function unhealthyModels(dir) {
74
+ const health = readHealth(dir)
75
+ const out = new Set()
76
+ for (const key of Object.keys(health.unhealthy ?? {})) {
77
+ out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key)
78
+ }
79
+ return out
80
+ }
package/src/index.ts CHANGED
@@ -12,13 +12,15 @@
12
12
 
13
13
  import { mkdirSync, writeFileSync } from "node:fs"
14
14
  import { join } from "node:path"
15
- import type { Plugin } from "@opencode-ai/plugin"
15
+ import type { Plugin } from "./plugin.js"
16
16
  import { GuardPlugin } from "./guard.js"
17
17
  import { PrunePlugin } from "./prune.js"
18
- import { RouterPlugin, unhealthyModels } from "./router.js"
18
+ import { RouterPlugin } from "./router.js"
19
19
  import { TelemetryPlugin } from "./telemetry.js"
20
20
  import { applyEffort, planEffort } from "./effort.js"
21
21
  import { profileIdFromConfig } from "./profile.js"
22
+ import { projectRoot } from "./project.js"
23
+ import { unhealthyModels } from "./health.js"
22
24
 
23
25
  export interface HarnessOptions {
24
26
  /** Profile id. Defaults to the one recorded in the config by `oc-harness use`. */
@@ -62,6 +64,10 @@ export const HarnessPlugin: Plugin = async (ctx, options?: HarnessOptions) => {
62
64
  // the hook. The config file does, so read the id from there.
63
65
  const id = opts.profile ?? config?.harness?.profile ?? profileIdFromConfig()
64
66
  if (!id) return
67
+ // The PROJECT root, not the worktree: the worktree is the filesystem
68
+ // root in a project that is not a git repository, so the spent-route
69
+ // file the router writes is somewhere else entirely.
70
+ const project = projectRoot(ctx.directory, ctx.worktree)
65
71
  try {
66
72
  // The same set of spent routes the router used. A lane that failed over
67
73
  // to a model with fewer effort levels must not keep the variant the
@@ -74,12 +80,17 @@ export const HarnessPlugin: Plugin = async (ctx, options?: HarnessOptions) => {
74
80
  typeof config?.harness?.sessionModel === "string"
75
81
  ? config.harness.sessionModel
76
82
  : undefined
77
- applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(ctx.worktree) }), {
83
+ applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(project) }), {
78
84
  sessionModel,
79
85
  })
80
86
  // Record it where the prune hook looks, so pruning picks up this
81
87
  // profile's thresholds without access to the resolved config.
82
- const dir = join(ctx.worktree, ".space")
88
+ //
89
+ // Under the PROJECT root, not the worktree: the worktree is the
90
+ // filesystem root in a project that is not a git repository, and the
91
+ // write fails there with a permission error that this catch swallows.
92
+ // The pruner then finds no active profile and does nothing at all.
93
+ const dir = join(project, ".space")
83
94
  mkdirSync(dir, { recursive: true })
84
95
  writeFileSync(join(dir, "active"), `${id}\n`)
85
96
  } catch {
package/src/lane-usage.js CHANGED
@@ -25,25 +25,50 @@ import { dirname, join, resolve } from "node:path"
25
25
  * @typedef {object} LaneUsage
26
26
  * @property {number} requests distinct assistant messages that used the lane
27
27
  * @property {number[]} ms durations of the requests that completed
28
+ * @property {number[]} ctx the context each request carried, in tokens
29
+ * @property {number[]} reasoning the reasoning each request spent, in tokens
28
30
  * @property {Set<string>} models the models the lane was seen on
29
31
  */
30
32
 
31
33
  /**
32
- * The telemetry file for the project containing `startDir`.
34
+ * The telemetry files for the project containing `startDir`.
35
+ *
36
+ * ALL of them, oldest first. The plugin rotates the file when it passes 4 MB
37
+ * and renames the old one to `telemetry.jsonl.1` - and the reader used to
38
+ * look at `telemetry.jsonl` alone, so a rotation silently threw away the
39
+ * history and every lane reported "never used". The number that decides
40
+ * whether the fleet stays at ten lanes was disappearing on a schedule.
33
41
  *
34
42
  * @param {string} startDir
35
- * @returns {string | null} the path, or null when the project has no telemetry
43
+ * @returns {string[]} the paths, or an empty array when the project has none
36
44
  */
37
- export function findTelemetryFile(startDir) {
45
+ export function findTelemetryFiles(startDir) {
38
46
  let dir = resolve(startDir)
39
- for (let i = 0; i < 6; i++) {
40
- const file = join(dir, ".space", "telemetry.jsonl")
41
- if (existsSync(file)) return file
47
+ for (let i = 0; i < 12; i++) {
48
+ const space = join(dir, ".space")
49
+ const files = []
50
+ for (const suffix of [".2", ".1", ""]) {
51
+ const f = join(space, `telemetry.jsonl${suffix}`)
52
+ if (existsSync(f)) files.push(f)
53
+ }
54
+ // Oldest first: `.2` was rotated twice, `.1` once, and the plain file is
55
+ // the newest.
56
+ if (files.length) return files
42
57
  const parent = dirname(dir)
43
58
  if (parent === dir) break
44
59
  dir = parent
45
60
  }
46
- return null
61
+ return []
62
+ }
63
+
64
+ /**
65
+ * The telemetry file for the project containing `startDir`.
66
+ *
67
+ * @param {string} startDir
68
+ * @returns {string | null} the path, or null when the project has no telemetry
69
+ */
70
+ export function findTelemetryFile(startDir) {
71
+ return findTelemetryFiles(startDir)[0] ?? null
47
72
  }
48
73
 
49
74
  /**
@@ -63,24 +88,34 @@ export function findTelemetryFile(startDir) {
63
88
  * three, so counting them would put a known-wrong figure next a known-right
64
89
  * one.
65
90
  *
66
- * @param {string | null} file
67
- * @returns {{ lanes: Record<string, LaneUsage>, records: number, ignored: number }}
91
+ * @param {string | string[] | null} file one file, or several oldest first
92
+ * @returns {{ lanes: Record<string, LaneUsage>, records: number, requests: number, ignored: number }}
68
93
  */
69
94
  export function readLaneUsage(file) {
70
95
  /** @type {Record<string, LaneUsage>} */
71
96
  const lanes = {}
72
- /** @type {Map<string, { lane: string, ms?: number, model?: string }>} */
97
+ /** @type {Map<string, { lane: string, ms?: number, model?: string, ctx?: number, reasoning?: number }>} */
73
98
  const byId = new Map()
74
99
  let records = 0
75
100
  let ignored = 0
76
101
 
77
- if (!file || !existsSync(file)) return { lanes, records, ignored }
102
+ const files = Array.isArray(file) ? file : file ? [file] : []
103
+ const readable = files.filter((f) => existsSync(f))
104
+ if (!readable.length) return { lanes, records, requests: 0, ignored }
78
105
 
79
106
  let text = ""
80
- try {
81
- text = readFileSync(file, "utf8")
82
- } catch {
83
- return { lanes, records, ignored }
107
+ for (const f of readable) {
108
+ try {
109
+ const chunk = readFileSync(f, "utf8")
110
+ // A file that does not end in a newline would otherwise be glued to
111
+ // the next one, and the two records that meet in the middle would
112
+ // parse as neither.
113
+ if (text && !text.endsWith("\n")) text += "\n"
114
+ text += chunk
115
+ } catch {
116
+ // A file we cannot read is a file we cannot count. The rest of the
117
+ // history is still worth reading.
118
+ }
84
119
  }
85
120
 
86
121
  for (const line of text.split("\n")) {
@@ -102,7 +137,13 @@ export function readLaneUsage(file) {
102
137
  if (rec?.kind !== "lane") continue
103
138
  const lane = rec.agent
104
139
  const id = rec.id
105
- if (typeof lane !== "string" || !lane) continue
140
+ if (typeof lane !== "string" || !lane) {
141
+ // A record with no lane cannot be attributed to one, so it is not
142
+ // counted - and it is counted as ignored, so a malformed file is
143
+ // visible in the tally rather than silently shrinking the numbers.
144
+ ignored++
145
+ continue
146
+ }
106
147
  if (typeof id !== "string" || !id) {
107
148
  ignored++
108
149
  continue
@@ -117,18 +158,31 @@ export function readLaneUsage(file) {
117
158
  lane,
118
159
  ms: typeof rec.ms === "number" ? rec.ms : prev?.ms,
119
160
  model: typeof rec.model === "string" ? rec.model : prev?.model,
161
+ ctx: typeof rec.ctx === "number" ? rec.ctx : prev?.ctx,
162
+ reasoning: typeof rec.reasoning === "number" ? rec.reasoning : prev?.reasoning,
120
163
  })
121
164
  }
122
165
 
123
166
  for (const rec of byId.values()) {
124
167
  const usage =
125
- lanes[rec.lane] ?? (lanes[rec.lane] = { requests: 0, ms: [], models: new Set() })
168
+ lanes[rec.lane] ?? (lanes[rec.lane] = {
169
+ requests: 0,
170
+ ms: [],
171
+ ctx: [],
172
+ reasoning: [],
173
+ models: new Set(),
174
+ })
126
175
  usage.requests++
127
176
  if (typeof rec.ms === "number") usage.ms.push(rec.ms)
177
+ if (typeof rec.ctx === "number") usage.ctx.push(rec.ctx)
178
+ if (typeof rec.reasoning === "number") usage.reasoning.push(rec.reasoning)
128
179
  if (rec.model) usage.models.add(rec.model)
129
180
  }
130
181
 
131
- return { lanes, records, ignored }
182
+ // `records` is the raw count and `requests` is the count that means
183
+ // something: one record is written when a message opens and again
184
+ // when it closes, so the raw count is twice the number of requests.
185
+ return { lanes, records, requests: byId.size, ignored }
132
186
  }
133
187
 
134
188
  /**
@@ -0,0 +1,38 @@
1
+ // The plugin type, checked against the real one.
2
+ //
3
+ // `src/plugin.ts` declares the shape structurally, so the published
4
+ // declarations do not make a consumer install `@opencode-ai/plugin` - a
5
+ // package that pulls in `effect`, `zod` and `@opencode-ai/sdk`.
6
+ //
7
+ // A structural type that drifts from the real one is a silent breakage: the
8
+ // kit would install, and opencode would not call the hooks. So the real
9
+ // type is checked HERE, in the repository, where the package is installed.
10
+ // A drift is a failed build.
11
+ //
12
+ // This file is not in the exports map, so a consumer never loads it, and
13
+ // the declarations it produces are never reached from `dist/index.d.ts`.
14
+
15
+ import type { Plugin as OpencodePlugin } from "@opencode-ai/plugin"
16
+
17
+ import type { Plugin } from "./plugin.js"
18
+
19
+ import { GuardPlugin } from "./guard.js"
20
+ import { PrunePlugin } from "./prune.js"
21
+ import { RouterPlugin } from "./router.js"
22
+ import { TelemetryPlugin } from "./telemetry.js"
23
+ import { HarnessPlugin } from "./index.js"
24
+
25
+ // The kit's own plugins have to be opencode plugins. If opencode's type
26
+ // changes shape, this stops compiling, and the fix is a real one rather than
27
+ // a guess.
28
+ const _guard: OpencodePlugin = GuardPlugin
29
+ const _prune: OpencodePlugin = PrunePlugin
30
+ const _router: OpencodePlugin = RouterPlugin
31
+ const _telemetry: OpencodePlugin = TelemetryPlugin
32
+ const _harness: OpencodePlugin = HarnessPlugin
33
+
34
+ // And the other way round: the local type has to be wide enough to hold
35
+ // them, so a plugin written against the local type is still a valid one.
36
+ const _local: Plugin = _harness
37
+
38
+ export type { OpencodePlugin }
package/src/plugin.ts ADDED
@@ -0,0 +1,39 @@
1
+ // The plugin type, without the plugin package.
2
+ //
3
+ // The kit's plugin functions are typed with opencode's own `Plugin` type,
4
+ // and that type lives in `@opencode-ai/plugin` - a package that depends on
5
+ // `effect`, `zod` and `@opencode-ai/sdk`, and has three peer dependencies of
6
+ // its own.
7
+ //
8
+ // A consumer that installs the kit to compile a config should not have to
9
+ // install all of that. And they do not have to: the published declarations
10
+ // imported the plugin's type, so `npm install opencode-harness-kit` gave a
11
+ // consumer whose type-check failed with "Cannot find module
12
+ // '@opencode-ai/plugin'". The optional peer dependency did not fix it -
13
+ // npm never installs an optional peer on its own.
14
+ //
15
+ // So the type is declared here, structurally: what opencode actually calls.
16
+ //
17
+ // The real type is still checked, in `plugin-type-check.ts`, against the
18
+ // real package - in CI, where the package is installed. A drift between the
19
+ // two is a failed build, not a broken consumer.
20
+
21
+ /**
22
+ * A plugin: a function opencode calls once, which returns the hooks it
23
+ * wants registered.
24
+ *
25
+ * The context and the options are opencode's, and the kit does not name
26
+ * their fields - it uses what it needs and tolerates the rest.
27
+ */
28
+ export type Plugin = (
29
+ ctx: any,
30
+ options?: any,
31
+ ) => Promise<Record<string, any>>
32
+
33
+ /**
34
+ * The hooks a plugin returns.
35
+ *
36
+ * opencode calls each one by name; the kit registers `config`, `tool.execute.before`
37
+ * and `chat.message`. A hook that is not in this list is not a hook.
38
+ */
39
+ export type Hooks = Record<string, (...args: any[]) => any>