catherd-cli 1.5.0 → 2.0.0-beta.20261022113309

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 (275) hide show
  1. package/README.md +164 -208
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -247
  5. package/package.json +6 -8
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/commands/catherd.md +2 -2
  10. package/plugin/skills/catherd/SKILL.md +97 -421
  11. package/plugin/skills/catherd-setup/SKILL.md +20 -19
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +1 -2
  14. package/src/adapters/cursor/home.ts +1 -1
  15. package/src/adapters/cursor/index.ts +4 -4
  16. package/src/cli.ts +22 -27
  17. package/src/domain/bars.ts +33 -107
  18. package/src/domain/blocked.ts +72 -0
  19. package/src/domain/branch.ts +51 -0
  20. package/src/domain/brief.ts +21 -6
  21. package/src/domain/catalog.ts +69 -324
  22. package/src/domain/cost.ts +15 -30
  23. package/src/domain/errors.ts +2 -2
  24. package/src/domain/fail-lines.ts +26 -0
  25. package/src/domain/failover.ts +37 -87
  26. package/src/domain/findings.ts +47 -0
  27. package/src/domain/gate-brief.ts +1 -0
  28. package/src/domain/ids.ts +13 -2
  29. package/src/domain/jev.ts +47 -2
  30. package/src/domain/judge.ts +124 -0
  31. package/src/domain/lane.ts +22 -3
  32. package/src/domain/milestone-memory.ts +53 -0
  33. package/src/domain/notice.ts +25 -8
  34. package/src/domain/notify.ts +32 -0
  35. package/src/domain/plan-doc.ts +198 -0
  36. package/src/domain/plan-lint.ts +141 -0
  37. package/src/domain/profile-rules.ts +101 -160
  38. package/src/domain/profile.ts +116 -63
  39. package/src/domain/record.ts +53 -2
  40. package/src/domain/role-prompts.ts +131 -45
  41. package/src/domain/role-scope.ts +4 -34
  42. package/src/domain/role-tools.ts +2 -2
  43. package/src/domain/roles.ts +2 -0
  44. package/src/domain/route.ts +17 -0
  45. package/src/domain/rules.ts +33 -0
  46. package/src/domain/run-events.ts +298 -0
  47. package/src/domain/run-machine.ts +668 -0
  48. package/src/domain/run-state.ts +1310 -0
  49. package/src/domain/scorecard.ts +197 -0
  50. package/src/domain/select.ts +151 -146
  51. package/src/domain/slopalytics.ts +72 -0
  52. package/src/entry/answer-command.ts +39 -0
  53. package/src/entry/catalog-command.ts +25 -174
  54. package/src/entry/check-bin.ts +17 -0
  55. package/src/entry/cli-kit.ts +1 -1
  56. package/src/entry/daemon-bin.ts +22 -0
  57. package/src/entry/glyphs.ts +15 -2
  58. package/src/entry/init-command.ts +18 -68
  59. package/src/entry/knowledge-command.ts +45 -4
  60. package/src/entry/lock-command.ts +6 -7
  61. package/src/entry/mcp/role-server.ts +18 -0
  62. package/src/entry/mcp/server.ts +13 -40
  63. package/src/entry/mcp/session-tools.ts +149 -0
  64. package/src/entry/panel/openapi.ts +189 -0
  65. package/src/entry/panel/server.ts +410 -0
  66. package/src/entry/panel-command.ts +68 -0
  67. package/src/entry/profile-command.ts +10 -14
  68. package/src/entry/role-cli-command.ts +47 -3
  69. package/src/entry/runs-command.ts +3 -19
  70. package/src/entry/scorecard-command.ts +187 -0
  71. package/src/entry/supervise-bin.ts +1 -1
  72. package/src/infra/env.ts +0 -2
  73. package/src/infra/git.ts +64 -0
  74. package/src/infra/heavy-lock.ts +45 -7
  75. package/src/infra/launch.ts +63 -7
  76. package/src/infra/memory.ts +45 -0
  77. package/src/infra/ntfy.ts +40 -0
  78. package/src/infra/open-url.ts +42 -0
  79. package/src/infra/paths.ts +2 -2
  80. package/src/infra/slopalytics.ts +30 -0
  81. package/src/infra/store.ts +6 -1
  82. package/src/services/admission.ts +24 -9
  83. package/src/services/backends.ts +4 -6
  84. package/src/services/capture.ts +1 -22
  85. package/src/services/catalog-service.ts +155 -262
  86. package/src/services/credentials.ts +4 -20
  87. package/src/services/daemon.ts +181 -0
  88. package/src/services/dispatch-service.ts +5 -1
  89. package/src/services/doctor-backends.ts +1 -1
  90. package/src/services/doctor-checks.ts +47 -0
  91. package/src/services/doctor.ts +4 -9
  92. package/src/services/failover-rungs.ts +109 -0
  93. package/src/services/finalize.ts +12 -0
  94. package/src/services/gate-env.ts +1 -2
  95. package/src/services/gate-service.ts +21 -6
  96. package/src/services/jev-service.ts +2 -4
  97. package/src/services/lane-service.ts +34 -3
  98. package/src/services/milestone-check.ts +433 -0
  99. package/src/services/milestone-tree.ts +170 -0
  100. package/src/services/milestones.ts +35 -8
  101. package/src/services/notifier.ts +69 -2
  102. package/src/services/notify-service.ts +26 -0
  103. package/src/services/panel-setup.ts +116 -0
  104. package/src/services/plan-input.ts +151 -0
  105. package/src/services/ports.ts +30 -4
  106. package/src/services/preflight.ts +82 -8
  107. package/src/services/profile-service.ts +30 -3
  108. package/src/services/profile-store.ts +8 -3
  109. package/src/services/protocol.ts +4 -0
  110. package/src/services/provenance.ts +31 -90
  111. package/src/services/questions.ts +9 -0
  112. package/src/services/reconcile.ts +11 -0
  113. package/src/services/routing-service.ts +26 -15
  114. package/src/services/run-briefs.ts +477 -0
  115. package/src/services/run-client.ts +217 -0
  116. package/src/services/run-effects.ts +837 -0
  117. package/src/services/run-events-store.ts +174 -0
  118. package/src/services/run-evidence.ts +1 -1
  119. package/src/services/run-read.ts +221 -0
  120. package/src/services/run-report.ts +176 -0
  121. package/src/services/run-service.ts +55 -6
  122. package/src/services/run-store.ts +54 -7
  123. package/src/services/scorecard-metrics.ts +86 -0
  124. package/src/services/scorecard.ts +382 -0
  125. package/src/services/summary.ts +27 -3
  126. package/src/services/tree-path.ts +27 -0
  127. package/src/services/workspace-admission.ts +5 -13
  128. package/src/services/workspace-service.ts +5 -0
  129. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  130. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  131. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  132. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  133. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  134. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  135. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  136. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  137. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  138. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  139. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  140. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  141. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  142. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  143. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  144. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  145. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  146. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  147. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  148. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  149. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  150. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  151. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  152. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  153. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  154. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  155. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  156. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  157. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  158. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  159. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  160. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  161. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  162. package/web/dist/assets/index-C5IbylvM.css +2 -0
  163. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  164. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  165. package/web/dist/assets/label-DFSYBc62.js +1 -0
  166. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  167. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  168. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  169. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  170. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  171. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  172. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  173. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  174. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  175. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  176. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  177. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  178. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  179. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  180. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  181. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  183. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  184. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  185. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  186. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  187. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  188. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  189. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  190. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  191. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  192. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  193. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  194. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  195. package/web/dist/assets/time--9kSQbbp.js +1 -0
  196. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  197. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  198. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  199. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  200. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  201. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  202. package/web/dist/index.html +15 -0
  203. package/catalog/ATTRIBUTION.md +0 -44
  204. package/catalog/scores.json +0 -3202
  205. package/catalog/sources.json +0 -97
  206. package/src/adapters/antigravity/events.ts +0 -131
  207. package/src/adapters/antigravity/home.ts +0 -46
  208. package/src/adapters/antigravity/index.ts +0 -335
  209. package/src/adapters/antigravity/models.ts +0 -37
  210. package/src/adapters/grok/events.ts +0 -112
  211. package/src/adapters/grok/home.ts +0 -187
  212. package/src/adapters/grok/index.ts +0 -358
  213. package/src/adapters/grok/models.ts +0 -48
  214. package/src/domain/calibration.ts +0 -132
  215. package/src/domain/sources.ts +0 -144
  216. package/src/entry/mcp/dispatch-tools.ts +0 -52
  217. package/src/entry/mcp/lane-tools.ts +0 -131
  218. package/src/entry/mcp/protocol-tools.ts +0 -63
  219. package/src/entry/mcp/push-tools.ts +0 -16
  220. package/src/entry/mcp/run-tools.ts +0 -164
  221. package/src/entry/mcp/setup-tools.ts +0 -109
  222. package/src/entry/mcp/workspace-tools.ts +0 -142
  223. package/src/entry/tui/commands.ts +0 -563
  224. package/src/entry/tui/effects.ts +0 -385
  225. package/src/entry/tui/fixtures.ts +0 -637
  226. package/src/entry/tui/profile-edits.ts +0 -146
  227. package/src/entry/tui/profile-tree.ts +0 -429
  228. package/src/entry/tui/providers/app.tsx +0 -162
  229. package/src/entry/tui/providers/boundary.tsx +0 -57
  230. package/src/entry/tui/providers/data.tsx +0 -160
  231. package/src/entry/tui/providers/keymap.tsx +0 -166
  232. package/src/entry/tui/providers/root.tsx +0 -45
  233. package/src/entry/tui/providers/theme.tsx +0 -17
  234. package/src/entry/tui/providers/toast.tsx +0 -77
  235. package/src/entry/tui/run.tsx +0 -114
  236. package/src/entry/tui/state.ts +0 -400
  237. package/src/entry/tui/stories.ts +0 -114
  238. package/src/entry/tui/text.ts +0 -130
  239. package/src/entry/tui/theme.ts +0 -140
  240. package/src/entry/tui/views/app.tsx +0 -208
  241. package/src/entry/tui/views/dialogs.tsx +0 -41
  242. package/src/entry/tui/views/profile-actions.ts +0 -331
  243. package/src/entry/tui/views/profiles.tsx +0 -520
  244. package/src/entry/tui/views/runs.tsx +0 -619
  245. package/src/entry/tui/views/save-dialog.tsx +0 -344
  246. package/src/entry/tui/views/status.tsx +0 -213
  247. package/src/entry/tui/widgets/chrome.tsx +0 -114
  248. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  249. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  250. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  251. package/src/entry/tui/widgets/dialog.tsx +0 -68
  252. package/src/entry/tui/widgets/line.tsx +0 -92
  253. package/src/entry/tui/widgets/list.tsx +0 -197
  254. package/src/entry/tui/widgets/spinner.tsx +0 -49
  255. package/src/entry/tui/widgets/toast.tsx +0 -62
  256. package/src/infra/sources/arena.ts +0 -95
  257. package/src/infra/sources/artificial-analysis.ts +0 -119
  258. package/src/infra/sources/cache.ts +0 -126
  259. package/src/infra/sources/csv.ts +0 -38
  260. package/src/infra/sources/epoch.ts +0 -82
  261. package/src/infra/sources/http.ts +0 -75
  262. package/src/infra/sources/litellm.ts +0 -58
  263. package/src/infra/sources/models-dev.ts +0 -59
  264. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  265. package/src/infra/sources/openrouter-models.ts +0 -60
  266. package/src/infra/sources/rows.ts +0 -38
  267. package/src/infra/sources/vectara.ts +0 -69
  268. package/src/infra/sources/zip.ts +0 -48
  269. package/src/services/catalog-refresh.ts +0 -201
  270. package/src/services/doctor-sources.ts +0 -76
  271. package/src/services/runs-page.ts +0 -290
  272. package/src/services/source-derive.ts +0 -397
  273. package/src/services/source-sync.ts +0 -312
  274. package/src/services/standins.ts +0 -238
  275. package/src/services/treat-likes.ts +0 -113
@@ -0,0 +1,187 @@
1
+ import { join, resolve } from "node:path";
2
+ import { defineCommand } from "citty";
3
+ import { CatherdError } from "../domain/errors.ts";
4
+ import {
5
+ passBar,
6
+ SCORECARD_TASKS,
7
+ type ScorecardFile,
8
+ type ScorecardTaskId,
9
+ type Verdict,
10
+ } from "../domain/scorecard.ts";
11
+ import {
12
+ defaultPreparedRoot,
13
+ loadTask,
14
+ type Prepared,
15
+ prepareTask,
16
+ readScorecard,
17
+ resultsFile,
18
+ saveTaskScore,
19
+ scoreTask,
20
+ scorecardRoot,
21
+ } from "../services/scorecard.ts";
22
+ import { JSON_ARG, mark, printJson, refuseInRole } from "./cli-kit.ts";
23
+
24
+ const BASELINE = "1.5.0";
25
+
26
+ /** POSIX single-quote escaping; a value of plain path characters stays bare. */
27
+ const quote = (v: string): string => (/^[\w@%+=:,./-]+$/.test(v) ? v : `'${v.replaceAll("'", `'\\''`)}'`);
28
+
29
+ /** The prepared task as the operator needs it: where to start, what to paste, and how to record. */
30
+ export function formatPrepared(p: Prepared, label = "<label>"): string[] {
31
+ const env =
32
+ p.limits && p.shim ? `CATHERD_SCORECARD_LIMITS=${quote(p.limits)} PATH=${quote(p.shim)}:"$PATH" ` : "";
33
+ return [
34
+ `${mark("ok")} ${p.task} prepared in ${p.dir} at ${p.startCommit.slice(0, 8)}`,
35
+ ` start: cd ${quote(p.repo)} && ${env}claude`,
36
+ ` prompt: ${p.prompt}`,
37
+ ` record: catherd scorecard record ${quote(p.dir)} --label ${label}`,
38
+ ];
39
+ }
40
+
41
+ const yes = (b: boolean): string => (b ? "yes" : "no");
42
+ const num = (n: number): string => String(Math.round(n * 10) / 10);
43
+ const k = (n: number): string => `${Math.round(n / 1000)}k`;
44
+
45
+ function row(id: ScorecardTaskId, current: ScorecardFile, baseline: ScorecardFile | null): string {
46
+ const s = current.tasks[id];
47
+ if (!s) return `${id} -`;
48
+ const was = baseline?.tasks[id];
49
+ const input = s.quota.backends.reduce((n, b) => n + b.input, 0);
50
+ const output = s.quota.backends.reduce((n, b) => n + b.output, 0);
51
+ return [
52
+ id,
53
+ s.catherdVersion,
54
+ `finished ${yes(s.finished)}`,
55
+ `interventions ${s.interventions.total}`,
56
+ `rounds ${s.reviewRounds}`,
57
+ `wall ${num(s.wallMinutes)} min (${was ? num(was.wallMinutes) : "?"} min)`,
58
+ `first try ${yes(s.firstTry)}`,
59
+ `quota ${k(input)}/${k(output)}`,
60
+ ].join(" ");
61
+ }
62
+
63
+ export function formatComparison(
64
+ current: ScorecardFile,
65
+ baseline: ScorecardFile | null,
66
+ v: Verdict,
67
+ ): string[] {
68
+ return [
69
+ `scorecard ${current.label} against ${baseline ? baseline.label : "no baseline"}`,
70
+ ...SCORECARD_TASKS.map((id) => row(id, current, baseline)),
71
+ ...(v.pass ? ["PASS"] : v.failures.map((f) => `FAIL: ${f}`)),
72
+ ];
73
+ }
74
+
75
+ /** The contributor commands read fixtures from the checkout; an installed package ships none. */
76
+ function checkoutRoot(): string {
77
+ const root = scorecardRoot();
78
+ if (!root)
79
+ throw new CatherdError("E_INPUT_INVALID", "this catherd is not a source checkout", {
80
+ fix: "run it from a catherd checkout: bun src/cli.ts scorecard …",
81
+ });
82
+ return root;
83
+ }
84
+
85
+ const stamp = (d: Date): string => d.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
86
+
87
+ const RESULTS_ARG = {
88
+ results: { type: "string", description: "results folder (default: scorecard/results)" },
89
+ } as const;
90
+ const resultsDir = (root: string, given: string | undefined): string =>
91
+ given ? resolve(given) : join(root, "results");
92
+
93
+ const prepare = defineCommand({
94
+ meta: { name: "prepare", description: `Prepare a task's starting point: ${SCORECARD_TASKS.join(", ")}` },
95
+ args: {
96
+ task: { type: "positional", required: true, description: SCORECARD_TASKS.join("|") },
97
+ source: {
98
+ type: "string",
99
+ description: "the repository to clone (a path or URL); the Sanitell task needs one",
100
+ },
101
+ dir: { type: "string", description: "where prepared tasks go (default: catherd's data dir)" },
102
+ setup: {
103
+ type: "boolean",
104
+ default: true,
105
+ description: "run the task's setup commands (--no-setup skips them)",
106
+ },
107
+ ...JSON_ARG,
108
+ },
109
+ async run({ args }) {
110
+ refuseInRole(process.env, "catherd scorecard prepare");
111
+ const root = checkoutRoot();
112
+ const task = loadTask(args.task, root);
113
+ const now = new Date();
114
+ const into = join(args.dir ? resolve(args.dir) : defaultPreparedRoot(), `${task.id}-${stamp(now)}`);
115
+ const p = await prepareTask({ task, root, into, source: args.source, now, setup: args.setup });
116
+ if (args.json) return printJson(p);
117
+ for (const line of formatPrepared(p)) console.log(line);
118
+ },
119
+ });
120
+
121
+ const record = defineCommand({
122
+ meta: { name: "record", description: "Score a prepared task's run and store its row under a label" },
123
+ args: {
124
+ dir: { type: "positional", required: true, description: "the folder `prepare` printed" },
125
+ label: {
126
+ type: "string",
127
+ required: true,
128
+ description: "a version or plan name: 1.5.0, plan-29, 2.0.0-beta.1",
129
+ },
130
+ run: { type: "string", description: "the run to score, when the folder holds more than one" },
131
+ chat: { type: "string", description: "messages you typed into the session after the start prompt" },
132
+ ...RESULTS_ARG,
133
+ ...JSON_ARG,
134
+ },
135
+ async run({ args }) {
136
+ refuseInRole(process.env, "catherd scorecard record");
137
+ const chat = args.chat === undefined ? 0 : Number(args.chat);
138
+ if (!Number.isInteger(chat) || chat < 0)
139
+ throw new CatherdError("E_INPUT_INVALID", `--chat ${args.chat} is not a count`, {
140
+ fix: "--chat <the number of messages you typed into the session>",
141
+ });
142
+ const root = checkoutRoot();
143
+ const out = resultsFile(resultsDir(root, args.results), args.label);
144
+ const s = await scoreTask({ dir: resolve(args.dir), root, run: args.run, chat, now: Date.now() });
145
+ const saved = saveTaskScore(out, args.label, s);
146
+ if (args.json) return printJson({ file: out, score: s });
147
+ const baseline = readScorecard(resultsFile(resultsDir(root, args.results), BASELINE));
148
+ console.log(
149
+ formatComparison(saved, baseline, passBar(saved, baseline))[1 + SCORECARD_TASKS.indexOf(s.task)],
150
+ );
151
+ console.log(out);
152
+ },
153
+ });
154
+
155
+ const compare = defineCommand({
156
+ meta: {
157
+ name: "compare",
158
+ description: `Check a label against the baseline (${BASELINE}) by the spec's pass bar`,
159
+ },
160
+ args: {
161
+ label: { type: "positional", required: true, description: "the label to check" },
162
+ baseline: { type: "string", description: `the label to compare with (default: ${BASELINE})` },
163
+ ...RESULTS_ARG,
164
+ ...JSON_ARG,
165
+ },
166
+ run({ args }) {
167
+ const dir = resultsDir(checkoutRoot(), args.results);
168
+ const current = readScorecard(resultsFile(dir, args.label));
169
+ if (!current)
170
+ throw new CatherdError("E_INPUT_INVALID", `no scorecard ${args.label} yet`, {
171
+ fix: `catherd scorecard record <dir> --label ${args.label}`,
172
+ });
173
+ const baseline = readScorecard(resultsFile(dir, args.baseline ?? BASELINE));
174
+ const verdict = passBar(current, baseline);
175
+ process.exitCode = verdict.pass ? 0 : 1;
176
+ if (args.json) return printJson({ current, baseline, verdict });
177
+ for (const line of formatComparison(current, baseline, verdict)) console.log(line);
178
+ },
179
+ });
180
+
181
+ export const scorecardCommand = defineCommand({
182
+ meta: {
183
+ name: "scorecard",
184
+ description: "Contributors: measure a catherd version on four fixed tasks against the 1.5.0 baseline",
185
+ },
186
+ subCommands: { prepare, record, compare },
187
+ });
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- // The detached supervisor's entry: it imports only what supervising needs, never src/cli.ts and its TUI.
2
+ // The detached supervisor's entry: it imports only what supervising needs, never src/cli.ts.
3
3
  import { errorMessage } from "../domain/errors.ts";
4
4
  import { runSupervise } from "./supervise-command.ts";
5
5
 
package/src/infra/env.ts CHANGED
@@ -9,8 +9,6 @@ const SECRET_ENV = new Set([
9
9
  "CODEX_SESSION_ID",
10
10
  "CATHERD_ORCHESTRATION_HOST",
11
11
  "TYPESAFE_API_KEY",
12
- // spec 1.2 §9: the user's Artificial Analysis key reads scores for catherd alone
13
- "ARTIFICIAL_ANALYSIS_API_KEY",
14
12
  "CLAUDE_CODE_MESSAGING_SOCKET",
15
13
  "CLAUDE_CODE_MESSAGING_TOKEN",
16
14
  "CLAUDE_CODE_SESSION_ID",
package/src/infra/git.ts CHANGED
@@ -95,3 +95,67 @@ export async function statusSnapshot(repo: string, timeoutMs?: number): Promise<
95
95
  for (const { xy, path } of parsePorcelainZ(r.out)) snap[path] = fingerprint(repo, xy, path);
96
96
  return snap;
97
97
  }
98
+
99
+ /** What a git command printed and how it ended, for the callers that show a failure (a commit hook's output). */
100
+ export interface GitOutput {
101
+ code: number | null;
102
+ out: string;
103
+ err: string;
104
+ timedOut: boolean;
105
+ }
106
+
107
+ /**
108
+ * `git -C repo <args>` with both streams, for a command that may run hooks (a commit): a long timeout, and no
109
+ * optional lock avoidance, since this one writes the index.
110
+ */
111
+ export async function gitRun(repo: string, args: string[], timeoutMs = 300_000): Promise<GitOutput> {
112
+ let p: Bun.Subprocess<"ignore", "pipe", "pipe">;
113
+ try {
114
+ p = Bun.spawn(["git", "-C", repo, ...args], {
115
+ stdin: "ignore",
116
+ stdout: "pipe",
117
+ stderr: "pipe",
118
+ env: { ...scrubSecrets(process.env), LC_ALL: "C" },
119
+ });
120
+ } catch (e) {
121
+ return { code: null, out: "", err: (e as Error).message, timedOut: false };
122
+ }
123
+ let timedOut = false;
124
+ const timer = setTimeout(() => {
125
+ timedOut = true;
126
+ p.kill("SIGKILL");
127
+ }, timeoutMs);
128
+ const out = new Response(p.stdout).text().catch(() => "");
129
+ const err = new Response(p.stderr).text().catch(() => "");
130
+ const code = await p.exited;
131
+ clearTimeout(timer);
132
+ return {
133
+ code: timedOut ? null : code,
134
+ out: timedOut ? "" : await out,
135
+ err: timedOut ? "" : await err,
136
+ timedOut,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * `git fetch -q origin <base>` with a cap: catherd's one network git call (spec 2.0 decision 21). True when it
142
+ * exits 0; a failure never throws, the caller works from the last fetch and says so.
143
+ */
144
+ export async function fetchBase(repo: string, base: string, timeoutMs = 60_000): Promise<boolean> {
145
+ const r = await gitRun(repo, ["fetch", "-q", "origin", base], timeoutMs);
146
+ return r.code === 0;
147
+ }
148
+
149
+ /** Whether `commit` is an ancestor of `ref`: true on exit 0, false on 1, null when git cannot tell. */
150
+ export async function isAncestor(repo: string, commit: string, ref: string): Promise<boolean | null> {
151
+ const r = await git(repo, ["merge-base", "--is-ancestor", commit, ref]);
152
+ if (r.kind === "ok") return true;
153
+ return r.kind === "failed" && r.exit === 1 ? false : null;
154
+ }
155
+
156
+ /** The checked-out branch, or null when HEAD is detached (or git cannot say). */
157
+ export async function currentBranch(repo: string): Promise<string | null> {
158
+ const r = await git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]);
159
+ const name = r.kind === "ok" ? r.out.trim() : "";
160
+ return name && name !== "HEAD" ? name : null;
161
+ }
@@ -1,12 +1,40 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, readFileSync } from "node:fs";
3
- import { availableParallelism } from "node:os";
2
+ import { existsSync, readFileSync, rmSync } from "node:fs";
3
+ import { availableParallelism, loadavg } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { roleScopeOfScratch } from "../domain/role-scope.ts";
5
6
  import { tryLock, withFileLock } from "./filelock.ts";
6
7
  import { locksDir } from "./paths.ts";
7
8
  import { isAlive, selfIdentity } from "./proc.ts";
8
9
  import { ensurePrivateDir, writeJsonAtomic } from "./store.ts";
9
10
 
11
+ /** Set in a locked command's env: a `catherd lock` inside it runs at once, holding nothing more. */
12
+ export const LOCK_HELD_ENV = "CATHERD_LOCK_HELD";
13
+
14
+ const envLoadWaitMs = Number(process.env.CATHERD_HEAVY_LOAD_WAIT_MS ?? 900_000);
15
+
16
+ /**
17
+ * Spec 2.0 decision 18: heavy slots wait while the load is above twice the cores, up to 15 minutes.
18
+ * CATHERD_HEAVY_LOAD_WAIT_MS overrides the wait (0 = none); the test preload sets 0, since a busy CI runner
19
+ * would otherwise stall every lock in the suite, spawned `catherd lock` processes included.
20
+ */
21
+ export const heavyLimits = {
22
+ loadWaitMs: envLoadWaitMs >= 0 ? envLoadWaitMs : 900_000,
23
+ loadPollMs: 15_000,
24
+ load: (): number => loadavg()[0] ?? 0,
25
+ cores: (): number => availableParallelism(),
26
+ };
27
+
28
+ /**
29
+ * The emptied TMPDIR and GOTMPDIR of one slot: under the role's scratch when the caller's TMPDIR is one (a role
30
+ * is told by its TMPDIR, so the slot dir must stay under it), else under catherd's locks dir.
31
+ */
32
+ export function slotTmpDir(slot: number, inherited?: string): string {
33
+ return roleScopeOfScratch(inherited)
34
+ ? join(inherited as string, `heavy-${slot}`)
35
+ : join(locksDir(), "tmp", `slot-${slot}`);
36
+ }
37
+
10
38
  /** A profile's `lock.heavy` as a slot count: a number, or half the cores. */
11
39
  export function heavySlots(setting: number | "cpus/2" | undefined): number {
12
40
  if (typeof setting === "number" && Number.isFinite(setting)) return Math.max(1, Math.floor(setting));
@@ -19,8 +47,8 @@ export function heavySlots(setting: number | "cpus/2" | undefined): number {
19
47
  */
20
48
  export async function withHeavySlot<T>(
21
49
  slots: number,
22
- fn: (slot: number) => T | Promise<T>,
23
- o: { pollMs?: number; onWait?: () => void } = {},
50
+ fn: (slot: number, tmp: string) => T | Promise<T>,
51
+ o: { pollMs?: number; onWait?: () => void; tmpBase?: string } = {},
24
52
  ): Promise<T> {
25
53
  const r = await withHeavySlotWithin(slots, Number.POSITIVE_INFINITY, fn, o);
26
54
  if (r.busy) throw new Error("unreachable: an unbounded wait never gives up");
@@ -34,19 +62,29 @@ export async function withHeavySlot<T>(
34
62
  export async function withHeavySlotWithin<T>(
35
63
  slots: number,
36
64
  waitMs: number,
37
- fn: (slot: number) => T | Promise<T>,
38
- o: { pollMs?: number; onWait?: () => void } = {},
65
+ fn: (slot: number, tmp: string) => T | Promise<T>,
66
+ o: { pollMs?: number; onWait?: () => void; tmpBase?: string } = {},
39
67
  ): Promise<{ busy: false; value: T } | { busy: true }> {
40
68
  const dir = locksDir();
41
69
  ensurePrivateDir(dir);
42
70
  const deadline = Date.now() + waitMs;
71
+ // an overloaded host runs the command late, not never: the wait is bounded, and the slot loop follows it
72
+ const loadDeadline = Date.now() + Math.min(heavyLimits.loadWaitMs, waitMs);
73
+ while (heavyLimits.load() > 2 * heavyLimits.cores() && Date.now() < loadDeadline) {
74
+ o.onWait?.();
75
+ await Bun.sleep(Math.min(heavyLimits.loadPollMs, Math.max(1, loadDeadline - Date.now())));
76
+ }
43
77
  for (;;) {
44
78
  for (let i = 0; i < slots; i++) {
45
79
  const release = tryLock(join(dir, `slot-${i}`));
46
80
  if (!release) continue;
81
+ const tmp = slotTmpDir(i, o.tmpBase);
47
82
  try {
48
- return { busy: false, value: await fn(i) };
83
+ rmSync(tmp, { recursive: true, force: true });
84
+ ensurePrivateDir(tmp);
85
+ return { busy: false, value: await fn(i, tmp) };
49
86
  } finally {
87
+ rmSync(tmp, { recursive: true, force: true });
50
88
  release();
51
89
  }
52
90
  }
@@ -1,25 +1,81 @@
1
1
  import { closeSync, openSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { ROLE_ENV } from "../domain/role-scope.ts";
4
5
  import { scrubSecrets } from "./env.ts";
5
6
  import { log as logRow } from "./log.ts";
6
7
  import { PRIVATE_FILE } from "./store.ts";
7
8
 
8
- /** A thin executable that supervises one spec; unlike src/cli.ts it never loads the TUI. */
9
+ /** A thin executable that supervises one spec; it never loads src/cli.ts. */
9
10
  export const SUPERVISE_ENTRY = fileURLToPath(new URL("../entry/supervise-bin.ts", import.meta.url));
10
11
 
11
- /** Starts `supervise-bin.ts <spec>` in its own session and returns at once; it outlives this process. */
12
- export function launchSupervisor(specPath: string): number {
13
- const log = openSync(join(dirname(specPath), "supervisor.log"), "a", PRIVATE_FILE);
14
- const p = Bun.spawn([process.execPath, SUPERVISE_ENTRY, specPath], {
12
+ /** The thin executable that drives one run; like the supervisor's, it never loads src/cli.ts. */
13
+ export const DAEMON_ENTRY = fileURLToPath(new URL("../entry/daemon-bin.ts", import.meta.url));
14
+
15
+ /** The thin executable that runs one milestone check (plan 30b); like the others it never loads src/cli.ts. */
16
+ export const CHECK_ENTRY = fileURLToPath(new URL("../entry/check-bin.ts", import.meta.url));
17
+
18
+ /**
19
+ * Starts `entry args` in its own session, its output appended to `logFile`, and returns at once: the process
20
+ * outlives this one.
21
+ */
22
+ export function launchDetached(
23
+ entry: string,
24
+ args: string[],
25
+ logFile: string,
26
+ env: Record<string, string>,
27
+ ): number {
28
+ const log = openSync(logFile, "a", PRIVATE_FILE);
29
+ const p = Bun.spawn([process.execPath, entry, ...args], {
15
30
  stdin: "ignore",
16
31
  stdout: log,
17
32
  stderr: log,
18
33
  detached: true,
19
- env: scrubSecrets(process.env),
34
+ env,
20
35
  });
21
36
  closeSync(log);
22
37
  p.unref();
23
- logRow("info", "launch", { spec: specPath, pid: p.pid });
24
38
  return p.pid;
25
39
  }
40
+
41
+ /** Starts `supervise-bin.ts <spec>` in its own session and returns at once; it outlives this process. */
42
+ export function launchSupervisor(specPath: string): number {
43
+ const pid = launchDetached(
44
+ SUPERVISE_ENTRY,
45
+ [specPath],
46
+ join(dirname(specPath), "supervisor.log"),
47
+ scrubSecrets(process.env),
48
+ );
49
+ logRow("info", "launch", { spec: specPath, pid });
50
+ return pid;
51
+ }
52
+
53
+ /**
54
+ * The daemon's env (spec 2.0, plan 30 ruling 4): the launching process's own, so Jev's key reaches its routing,
55
+ * without the session identity of whoever launched it, and never a role's scope: it is nobody's role.
56
+ */
57
+ export function daemonEnv(base: Record<string, string | undefined> = process.env): Record<string, string> {
58
+ const env = scrubSecrets(base);
59
+ if (base.TYPESAFE_API_KEY !== undefined) env.TYPESAFE_API_KEY = base.TYPESAFE_API_KEY;
60
+ delete env[ROLE_ENV];
61
+ return env;
62
+ }
63
+
64
+ /** Starts `daemon-bin.ts <run id>` for a run; its log is `<run dir>/daemon.log`. */
65
+ export function launchDaemon(runId: string, runDir: string): number {
66
+ const pid = launchDetached(DAEMON_ENTRY, [runId], join(runDir, "daemon.log"), daemonEnv());
67
+ logRow("info", "launch", { daemon: runId, pid });
68
+ return pid;
69
+ }
70
+
71
+ /** Starts `check-bin.ts <flight file>` detached, so a tool timeout that kills its caller does not lose the check. */
72
+ export function launchCheckWorker(flight: string): number {
73
+ const pid = launchDetached(
74
+ CHECK_ENTRY,
75
+ [flight],
76
+ join(dirname(flight), "worker.log"),
77
+ scrubSecrets(process.env),
78
+ );
79
+ logRow("info", "launch", { check: flight, pid });
80
+ return pid;
81
+ }
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { freemem, totalmem } from "node:os";
3
+
4
+ const MB = 2 ** 20;
5
+
6
+ const sysctlLevel = (): string | null => {
7
+ try {
8
+ const r = Bun.spawnSync(["sysctl", "-n", "kern.memorystatus_level"]);
9
+ return r.exitCode === 0 ? r.stdout.toString() : null;
10
+ } catch {
11
+ return null;
12
+ }
13
+ };
14
+
15
+ const procMeminfo = (): string | null => {
16
+ try {
17
+ return readFileSync("/proc/meminfo", "utf8");
18
+ } catch {
19
+ return null;
20
+ }
21
+ };
22
+
23
+ /**
24
+ * The memory the machine could give a new process, in MB. macOS reports almost nothing through `os.freemem()`
25
+ * (it counts cache as used), so it is the kernel's free percentage of the total; Linux has `MemAvailable`;
26
+ * anything unreadable falls back to `os.freemem()`. Never throws.
27
+ */
28
+ export function availableMemoryMb(
29
+ o: {
30
+ platform?: NodeJS.Platform;
31
+ sysctl?: () => string | null;
32
+ meminfo?: () => string | null;
33
+ } = {},
34
+ ): number {
35
+ const platform = o.platform ?? process.platform;
36
+ if (platform === "darwin") {
37
+ const level = Number.parseInt((o.sysctl ?? sysctlLevel)() ?? "", 10);
38
+ if (Number.isInteger(level) && level >= 0 && level <= 100)
39
+ return Math.floor(((level / 100) * totalmem()) / MB);
40
+ } else if (platform === "linux") {
41
+ const kb = /^MemAvailable:\s+(\d+) kB$/m.exec((o.meminfo ?? procMeminfo)() ?? "")?.[1];
42
+ if (kb !== undefined) return Math.floor(Number(kb) / 1024);
43
+ }
44
+ return Math.floor(freemem() / MB);
45
+ }
@@ -0,0 +1,40 @@
1
+ import type { NotifyMessage } from "../domain/notify.ts";
2
+ import { log } from "./log.ts";
3
+
4
+ /** A header value as ntfy reads it: plain ASCII as it is, anything else as an RFC 2047 encoded word. */
5
+ const headerValue = (s: string): string =>
6
+ /^[\x20-\x7e]*$/.test(s) ? s : `=?UTF-8?B?${Buffer.from(s).toString("base64")}?=`;
7
+
8
+ /**
9
+ * Posts one message to an ntfy topic. True once the server accepted it; any error, a non-2xx answer or a
10
+ * server slower than `timeoutMs` is false, never a throw, and the log never carries the topic (the url is a
11
+ * secret of the user's).
12
+ */
13
+ export async function postNotify(
14
+ url: string,
15
+ m: NotifyMessage,
16
+ o: { fetch?: typeof fetch; timeoutMs?: number } = {},
17
+ ): Promise<boolean> {
18
+ const timeoutMs = o.timeoutMs ?? 10_000;
19
+ let timer: ReturnType<typeof setTimeout> | undefined;
20
+ try {
21
+ const sent = (o.fetch ?? fetch)(url, {
22
+ method: "POST",
23
+ body: m.message,
24
+ headers: { Title: headerValue(m.title), Tags: m.tags.join(",") },
25
+ signal: AbortSignal.timeout(timeoutMs),
26
+ });
27
+ // a fetch that ignores its signal still gives up
28
+ const gaveUp = new Promise<never>((_, reject) => {
29
+ timer = setTimeout(() => reject(new Error(`no answer in ${timeoutMs} ms`)), timeoutMs);
30
+ });
31
+ const res = await Promise.race([sent, gaveUp]);
32
+ if (!res.ok) log("warn", "notify", { ntfy: res.status });
33
+ return res.ok;
34
+ } catch (e) {
35
+ log("warn", "notify", { ntfy: e instanceof Error ? e.name : "error" });
36
+ return false;
37
+ } finally {
38
+ clearTimeout(timer);
39
+ }
40
+ }
@@ -0,0 +1,42 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * A page that sends the browser on to `url`, so the opener's argv (readable by every local user, and kept in
7
+ * /proc/<pid>/cmdline) carries a path instead of the token. The dir is 0700 and the file 0600; `remove` deletes both.
8
+ */
9
+ export function writeRedirectPage(url: string): {
10
+ path: string;
11
+ remove(): void;
12
+ } {
13
+ const dir = mkdtempSync(join(tmpdir(), "catherd-panel-"));
14
+ const path = join(dir, "sign-in.html");
15
+ writeFileSync(
16
+ path,
17
+ `<!doctype html><meta charset="utf-8"><title>catherd panel</title><script>location.replace(${JSON.stringify(url)})</script>\n`,
18
+ { mode: 0o600 },
19
+ );
20
+ return { path, remove: () => rmSync(dir, { recursive: true, force: true }) };
21
+ }
22
+
23
+ /** The command that opens `url` in the user's browser on `platform`. */
24
+ export function openerFor(platform: NodeJS.Platform, url: string): string[] {
25
+ if (platform === "darwin") return ["open", url];
26
+ if (platform === "win32") return ["cmd", "/c", "start", "", url];
27
+ return ["xdg-open", url];
28
+ }
29
+
30
+ /** Opens `url` in the default browser, detached; a machine with no opener simply opens nothing. */
31
+ export function openUrl(url: string): void {
32
+ try {
33
+ Bun.spawn(openerFor(process.platform, url), {
34
+ stdin: "ignore",
35
+ stdout: "ignore",
36
+ stderr: "ignore",
37
+ detached: true,
38
+ }).unref();
39
+ } catch {
40
+ // no opener on PATH: the printed link still works
41
+ }
42
+ }
@@ -73,5 +73,5 @@ export const runsDir = (toplevel: string): string => join(repoDir(toplevel), "ru
73
73
  export const logsDir = (): string => join(dataDir(), "logs");
74
74
  export const discoveryDir = (): string => join(dataDir(), "discovery");
75
75
  export const locksDir = (): string => join(dataDir(), "locks");
76
- /** Spec 1.2 §3.3: each source's last good answer, the sync state and lock, and what the sync derived. */
77
- export const sourcesDir = (): string => join(dataDir(), "sources");
76
+ /** The Slopalytics index: the last good answer of the proxy, one intelligence per model and effort. */
77
+ export const indexPath = (): string => join(dataDir(), "slopalytics.json");
@@ -0,0 +1,30 @@
1
+ import { existsSync } from "node:fs";
2
+ import { parseProxy, type IndexFile, IndexFileSchema, type SlopRecord } from "../domain/slopalytics.ts";
3
+ import { readVersioned, writeJsonAtomic } from "./store.ts";
4
+
5
+ export const SLOPALYTICS_URL = "https://slopalyticsproxy.vercel.app";
6
+
7
+ /** The proxy's answer, parsed. Throws on a transport error, a non-2xx, or an answer with no indexed pair. */
8
+ export async function fetchProxy(
9
+ o: { fetch?: typeof fetch; timeoutMs?: number } = {},
10
+ ): Promise<{ fetchedAt: string | null; models: SlopRecord[] }> {
11
+ const res = await (o.fetch ?? fetch)(SLOPALYTICS_URL, {
12
+ signal: AbortSignal.timeout(o.timeoutMs ?? 15_000),
13
+ });
14
+ if (!res.ok) throw new Error(`http ${res.status}`);
15
+ const parsed = parseProxy(await res.json());
16
+ if (parsed.models.length === 0) throw new Error("no indexed pairs");
17
+ return parsed;
18
+ }
19
+
20
+ /** The cached index, or null when there is none or it cannot be read (the next refresh rewrites it). */
21
+ export function readIndexFile(file: string): IndexFile | null {
22
+ if (!existsSync(file)) return null;
23
+ try {
24
+ return readVersioned(file, IndexFileSchema, 1);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export const writeIndexFile = (file: string, f: IndexFile): void => writeJsonAtomic(file, f, { mode: 0o600 });
@@ -150,9 +150,14 @@ function endsMidLine(file: string): boolean {
150
150
  * tail left by an earlier crash is ended first, in the same write, so the new row stays whole.
151
151
  */
152
152
  export function appendJsonl(file: string, value: unknown): void {
153
+ appendJsonlBatch(file, [value]);
154
+ }
155
+
156
+ /** Several rows in one write, so a crash leaves all of them or at worst a torn last one. */
157
+ export function appendJsonlBatch(file: string, values: unknown[]): void {
153
158
  ensurePrivateDir(dirname(file));
154
159
  const prefix = endsMidLine(file) ? "\n" : "";
155
- appendPrivate(file, `${prefix}${JSON.stringify(value)}\n`);
160
+ appendPrivate(file, prefix + values.map((v) => `${JSON.stringify(v)}\n`).join(""));
156
161
  }
157
162
 
158
163
  const madePrivate = new Set<string>();