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
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ // attribute.mjs - why did context per request drop?
3
+ //
4
+ // measure-install.mjs shows context falling from 123,776 to 68,836 tokens per
5
+ // assistant message. That is a correlation between two windows of different
6
+ // work, and it does not say which mechanism did it. This splits the number into
7
+ // its two parts, which are physically different:
8
+ //
9
+ // fixed overhead - the system prompt and tool definitions, sent on every
10
+ // request. Paid on turn one, before any work happens.
11
+ // history - what the conversation has accumulated since: tool output,
12
+ // files read, the agent's own earlier messages.
13
+ //
14
+ // Those move independently. A kit that only made the system prompt shorter
15
+ // would show up in the first; a kit that made tool output smaller shows up in
16
+ // the second. Knowing which one moved is the difference between "the numbers
17
+ // look good" and "the numbers look good for a reason we can point at".
18
+ //
19
+ // Turn count is reported alongside, and history is also given per turn,
20
+ // because shorter sessions accumulate less history on their own. That
21
+ // confound is the reason the per-turn column exists.
22
+ //
23
+ // node eval/attribute.mjs human-readable
24
+ // node eval/attribute.mjs --json
25
+
26
+ import { existsSync, statSync } from "node:fs"
27
+ import { homedir } from "node:os"
28
+ import { join } from "node:path"
29
+ import { canQuerySqlite, sqliteMissingMessage, query as queryDb } from "../src/sqlite.js"
30
+
31
+ import { historyPerTurn, payback } from "./metrics.js"
32
+
33
+ const args = process.argv.slice(2)
34
+ const flag = (...n) => {
35
+ const i = args.findIndex((a) => n.includes(a))
36
+ return i >= 0 ? args[i + 1] : undefined
37
+ }
38
+ const wantJson = args.includes("--json")
39
+ const DB = flag("--db") ?? join(homedir(), ".local/share/opencode/opencode.db")
40
+
41
+ const installDir = join(homedir(), ".config/opencode/agents")
42
+ const split = flag("--split")
43
+ ? Date.parse(flag("--split"))
44
+ : existsSync(installDir)
45
+ ? statSync(installDir).mtimeMs
46
+ : Date.now() - 7 * 864e5
47
+
48
+ if (!canQuerySqlite()) {
49
+ console.error(sqliteMissingMessage())
50
+ process.exit(1)
51
+ }
52
+
53
+ if (!existsSync(DB)) {
54
+ console.error(`opencode database not found: ${DB}`)
55
+ process.exit(1)
56
+ }
57
+
58
+ const SESSION_FLOOR = flag("--since") ? Date.parse(flag("--since")) : 0
59
+
60
+ const sql = `
61
+ with s as (
62
+ select id, time_created from session where time_created >= ${SESSION_FLOOR}
63
+ ),
64
+ r as (
65
+ select m.session_id as sid,
66
+ s.time_created as st,
67
+ json_extract(m.data,'$.tokens.input')
68
+ + json_extract(m.data,'$.tokens.cache.read') as ctx,
69
+ row_number() over (partition by m.session_id order by m.time_created) as rn,
70
+ count(*) over (partition by m.session_id) as n
71
+ from message m join s on s.id = m.session_id
72
+ where json_extract(m.data,'$.role') = 'assistant'
73
+ and json_extract(m.data,'$.tokens') is not null
74
+ ),
75
+ per as (
76
+ select sid, min(st) as st, max(n) as turns,
77
+ max(case when rn = 1 then ctx end) as first_ctx,
78
+ max(case when rn = n then ctx end) as last_ctx
79
+ from r group by sid
80
+ )
81
+ select case when st < ${split} then 'before' else 'after' end as win,
82
+ count(*) as sessions,
83
+ avg(turns) as turns,
84
+ avg(first_ctx) as fixed,
85
+ avg(last_ctx) as last_ctx,
86
+ avg(last_ctx - first_ctx) as history
87
+ from per group by win`
88
+
89
+ const rows = queryDb(DB, sql)
90
+
91
+ const pick = (win) => {
92
+ const r = rows.find((x) => x.win === win)
93
+ if (!r) return null
94
+ const sessions = Number(r.sessions)
95
+ const turns = Number(r.turns)
96
+ const fixed = Math.round(Number(r.fixed))
97
+ const last = Math.round(Number(r.last_ctx))
98
+ const history = Math.round(Number(r.history))
99
+ return {
100
+ sessions,
101
+ turns: round(turns),
102
+ fixed,
103
+ last,
104
+ history,
105
+ history_per_turn: historyPerTurn(history, turns),
106
+ }
107
+ }
108
+
109
+ function round(n) {
110
+ return Math.round(n * 10) / 10
111
+ }
112
+
113
+ const before = pick("before")
114
+ const after = pick("after")
115
+
116
+ const report = {
117
+ db: DB,
118
+ split: new Date(split).toISOString(),
119
+ before,
120
+ after,
121
+ delta: {
122
+ // The kit's own price, paid on every request.
123
+ fixed: before && after ? after.fixed - before.fixed : null,
124
+ // What it was supposed to buy.
125
+ history: before && after ? after.history - before.history : null,
126
+ history_per_turn:
127
+ before && after && before.history_per_turn !== null
128
+ ? round(after.history_per_turn - before.history_per_turn)
129
+ : null,
130
+ },
131
+ }
132
+
133
+ if (wantJson) {
134
+ console.log(JSON.stringify(report, null, 2))
135
+ process.exit(0)
136
+ }
137
+
138
+ if (!before || !after) {
139
+ console.error("not enough data on one side of the split to compare")
140
+ process.exit(1)
141
+ }
142
+
143
+ const row = (label, a, b) =>
144
+ ` ${label.padEnd(24)} ${String(a).padStart(12)} ${String(b).padStart(12)}`
145
+
146
+ console.log("CONTEXT ATTRIBUTION")
147
+ console.log(` db ${DB}`)
148
+ console.log(` split ${report.split} <- kit installed`)
149
+ console.log("")
150
+ console.log(row("", "before", "after"))
151
+ console.log(row("sessions", before.sessions, after.sessions))
152
+ console.log(row("turns per session", before.turns, after.turns))
153
+ console.log("")
154
+ console.log(row("fixed overhead", before.fixed, after.fixed))
155
+ console.log(row("context at end", before.last, after.last))
156
+ console.log(row("history", before.history, after.history))
157
+ console.log(row("history per turn", before.history_per_turn, after.history_per_turn))
158
+ console.log("")
159
+ console.log(` fixed overhead ${report.delta.fixed > 0 ? "+" : ""}${report.delta.fixed} tokens`)
160
+ console.log(` history ${report.delta.history} tokens per session`)
161
+ console.log(` history per turn ${report.delta.history_per_turn > 0 ? "+" : ""}${report.delta.history_per_turn} tokens`)
162
+ console.log("")
163
+
164
+ const pays = payback(report.delta.fixed, report.delta.history)
165
+ console.log(
166
+ pays
167
+ ? " The kit costs more per request and buys back more per session. That is the whole claim, decomposed."
168
+ : " The kit does not currently pay for itself on this measure. Report it as it is.",
169
+ )
170
+ process.exit(0)
package/eval/flags.mjs ADDED
@@ -0,0 +1,88 @@
1
+ // flags.mjs - reading a number out of a command line, without the two ways it
2
+ // used to go wrong.
3
+ //
4
+ // A flag that cannot be parsed must be an ERROR, not a disabled check.
5
+ //
6
+ // `--min-narrow` with no value became `undefined`, which became `null`, which
7
+ // meant "no gate" - and the report said PASS. `--min-narrow abc` became NaN,
8
+ // and `!Number.isNaN(NaN)` is false, so the comparison never ran and the gate
9
+ // was skipped. A typo in a CI gate turned the gate off without saying so.
10
+ //
11
+ // The same thing happened to `--reps abc` in the A/B runner: NaN, the loop
12
+ // body never executed, and the suite reported success having run nothing.
13
+
14
+ /**
15
+ * A number from a flag, or null when the flag was not given.
16
+ *
17
+ * @param {string} name the flag, including the dashes
18
+ * @param {string[] | undefined} argv
19
+ * @returns {number | null}
20
+ */
21
+ export function numberFlag(name, argv = process.argv.slice(2)) {
22
+ const raw = rawValue(name, argv)
23
+ if (raw === undefined) return null
24
+ const n = Number(raw)
25
+ if (!Number.isFinite(n)) {
26
+ throw new Error(`${name} must be a number, got: ${raw}`)
27
+ }
28
+ return n
29
+ }
30
+
31
+ /**
32
+ * A whole number from a flag, at least one, or null when the flag was not
33
+ * given.
34
+ *
35
+ * @param {string} name the flag, including the dashes
36
+ * @param {string[] | undefined} argv
37
+ * @returns {number | null}
38
+ */
39
+ export function countFlag(name, argv = process.argv.slice(2)) {
40
+ const raw = rawValue(name, argv)
41
+ if (raw === undefined) return null
42
+ const n = Number(raw)
43
+ if (!Number.isInteger(n) || n < 1) {
44
+ throw new Error(`${name} must be a whole number of at least 1, got: ${raw}`)
45
+ }
46
+ return n
47
+ }
48
+
49
+ /**
50
+ * The raw text of a flag, or undefined when the flag was not given.
51
+ *
52
+ * The LAST occurrence wins, so an argument appended to an npm script
53
+ * overrides the one in the script: `npm run measure:prune -- --sessions 1`
54
+ * has to mean one session, not the sixty the script says.
55
+ *
56
+ * @param {string} name the flag, including the dashes
57
+ * @param {string[]} argv
58
+ * @returns {string | undefined}
59
+ */
60
+ function rawValue(name, argv) {
61
+ let value
62
+ let seen = false
63
+ for (let i = 0; i < argv.length; i++) {
64
+ if (argv[i] === name) {
65
+ if (argv[i + 1] === undefined) throw new Error(`${name} needs a value`)
66
+ value = argv[i + 1]
67
+ seen = true
68
+ i++
69
+ continue
70
+ }
71
+ if (argv[i].startsWith(`${name}=`)) {
72
+ value = argv[i].slice(name.length + 1)
73
+ seen = true
74
+ }
75
+ }
76
+ return seen ? value : undefined
77
+ }
78
+
79
+ /**
80
+ * The value of a flag that takes a string.
81
+ *
82
+ * @param {string} name the flag, including the dashes
83
+ * @param {string[] | undefined} argv
84
+ * @returns {string | undefined}
85
+ */
86
+ export function stringFlag(name, argv = process.argv.slice(2)) {
87
+ return rawValue(name, argv)
88
+ }
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env node
2
+ // measure-install.mjs - compare a real OpenCode install against itself, before
3
+ // and after the kit was installed, so "does this kit help" is a number instead
4
+ // of an impression.
5
+ //
6
+ // node eval/measure-install.mjs human-readable table
7
+ // node eval/measure-install.mjs --json machine-readable
8
+ // node eval/measure-install.mjs --before <iso> --after <iso>
9
+ // node eval/measure-install.mjs --min-narrow 50 --min-ctx-drop 20
10
+ // exit 1 when either regresses
11
+ //
12
+ // Node 22 has no node:sqlite, so this shells out to the sqlite3 CLI in
13
+ // read-only mode, the same way bin/baseline.mjs does. Both windows are computed
14
+ // in a single pass: the 988 MB install database is slow enough that a second
15
+ // scan is worth avoiding.
16
+ //
17
+ // Why these three metrics. eval/run.mjs proves the pruner stubs bytes and the
18
+ // allowlist rewrites permission globs. It cannot prove that the agent changed
19
+ // how it reads, because that is a property of the transcripts, not of a
20
+ // synthetic one. Narrow reads and context per request are that property.
21
+
22
+ import { existsSync, statSync } from "node:fs"
23
+ import { homedir } from "node:os"
24
+ import { join } from "node:path"
25
+
26
+ const args = process.argv.slice(2)
27
+
28
+ function flag(...names) {
29
+ const i = args.findIndex((a) => names.includes(a))
30
+ return i >= 0 ? args[i + 1] : undefined
31
+ }
32
+ const has = (...names) => args.some((a) => names.includes(a))
33
+
34
+ const wantJson = has("--json")
35
+ const DB = flag("--db") ?? join(homedir(), ".local/share/opencode/opencode.db")
36
+
37
+ // The install boundary is the installed agent symlinks: `oc-harness install`
38
+ // creates them, and nothing else does. Falling back to the plugin symlink keeps
39
+ // this working on an install made by hand.
40
+ const installDir = join(homedir(), ".config/opencode/agents")
41
+ const installStamp = existsSync(installDir)
42
+ ? statSync(installDir).mtimeMs
43
+ : Date.now() - 7 * 864e5
44
+
45
+ // Three edges, not two, and the two outer windows are the SAME LENGTH.
46
+ //
47
+ // The after-window used to be "everything since the install", which grows
48
+ // without bound. That makes the comparison a different one every time the
49
+ // script runs: a week after the install it compares 7 days against 7 days,
50
+ // and a month after it compares 7 days against 30, and the answer moves
51
+ // because the WORK moved, not because the kit did. A gate whose number
52
+ // changes because you ran it on a different day is not a gate.
53
+ //
54
+ // The windows are now fixed and equal around the split: `--days` of work on
55
+ // each side. `--since` puts the old unbounded behaviour back, for the case
56
+ // where the question really is "everything since".
57
+ let days
58
+ try {
59
+ days = numberFlag("--days") ?? 7
60
+ } catch (err) {
61
+ console.error(String(errorMessage(err)))
62
+ process.exit(1)
63
+ }
64
+ if (days <= 0) {
65
+ console.error(`--days must be more than 0, got: ${days}`)
66
+ process.exit(1)
67
+ }
68
+ // `--since` is a BOOLEAN. It used to be read with the value-after-the-flag
69
+ // helper, so `--since` as the last argument was indistinguishable from
70
+ // `--since` followed by nothing, and the unbounded window was never taken.
71
+ const since = args.includes("--since")
72
+ const split = stringFlag("--split") ? Date.parse(stringFlag("--split")) : installStamp
73
+ const before = stringFlag("--before")
74
+ ? Date.parse(stringFlag("--before"))
75
+ : split - days * 864e5
76
+ const after = stringFlag("--after")
77
+ ? Date.parse(stringFlag("--after"))
78
+ : since
79
+ ? Date.now()
80
+ : Math.min(Date.now(), split + days * 864e5)
81
+
82
+ if (Number.isNaN(before) || Number.isNaN(after) || Number.isNaN(split)) {
83
+ console.error("--before/--after/--split take an ISO timestamp, e.g. 2026-10-08T05:41:00Z")
84
+ process.exit(1)
85
+ }
86
+ if (!(before < split && split < after)) {
87
+ console.error(`need before < split < after, got ${new Date(before).toISOString()} / ${new Date(split).toISOString()} / ${new Date(after).toISOString()}`)
88
+ process.exit(1)
89
+ }
90
+
91
+ // A gate that cannot be parsed is an error, not a disabled gate. A typo used
92
+ // to turn the check off silently and the report said PASS.
93
+ let minNarrow
94
+ let minCtxDrop
95
+ try {
96
+ minNarrow = numberFlag("--min-narrow")
97
+ minCtxDrop = numberFlag("--min-ctx-drop")
98
+ } catch (err) {
99
+ console.error(String(errorMessage(err)))
100
+ process.exit(1)
101
+ }
102
+
103
+ if (!canQuerySqlite()) {
104
+ console.error(sqliteMissingMessage())
105
+ process.exit(1)
106
+ }
107
+
108
+ if (!existsSync(DB)) {
109
+ console.error(`opencode database not found: ${DB}`)
110
+ console.error("pass --db <path> or run opencode at least once first")
111
+ process.exit(1)
112
+ }
113
+
114
+ // Both helpers live in ./metrics.js so they can be tested without a database.
115
+ import { pct, readsPerSearch, ctxDropPct as computeCtxDropPct } from "./metrics.js"
116
+ import { canQuerySqlite, sqliteMissingMessage, query as queryDb } from "../src/sqlite.js"
117
+ import { numberFlag, stringFlag } from "./flags.mjs"
118
+
119
+ /**
120
+ * The message of a caught value, whatever it turned out to be.
121
+ *
122
+ * `catch` gives `unknown` under strict mode, and the file is not strict -
123
+ * but a message is what these scripts exist to print.
124
+ *
125
+ * @param {unknown} err
126
+ * @returns {string}
127
+ */
128
+ function errorMessage(err) {
129
+ return err instanceof Error ? err.message : String(err)
130
+ }
131
+
132
+ function query(sql) {
133
+ return queryDb(DB, sql)
134
+ }
135
+
136
+ const TOOLS = `json_extract(data,'$.type')='tool'`
137
+ const TOOL_NAME = `json_extract(data,'$.tool')`
138
+ const isRead = `(${TOOL_NAME}='read')`
139
+ const isSearch = `(${TOOL_NAME} in ('grep','glob'))`
140
+ // A read is narrow when the caller bounded it. `offset` is the strong signal;
141
+ // `limit` alone still means the agent chose not to take the whole file.
142
+ const isNarrow = `(json_extract(data,'$.state.input.offset') is not null or json_extract(data,'$.state.input.limit') is not null)`
143
+
144
+ const window = (ms) =>
145
+ `case when time_created < ${ms} then 'before' when time_created < ${after} then 'after' else 'other' end`
146
+
147
+ const toolRows = query(`
148
+ select ${window(split)} as win,
149
+ sum(${isRead}) as reads,
150
+ sum(case when ${isRead} and ${isNarrow} then 1 else 0 end) as narrow,
151
+ sum(${isSearch}) as searches,
152
+ sum(${TOOL_NAME}='bash') as bash
153
+ from part
154
+ where ${TOOLS} and time_created >= ${before} and time_created < ${after}
155
+ and ${TOOL_NAME} in ('read','grep','glob','bash')
156
+ group by win`)
157
+
158
+ const ctxRows = query(`
159
+ select ${window(split)} as win,
160
+ count(*) as messages,
161
+ avg(json_extract(data,'$.tokens.input') + json_extract(data,'$.tokens.cache.read')) as avg_ctx,
162
+ avg(json_extract(data,'$.tokens.reasoning')) as avg_reasoning
163
+ from message
164
+ where json_extract(data,'$.role')='assistant'
165
+ and time_created >= ${before} and time_created < ${after}
166
+ and json_extract(data,'$.tokens') is not null
167
+ group by win`)
168
+
169
+ function pick(rows, win) {
170
+ const r = rows.find((x) => x.win === win)
171
+ const reads = r ? Number(r.reads ?? 0) : 0
172
+ const narrow = r ? Number(r.narrow ?? 0) : 0
173
+ const searches = r ? Number(r.searches ?? 0) : 0
174
+ const c = ctxRows.find((x) => x.win === win)
175
+ return {
176
+ reads,
177
+ narrow,
178
+ narrow_pct: pct(narrow, reads),
179
+ searches,
180
+ reads_per_search: readsPerSearch(reads, searches),
181
+ bash: r ? Number(r.bash ?? 0) : 0,
182
+ messages: c ? Number(c.messages) : 0,
183
+ // NULL, not zero, when the window has no rows. Zero would say "the
184
+ // average context per request was zero", and the drop computed from it
185
+ // would be 100%.
186
+ // A NULL average is not zero. `avg()` over rows with no tokens is NULL,
187
+ // and turning that into 0 reported a 100% context improvement.
188
+ avg_ctx: c && c.avg_ctx !== null ? Math.round(Number(c.avg_ctx)) : null,
189
+ avg_reasoning: c ? Math.round(Number(c.avg_reasoning ?? 0)) : null,
190
+ }
191
+ }
192
+
193
+ const before_ = pick(toolRows, "before")
194
+ const after_ = pick(toolRows, "after")
195
+
196
+ const ctxDropPct = computeCtxDropPct(before_.avg_ctx, after_.avg_ctx)
197
+
198
+ // A window of a few hundred reads moves with the day's task mix. Say so instead
199
+ // of letting a small sample pass as a result. A window with NO reads is the
200
+ // thinnest there is.
201
+ //
202
+ // BOTH windows are checked. A comparison against a thin before-window is a
203
+ // comparison against noise, and the report said nothing about it.
204
+ const thin = after_.reads < 500 || before_.reads < 500
205
+
206
+ // The two windows are the same LENGTH now, and that is the most that can be
207
+ // said for them. They are still not the same WORK, and a number computed
208
+ // across two different jobs is a number about the jobs.
209
+ const confounders = []
210
+ if (before_.reads > 0 && after_.reads > 0) {
211
+ const ratio = after_.reads / before_.reads
212
+ if (ratio >= 2 || ratio <= 0.5) {
213
+ confounders.push(
214
+ `the two windows did ${ratio >= 2 ? "more" : "less"} than twice the work (${before_.reads} vs ${after_.reads} reads), so the comparison is between different jobs`,
215
+ )
216
+ }
217
+ }
218
+ if (before_.messages > 0 && after_.messages > 0) {
219
+ const ratio = after_.messages / before_.messages
220
+ if (ratio >= 2 || ratio <= 0.5) {
221
+ confounders.push(
222
+ `the two windows did ${ratio >= 2 ? "more" : "less"} than twice the messages (${before_.messages} vs ${after_.messages}), so the comparison is between different jobs`,
223
+ )
224
+ }
225
+ }
226
+
227
+ const failures = []
228
+ /** @type {string[]} */
229
+ const skipped = []
230
+ if (minNarrow !== null) {
231
+ if (after_.reads === 0) {
232
+ failures.push("no reads in the after window, so the narrow-read share cannot be measured")
233
+ } else if (after_.narrow_pct < minNarrow) {
234
+ failures.push(`narrow reads ${after_.narrow_pct}% < ${minNarrow}%`)
235
+ }
236
+ }
237
+ if (minCtxDrop !== null) {
238
+ if (confounders.length) {
239
+ // The number is about the jobs, not about the kit. Gating on it anyway
240
+ // means the gate fails for reasons nobody can act on, and a gate that
241
+ // fails for reasons nobody can act on is a gate people switch off.
242
+ //
243
+ // The ATTRIBUTABLE measurement of the same thing - the pruner replayed
244
+ // over the stored history of real sessions - is eval/measure-prune.mjs,
245
+ // and that is the one the gate uses.
246
+ skipped.push("context per request")
247
+ } else if (ctxDropPct === null) {
248
+ failures.push("no context data in the after window, so the drop cannot be measured")
249
+ } else if (ctxDropPct < minCtxDrop) {
250
+ failures.push(`context per request fell ${ctxDropPct}% < ${minCtxDrop}%`)
251
+ }
252
+ }
253
+
254
+ const verdict = failures.length ? "FAIL" : "PASS"
255
+
256
+ const report = {
257
+ db: DB,
258
+ before: new Date(before).toISOString(),
259
+ split: new Date(split).toISOString(),
260
+ after: new Date(after).toISOString(),
261
+ before_window: before_,
262
+ after_window: after_,
263
+ ctx_drop_pct: ctxDropPct,
264
+ thin_sample: thin,
265
+ confounders,
266
+ skipped,
267
+ verdict,
268
+ failures,
269
+ }
270
+
271
+ if (wantJson) {
272
+ console.log(JSON.stringify(report, null, 2))
273
+ process.exit(failures.length ? 1 : 0)
274
+ }
275
+
276
+ const row = (label, a, b) =>
277
+ ` ${label.padEnd(22)} ${String(a).padStart(14)} ${String(b).padStart(14)}`
278
+
279
+ console.log("INSTALL EFFECTIVENESS")
280
+ console.log(` db ${DB}`)
281
+ console.log(` before ${report.before}`)
282
+ console.log(` split ${report.split} <- kit installed`)
283
+ console.log(` after ${report.after}`)
284
+ console.log(
285
+ ` window ${Math.round((split - before) / 864e5)}d before, ${Math.round((after - split) / 864e5)}d after`,
286
+ )
287
+ console.log("")
288
+ console.log(row("", "before", "after"))
289
+ console.log(row("read calls", before_.reads, after_.reads))
290
+ console.log(row("narrow reads", `${before_.narrow} (${before_.narrow_pct}%)`, `${after_.narrow} (${after_.narrow_pct}%)`))
291
+ console.log(row("grep/glob", before_.searches, after_.searches))
292
+ console.log(row("reads per search", before_.reads_per_search ?? "-", after_.reads_per_search ?? "-"))
293
+ console.log(row("assistant msgs", before_.messages, after_.messages))
294
+ console.log(row("avg ctx / request", before_.avg_ctx, after_.avg_ctx))
295
+ console.log(row("avg reasoning", before_.avg_reasoning, after_.avg_reasoning))
296
+ console.log("")
297
+ console.log(` context per request: ${ctxDropPct === null ? "n/a" : `${ctxDropPct}% ${ctxDropPct >= 0 ? "lower" : "HIGHER"}`}`)
298
+ if (thin) {
299
+ console.log("")
300
+ console.log(
301
+ ` NOTE ${before_.reads} reads before and ${after_.reads} after: a thin sample on at least one side.`,
302
+ )
303
+ console.log(" Treat the direction as a hint, not a result.")
304
+ }
305
+ for (const skip of skipped) {
306
+ console.log("")
307
+ console.log(` NOTE ${skip} is reported but NOT gated: the two windows are not comparable.`)
308
+ console.log(" eval/measure-prune.mjs is the attributable measurement of the same thing.")
309
+ }
310
+ for (const c of confounders) {
311
+ console.log("")
312
+ console.log(` NOTE ${c}.`)
313
+ console.log(" The A/B in eval/RESULTS.md is the controlled measurement;")
314
+ console.log(" this one is an observation about one install.")
315
+ }
316
+ if (failures.length) {
317
+ console.log("")
318
+ console.log(` FAIL ${failures.join("; ")}`)
319
+ } else if (minNarrow !== null || minCtxDrop !== null) {
320
+ console.log("")
321
+ console.log(` PASS narrow reads and context per request both held.`)
322
+ }
323
+ process.exit(failures.length ? 1 : 0)