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
package/src/router.ts CHANGED
@@ -18,9 +18,13 @@
18
18
  // retry it. The daily window resets at 00:00 UTC, so waiting inside a session
19
19
  // is the wrong shape anyway.
20
20
 
21
- import type { Plugin } from "@opencode-ai/plugin"
21
+ import type { Plugin } from "./plugin.js"
22
22
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
23
23
  import { join } from "node:path"
24
+ import { safeStringify } from "./safe.js"
25
+ import { projectRoot } from "./project.js"
26
+ import { utilityModel } from "./config.js"
27
+ import { readHealth, writeHealth, unhealthyModels } from "./health.js"
24
28
  import {
25
29
  loadProfile,
26
30
  pruneThreshold,
@@ -32,55 +36,6 @@ import {
32
36
  resolveRoles,
33
37
  } from "./profile.js"
34
38
 
35
- interface Health {
36
- unhealthy: Record<string, { at: string; reason: string }>
37
- active?: string
38
- }
39
-
40
- function healthPath(worktree: string): string {
41
- return join(worktree, ".space", "health.json")
42
- }
43
-
44
- function readHealth(worktree: string): Health {
45
- try {
46
- return JSON.parse(readFileSync(healthPath(worktree), "utf8"))
47
- } catch {
48
- return { unhealthy: {} }
49
- }
50
- }
51
-
52
- function writeHealth(worktree: string, h: Health) {
53
- try {
54
- mkdirSync(join(worktree, ".space"), { recursive: true })
55
- writeFileSync(healthPath(worktree), JSON.stringify(h, null, 2))
56
- } catch {
57
- // Best effort.
58
- }
59
- }
60
-
61
- /**
62
- * The models a previous session found spent.
63
- *
64
- * Exported so the effort hook resolves a lane's variant against the model the
65
- * lane actually ended up on. A lane that failed over from a model with a
66
- * `none` effort level to one without levels has to lose its variant, or the
67
- * config asks for an effort the new model does not publish.
68
- *
69
- * @param {string} worktree
70
- * @returns {Set<string>}
71
- */
72
- export function unhealthyModels(worktree: string): Set<string> {
73
- const health = readHealth(worktree)
74
- // The health file is keyed by the qualified route, `opencode/<model>`. The
75
- // lane chains list bare model ids, so the prefix is dropped here. Comparing
76
- // the two directly would never match, and a spent route would look healthy.
77
- const out = new Set<string>()
78
- for (const key of Object.keys(health.unhealthy ?? {})) {
79
- out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key)
80
- }
81
- return out
82
- }
83
-
84
39
  /** Patterns that mean "this route is spent", not "this request was bad". */
85
40
  const SPENT = [
86
41
  /FreeUsageLimitError/i,
@@ -103,17 +58,21 @@ const WARN_TEXT =
103
58
  "new session. Run `oc-harness list` to see the lanes and their chains, or `oc-harness doctor` " +
104
59
  "to check whether the profile still matches live metadata."
105
60
 
106
- export const RouterPlugin: Plugin = async ({ worktree }) => {
61
+ export const RouterPlugin: Plugin = async ({ worktree, directory }) => {
62
+ // The project root, not the worktree: the worktree is the filesystem root in
63
+ // a project that is not a git repository, so the health file was written
64
+ // nowhere and no lane ever failed over.
65
+ const project = projectRoot(directory, worktree)
107
66
  let failedSessions = 0
108
67
  let warned = false
109
68
 
110
69
  return {
111
70
  // Runs once, before the session does any work.
112
- config: async (config) => {
71
+ config: async (config: any) => {
113
72
  try {
114
73
  const cfg = config as Record<string, any>
115
- const health = readHealth(worktree)
116
- const unhealthy = unhealthyModels(worktree)
74
+ const health = readHealth(project)
75
+ const unhealthy = unhealthyModels(project)
117
76
 
118
77
  // config.harness is stripped by schema validation, so the active
119
78
  // profile comes from the config file on disk.
@@ -139,13 +98,15 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
139
98
  if (health.unhealthy?.[baseKey]) {
140
99
  const queue = [id, ...(profile.fallback ?? [])]
141
100
  for (const candidate of queue) {
142
- if (health.unhealthy?.[`opencode/${candidate}`]) continue
143
101
  let fallback
144
102
  try {
145
103
  fallback = loadProfile(candidate)
146
104
  } catch {
147
105
  continue
148
106
  }
107
+ // The health file is keyed by MODEL, and a fleet's profile id is
108
+ // not its model - so the check has to be against the model's own
109
+ // key. Checking the id was a check that could never fire.
149
110
  const key = `opencode/${fallback.model}`
150
111
  if (health.unhealthy?.[key]) continue
151
112
  const fleet = isFleet(profile)
@@ -158,7 +119,7 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
158
119
  threshold: fleet ? pruneThreshold(profile) : undefined,
159
120
  })
160
121
  health.active = candidate
161
- writeHealth(worktree, health)
122
+ writeHealth(project, health)
162
123
  break
163
124
  }
164
125
  }
@@ -189,12 +150,15 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
189
150
  }
190
151
  },
191
152
 
192
- event: async ({ event }) => {
153
+ event: async ({ event }: any) => {
193
154
  const e = event as any
194
155
  const type = e?.type
195
156
  if (!type) return
196
157
 
197
- const text = JSON.stringify(e)
158
+ // The event is opencode's object, and it can hold a reference back to
159
+ // itself. A plain JSON.stringify on a cyclic object throws, and a hook
160
+ // that throws takes the event down with it.
161
+ const text = safeStringify(e)
198
162
  const reason = classify(text)
199
163
  if (!reason) return
200
164
 
@@ -203,14 +167,14 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
203
167
  const key = modelID ? `opencode/${modelID}` : undefined
204
168
  if (!key) return
205
169
 
206
- const health = readHealth(worktree)
170
+ const health = readHealth(project)
207
171
  if (health.unhealthy[key]) return // already recorded, do not spam
208
172
  health.unhealthy[key] = { at: new Date().toISOString(), reason }
209
- writeHealth(worktree, health)
173
+ writeHealth(project, health)
210
174
  failedSessions++
211
175
  },
212
176
 
213
- "chat.message": async (input, output) => {
177
+ "chat.message": async (input: any, output: any) => {
214
178
  // If this session has already hit a spent route, say so once, early,
215
179
  // instead of letting the agent burn turns discovering it.
216
180
  if (failedSessions === 0) return
@@ -226,7 +190,7 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
226
190
  return
227
191
  }
228
192
  // Nothing to attach to: the health file and telemetry still record it.
229
- writeHealth(worktree, readHealth(worktree))
193
+ writeHealth(project, readHealth(project))
230
194
  },
231
195
  }
232
196
  }
@@ -268,7 +232,12 @@ function applySessionModel(
268
232
  try {
269
233
  const model = `${profile.provider}/${profile.model}`
270
234
  cfg.model = model
271
- cfg.small_model = model
235
+ // The utility route belongs to the fleet, not to the model that happens
236
+ // to be the session's. Overwriting it with the fallback's session model
237
+ // threw away the cheap route exactly when the fleet failed over, and
238
+ // `verify` then called the difference between the file on disk and the
239
+ // running session a drift.
240
+ cfg.small_model = utilityModel(profile) ?? model
272
241
  const variant = profile.effort?.default
273
242
  if (profile.effort?.levels?.length && variant) cfg.variant = variant
274
243
  else delete cfg.variant
package/src/rules.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { homedir, tmpdir } from "node:os"
5
5
  import { resolve as resolvePath, sep } from "node:path"
6
+ import { deniedBy } from "./denylist.js"
6
7
 
7
8
  // Two ideas here:
8
9
  //
@@ -147,9 +148,22 @@ export function segments(command) {
147
148
  if (command[i + 1] === "|") i++
148
149
  continue
149
150
  }
150
- if (ch === "&" && command[i + 1] === "&") {
151
+ if (ch === "&") {
152
+ // `&>` and `>&` are redirections, not separators: `cmd &> log` writes
153
+ // both streams to a file, and `cmd 2>&1` duplicates a descriptor. Both
154
+ // keep the `&` as part of the command.
155
+ if (command[i - 1] === ">" || command[i + 1] === ">") {
156
+ cur += ch
157
+ continue
158
+ }
159
+ // `&&` runs the next command only if the first one succeeded, and a
160
+ // single `&` runs it regardless. Both are command boundaries.
161
+ if (command[i + 1] === "&") {
162
+ flush()
163
+ i++
164
+ continue
165
+ }
151
166
  flush()
152
- i++
153
167
  continue
154
168
  }
155
169
  cur += ch
@@ -210,7 +224,20 @@ export function tokenize(segment) {
210
224
  }
211
225
 
212
226
  const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/
213
- const GIT_WRAPPERS = new Set(["env", "sudo", "doas", "nohup", "time", "command", "exec", "xargs"])
227
+ // The wrappers that put another command in front of the real one. `npx`
228
+ // runs a package's binary, so `npx npm publish` is `npm publish` with two
229
+ // words in front of it.
230
+ const GIT_WRAPPERS = new Set([
231
+ "env",
232
+ "sudo",
233
+ "doas",
234
+ "nohup",
235
+ "time",
236
+ "command",
237
+ "exec",
238
+ "xargs",
239
+ "npx",
240
+ ])
214
241
  // Wrappers whose flag consumes the next word, e.g. `sudo -u deploy git push`.
215
242
  const WRAPPER_VALUE_FLAGS = new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"])
216
243
  // git options that take a value, so the subcommand is the word after them.
@@ -322,13 +349,32 @@ const MAX_SHELL_DEPTH = 2
322
349
  export function shellPayload(segment) {
323
350
  const tokens = tokenize(segment)
324
351
  if (tokens.length < 2) return null
325
- if (tokens[0] === "eval") return tokens.slice(1).join(" ") || null
326
- if (!SHELL_BIN.test(tokens[0])) return null
327
- const c = tokens.findIndex((t) => /^-[a-zA-Z]*c$/.test(t))
352
+ // The wrappers come off FIRST. `sudo bash -c "rm -rf /"` hides a whole
353
+ // command line behind one argument, and the shell doing the hiding is the
354
+ // second word - so looking only at the first word missed every command
355
+ // with a wrapper in front of it, which is the shape a wrapper is used for.
356
+ const i = skipPrefixes(tokens)
357
+ if (i >= tokens.length) return null
358
+ if (tokens[i] === "eval") return tokens.slice(i + 1).join(" ") || null
359
+ if (!SHELL_BIN.test(tokens[i])) return null
360
+ const c = tokens.findIndex((t, n) => n > i && /^-[a-zA-Z]*c$/.test(t))
328
361
  if (c === -1 || c === tokens.length - 1) return null
329
362
  return tokens.slice(c + 1).join(" ") || null
330
363
  }
331
364
 
365
+ /**
366
+ * Commands the guard deliberately does NOT block.
367
+ *
368
+ * `git push` is ask-gated through opencode's permission rules rather than
369
+ * guard-blocked, so it is the one command the agent reaches for that the
370
+ * guard has a policy about and lets through. It lives here, beside the rules,
371
+ * so there is ONE place that says what the guard's policy is - a keyword list
372
+ * in the telemetry was a second copy of it, and it drifted.
373
+ *
374
+ * @type {RegExp[]}
375
+ */
376
+ export const ASK_GATED = [/^\s*git\s+push\b/]
377
+
332
378
  /** @type {Rule[]} */
333
379
  export const BASH_RULES = [
334
380
  {
@@ -367,7 +413,18 @@ export const BASH_RULES = [
367
413
  {
368
414
  name: "history rewrite",
369
415
  scope: "segment",
370
- test: /^git\s+(reset\s+--hard|clean\s+-[a-zA-Z]*f|rebase\s+.*--force)/i,
416
+ // The subcommand is resolved rather than read off the front of the text,
417
+ // because git takes options before it: `git -C /repo reset --hard` is the
418
+ // same command as `git reset --hard`, and a rule anchored at `^git` is
419
+ // defeated by every one of them. `gitSubcommand` already knows which
420
+ // options take a value and which ones are global.
421
+ check: (text) => {
422
+ const sub = gitSubcommand(text)
423
+ if (sub === "reset") return /(^|\s)--hard(\s|$)/.test(text)
424
+ if (sub === "clean") return /(^|\s)-[a-zA-Z]*f/.test(text)
425
+ if (sub === "rebase") return /(^|\s)--force/.test(text)
426
+ return false
427
+ },
371
428
  why: "destroys work that only exists locally",
372
429
  },
373
430
  ]
@@ -407,6 +464,41 @@ function matches(rule, text) {
407
464
  return !rule.unless?.test(text)
408
465
  }
409
466
 
467
+ /**
468
+ * The shared denylist, checked the way the rules are checked.
469
+ *
470
+ * The denylist used to be matched against the RAW command string, which is
471
+ * what lets `cd /tmp && npm publish` through: the pattern is `npm publish*`
472
+ * and the command does not start with it. It also let `sudo npm publish` and
473
+ * `sh -c "npm publish"` through, for the same reason.
474
+ *
475
+ * So the denylist is checked per SEGMENT, with the wrappers stripped, and
476
+ * the payload of a `bash -c` is checked too - exactly the way the rules are
477
+ * checked. One list, one way of reading it.
478
+ *
479
+ * @param {string} command
480
+ * @param {number} [depth]
481
+ * @returns {{ pattern: string, segment: string } | null}
482
+ */
483
+ export function checkDenied(command, depth = 0) {
484
+ if (!command || !command.trim()) return null
485
+ const parts = segments(command)
486
+ const normalized = parts.map((seg) => stripPrefixes(seg))
487
+ for (let i = 0; i < parts.length; i++) {
488
+ const pattern = deniedBy(normalized[i])
489
+ if (pattern) return { pattern, segment: parts[i] }
490
+ }
491
+ if (depth < MAX_SHELL_DEPTH) {
492
+ for (const seg of parts) {
493
+ const payload = shellPayload(seg)
494
+ if (!payload) continue
495
+ const hit = checkDenied(payload, depth + 1)
496
+ if (hit) return hit
497
+ }
498
+ }
499
+ return null
500
+ }
501
+
410
502
  /**
411
503
  * @param {string} command
412
504
  * @param {Rule[]} [rules]
package/src/safe.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Stringify without throwing.
2
+ //
3
+ // An event object comes from opencode, and opencode passes the object it
4
+ // holds - which can hold anything, including a reference back to itself. A
5
+ // plain `JSON.stringify` on a cyclic object throws, and a hook that throws
6
+ // takes the event down with it.
7
+ //
8
+ // This is the one place the kit stringifies something it did not build, so it
9
+ // is the one place that has to be careful.
10
+
11
+ /**
12
+ * Stringify a value, never throwing.
13
+ *
14
+ * @param {unknown} value
15
+ * @returns {string}
16
+ */
17
+ export function safeStringify(value: unknown): string {
18
+ try {
19
+ return JSON.stringify(value) ?? String(value)
20
+ } catch {
21
+ try {
22
+ return String(value)
23
+ } catch {
24
+ return "[unstringifiable]"
25
+ }
26
+ }
27
+ }
package/src/sqlite.js ADDED
@@ -0,0 +1,144 @@
1
+ // The one way this kit talks to the opencode database.
2
+ //
3
+ // It shells out to the `sqlite3` CLI rather than using a node driver, so
4
+ // there is no native module to build and no runtime dependency to install -
5
+ // the tool is either on the machine or it is not.
6
+ //
7
+ // When it is not, the failure used to be a raw `spawnSync sqlite3 ENOENT`
8
+ // stack trace. A missing tool is a thing a person can fix, and the error
9
+ // said what to install.
10
+ //
11
+ // Plain JS with JSDoc types, like the rest of src/: the CLI and the eval
12
+ // scripts have to run under plain node.
13
+
14
+ import { execFileSync } from "node:child_process"
15
+
16
+ /**
17
+ * Whether the sqlite3 CLI is available.
18
+ * @returns {boolean}
19
+ */
20
+ /** @type {boolean | null} */
21
+ let sqliteCli = null
22
+ export function hasSqlite() {
23
+ // Memoised. The measurement scripts issue one query per message, and a
24
+ // process spawn per query is thousands of throwaway processes.
25
+ if (sqliteCli !== null) return sqliteCli
26
+ try {
27
+ execFileSync("sqlite3", ["-version"], { stdio: "ignore" })
28
+ sqliteCli = true
29
+ } catch {
30
+ sqliteCli = false
31
+ }
32
+ return sqliteCli
33
+ }
34
+
35
+ /**
36
+ * Whether python3 with its built-in sqlite3 module is available.
37
+ *
38
+ * The fallback exists because python3 is on more machines than the sqlite3
39
+ * CLI is, and a measurement harness that cannot run is a measurement nobody
40
+ * takes.
41
+ *
42
+ * @returns {boolean}
43
+ */
44
+ /** @type {boolean | null} */
45
+ let pythonSqlite = null
46
+ export function hasPythonSqlite() {
47
+ if (pythonSqlite !== null) return pythonSqlite
48
+ try {
49
+ execFileSync("python3", ["-c", "import sqlite3"], { stdio: "ignore" })
50
+ pythonSqlite = true
51
+ } catch {
52
+ pythonSqlite = false
53
+ }
54
+ return pythonSqlite
55
+ }
56
+
57
+ /**
58
+ * Whether this machine can read a sqlite database at all.
59
+ * @returns {boolean}
60
+ */
61
+ export function canQuerySqlite() {
62
+ return hasSqlite() || hasPythonSqlite()
63
+ }
64
+
65
+ /**
66
+ * Explain that there is no way to read a sqlite database here, and what to
67
+ * install.
68
+ * @returns {string}
69
+ */
70
+ export function sqliteMissingMessage() {
71
+ return [
72
+ "this script needs a way to read a sqlite database, and this machine has neither.",
73
+ "",
74
+ " the sqlite3 command-line tool: brew install sqlite3 / apt install sqlite3",
75
+ " or python3 with its built-in sqlite3 module: apt install python3",
76
+ "",
77
+ "It is the sqlite3 CLI and not the sqlite3 library: the python3 and node",
78
+ "modules of that name do not put a `sqlite3` command on your PATH.",
79
+ ].join("\n")
80
+ }
81
+
82
+ /**
83
+ * A database path as a URI.
84
+ *
85
+ * A path with a `?` or a `#` in it is parsed as a URI query or fragment
86
+ * rather than as a filename, so it is percent-encoded.
87
+ *
88
+ * @param {string} db
89
+ * @returns {string}
90
+ */
91
+ function encodePath(db) {
92
+ return db.split("/").map(encodeURIComponent).join("/")
93
+ }
94
+
95
+ /**
96
+ * Run a query and return the rows.
97
+ *
98
+ * The sqlite3 CLI is the fast path. When it is not installed, python3's
99
+ * built-in sqlite3 module does the same job - and python3 is on more machines
100
+ * than the CLI is, so the measurement harness runs where it otherwise could
101
+ * not.
102
+ *
103
+ * @param {string} db the database path
104
+ * @param {string} sql
105
+ * @returns {any[]}
106
+ */
107
+ export function query(db, sql) {
108
+ if (hasSqlite()) {
109
+ // The path is percent-encoded, because it goes into a URI. A `?` in a
110
+ // path is a query separator, so a database called "we?ird.db" was read
111
+ // as a database called "we" with the query "ird.db?mode=ro" - and the
112
+ // error was "no such table" from a database that had the table.
113
+ const out = execFileSync("sqlite3", ["-json", `file:${encodePath(db)}?mode=ro`, sql], {
114
+ encoding: "utf8",
115
+ maxBuffer: 256 * 1024 * 1024,
116
+ }).trim()
117
+ return out ? JSON.parse(out) : []
118
+ }
119
+ return queryWithPython(db, sql)
120
+ }
121
+
122
+ /**
123
+ * The same query, through python3's sqlite3 module.
124
+ *
125
+ * @param {string} db the database path
126
+ * @param {string} sql
127
+ * @returns {any[]}
128
+ */
129
+ function queryWithPython(db, sql) {
130
+ // The cursor is used once. The description came from a SECOND execute of
131
+ // the same statement, which re-ran the whole scan.
132
+ const script = [
133
+ "import json, sqlite3, sys",
134
+ "con = sqlite3.connect('file:' + sys.argv[1] + '?mode=ro', uri=True)",
135
+ "cur = con.execute(sys.argv[2])",
136
+ "cols = [d[0] for d in cur.description]",
137
+ "print(json.dumps([dict(zip(cols, r)) for r in cur.fetchall()]))",
138
+ ].join("\n")
139
+ const out = execFileSync("python3", ["-c", script, encodePath(db), sql], {
140
+ encoding: "utf8",
141
+ maxBuffer: 256 * 1024 * 1024,
142
+ }).trim()
143
+ return out ? JSON.parse(out) : []
144
+ }