opencode-harness-kit 0.6.3 → 0.7.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.
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+ // compare.mjs - grade any agent CLI against the same suite, with the same
3
+ // grader, in a fresh copy of the same subject.
4
+ //
5
+ // eval/ab.mjs measures this kit against opencode, because it has to read
6
+ // opencode's event stream to get token and context numbers. That makes it
7
+ // useless for comparing against Aider, OpenHands or SWE-agent, whose output
8
+ // is a terminal transcript rather than the same JSON.
9
+ //
10
+ // This script takes the other route: it does not care how the agent prints.
11
+ // It runs the command, treats its stdout as the answer, and grades the answer
12
+ // or the resulting tree with eval/verify.mjs - the same citation resolver and
13
+ // the same mutation comparison the kit is measured with. A harness that
14
+ // writes a citation to the right line passes; one that writes prose does not.
15
+ //
16
+ // node eval/compare.mjs --target <dir> --suite eval/tasks-self.json \
17
+ // --reps 3 --timeout 300 \
18
+ // --arm 'aider=aider --yes --no-auto-commits --message "$PROMPT"' \
19
+ // --arm 'swe=python -m sweagent run --problem "$PROMPT"'
20
+ //
21
+ // The command runs through `sh -c` in the run's copy, with:
22
+ // $PROMPT the task text
23
+ // $TASK_ID the task id
24
+ // $DIR the run's copy of the subject
25
+ // $REP 1-based repetition
26
+ // $ARM the arm's name
27
+ //
28
+ // It does NOT install anything. An arm whose binary is not on PATH is
29
+ // reported as unusable and the run exits 2, because a missing harness
30
+ // reported as "0% pass" is a lie about the harness.
31
+
32
+ import { spawnSync } from "node:child_process"
33
+ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
34
+ import { tmpdir } from "node:os"
35
+ import { join } from "node:path"
36
+ import { checkPremises } from "./premises.mjs"
37
+ import { gradeAnswer, gradeMutation } from "./verify.mjs"
38
+
39
+ const args = process.argv.slice(2)
40
+
41
+ /** @param {string} name */
42
+ function flag(name) {
43
+ const i = args.indexOf(name)
44
+ if (i < 0) return undefined
45
+ const value = args[i + 1]
46
+ if (value === undefined || value.startsWith("--")) {
47
+ console.error(`${name} needs a value`)
48
+ process.exit(2)
49
+ }
50
+ return value
51
+ }
52
+
53
+ /** @param {string} name */
54
+ function flags(name) {
55
+ const out = []
56
+ for (let i = 0; i < args.length; i++) {
57
+ if (args[i] !== name) continue
58
+ const value = args[i + 1]
59
+ if (value === undefined) {
60
+ console.error(`${name} needs a value`)
61
+ process.exit(2)
62
+ }
63
+ out.push(value)
64
+ }
65
+ return out
66
+ }
67
+
68
+ function usage(code) {
69
+ const text = [
70
+ "usage: node eval/compare.mjs --target <dir> --suite <file> --arm name=command [...]",
71
+ "",
72
+ " --target <dir> prepared subject (eval/prepare.mjs)",
73
+ " --suite <file> task suite (eval/tasks-self.json)",
74
+ " --arm name=command repeatable; command runs through sh -c",
75
+ " --reps <n> repetitions per task per arm (default 3)",
76
+ " --timeout <seconds> per run (default 300)",
77
+ " --only <id,id> run only these task ids",
78
+ " --json print one JSON object, and nothing else on stdout",
79
+ ].join("\n")
80
+ ;(code === 0 ? console.log : console.error)(text)
81
+ process.exit(code)
82
+ }
83
+
84
+ if (args.includes("--help") || args.includes("-h")) usage(0)
85
+
86
+ const target = flag("--target")
87
+ const suitePath = flag("--suite")
88
+ const repsRaw = flag("--reps")
89
+ const timeoutRaw = flag("--timeout")
90
+ const only = flag("--only")?.split(",").map((s) => s.trim()).filter(Boolean)
91
+ const asJson = args.includes("--json")
92
+ const armSpecs = flags("--arm")
93
+
94
+ if (!target || !suitePath || !armSpecs.length) usage(2)
95
+ if (!existsSync(target)) {
96
+ console.error(`target not found: ${target}`)
97
+ process.exit(2)
98
+ }
99
+ if (!existsSync(suitePath)) {
100
+ console.error(`suite not found: ${suitePath}`)
101
+ process.exit(2)
102
+ }
103
+
104
+ const reps = repsRaw === undefined ? 3 : Number(repsRaw)
105
+ if (!Number.isInteger(reps) || reps < 1) {
106
+ console.error(`--reps must be a positive integer, got ${JSON.stringify(repsRaw)}`)
107
+ process.exit(2)
108
+ }
109
+ const timeout = timeoutRaw === undefined ? 300 : Number(timeoutRaw)
110
+ if (!Number.isInteger(timeout) || timeout < 1) {
111
+ console.error(`--timeout must be a positive integer of seconds, got ${JSON.stringify(timeoutRaw)}`)
112
+ process.exit(2)
113
+ }
114
+
115
+ const arms = armSpecs.map((spec) => {
116
+ const at = spec.indexOf("=")
117
+ if (at < 1) {
118
+ console.error(`--arm needs name=command, got ${JSON.stringify(spec)}`)
119
+ process.exit(2)
120
+ }
121
+ return { name: spec.slice(0, at), command: spec.slice(at + 1) }
122
+ })
123
+
124
+ const suite = JSON.parse(readFileSync(suitePath, "utf8"))
125
+ const premises = checkPremises(target, suite)
126
+ for (const w of premises.warnings) console.error(`warn: ${w.id}: ${w.problem}`)
127
+ if (!premises.ok) {
128
+ console.error(`FAIL: ${premises.fatal.length} premise(s) in ${suitePath} do not hold against ${target}.`)
129
+ for (const f of premises.fatal) console.error(` ${f.id}: ${f.problem}`)
130
+ process.exit(2)
131
+ }
132
+
133
+ const tasks = suite.tasks.filter((t) => !only || only.includes(t.id))
134
+ if (!tasks.length) {
135
+ console.error("no tasks selected")
136
+ process.exit(2)
137
+ }
138
+
139
+ // A citation to the suite itself, to a build output or to a test is not
140
+ // evidence about the source. Kept in step with eval/ab.mjs by the comment
141
+ // there; this list is the grading rule, not a per-arm one.
142
+ const SUITE_FILES = [
143
+ "tasks-self.json",
144
+ "tasks.json",
145
+ "tasks-ts.json",
146
+ "dist/",
147
+ // The tests quote the answer, so citing one proves it was grepped, not read.
148
+ "test/",
149
+ "eval/",
150
+ ]
151
+
152
+ /** @type {{arm: string, id: string, rep: number, ok: boolean, invalid: boolean, seconds: number, why: string}[]} */
153
+ const rows = []
154
+
155
+ for (let rep = 1; rep <= reps; rep++) {
156
+ for (const task of tasks) {
157
+ for (const arm of arms) {
158
+ // cpSync copies INTO an existing directory. The run copy must be a
159
+ // new path, or the subject lands at <dir>/<basename(target)> and every
160
+ // command that names src/... looks at the wrong tree.
161
+ const base = mkdtempSync(join(tmpdir(), `compare-${arm.name.replace(/[^a-z0-9-]+/gi, "-")}-`))
162
+ const dir = join(base, "subject")
163
+ cpSync(target, dir, { recursive: true })
164
+ const env = {
165
+ ...process.env,
166
+ PROMPT: task.task,
167
+ TASK_ID: task.id,
168
+ DIR: dir,
169
+ REP: String(rep),
170
+ ARM: arm.name,
171
+ // opencode (and some wrappers) resolve the project from PWD, which a
172
+ // child inherits from this script rather than from `cwd`.
173
+ PWD: dir,
174
+ }
175
+ const started = Date.now()
176
+ const res = spawnSync(arm.command, {
177
+ shell: true,
178
+ cwd: dir,
179
+ env,
180
+ encoding: "utf8",
181
+ timeout: timeout * 1000,
182
+ maxBuffer: 32 * 1024 * 1024,
183
+ })
184
+ const seconds = (Date.now() - started) / 1000
185
+
186
+ // `sh -c` exits 127 when the command it was given is not on PATH, so
187
+ // the ENOENT is on the shell's stderr rather than on spawnSync's error.
188
+ if (
189
+ (res.error && /** @type {NodeJS.ErrnoException} */ (res.error).code === "ENOENT") ||
190
+ res.status === 127
191
+ ) {
192
+ console.error(`arm ${arm.name}: command not found: ${arm.command}`)
193
+ rmSync(base, { recursive: true, force: true })
194
+ process.exit(2)
195
+ }
196
+
197
+ const timedOut = res.error?.name === "Error" && /ETIMEDOUT|timed out/i.test(res.error.message)
198
+ const invalid = Boolean(res.error) || res.status === null
199
+ let ok = false
200
+ let why = ""
201
+
202
+ if (task.mutate) {
203
+ const g = gradeMutation(dir, task.mutate, { pristine: target })
204
+ ok = g.ok
205
+ why = g.ok ? "tree matches" : g.reason ?? "tree does not match"
206
+ } else {
207
+ const g = gradeAnswer(dir, res.stdout ?? "", task.want, { exclude: SUITE_FILES })
208
+ ok = g.ok
209
+ why = g.ok
210
+ ? `cited ${g.cited}`
211
+ : g.cited === 0
212
+ ? "no citation"
213
+ : `cited ${g.cited}, none held`
214
+ }
215
+
216
+ if (timedOut && !ok) why = `timed out after ${timeout}s; ${why}`
217
+ if (invalid && !ok) why = `${res.error?.message ?? "no exit status"}; ${why}`
218
+
219
+ rows.push({ arm: arm.name, id: task.id, rep, ok, invalid, seconds: +seconds.toFixed(1), why })
220
+ if (!asJson) {
221
+ console.log(` ${task.id} ${arm.name}-${rep} ... ${ok ? "PASS" : "fail"}, ${seconds.toFixed(1)}s, ${why}`)
222
+ }
223
+ rmSync(base, { recursive: true, force: true })
224
+ }
225
+ }
226
+ }
227
+
228
+ function median(values) {
229
+ if (!values.length) return null
230
+ const s = [...values].sort((a, b) => a - b)
231
+ const m = Math.floor(s.length / 2)
232
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
233
+ }
234
+
235
+ const summary = arms.map((arm) => {
236
+ const rs = rows.filter((r) => r.arm === arm.name)
237
+ const valid = rs.filter((r) => !r.invalid)
238
+ return {
239
+ arm: arm.name,
240
+ attempts: rs.length,
241
+ pass: rs.filter((r) => r.ok).length,
242
+ invalid: rs.length - valid.length,
243
+ medianSeconds: median(valid.map((r) => r.seconds)),
244
+ }
245
+ })
246
+
247
+ if (asJson) {
248
+ console.log(JSON.stringify({ target, suite: suitePath, reps, timeout, arms: summary, runs: rows }, null, 2))
249
+ } else {
250
+ console.log("")
251
+ console.log(`model-free comparison: ${tasks.length} task(s) x ${reps} rep(s) x ${arms.length} arm(s)`)
252
+ for (const s of summary) {
253
+ console.log(
254
+ ` ${s.arm.padEnd(12)} ${s.pass}/${s.attempts} pass` +
255
+ (s.invalid ? `, ${s.invalid} invalid` : "") +
256
+ (s.medianSeconds === null ? "" : `, median ${s.medianSeconds}s`),
257
+ )
258
+ }
259
+ }
260
+
261
+ // A run where every arm crashed measured nothing. Say so rather than
262
+ // printing a 0% that looks like a result.
263
+ const usable = summary.some((s) => s.attempts > s.invalid)
264
+ if (!usable) {
265
+ console.error("no arm produced a usable run")
266
+ process.exit(2)
267
+ }
package/eval/verify.mjs CHANGED
@@ -161,6 +161,15 @@ export function resolveInTarget(target, ref) {
161
161
  const rel = target.replace(/^\/private/, "")
162
162
  if (ref.startsWith(rel)) candidates.push(`/private${ref}`)
163
163
  if (ref.startsWith(`/private${rel}`)) candidates.push(ref.replace(/^\/private/, ""))
164
+ // A run copy is a byte copy of the snapshot, so a path inside one names
165
+ // the same file relative to the snapshot. This is what makes `--rescore`
166
+ // grade an answer that cited `/tmp/opencode-ab-run-<id>/src/a.ts` after
167
+ // the copy is gone: the live run resolved it against the copy, the
168
+ // re-score has only the snapshot, and without this the same answer reads
169
+ // as "cited 0" - which is how a re-score reported 10/12 where the run it
170
+ // was re-scoring had scored 15/17.
171
+ const copy = /\/opencode-ab-run-[^/]+\/(.+)$/.exec(ref)
172
+ if (copy) candidates.push(join(target, copy[1]))
164
173
  } else {
165
174
  candidates.push(join(target, ref))
166
175
  }
package/lanes.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "lanes": {
5
5
  "build": {
6
6
  "mode": "primary",
7
- "description": "Writes and verifies code. Use when the plan is settled and files need to change.",
8
- "steps": 40,
7
+ "description": "Writes and verifies code. Use when the plan is settled and files need to change.",
8
+ "steps": 120,
9
9
  "permission": { "edit": "allow" },
10
10
  "prompt": "prompts/build.md"
11
11
  },
@@ -69,18 +69,22 @@
69
69
  "permission": {
70
70
  "edit": "deny",
71
71
  "bash": {
72
- "*": "allow",
72
+ "*": "ask",
73
73
  "git status *": "allow",
74
74
  "git diff *": "allow",
75
- "mkdir*": "allow",
76
75
  "ps*": "allow",
76
+ "node --test*": "allow",
77
+ "npm test*": "allow",
78
+ "npm run *": "allow",
79
+ "npx *": "allow",
80
+ "bun test*": "allow",
81
+ "deno test*": "allow",
82
+ "make*": "allow",
77
83
  "python3 -m pytest*": "allow",
78
84
  "pytest*": "allow",
79
85
  "ruff*": "allow",
80
86
  "mypy*": "allow",
81
87
  "black*": "allow",
82
- "npm test*": "allow",
83
- "npm run *": "allow",
84
88
  "npx tsc*": "allow",
85
89
  "cargo test*": "allow",
86
90
  "go test*": "allow"
@@ -102,11 +106,16 @@
102
106
  "permission": {
103
107
  "edit": "deny",
104
108
  "bash": {
105
- "*": "allow",
109
+ "*": "deny",
106
110
  "pdftotext*": "allow",
107
- "python3 -c*": "allow",
108
111
  "cat*": "allow",
109
- "ls*": "allow"
112
+ "ls*": "allow",
113
+ "head*": "allow",
114
+ "tail*": "allow",
115
+ "wc*": "allow",
116
+ "file*": "allow",
117
+ "grep*": "allow",
118
+ "rg*": "allow"
110
119
  }
111
120
  },
112
121
  "prompt": "prompts/doc.md"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-harness-kit",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "Harness kit for OpenCode: capability profiles, a fleet of job lanes with one free model each, role-based reasoning effort, context pruning, and measured baselines.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/pham-canh/opencode-harness-kit#readme",
@@ -67,7 +67,7 @@
67
67
  "typecheck": "tsc --noEmit && npm run typecheck:scripts",
68
68
  "typecheck:scripts": "tsc -p tsconfig.scripts.json",
69
69
  "pretest": "node -e \"const fs=require('fs');if(!fs.existsSync('test')){console.error('no test directory here - this is an installed package, not a checkout');process.exit(1)}\"",
70
- "test": "node --test \"test/*.test.js\"",
70
+ "test": "OPENCODE_HARNESS_CATALOG_URL=file://test/fixtures/catalogue.json node --test \"test/*.test.js\"",
71
71
  "test:all": "npm run build && npm run typecheck && npm test",
72
72
  "test:dist": "git diff --exit-code -- dist/ || (echo \"dist/ is out of sync with src/. Run: npm run build && git add dist/\" && exit 1)",
73
73
  "check": "npm run build && npm run test:dist && npm run typecheck && npm test",
@@ -105,6 +105,10 @@
105
105
  "esbuild": "^0.28.2",
106
106
  "typescript": "^7.0.2"
107
107
  },
108
+ "overrides": {
109
+ "zod": "4.6.7",
110
+ "toml": "5.0.1"
111
+ },
108
112
  "publishConfig": {
109
113
  "access": "public"
110
114
  },
@@ -110,7 +110,7 @@
110
110
  "topEffort": "max",
111
111
  "context": 262144,
112
112
  "default": false,
113
- "note": "Open-weight MoE with about 2.5B active parameters for local coding agents. Knowledge cutoff 2026-06, no image input. May train on prompts during its free period."
113
+ "note": "Open-weight MoE, 40B total with about 2.5B active parameters, built for coding agents and small enough to serve locally. Full effort ladder, 262k context, 64k output, no image input. Knowledge cutoff 2026-06. May train on prompts during its free period."
114
114
  },
115
115
  {
116
116
  "id": "big-pickle",
@@ -122,17 +122,6 @@
122
122
  "context": 160000,
123
123
  "default": false,
124
124
  "note": "Stealth model, no public identity, no effort levels, no image input. The provider may train on it during its free period."
125
- },
126
- {
127
- "id": "flint-40b-free",
128
- "file": "profiles/flint-40b-free.json",
129
- "title": "Flint 40B Free",
130
- "retention": "may-train",
131
- "free": true,
132
- "topEffort": "max",
133
- "context": 262144,
134
- "default": false,
135
- "note": "Open-weight MoE, 40B total with about 2.5B active, built for coding agents and small enough to serve locally. Full effort ladder, 262k context, 64k output, no image input."
136
125
  }
137
126
  ],
138
127
  "excluded": [],
@@ -38,5 +38,29 @@ Only if pass 1 is clean.
38
38
  Two sections, pass 1 then pass 2. If pass 1 blocked, pass 2 does not run and
39
39
  you say why.
40
40
 
41
+ ## Machine verdict
42
+
43
+ When the request asks for a schema'd verdict (it names
44
+ `oc-harness/review@1`), the two sections are replaced: your final message is
45
+ exactly one JSON object and nothing else, with no code fence and no sentence
46
+ around it.
47
+
48
+ ```json
49
+ {
50
+ "schema": "oc-harness/review@1",
51
+ "verdict": "pass",
52
+ "blockers": [],
53
+ "notes": "short optional note"
54
+ }
55
+ ```
56
+
57
+ - `pass` means `blockers` is empty. `fail` means at least one blocker.
58
+ - Each blocker is `{ "file": "path/from/repo", "line": 1, "issue": "...",
59
+ "consequence": "..." }`. A blocker without a line and a consequence is not
60
+ actionable and fails validation.
61
+ - Anything that is not this object - prose, a fenced block, a missing field -
62
+ is treated as a failed review by the caller. When in doubt, `fail` with a
63
+ blocker that says what could not be checked.
64
+
41
65
  See also the `space-review` skill for the full plan -> build -> review -> finish
42
66
  loop and the shared task-file contract.
package/retention.yml CHANGED
@@ -110,14 +110,6 @@ models:
110
110
  verified: 2026-10-19
111
111
  source: https://opencode.ai/docs/zen
112
112
 
113
- opencode/flint-40b-free:
114
- retention: may-train
115
- note: >-
116
- Free trial endpoint on opencode zen; treated as may-train until terms
117
- state otherwise.
118
- verified: 2026-10-22
119
- source: https://opencode.ai/docs/zen
120
-
121
113
  # --- Paid routes, kept because the catalog still serves them and a profile
122
114
  # may be pointed at one. ---
123
115