opencode-harness-kit 0.4.2 → 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 +544 -19
  2. package/CONTRIBUTING.md +242 -0
  3. package/GLOBAL.md +34 -5
  4. package/MIGRATION.md +151 -0
  5. package/README.md +294 -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
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ // prepare.mjs - build the subject the A/B suite runs against.
3
+ //
4
+ // Everything measured in eval/RESULTS.md was run against a directory somebody
5
+ // made by hand in a temp folder. That is not a result anyone else can check, and
6
+ // the directory is already gone on some of the older runs. This makes it a
7
+ // command.
8
+ //
9
+ // node eval/prepare.mjs --repo ~/work/lablab-harness --ref 8a674f5
10
+ // node eval/prepare.mjs --repo ~/work/lablab-harness --ref 8a674f5 --json
11
+ //
12
+ // node eval/prepare.mjs --from ~/work/gibwork/vendor/gibwork-sentinel
13
+ //
14
+ // --repo uses `git archive`, so the subject is exactly a committed tree: no
15
+ // uncommitted edits, no .git, no build output that was never committed. --from
16
+ // copies a plain directory, for sources that are not in git.
17
+ //
18
+ // Either way the subject lands somewhere stable under the scratch root rather
19
+ // than a fresh mktemp, so a run can be pointed at the same bytes twice.
20
+
21
+ import { execFileSync, spawnSync } from "node:child_process"
22
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"
23
+ import { homedir, tmpdir } from "node:os"
24
+ import { basename, isAbsolute, join, relative, resolve } from "node:path"
25
+
26
+ const args = process.argv.slice(2)
27
+ const flag = (...n) => {
28
+ const i = args.findIndex((a) => n.includes(a))
29
+ return i >= 0 ? args[i + 1] : undefined
30
+ }
31
+
32
+ const repo = flag("--repo")
33
+ const ref = flag("--ref") ?? "HEAD"
34
+ const from = flag("--from")
35
+ const wantJson = args.includes("--json")
36
+ const name = flag("--name")
37
+
38
+ if (!repo && !from) {
39
+ console.error("usage: node eval/prepare.mjs (--repo <path> [--ref <sha>] | --from <path>) [--name <n>] [--json]")
40
+ process.exit(1)
41
+ }
42
+ if (repo && from) {
43
+ console.error("pass --repo or --from, not both")
44
+ process.exit(1)
45
+ }
46
+
47
+ /**
48
+ * The same scan ab.mjs runs before it will start. Repeated here because the
49
+ * whole point is that a subject which leaks is a subject an agent can walk out
50
+ * of, and the person preparing it should find out now, not after twenty runs.
51
+ */
52
+ function findLeakedPaths(dir, depth = 0) {
53
+ if (depth > 6) return []
54
+ const found = []
55
+ let entries = []
56
+ try {
57
+ entries = readdirSync(dir, { withFileTypes: true })
58
+ } catch {
59
+ return []
60
+ }
61
+ for (const e of entries) {
62
+ if (e.name === ".git" || e.name === ".space" || e.name === "node_modules") continue
63
+ const p = join(dir, e.name)
64
+ if (e.isDirectory()) {
65
+ found.push(...findLeakedPaths(p, depth + 1))
66
+ continue
67
+ }
68
+ if (!/\.(md|py|ts|tsx|js|json|txt|ya?ml|toml|sh)$/.test(e.name)) continue
69
+ let body
70
+ try {
71
+ body = readFileSync(p, "utf8")
72
+ } catch {
73
+ continue
74
+ }
75
+ for (const m of body.matchAll(/\/(?:Users|home)\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*/g)) {
76
+ const abs = m[0].replace(/[.,;:)]+$/, "")
77
+ if (!existsSync(abs)) continue
78
+ if (abs === dir || abs.startsWith(dir + "/")) continue
79
+ found.push({ file: p.slice(dir.length + 1), path: abs })
80
+ }
81
+ }
82
+ const seen = new Set()
83
+ return found.filter((f) => (seen.has(f.path) ? false : seen.add(f.path)))
84
+ }
85
+
86
+ const scratch = resolve(process.env.SPACE_HARNESS_SCRATCH ?? join(tmpdir(), "space-harness-subjects"))
87
+ mkdirSync(scratch, { recursive: true })
88
+
89
+ /**
90
+ * The directory NAME of a subject.
91
+ *
92
+ * A name, not a path. `--name ../..` used to resolve outside the scratch
93
+ * root, and the `rmSync` below then deleted whatever was there - a subject
94
+ * label is a command-line argument, and the command below it removes a
95
+ * directory tree recursively.
96
+ *
97
+ * The check is made twice on purpose: once here, on the text, and once on
98
+ * the resolved path, below. Either alone would be enough; both together mean
99
+ * a mistake in either cannot delete anything.
100
+ *
101
+ * @param {string | undefined} raw
102
+ * @returns {string | null}
103
+ */
104
+ function safeLabel(raw) {
105
+ if (!raw) return null
106
+ if (/[\\/]/.test(raw)) return null
107
+ if (raw === "." || raw === "..") return null
108
+ if (raw.includes("..")) return null
109
+ if (raw.length === 0) return null
110
+ return raw
111
+ }
112
+
113
+ const requested = name ?? (from ? basename(from) : `${basename(repo ?? "repo")}-${ref}`)
114
+ const label = safeLabel(requested)
115
+ if (!label) {
116
+ console.error(`--name must be a single directory name, not a path: ${requested}`)
117
+ process.exit(1)
118
+ }
119
+ const out = join(scratch, label)
120
+
121
+ // Belt and braces: the resolved path has to still be inside the scratch root.
122
+ const rel = relative(scratch, resolve(out))
123
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
124
+ console.error(`refusing to write outside the scratch root: ${out}`)
125
+ process.exit(1)
126
+ }
127
+
128
+ if (existsSync(out)) rmSync(out, { recursive: true, force: true })
129
+ mkdirSync(out, { recursive: true })
130
+
131
+ let source
132
+ let pinned
133
+ if (from) {
134
+ if (!existsSync(from)) {
135
+ console.error(`--from path not found: ${from}`)
136
+ process.exit(1)
137
+ }
138
+ cpSync(from, out, { recursive: true })
139
+ source = from
140
+ pinned = "not a git repo, copied as found"
141
+ } else {
142
+ if (!existsSync(join(repo, ".git"))) {
143
+ console.error(`not a git repository: ${repo}`)
144
+ process.exit(1)
145
+ }
146
+ let sha
147
+ try {
148
+ sha = execFileSync("git", ["-C", repo, "rev-parse", ref], { encoding: "utf8" }).trim()
149
+ } catch {
150
+ console.error(`cannot resolve ref ${ref} in ${repo}`)
151
+ process.exit(1)
152
+ }
153
+ // git archive writes a tar to stdout. Piping it into tar keeps this in one
154
+ // process rather than leaving a tarball behind for someone to trip over.
155
+ //
156
+ // No shell. The previous version built one `sh -c` string and quoted the
157
+ // paths with `JSON.stringify`, which is not shell quoting: it escapes `"` and
158
+ // `\` and leaves `$`, backticks and `!` alone, so a path containing `$(...)`
159
+ // was executed by the shell. `git` and `tar` are called with argument arrays
160
+ // and the tar goes through a pipe, so nothing is ever parsed as a command.
161
+ //
162
+ // `eval/` and `test/` are EXCLUDED, and that is not a detail. The subject of
163
+ // the self suite is this repository: `eval/` holds the grader and the
164
+ // answer key, and `test/` quotes the answer in its own assertions. An agent
165
+ // that found `eval/verify.mjs` would use the grader to check its own work,
166
+ // and one that found the answer in a test would cite the test.
167
+ //
168
+ // Both were measured. An agent that read the grader made 48 tool calls to a
169
+ // task that needed 5, and the answer was somewhere else in the file it was
170
+ // asked about.
171
+ const archive = spawnSync(
172
+ "git",
173
+ [
174
+ "-C", repo, "archive", "--format=tar", sha, "--",
175
+ ".",
176
+ ":(exclude)eval",
177
+ ":(exclude)test",
178
+ ],
179
+ {
180
+ maxBuffer: 512 * 1024 * 1024,
181
+ },
182
+ )
183
+ if (archive.status !== 0 || archive.error) {
184
+ console.error(`cannot archive ${repo} at ${sha}`)
185
+ if (archive.stderr?.length) console.error(archive.stderr.toString().trimEnd())
186
+ process.exit(1)
187
+ }
188
+ const untar = spawnSync("tar", ["-x", "-C", out], {
189
+ input: archive.stdout,
190
+ maxBuffer: 512 * 1024 * 1024,
191
+ })
192
+ if (untar.status !== 0 || untar.error) {
193
+ console.error(`cannot unpack the archive into ${out}`)
194
+ if (untar.stderr?.length) console.error(untar.stderr.toString().trimEnd())
195
+ process.exit(1)
196
+ }
197
+ source = repo
198
+ pinned = sha.slice(0, 12)
199
+ }
200
+
201
+ // Record what this subject is, next to it, so a later run can be checked against
202
+ // it without having to reconstruct the command.
203
+ const manifest = {
204
+ source,
205
+ ref: from ? null : ref,
206
+ commit: from ? null : pinned,
207
+ built_at: new Date().toISOString(),
208
+ command: `node eval/prepare.mjs ${from ? "--from " + from : `--repo ${repo} --ref ${ref}`}`,
209
+ }
210
+ writeFileSync(join(out, ".space-harness-subject.json"), JSON.stringify(manifest, null, 2) + "\n")
211
+
212
+ const leaks = findLeakedPaths(out)
213
+ const report = { path: out, ...manifest, leaked_paths: leaks.map((l) => l.path) }
214
+
215
+ if (wantJson) {
216
+ console.log(JSON.stringify(report, null, 2))
217
+ } else {
218
+ console.log(`subject: ${out}`)
219
+ console.log(` from ${source}`)
220
+ console.log(` pinned ${pinned ?? "-"}`)
221
+ if (leaks.length) {
222
+ console.log("")
223
+ console.log(` ${leaks.length} absolute path(s) inside this subject resolve on this machine:`)
224
+ for (const l of leaks.slice(0, 8)) console.log(` ${l.path}`)
225
+ console.log("")
226
+ console.log(" ab.mjs will refuse to run against this unless you pass --sanitize,")
227
+ console.log(" which rewrites them in each run copy. Do that, or scrub the source.")
228
+ } else {
229
+ console.log(" no absolute paths resolve outside it")
230
+ }
231
+ console.log("")
232
+ console.log(` next: node eval/ab.mjs --target ${out} --suite eval/tasks.json --reps 2 --sanitize`)
233
+ }
package/eval/run.mjs ADDED
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env node
2
+ // eval/run.mjs - did the harness actually change anything?
3
+ //
4
+ // A harness that claims to improve efficiency has to be measurable, or the
5
+ // claim is decoration. This runs the shipped transformers over a synthetic
6
+ // transcript shaped like a real one and reports what changed.
7
+ //
8
+ // It measures the mechanical effects that are deterministic and offline:
9
+ // - how much context the pruner removes
10
+ // - how many permission prompts the allowlist removes
11
+ // - whether effort tiering cuts the latency it predicts to cut
12
+ //
13
+ // It does NOT measure model quality. That needs real runs against real tasks,
14
+ // and a synthetic transcript cannot tell you whether the code got better.
15
+ //
16
+ // Usage:
17
+ // node eval/run.mjs human-readable
18
+ // node eval/run.mjs --json machine-readable
19
+ // node eval/run.mjs --compare measure with the kit disabled, for a diff
20
+
21
+ import { execFileSync } from "node:child_process"
22
+ import { existsSync } from "node:fs"
23
+ import { homedir } from "node:os"
24
+ import { dirname, join } from "node:path"
25
+ import { fileURLToPath } from "node:url"
26
+ import { query as queryDb } from "../src/sqlite.js"
27
+
28
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
29
+ const DIST = join(ROOT, "dist", "index.js")
30
+ const wantJson = process.argv.includes("--json")
31
+ const compare = process.argv.includes("--compare")
32
+
33
+ if (!existsSync(DIST)) {
34
+ console.error("dist/index.js missing. Run: npm run build")
35
+ process.exit(1)
36
+ }
37
+
38
+ /**
39
+ * A transcript shaped like the one that produced the real baseline: tool calls
40
+ * with kilobyte-scale output, a small amount of prose, and reasoning.
41
+ */
42
+ function syntheticTranscript({ turns = 40, toolOutputBytes = 9000 } = {}) {
43
+ const messages = []
44
+ for (let i = 0; i < turns; i++) {
45
+ messages.push({
46
+ info: { id: `a${i}`, role: "assistant", time: { created: 1789000000000 + i } },
47
+ parts: [
48
+ { id: `s${i}`, sessionID: "ses", messageID: `a${i}`, type: "step-start" },
49
+ {
50
+ id: `r${i}`,
51
+ sessionID: "ses",
52
+ messageID: `a${i}`,
53
+ type: "reasoning",
54
+ text: "thinking ".repeat(400),
55
+ },
56
+ {
57
+ id: `t${i}`,
58
+ sessionID: "ses",
59
+ messageID: `a${i}`,
60
+ type: "tool",
61
+ callID: `c${i}`,
62
+ tool: i % 3 === 0 ? "bash" : "read",
63
+ state: {
64
+ status: "completed",
65
+ input: i % 3 === 0 ? { command: `pytest -q tests/test_${i}.py` } : { filePath: `src/mod_${i}.py` },
66
+ output: "x".repeat(toolOutputBytes),
67
+ title: "tool",
68
+ metadata: {},
69
+ time: { start: 1, end: 2 },
70
+ },
71
+ },
72
+ {
73
+ id: `x${i}`,
74
+ sessionID: "ses",
75
+ messageID: `a${i}`,
76
+ type: "text",
77
+ text: "Short prose conclusion for this step.",
78
+ },
79
+ ],
80
+ })
81
+ if (i % 8 === 0) {
82
+ messages.push({
83
+ info: { id: `u${i}`, role: "user", time: { created: 1789000000000 + i } },
84
+ parts: [{ id: `ut${i}`, sessionID: "ses", messageID: `u${i}`, type: "text", text: "continue" }],
85
+ })
86
+ }
87
+ }
88
+ return messages
89
+ }
90
+
91
+ function bytesOf(messages) {
92
+ return JSON.stringify(messages).length
93
+ }
94
+
95
+ async function measurePruning() {
96
+ const { HarnessPlugin } = await import(DIST)
97
+ const worktree = join(ROOT, ".space-eval")
98
+ const { mkdirSync, writeFileSync, rmSync } = await import("node:fs")
99
+ mkdirSync(join(worktree, ".space"), { recursive: true })
100
+ writeFileSync(join(worktree, ".space", "active"), "fleet-safe\n")
101
+
102
+ const hooks = await HarnessPlugin(
103
+ { worktree, directory: worktree, project: {}, client: {}, $: () => {} },
104
+ { only: ["prune"], profile: "fleet-safe" },
105
+ )
106
+ const transform = hooks["experimental.chat.messages.transform"]
107
+ if (!transform) {
108
+ rmSync(worktree, { recursive: true, force: true })
109
+ throw new Error("prune hook did not activate; check the active marker path")
110
+ }
111
+
112
+ const messages = syntheticTranscript()
113
+ const before = bytesOf(messages)
114
+ await transform({}, { messages })
115
+ const after = bytesOf(messages)
116
+
117
+ const toolParts = messages.flatMap((m) => m.parts).filter((p) => p.type === "tool")
118
+ const pruned = toolParts.filter((p) => p.state?.metadata?.pruned).length
119
+ const assistantTurns = messages.filter((m) => m.info.role === "assistant").length
120
+ const usersIntact = messages
121
+ .filter((m) => m.info.role === "user")
122
+ .every((m) => m.parts[0].text === "continue")
123
+
124
+ rmSync(worktree, { recursive: true, force: true })
125
+ return {
126
+ bytesBefore: before,
127
+ bytesAfter: after,
128
+ reductionPct: +(((before - after) / before) * 100).toFixed(1),
129
+ assistantTurns,
130
+ toolParts: toolParts.length,
131
+ pruned,
132
+ usersIntact,
133
+ }
134
+ }
135
+
136
+ /** Replay a corpus of bash commands through an allowlist. */
137
+ async function measurePrompts() {
138
+ const { BASH_ALLOWLIST } = await import(join(ROOT, "src", "config.js"))
139
+ const corpus = [
140
+ // Shape from the measured 5,239 real commands, compressed.
141
+ ...Array(1742).fill("cd /Users/x/proj && ls -la"),
142
+ ...Array(326).fill("ls -la"),
143
+ ...Array(219).fill("# build the app"),
144
+ ...Array(217).fill('python3 -c "print(1)"'),
145
+ ...Array(151).fill("gh pr list"),
146
+ ...Array(148).fill("echo hello"),
147
+ ...Array(141).fill("sleep 2"),
148
+ ...Array(133).fill("ssh -o StrictHostKeyChecking=no deploy@host uptime"),
149
+ ...Array(93).fill("curl -s https://example.test/api"),
150
+ ...Array(80).fill("cat src/app.py"),
151
+ ...Array(67).fill("TOKEN=$(curl -s https://example.test/token)"),
152
+ ...Array(66).fill("rm -rf build/"),
153
+ ...Array(59).fill("git add -A"),
154
+ ...Array(47).fill("osascript -e 'tell app \"Finder\" to quit'"),
155
+ ...Array(43).fill("pkill -f node"),
156
+ ]
157
+ const glob = (cmd, pat) =>
158
+ new RegExp("^" + pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") + "$").test(cmd)
159
+ const prompts = corpus.filter((c) => !BASH_ALLOWLIST.some((p) => glob(c, p))).length
160
+ return {
161
+ commands: corpus.length,
162
+ prompts,
163
+ promptSharePct: +((prompts / corpus.length) * 100).toFixed(1),
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Effort tiering, measured from the install database where that is possible.
169
+ *
170
+ * Per-variant medians are read for the profile's own model, so this no longer
171
+ * mixes models the way it used to. When every variant the role map depends on is
172
+ * present, the output is labelled measured; otherwise it falls back to the
173
+ * fixed constants below and says so, rather than substituting one tier's median
174
+ * for another.
175
+ *
176
+ * Even when measured, the saving is a median mix projected over a session, not
177
+ * an end-to-end session measurement. `oc-harness baseline` reports the same
178
+ * per-variant medians with sample sizes.
179
+ */
180
+ /**
181
+ * Per-variant median request latency for one model, straight from the install
182
+ * database. Returns null when the database is missing or has no data for that
183
+ * model, so the caller can fall back rather than invent a number.
184
+ *
185
+ * Median ranks match bin/baseline.mjs: rank (n+1)/2 for odd n, the mean of the
186
+ * two middle ranks for even n.
187
+ *
188
+ * The query is transcribed rather than imported because bin/baseline.mjs runs
189
+ * its whole report at module top level and calls process.exit, so importing it
190
+ * would print the baseline report and kill this run. Keep the two in step when
191
+ * either changes.
192
+ */
193
+ function mediansForModel(modelID, providerID) {
194
+ const db = join(homedir(), ".local/share/opencode/opencode.db")
195
+ if (!existsSync(db)) return null
196
+
197
+ const sql = `
198
+ select variant, printf('%.1f', avg(ms)) as median_ms, n
199
+ from (
200
+ select variant, ms, rn, n
201
+ from (
202
+ select coalesce(json_extract(data,'$.variant'),'(default)') as variant,
203
+ (json_extract(data,'$.time.completed')
204
+ - json_extract(data,'$.time.created')) / 1000.0 as ms,
205
+ row_number() over (
206
+ partition by coalesce(json_extract(data,'$.variant'),'(default)')
207
+ order by (json_extract(data,'$.time.completed')
208
+ - json_extract(data,'$.time.created')) / 1000.0) as rn,
209
+ count(*) over (
210
+ partition by coalesce(json_extract(data,'$.variant'),'(default)')) as n
211
+ from message
212
+ where json_extract(data,'$.role') = 'assistant'
213
+ ${providerID ? `and json_extract(data,'$.providerID') = '${providerID.replace(/'/g, "''")}'` : ""}
214
+ and json_extract(data,'$.modelID') = '${modelID.replace(/'/g, "''")}'
215
+ and (json_extract(data,'$.time.completed')
216
+ - json_extract(data,'$.time.created')) / 1000.0 > 0
217
+ )
218
+ )
219
+ where rn = cast((n + 1) / 2 as integer)
220
+ or (n % 2 = 0 and rn = cast((n + 2) / 2 as integer))
221
+ group by variant, n`
222
+
223
+ let rows
224
+ try {
225
+ // The shared helper, so this script runs on a machine without the sqlite3
226
+ // CLI like every other measurement script.
227
+ rows = queryDb(db, sql)
228
+ } catch {
229
+ return null
230
+ }
231
+ if (!rows.length) return null
232
+
233
+ const MEDIAN = {}
234
+ const samples = {}
235
+ for (const r of rows) {
236
+ MEDIAN[r.variant] = Number(r.median_ms)
237
+ samples[r.variant] = r.n
238
+ }
239
+ return { MEDIAN, samples, source: "measured" }
240
+ }
241
+
242
+ async function measureEffort() {
243
+ const { loadProfile, resolveRoles } = await import(join(ROOT, "src", "profile.js"))
244
+
245
+ const FALLBACK = { max: 18.7, xhigh: 7.8, high: 7.8, medium: 7.8, low: 7.8 }
246
+ const p = loadProfile("fleet-safe")
247
+ const roles = resolveRoles(p)
248
+
249
+ // A realistic session: one plan, eight builds, two reviews, four verifications.
250
+ const mix = { plan: 1, build: 8, reviewer: 2, verify: 4, locate: 0 }
251
+
252
+ // Every variant this report depends on has to have been measured. A missing
253
+ // one must not be substituted with another tier's median: that would print a
254
+ // number under a "measured" header for a tier that was never measured.
255
+ const needed = new Set([...Object.values(roles).filter(Boolean), "max"])
256
+ const found = mediansForModel(p.model, p.provider)
257
+ const complete = found && [...needed].every((v) => typeof found.MEDIAN[v] === "number")
258
+
259
+ const { MEDIAN, samples, source } = complete
260
+ ? found
261
+ : { MEDIAN: FALLBACK, samples: {}, source: "projection" }
262
+
263
+ let allMax = 0
264
+ let tiered = 0
265
+ for (const [role, count] of Object.entries(mix)) {
266
+ allMax += count * MEDIAN.max
267
+ const level = roles[role]
268
+ tiered += count * MEDIAN[level ?? "max"]
269
+ }
270
+ // Only the variants this report actually depends on: the completeness gate
271
+ // covers these, so listing anything else would print unchecked numbers under
272
+ // a measured header.
273
+ const shown = Object.entries(samples).filter(([v]) => needed.has(v))
274
+ const thin = shown.filter(([, n]) => n < 30)
275
+
276
+ return {
277
+ mix,
278
+ source,
279
+ medians: MEDIAN,
280
+ samples: Object.fromEntries(shown),
281
+ allMaxSeconds: +allMax.toFixed(1),
282
+ tieredSeconds: +tiered.toFixed(1),
283
+ savedPct: +(((allMax - tiered) / allMax) * 100).toFixed(1),
284
+ measured: source === "measured",
285
+ caveat:
286
+ source === "measured"
287
+ ? thin.length
288
+ ? `Median per request on this model from the install database. ` +
289
+ `Treat the tiers with few samples as indicative only: ` +
290
+ thin.map(([v, n]) => `${v} (n=${n})`).join(", ") +
291
+ `. The saving below is that median mix projected over a session, ` +
292
+ `not a measured end-to-end session.`
293
+ : `Median per request on this model from the install database, ` +
294
+ `projected over a session mix. Not an end-to-end session measurement.`
295
+ : `PROJECTION, not a measurement: no complete per-variant data for ` +
296
+ `${p.model} in the install database, so fixed constants are used. ` +
297
+ `The cause may be a missing or unreadable database, or a variant ` +
298
+ `that has not been exercised yet. \`oc-harness baseline\` reports ` +
299
+ `per-variant medians with sample sizes and will say which is which.`,
300
+ }
301
+ }
302
+
303
+ const [pruning, prompts, effort] = await Promise.all([
304
+ measurePruning(),
305
+ measurePrompts(),
306
+ measureEffort(),
307
+ ])
308
+
309
+ const report = { generated: new Date().toISOString(), pruning, prompts, effort }
310
+
311
+ if (wantJson) {
312
+ console.log(JSON.stringify(report, null, 2))
313
+ process.exit(0)
314
+ }
315
+
316
+ const n = (x) => Number(x).toLocaleString("en-US")
317
+ const pad = (k) => k.padEnd(34)
318
+ const stamp = (s) => `\x1b[2m${s}\x1b[0m`
319
+ const wrap = (text, width) => {
320
+ const words = text.split(/\s+/)
321
+ const lines = []
322
+ let line = ""
323
+ for (const w of words) {
324
+ if ((line + " " + w).trim().length > width) {
325
+ lines.push(line.trim())
326
+ line = w
327
+ } else {
328
+ line += " " + w
329
+ }
330
+ }
331
+ if (line.trim()) lines.push(line.trim())
332
+ return lines
333
+ }
334
+
335
+ console.log(`\nopencode-harness-kit eval ${report.generated}\n`)
336
+
337
+ console.log(`\nCONTEXT PRUNING (synthetic transcript: ${pruning.assistantTurns} assistant turns, ${pruning.toolParts} tool calls)`)
338
+ console.log(` ${pad("bytes before")} ${n(pruning.bytesBefore)}`)
339
+ console.log(` ${pad("bytes after")} ${n(pruning.bytesAfter)}`)
340
+ console.log(` ${pad("reduction")} ${pruning.reductionPct}%`)
341
+ console.log(` ${pad("tool parts pruned")} ${pruning.pruned} / ${pruning.toolParts}`)
342
+ console.log(` ${pad("user messages intact")} ${pruning.usersIntact ? "yes" : "NO"}`)
343
+
344
+ console.log("\nPERMISSION PROMPTS (corpus shaped from 5,239 measured commands)")
345
+ console.log(` ${pad("commands")} ${n(prompts.commands)}`)
346
+ console.log(` ${pad("prompts")} ${n(prompts.prompts)} (${prompts.promptSharePct}%)`)
347
+ console.log(` ${pad("previously")} 95.3% with a narrow allowlist`)
348
+
349
+ const tierHeader = effort.measured
350
+ ? "EFFORT TIERING (medians measured on this model, projected over a session mix)"
351
+ : "EFFORT TIERING (PROJECTION - cross-model medians, not a measurement)"
352
+ console.log(`\n${tierHeader}`)
353
+ console.log(` ${pad("all-max session")} ${effort.allMaxSeconds}s`)
354
+ console.log(` ${pad("tiered session")} ${effort.tieredSeconds}s`)
355
+ console.log(` ${pad("implied saving")} ${effort.savedPct}%`)
356
+ console.log(` ${pad("measured")} ${effort.measured ? "per-variant medians yes, session no" : "no"}`)
357
+ if (effort.measured) {
358
+ // Print from samples, not medians: both are restricted to the variants the
359
+ // completeness gate checked, so they cannot drift apart here.
360
+ const med = Object.entries(effort.samples)
361
+ .map(([v, n]) => `${v} ${effort.medians[v]}s (n=${n})`)
362
+ .join(", ")
363
+ console.log(` ${pad("median per request")} ${stamp(med)}`)
364
+ }
365
+ for (const line of wrap(effort.caveat, 60)) console.log(` ${pad("")} ${stamp(line)}`)
366
+
367
+ if (compare) {
368
+ const off = syntheticTranscript()
369
+ console.log(
370
+ `\n ${pad("kit disabled")} ${n(bytesOf(off))} bytes ` +
371
+ `(${(((bytesOf(off) - pruning.bytesAfter) / bytesOf(off)) * 100).toFixed(1)}% larger than pruned)`,
372
+ )
373
+ }
374
+
375
+ console.log("\n Not measured here: code quality, task completion, review defects.")
376
+ console.log(" Those need real runs against real tasks. See docs/RESEARCH.md.\n")
@@ -0,0 +1,81 @@
1
+ {
2
+ "$comment": [
3
+ "Verification tasks for eval/ab.mjs --suite, written against THIS repository.",
4
+ "",
5
+ "The earlier suite was written against `lablab-harness@8a674f5`, which is",
6
+ "not public. A subject nobody else can fetch is a result nobody else can",
7
+ "check, and the A/B is the one number in this repository that decides",
8
+ "whether the kit is worth its cost. This suite runs against the kit's own",
9
+ "tree at a pinned commit, so `git clone && git checkout <sha>` reproduces",
10
+ "it exactly.",
11
+ "",
12
+ "Grading is by citation, not by prose: each read task names `want`, a",
13
+ "substring of one real source line, and eval/verify.mjs resolves the path:line",
14
+ "the agent cited and reads that line out of the snapshot. An answer can only",
15
+ "pass by pointing at the code.",
16
+ "",
17
+ "The control comes first and is kept, because it is the control: one file,",
18
+ "one constant. The rest need enumeration, discrimination between two similar",
19
+ "things, or two files - which is what the multi-step tasks are for.",
20
+ "",
21
+ "Every `want` below was read out of the tree with grep before it was written",
22
+ "here. Re-check with: git archive | tar -x && grep -n"
23
+ ],
24
+ "snapshot": "this repository, at the commit the suite is committed with",
25
+ "tasks": [
26
+ {
27
+ "id": "read-control",
28
+ "$comment": "Control. One file, one constant.",
29
+ "task": "In this repository, one exported constant names the five lanes that are part of the plan -> build -> review -> finish loop. Report the exact file path and line number where that constant is defined, and quote the line. Do not modify any files.",
30
+ "want": "export const WORKFLOW_LANES = [\"build\", \"plan\", \"reviewer\", \"locate\", \"verify\"]"
31
+ },
32
+ {
33
+ "id": "read-multi-denylist",
34
+ "$comment": "One file, eleven candidates, and two of them are the same command written with and without a space - so the agent cannot stop at the first `chmod 777`.",
35
+ "task": "In this repository, one source file lists the bash commands the kit denies outright. The list has eleven entries, and two of them are the same command written with and without a space. Report the file and the line number of the entry that denies `gh repo delete`, and quote it. Do not modify any files.",
36
+ "want": "\"gh repo delete*\""
37
+ },
38
+ {
39
+ "id": "read-multi-gate",
40
+ "$comment": "Three similar checks in one file, and the task names which one. The earlier suite had the same shape and the same reason: a check that reads as a match to the wrong line is a correct answer scored wrong.",
41
+ "task": "One source file in this repository has a rule that decides whether a git command rewrites history. It checks three git subcommands, and each one matches a different flag. Report the file and the line number of the check that rejects `git clean -fd`, and quote the comparison. Do not modify any files.",
42
+ "want": "return /(^|\\s)-[a-zA-Z]*f/.test(text)"
43
+ },
44
+ {
45
+ "id": "read-two-files",
46
+ "$comment": "Needs two files: the lane table is a JSON file and the list of names is a constant in the code, so the agent has to know the same ten names live in both.",
47
+ "task": "The ten job lanes in this repository are named in two files: a JSON table, and a source file that holds them as a constant. Report the file and the line number of the constant, and quote the line that declares it. Do not modify any files.",
48
+ "want": "export const LANE_NAMES = ["
49
+ },
50
+ {
51
+ "id": "edit-rename",
52
+ "$comment": "A rename, in a file with four uses of the name. mustContainMin counts the occurrences, because a plain substring check passes a partial rename. mustNotContain is as important as mustContain: leaving the old name in one place is not the change that was asked for.",
53
+ "task": "In src/guard.ts the constant that marks the kit's own denial message is used in four places. Rename it to `DENIAL_MARKER` everywhere in that file, without changing what it holds. Make no other changes and do not reformat anything.",
54
+ "mutate": {
55
+ "file": "src/guard.ts",
56
+ "mustContainMin": {
57
+ "DENIAL_MARKER": 4
58
+ },
59
+ "mustNotContain": [
60
+ "const MARKER"
61
+ ]
62
+ }
63
+ },
64
+ {
65
+ "id": "edit-multi",
66
+ "$comment": "Two separate edits in one file, both of which have to land. An agent that does one and stops is the obvious failure, and the grader catches it because both strings are required.",
67
+ "task": "In src/denylist.js the BASH_DENYLIST holds the commands the kit never allows. Change the entry `gh pr close*` to `gh pr close *`, and change the entry `gh release*` to `gh release *`. Make no other changes and do not reformat anything.",
68
+ "mutate": {
69
+ "file": "src/denylist.js",
70
+ "mustContain": [
71
+ "gh pr close *",
72
+ "gh release *"
73
+ ],
74
+ "mustNotContain": [
75
+ "gh pr close*",
76
+ "gh release*"
77
+ ]
78
+ }
79
+ }
80
+ ]
81
+ }