catherd-cli 1.4.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 (320) hide show
  1. package/README.md +188 -169
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -239
  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 -356
  11. package/plugin/skills/catherd-setup/SKILL.md +21 -20
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +19 -4
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/codex/events.ts +10 -0
  16. package/src/adapters/codex/index.ts +17 -5
  17. package/src/adapters/cursor/home.ts +1 -1
  18. package/src/adapters/cursor/index.ts +10 -4
  19. package/src/adapters/opencode/index.ts +38 -1
  20. package/src/cli.ts +27 -27
  21. package/src/domain/agents.ts +3 -1
  22. package/src/domain/bars.ts +33 -107
  23. package/src/domain/blocked.ts +72 -0
  24. package/src/domain/branch.ts +51 -0
  25. package/src/domain/brief.ts +106 -0
  26. package/src/domain/catalog.ts +69 -313
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +19 -29
  29. package/src/domain/errors.ts +6 -2
  30. package/src/domain/fail-lines.ts +26 -0
  31. package/src/domain/failover.ts +37 -75
  32. package/src/domain/findings.ts +47 -0
  33. package/src/domain/gate-brief.ts +53 -0
  34. package/src/domain/hints.ts +13 -2
  35. package/src/domain/ids.ts +13 -2
  36. package/src/domain/jev.ts +47 -2
  37. package/src/domain/judge.ts +124 -0
  38. package/src/domain/lane.ts +118 -3
  39. package/src/domain/milestone-memory.ts +53 -0
  40. package/src/domain/notice.ts +32 -8
  41. package/src/domain/notify.ts +32 -0
  42. package/src/domain/plan-doc.ts +198 -0
  43. package/src/domain/plan-lint.ts +141 -0
  44. package/src/domain/profile-rules.ts +175 -138
  45. package/src/domain/profile.ts +140 -63
  46. package/src/domain/record.ts +68 -3
  47. package/src/domain/reply.ts +29 -0
  48. package/src/domain/role-prompts.ts +182 -56
  49. package/src/domain/role-scope.ts +76 -0
  50. package/src/domain/role-tools.ts +34 -0
  51. package/src/domain/roles.ts +2 -0
  52. package/src/domain/route.ts +90 -0
  53. package/src/domain/rules.ts +33 -0
  54. package/src/domain/run-events.ts +298 -0
  55. package/src/domain/run-machine.ts +668 -0
  56. package/src/domain/run-state.ts +1310 -0
  57. package/src/domain/scorecard.ts +197 -0
  58. package/src/domain/select.ts +229 -72
  59. package/src/domain/slopalytics.ts +72 -0
  60. package/src/domain/state.ts +5 -0
  61. package/src/domain/util.ts +22 -0
  62. package/src/domain/workspace.ts +130 -0
  63. package/src/entry/answer-command.ts +39 -0
  64. package/src/entry/catalog-command.ts +24 -165
  65. package/src/entry/check-bin.ts +17 -0
  66. package/src/entry/cli-kit.ts +18 -2
  67. package/src/entry/daemon-bin.ts +22 -0
  68. package/src/entry/deps.ts +2 -0
  69. package/src/entry/doctor-command.ts +20 -2
  70. package/src/entry/glyphs.ts +15 -2
  71. package/src/entry/host-arg.ts +28 -0
  72. package/src/entry/init-command.ts +20 -69
  73. package/src/entry/knowledge-command.ts +121 -5
  74. package/src/entry/lock-command.ts +103 -8
  75. package/src/entry/mcp/handshake.ts +6 -2
  76. package/src/entry/mcp/role-bin.ts +20 -0
  77. package/src/entry/mcp/role-server.ts +134 -0
  78. package/src/entry/mcp/server.ts +85 -21
  79. package/src/entry/mcp/session-tools.ts +149 -0
  80. package/src/entry/panel/openapi.ts +189 -0
  81. package/src/entry/panel/server.ts +410 -0
  82. package/src/entry/panel-command.ts +68 -0
  83. package/src/entry/pause-command.ts +67 -0
  84. package/src/entry/profile-command.ts +33 -18
  85. package/src/entry/role-cli-command.ts +159 -0
  86. package/src/entry/runs-command.ts +81 -27
  87. package/src/entry/scorecard-command.ts +187 -0
  88. package/src/entry/supervise-bin.ts +1 -1
  89. package/src/entry/supervise-command.ts +30 -3
  90. package/src/entry/workspace-command.ts +44 -0
  91. package/src/infra/boot-lock.ts +25 -0
  92. package/src/infra/delivery.ts +3 -0
  93. package/src/infra/dispatch-dir.ts +2 -0
  94. package/src/infra/env.ts +11 -3
  95. package/src/infra/git.ts +70 -0
  96. package/src/infra/heavy-lock.ts +133 -6
  97. package/src/infra/host-probe.ts +16 -0
  98. package/src/infra/launch.ts +63 -7
  99. package/src/infra/lock-activity.ts +88 -0
  100. package/src/infra/login-env.ts +48 -0
  101. package/src/infra/memory.ts +45 -0
  102. package/src/infra/ntfy.ts +40 -0
  103. package/src/infra/open-url.ts +42 -0
  104. package/src/infra/paths.ts +37 -2
  105. package/src/infra/role-mcp.ts +69 -0
  106. package/src/infra/slopalytics.ts +30 -0
  107. package/src/infra/store.ts +6 -1
  108. package/src/infra/supervisor.ts +107 -16
  109. package/src/services/admission.ts +133 -31
  110. package/src/services/backends.ts +4 -6
  111. package/src/services/budget.ts +26 -6
  112. package/src/services/capture.ts +1 -22
  113. package/src/services/catalog-service.ts +155 -254
  114. package/src/services/credentials.ts +15 -26
  115. package/src/services/daemon.ts +181 -0
  116. package/src/services/dispatch-service.ts +209 -17
  117. package/src/services/dispatches.ts +30 -2
  118. package/src/services/doctor-access.ts +8 -3
  119. package/src/services/doctor-backends.ts +1 -1
  120. package/src/services/doctor-checks.ts +47 -0
  121. package/src/services/doctor-docker.ts +221 -0
  122. package/src/services/doctor-role-mcp.ts +65 -0
  123. package/src/services/doctor.ts +72 -15
  124. package/src/services/end-push.ts +157 -0
  125. package/src/services/failover-rungs.ts +109 -0
  126. package/src/services/finalize.ts +24 -2
  127. package/src/services/gate-env.ts +217 -0
  128. package/src/services/gate-service.ts +231 -45
  129. package/src/services/global-install.ts +46 -13
  130. package/src/services/jev-service.ts +2 -4
  131. package/src/services/lane-edit.ts +154 -0
  132. package/src/services/lane-service.ts +291 -55
  133. package/src/services/milestone-check.ts +433 -0
  134. package/src/services/milestone-tree.ts +170 -0
  135. package/src/services/milestones.ts +167 -29
  136. package/src/services/notifier.ts +124 -4
  137. package/src/services/notify-service.ts +26 -0
  138. package/src/services/orchestrator-wait.ts +82 -0
  139. package/src/services/panel-setup.ts +116 -0
  140. package/src/services/pause.ts +161 -0
  141. package/src/services/peek.ts +79 -17
  142. package/src/services/plan-input.ts +151 -0
  143. package/src/services/ports.ts +60 -5
  144. package/src/services/preflight.ts +232 -24
  145. package/src/services/profile-service.ts +31 -3
  146. package/src/services/profile-store.ts +18 -6
  147. package/src/services/protocol.ts +123 -25
  148. package/src/services/provenance.ts +34 -89
  149. package/src/services/questions.ts +24 -0
  150. package/src/services/reconcile.ts +33 -1
  151. package/src/services/reentry.ts +7 -6
  152. package/src/services/role-access.ts +27 -0
  153. package/src/services/routing-service.ts +161 -42
  154. package/src/services/run-briefs.ts +477 -0
  155. package/src/services/run-client.ts +217 -0
  156. package/src/services/run-effects.ts +837 -0
  157. package/src/services/run-events-store.ts +174 -0
  158. package/src/services/run-evidence.ts +1 -1
  159. package/src/services/run-pin.ts +125 -0
  160. package/src/services/run-read.ts +221 -0
  161. package/src/services/run-report.ts +176 -0
  162. package/src/services/run-service.ts +204 -23
  163. package/src/services/run-store.ts +159 -18
  164. package/src/services/scorecard-metrics.ts +86 -0
  165. package/src/services/scorecard.ts +382 -0
  166. package/src/services/scratch.ts +60 -0
  167. package/src/services/sessions.ts +3 -1
  168. package/src/services/state.ts +21 -0
  169. package/src/services/summary.ts +76 -12
  170. package/src/services/tree-path.ts +27 -0
  171. package/src/services/verifier-brief.ts +23 -0
  172. package/src/services/verifier-step.ts +48 -0
  173. package/src/services/workspace-admission.ts +259 -0
  174. package/src/services/workspace-service.ts +259 -0
  175. package/src/services/workspace-store.ts +95 -0
  176. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  177. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  178. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  179. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  180. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  181. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  182. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  183. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  184. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  185. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  186. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  187. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  188. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  189. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  190. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  191. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  192. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  193. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  194. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  195. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  196. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  197. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  198. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  199. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  200. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  201. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  202. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  203. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  204. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  205. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  206. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  207. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  208. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  209. package/web/dist/assets/index-C5IbylvM.css +2 -0
  210. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  211. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  212. package/web/dist/assets/label-DFSYBc62.js +1 -0
  213. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  214. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  215. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  216. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  217. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  218. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  219. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  220. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  221. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  222. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  223. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  224. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  225. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  226. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  227. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  228. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  229. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  230. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  231. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  232. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  233. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  234. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  235. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  236. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  237. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  238. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  239. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  240. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  241. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  242. package/web/dist/assets/time--9kSQbbp.js +1 -0
  243. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  244. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  245. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  246. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  247. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  248. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  249. package/web/dist/index.html +15 -0
  250. package/catalog/ATTRIBUTION.md +0 -36
  251. package/catalog/scores.json +0 -3202
  252. package/catalog/sources.json +0 -97
  253. package/src/adapters/antigravity/events.ts +0 -131
  254. package/src/adapters/antigravity/home.ts +0 -46
  255. package/src/adapters/antigravity/index.ts +0 -320
  256. package/src/adapters/antigravity/models.ts +0 -37
  257. package/src/adapters/grok/events.ts +0 -112
  258. package/src/adapters/grok/home.ts +0 -132
  259. package/src/adapters/grok/index.ts +0 -339
  260. package/src/adapters/grok/models.ts +0 -48
  261. package/src/domain/calibration.ts +0 -125
  262. package/src/domain/sources.ts +0 -144
  263. package/src/entry/mcp/dispatch-tools.ts +0 -52
  264. package/src/entry/mcp/lane-tools.ts +0 -84
  265. package/src/entry/mcp/protocol-tools.ts +0 -57
  266. package/src/entry/mcp/run-tools.ts +0 -148
  267. package/src/entry/mcp/setup-tools.ts +0 -108
  268. package/src/entry/tui/commands.ts +0 -563
  269. package/src/entry/tui/effects.ts +0 -381
  270. package/src/entry/tui/fixtures.ts +0 -637
  271. package/src/entry/tui/profile-edits.ts +0 -146
  272. package/src/entry/tui/profile-tree.ts +0 -429
  273. package/src/entry/tui/providers/app.tsx +0 -162
  274. package/src/entry/tui/providers/boundary.tsx +0 -57
  275. package/src/entry/tui/providers/data.tsx +0 -160
  276. package/src/entry/tui/providers/keymap.tsx +0 -166
  277. package/src/entry/tui/providers/root.tsx +0 -45
  278. package/src/entry/tui/providers/theme.tsx +0 -17
  279. package/src/entry/tui/providers/toast.tsx +0 -77
  280. package/src/entry/tui/run.tsx +0 -114
  281. package/src/entry/tui/state.ts +0 -373
  282. package/src/entry/tui/stories.ts +0 -114
  283. package/src/entry/tui/text.ts +0 -130
  284. package/src/entry/tui/theme.ts +0 -140
  285. package/src/entry/tui/views/app.tsx +0 -208
  286. package/src/entry/tui/views/dialogs.tsx +0 -41
  287. package/src/entry/tui/views/profile-actions.ts +0 -331
  288. package/src/entry/tui/views/profiles.tsx +0 -520
  289. package/src/entry/tui/views/runs.tsx +0 -573
  290. package/src/entry/tui/views/save-dialog.tsx +0 -340
  291. package/src/entry/tui/views/status.tsx +0 -209
  292. package/src/entry/tui/widgets/chrome.tsx +0 -114
  293. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  294. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  295. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  296. package/src/entry/tui/widgets/dialog.tsx +0 -68
  297. package/src/entry/tui/widgets/line.tsx +0 -92
  298. package/src/entry/tui/widgets/list.tsx +0 -197
  299. package/src/entry/tui/widgets/spinner.tsx +0 -49
  300. package/src/entry/tui/widgets/toast.tsx +0 -62
  301. package/src/infra/sources/arena.ts +0 -87
  302. package/src/infra/sources/artificial-analysis.ts +0 -112
  303. package/src/infra/sources/cache.ts +0 -124
  304. package/src/infra/sources/csv.ts +0 -38
  305. package/src/infra/sources/epoch.ts +0 -82
  306. package/src/infra/sources/http.ts +0 -75
  307. package/src/infra/sources/litellm.ts +0 -58
  308. package/src/infra/sources/models-dev.ts +0 -59
  309. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  310. package/src/infra/sources/openrouter-models.ts +0 -60
  311. package/src/infra/sources/rows.ts +0 -38
  312. package/src/infra/sources/vectara.ts +0 -69
  313. package/src/infra/sources/zip.ts +0 -48
  314. package/src/services/catalog-refresh.ts +0 -201
  315. package/src/services/doctor-sources.ts +0 -76
  316. package/src/services/runs-page.ts +0 -255
  317. package/src/services/source-derive.ts +0 -374
  318. package/src/services/source-sync.ts +0 -305
  319. package/src/services/standins.ts +0 -204
  320. package/src/services/treat-likes.ts +0 -97
@@ -37,11 +37,6 @@ const HAIKU = "claude-code:claude-haiku-4-5-20251001#default";
37
37
  /** spec 1.3 §4.6: `auto` has no family; only the live kit and the capture run it */
38
38
  const AUTO = "cursor:auto#default";
39
39
  const BUNNY = "opencode:opencode/space-bunny-free#default";
40
- /** spec 1.3 §5.6: grok 1.0.44's default model (research §3.4) at its cheapest effort */
41
- const GROK = "grok:grok-4.6#low";
42
- /** spec 1.3 §6.6: the cheapest Gemini effort; capture runs isolated, on GEMINI_API_KEY */
43
- const FLASH_LOW = "antigravity:gemini-3.8-flash#low";
44
-
45
40
  /** Spec §11.7–8: one cheap run per backend, plus a read-only role trying to write where enforcement is advisory. */
46
41
  const CAPTURE_CASES: CaptureCase[] = [
47
42
  { backend: "codex", name: "ok", rung: "codex:gpt-6-luna#low", access: "read-only", brief: SAY_HELLO },
@@ -53,22 +48,6 @@ const CAPTURE_CASES: CaptureCase[] = [
53
48
  { backend: "cursor", name: "ok", rung: AUTO, access: "workspace-write", brief: WORK },
54
49
  { backend: "cursor", name: "resume", rung: AUTO, access: "read-only", brief: SAY_HELLO, resume: RECALL },
55
50
  { backend: "cursor", name: "read-only-write", rung: AUTO, access: "read-only", brief: TRY_WRITE },
56
- // spec 1.3 §5.6: the same three on grok; its read-only is the kernel profile, or the read tools on a Mac
57
- // with a symlinked docker socket
58
- { backend: "grok", name: "ok", rung: GROK, access: "workspace-write", brief: WORK },
59
- { backend: "grok", name: "resume", rung: GROK, access: "read-only", brief: SAY_HELLO, resume: RECALL },
60
- { backend: "grok", name: "read-only-write", rung: GROK, access: "read-only", brief: TRY_WRITE },
61
- // spec 1.3 §6.6: the same three on agy; read-only runs under catherd's deny rules (research §8 agy 2, 5, 6)
62
- { backend: "antigravity", name: "ok", rung: FLASH_LOW, access: "workspace-write", brief: WORK },
63
- {
64
- backend: "antigravity",
65
- name: "resume",
66
- rung: FLASH_LOW,
67
- access: "read-only",
68
- brief: SAY_HELLO,
69
- resume: RECALL,
70
- },
71
- { backend: "antigravity", name: "read-only-write", rung: FLASH_LOW, access: "read-only", brief: TRY_WRITE },
72
51
  ];
73
52
  export const CAPTURE_BACKENDS = [...new Set(CAPTURE_CASES.map((c) => c.backend))];
74
53
 
@@ -122,7 +101,7 @@ async function runOnce(
122
101
  const p = Bun.spawn([plan.cmd, ...plan.args], {
123
102
  cwd: plan.cwd,
124
103
  env: workerEnv(process.env, plan.env, plan.cwd),
125
- // spec 1.3 §3.3: the brief goes on stdin only to a CLI whose plan reads it there (grok reads a file)
104
+ // spec 1.3 §3.3: the brief goes on stdin only to a CLI whose plan reads it there (one reads a file)
126
105
  stdin: plan.stdinPath ? Bun.file(plan.stdinPath) : "ignore",
127
106
  stdout: "pipe",
128
107
  stderr: "pipe",
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import type { DiscoveredModel } from "../adapters/backend.ts";
4
4
  import { discovered, listingRepo, readDiscovery, writeDiscovery } from "../adapters/discovery.ts";
@@ -7,55 +7,32 @@ import "../adapters/all.ts";
7
7
  import {
8
8
  buildCatalog,
9
9
  type Catalog,
10
- CanonicalRung,
11
10
  capableFor,
12
- DIMS,
13
11
  type Family,
14
12
  type ModelsFile,
15
13
  ModelsFileSchema,
16
- type Override,
17
- OverrideSchema,
14
+ type Pair,
18
15
  rungInfo,
19
- type ScoresFile,
20
- ScoresFileSchema,
21
- scoresOf,
22
16
  } from "../domain/catalog.ts";
23
- import { costOf, DEFAULT_BILLING, type BillingMode } from "../domain/cost.ts";
24
- import { CatherdError, errorMessage } from "../domain/errors.ts";
17
+ import { type BillingMode, type Cost, costOf, DEFAULT_BILLING } from "../domain/cost.ts";
18
+ import { errorMessage } from "../domain/errors.ts";
25
19
  import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
26
- import type { Kind } from "../domain/lane.ts";
27
- import { routeAt } from "../domain/route.ts";
28
20
  import { ROLES, type Role } from "../domain/roles.ts";
29
- import { median } from "../domain/util.ts";
30
21
  import { assetPath } from "../infra/assets.ts";
31
- import { withFileLock } from "../infra/filelock.ts";
32
22
  import { log } from "../infra/log.ts";
33
- import { configDir } from "../infra/paths.ts";
34
- import { readDerived } from "../infra/sources/cache.ts";
35
- import { ensurePrivateDir, readVersioned, writeJsonAtomic } from "../infra/store.ts";
23
+ import { withFileLock } from "../infra/filelock.ts";
24
+ import { indexPath } from "../infra/paths.ts";
25
+ import { type IndexFile, IndexFileSchema } from "../domain/slopalytics.ts";
26
+ import { fetchProxy, readIndexFile, writeIndexFile } from "../infra/slopalytics.ts";
27
+ import { PRIVATE_DIR, readVersioned } from "../infra/store.ts";
36
28
  import type { CatalogFilter } from "./ports.ts";
37
- import { listRuns, readAgentRuns, readRecords, readRoutes } from "./run-store.ts";
38
- import { valuesUsed } from "./provenance.ts";
39
29
  import { type EvidenceTable, evidenceLine, evidenceOf, runEvidence } from "./run-evidence.ts";
40
- import { withStandIns } from "./standins.ts";
41
30
 
42
31
  const DAY_MS = 24 * 3_600_000;
43
- /** Spec §5.2: `secs_per_task` counts once a rung has this many of the user's own runs. */
44
- const MIN_SAMPLES = 5;
45
32
 
46
33
  let models: ModelsFile | null = null;
47
- let scores: ScoresFile | null = null;
48
34
  export const shippedModels = (): ModelsFile =>
49
35
  (models ??= readVersioned(assetPath("catalog/models.json"), ModelsFileSchema, 1));
50
- export const shippedScores = (): ScoresFile =>
51
- (scores ??= readVersioned(assetPath("catalog/scores.json"), ScoresFileSchema, 1));
52
-
53
- export const overridePath = (): string => join(configDir(), "catalog.override.json");
54
-
55
- export function readOverride(): Override {
56
- const file = overridePath();
57
- return existsSync(file) ? readVersioned(file, OverrideSchema, 1) : OverrideSchema.parse({});
58
- }
59
36
 
60
37
  /**
61
38
  * Every backend's last listing (spec §3.5 `<data>/discovery/<backend>.json`); for a backend listed per
@@ -70,75 +47,36 @@ function listedModels(repo?: string): Catalog["listed"] {
70
47
  return out;
71
48
  }
72
49
 
50
+ const RUN_INDEX = "slopalytics.json";
51
+
73
52
  /**
74
- * Spec §5.2: the median seconds of the user's own successful runs per canonical rung and lane kind
75
- * (`|*` over every kind), kept only with at least MIN_SAMPLES runs. A run counts under the kind of its
76
- * lane's route in force when it started; a run with none counts only under `|*`. Native `claude:`
77
- * subagent runs (agents.jsonl, from record_agent_run) count too; one that names no lane counts only
78
- * under `|*`.
53
+ * The shipped families on the Slopalytics index (the run's own pin when `runDir` holds one, else the machine's),
54
+ * with every listing (in `repo` for a per-repo backend).
79
55
  */
80
- export function measuredSecs(base: Catalog): Catalog["secs"] {
81
- const groups = new Map<string, number[]>();
82
- const add = (k: string, secs: number) => groups.set(k, [...(groups.get(k) ?? []), secs]);
83
- const count = (
84
- routes: ReturnType<typeof readRoutes>,
85
- rung: string,
86
- secs: number,
87
- lane: string | null,
88
- startedAt: string,
89
- ) => {
90
- let canonical: string;
91
- try {
92
- canonical = rungInfo(base, rung).canonical;
93
- } catch {
94
- return;
95
- }
96
- // the kind the lane was routed as when this run started, not its newest route
97
- const kind: Kind | null = lane ? (routeAt(routes, lane, startedAt)?.kind ?? null) : null;
98
- add(`${canonical}|*`, secs);
99
- if (kind) add(`${canonical}|${kind}`, secs);
100
- };
101
- for (const run of listRuns().runs) {
102
- const routes = readRoutes(run);
103
- for (const r of readRecords(run).records)
104
- if (r.status === "ok") count(routes, r.rung, r.secs, r.lane, r.startedAt);
105
- for (const a of readAgentRuns(run)) {
106
- if (a.status !== "ok" || a.secs === null) continue;
107
- // a row is written when the subagent ends; it started `secs` earlier
108
- const lane = typeof a.lane === "string" ? a.lane : null;
109
- const end = Date.parse(a.at);
110
- // a duration past the Date range (record_agent_run takes any) keeps the end, not a RangeError
111
- const start = new Date(end - a.secs * 1000);
112
- const startedAt = Number.isNaN(start.getTime()) ? a.at : start.toISOString();
113
- count(routes, a.rung, a.secs, lane, startedAt);
114
- }
115
- }
116
- const secs: Catalog["secs"] = {};
117
- for (const [k, xs] of groups) if (xs.length >= MIN_SAMPLES) secs[k] = median(xs) as number;
118
- return secs;
56
+ export function loadCatalog(o: { repo?: string; runDir?: string } = {}): Catalog {
57
+ const pin = o.runDir === undefined ? null : join(o.runDir, RUN_INDEX);
58
+ // a missing pin falls back to the machine's index; one that exists but cannot be read is an error, not a fallback
59
+ const pinned = pin !== null && existsSync(pin) ? readVersioned(pin, IndexFileSchema, 1) : null;
60
+ return buildCatalog({
61
+ models: shippedModels(),
62
+ index: pinned ?? readIndexFile(indexPath()),
63
+ listed: listedModels(o.repo),
64
+ });
119
65
  }
120
66
 
121
67
  /**
122
- * The shipped catalog with every listing (in `repo` for a backend listed per repository), the user's
123
- * override (or `override` in its place: what a change to it would make) and, unless `timings: false`, own
124
- * timings.
68
+ * What a run starts on: the index refreshed if it is due, read once, with the catalog built on exactly that
69
+ * read, so the start check and the pin written after it cannot disagree.
125
70
  */
126
- export function loadCatalog(o: { timings?: boolean; repo?: string; override?: Override } = {}): Catalog {
127
- // spec 1.2 §3.2: whatever the last sync derived; without one (first run, offline), the shipped values alone
128
- const synced = readDerived();
129
- // spec 1.2 §6.1: every value a rung lacks on a dimension the bars use comes from its nearest stand-in
130
- const base = withStandIns(
131
- buildCatalog({
132
- models: shippedModels(),
133
- scores: shippedScores(),
134
- synced: synced?.scores,
135
- facts: synced?.facts,
136
- features: synced?.features,
137
- override: o.override ?? readOverride(),
138
- listed: listedModels(o.repo),
139
- }),
140
- );
141
- return o.timings === false ? base : { ...base, secs: measuredSecs(base) };
71
+ export async function startIndex(repo: string): Promise<{ catalog: Catalog; index: IndexFile | null }> {
72
+ await refreshIndex({ auto: true });
73
+ const index = readIndexFile(indexPath());
74
+ return { catalog: buildCatalog({ models: shippedModels(), index, listed: listedModels(repo) }), index };
75
+ }
76
+
77
+ /** Pins `index` into the run: later routes of the run read it, whatever the machine's index becomes. */
78
+ export function writeRunIndex(runDir: string, index: IndexFile | null): void {
79
+ if (index) writeIndexFile(join(runDir, RUN_INDEX), index);
142
80
  }
143
81
 
144
82
  export interface Refreshed {
@@ -228,142 +166,19 @@ export async function freshenDiscovery(rungs: string[], now = Date.now(), repo?:
228
166
  });
229
167
  }
230
168
 
231
- /** A rung given as `backend:model#effort` or as a canonical `model#effort`, in canonical form. */
232
- function canonicalOf(c: Catalog, rung: string): string {
233
- let canonical = rung;
234
- try {
235
- if (rung.includes(":")) canonical = rungInfo(c, rung).canonical;
236
- } catch {
237
- // a rung the catalog cannot resolve stays as given; the canonical check below refuses it
238
- }
239
- // a backend's own model id without its backend: the family that backend runs it for names it
240
- const hash = canonical.lastIndexOf("#");
241
- const model = canonical.slice(0, hash);
242
- if (hash > 0 && !c.families.some((f) => f.id === model)) {
243
- const family = c.families.find((f) => Object.values(f.on).some((m) => m?.id === model));
244
- if (family) canonical = `${family.id}${canonical.slice(hash)}`;
245
- }
246
- if (!CanonicalRung.safeParse(canonical).success)
247
- throw new CatherdError("E_INPUT_INVALID", `"${rung}" is not a rung`, {
248
- fix: "name it as backend:model#effort or model#effort, e.g. codex:gpt-6-sol#high",
249
- });
250
- return canonical;
251
- }
252
-
253
- /** Spec §5.2 "treat like": the user maps an unscored rung onto a scored one in catalog.override.json. */
254
- export async function saveTreatLike(rung: string, like: string): Promise<{ rung: string; like: string }> {
255
- const c = loadCatalog({ timings: false });
256
- const from = canonicalOf(c, rung);
257
- const to = canonicalOf(c, like);
258
- if (!c.scores[to])
259
- throw new CatherdError("E_CONFIG_INVALID", `${like} has no scores of its own to lend`, {
260
- fix: "treat it like a scored rung; catalog_query lists them",
261
- });
262
- if (from === to)
263
- throw new CatherdError("E_CONFIG_INVALID", `${rung} cannot be treated like itself`, {
264
- fix: "name a different, scored rung",
265
- });
266
- // a rung a sync scored on some dimensions may still borrow the others; one with every value `to` lends may not
267
- const own = c.scores[from] ?? {};
268
- if (DIMS.filter((d) => c.scores[to]?.[d]).every((d) => own[d]))
269
- throw new CatherdError(
270
- "E_CONFIG_INVALID",
271
- `${from} has scores of its own; a treat-like would not change it`,
272
- {
273
- fix: "treat-like maps only unscored rungs; catalog_query shows each rung's scores",
274
- },
275
- );
276
- ensurePrivateDir(dirname(overridePath()));
277
- await withFileLock(overridePath(), () => {
278
- const cur = readOverride();
279
- const next = { ...cur, schema: 1, treatLike: { ...cur.treatLike, [from]: to } };
280
- const checked = OverrideSchema.safeParse(next);
281
- if (!checked.success)
282
- throw new CatherdError(
283
- "E_CONFIG_INVALID",
284
- `the treat-like ${from} → ${to} would not be a valid override`,
285
- {
286
- fix: checked.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
287
- },
288
- );
289
- writeJsonAtomic(overridePath(), next);
290
- });
291
- return { rung: from, like: to };
292
- }
293
-
294
- /** A rung given as `backend:model#effort` or `model#effort`, as the canonical rung the override keys it by. */
295
- export const canonicalRung = (rung: string): string => canonicalOf(loadCatalog({ timings: false }), rung);
296
-
297
- /**
298
- * Spec 1.2 §6.4 `treat-like --clear|--reset`: removes the user's treat-likes for `rungs` (canonical), or every
299
- * one with `"all"`, from catalog.override.json; the shipped ones are not the user's to remove. Returns the
300
- * mappings removed. Leaves the override's other fields as they are.
301
- */
302
- export async function removeTreatLikes(rungs: string[] | "all"): Promise<[string, string][]> {
303
- if (!existsSync(overridePath())) return [];
304
- let removed: [string, string][] = [];
305
- await withFileLock(overridePath(), () => {
306
- const cur = readOverride();
307
- const gone = Object.entries(cur.treatLike).filter(([r]) => rungs === "all" || rungs.includes(r));
308
- removed = gone;
309
- if (gone.length === 0) return;
310
- const treatLike = Object.fromEntries(
311
- Object.entries(cur.treatLike).filter(([r]) => !gone.some(([g]) => g === r)),
312
- );
313
- writeJsonAtomic(overridePath(), { ...cur, schema: 1, treatLike });
314
- });
315
- return removed;
316
- }
317
-
318
169
  /** The backend a billing or harness key bills or isolates: `opencode-go` is a provider of the opencode backend. */
319
170
  export const backendOfKey = (key: string) => (key === "opencode-go" ? "opencode" : key);
320
171
 
321
- function rungRows(
322
- c: Catalog,
323
- backend: string,
324
- model: string,
325
- efforts: string[],
326
- billing: Partial<Record<string, BillingMode>>,
327
- evidence: EvidenceTable,
328
- ) {
329
- return (efforts.length ? efforts : ["default"]).map((effort) => {
330
- const rung = `${backend}:${model}#${effort}`;
331
- const info = rungInfo(c, rung);
332
- const s = scoresOf(c, info.canonical);
333
- const scored: Record<
334
- string,
335
- {
336
- value: number;
337
- benchmark: string;
338
- confidence: string;
339
- source: string;
340
- date: string;
341
- /** spec 1.2 §5.3: the rung a borrowed or inferred value belongs to */
342
- from?: string;
343
- }
344
- > = {};
345
- for (const v of valuesUsed(c, info.canonical))
346
- scored[v.dim] = {
347
- value: v.value,
348
- benchmark: v.benchmark,
349
- // a value lent by a treat-like or a stand-in is catherd's guess for this rung, whatever its own confidence
350
- confidence: v.inferred ? "inferred" : v.confidence,
351
- source: v.source,
352
- date: v.date,
353
- ...(v.from ? { from: v.from } : {}),
354
- };
355
- const like = c.treatLike[info.canonical] ?? null;
356
- return {
357
- rung,
358
- enabled: s !== null,
359
- ...(s === null ? { why: "unscored: map it with catherd catalog treat-like <rung> <scored rung>" } : {}),
360
- scores: scored,
361
- treatLike: s?.via ? like : null,
362
- cost: costOf(info.family, effort, billing[info.key] ?? DEFAULT_BILLING[info.key]),
363
- /** spec 1.2 §8: catherd's own runs on it, over every kind; never used for routing */
364
- evidence: evidenceLine(evidenceOf(evidence, info.canonical)),
365
- };
366
- });
172
+ /** One indexed pair as `catalog_query` and `catalog list` show it. */
173
+ export interface IndexedPair {
174
+ rung: string;
175
+ intelligence: number;
176
+ price: Pair["price"];
177
+ speed: number | null;
178
+ context: number | null;
179
+ cost: Cost;
180
+ /** spec 1.2 §8: catherd's own runs on it, over every kind; never used for routing */
181
+ evidence: string | null;
367
182
  }
368
183
 
369
184
  export interface CatalogModel {
@@ -373,70 +188,156 @@ export interface CatalogModel {
373
188
  model: string;
374
189
  billing: string;
375
190
  efforts: string[];
376
- context: number | null;
377
191
  capabilities: Family["capabilities"] | null;
378
192
  roles: Role[];
379
193
  listed: boolean | null;
380
194
  notes: Record<string, string>;
381
- /** spec 1.2 §4.1: the family's API price, dollars per million tokens, and its speed facts */
382
- price: Family["price"] | null;
383
- speed: Record<string, number>;
384
- rungs: ReturnType<typeof rungRows>;
195
+ rungs: IndexedPair[];
196
+ }
197
+
198
+ function pairsOf(
199
+ c: Catalog,
200
+ backend: string,
201
+ model: string,
202
+ billing: Partial<Record<string, BillingMode>>,
203
+ evidence: EvidenceTable,
204
+ ): IndexedPair[] {
205
+ const probe = rungInfo(c, `${backend}:${model}#default`);
206
+ const efforts = [...new Set([...probe.efforts, "default"])];
207
+ return efforts
208
+ .flatMap((effort) => {
209
+ const rung = `${backend}:${model}#${effort}`;
210
+ const info = rungInfo(c, rung);
211
+ const pair = info.pair;
212
+ if (!pair) return [];
213
+ return [
214
+ {
215
+ rung,
216
+ intelligence: pair.intelligence,
217
+ price: pair.price,
218
+ speed: pair.speed,
219
+ context: info.context,
220
+ cost: costOf(info.family, pair, effort, billing[info.key] ?? DEFAULT_BILLING[info.key]),
221
+ evidence: evidenceLine(evidenceOf(evidence, info.canonical)),
222
+ },
223
+ ];
224
+ })
225
+ .sort((a, b) => a.intelligence - b.intelligence);
385
226
  }
386
227
 
387
228
  /**
388
- * Spec §4.8 `catalog_query`: shipped families on each backend, plus listed models catherd cannot score;
389
- * a backend listed per repository as it is listed in `f.repo` (a git toplevel), as `route` reads it.
229
+ * Spec §4.8 `catalog_query`: the indexed pairs of the shipped families on each backend, and of the listed models
230
+ * catherd has no family for; a model with no indexed pair is not listed. A backend listed per repository as it
231
+ * is listed in `f.repo` (a git toplevel), as `route` reads it.
390
232
  */
391
233
  export function catalogQuery(
392
234
  f: CatalogFilter,
393
235
  billing: Partial<Record<string, BillingMode>> = {},
394
236
  ): { total: number; models: CatalogModel[] } {
395
- const c = loadCatalog({ timings: false, repo: f.repo });
237
+ const c = loadCatalog({ repo: f.repo });
396
238
  const evidence = runEvidence(c);
397
239
  const rows: CatalogModel[] = [];
398
240
  const known = new Set<string>();
399
- const entry = (backend: string, model: string, fam: Family | null): CatalogModel => {
241
+ const entry = (backend: string, model: string, fam: Family | null) => {
242
+ const rungs = pairsOf(c, backend, model, billing, evidence);
243
+ if (rungs.length === 0) return;
400
244
  const probe = rungInfo(c, `${backend}:${model}#default`);
401
- return {
245
+ rows.push({
402
246
  id: fam?.id ?? model,
403
247
  name: fam?.name ?? null,
404
248
  backend,
405
249
  model,
406
250
  billing: probe.key,
407
- efforts: probe.efforts,
408
- context: probe.context,
251
+ efforts: rungs.map((r) => r.rung.slice(r.rung.lastIndexOf("#") + 1)),
409
252
  capabilities: fam?.capabilities ?? null,
410
253
  roles: ROLES.filter((r) => capableFor(c, probe, r)),
411
254
  listed: probe.listed,
412
255
  notes: fam?.notes ?? {},
413
- price: fam?.price ?? null,
414
- speed: fam?.speed ?? {},
415
- rungs: rungRows(c, backend, model, probe.efforts, billing, evidence),
416
- };
256
+ rungs,
257
+ });
417
258
  };
418
259
  for (const fam of c.families)
419
260
  for (const [key, on] of Object.entries(fam.on)) {
420
261
  const backends = key === "claude-code" ? ["claude", "claude-code"] : [backendOfKey(key)];
421
- for (const b of backends) rows.push(entry(b, on.id, fam));
262
+ for (const b of backends) entry(b, on.id, fam);
422
263
  known.add(`${backendOfKey(key)}:${on.id}`);
423
264
  }
424
265
  for (const [backend, l] of Object.entries(c.listed))
425
- for (const m of l.models) if (!known.has(`${backend}:${m.id}`)) rows.push(entry(backend, m.id, null));
266
+ for (const m of l.models) if (!known.has(`${backend}:${m.id}`)) entry(backend, m.id, null);
426
267
  const needle = f.text?.toLowerCase();
427
268
  const kept = rows
428
269
  .filter(
429
270
  (m) =>
430
271
  (!f.role || m.roles.includes(f.role)) &&
431
272
  (!f.backend || m.backend === f.backend || m.billing === f.backend) &&
432
- (!needle || `${m.id} ${m.model} ${m.name ?? ""}`.toLowerCase().includes(needle)) &&
433
- (!f.scoredOnly || m.rungs.some((r) => r.enabled)),
273
+ (!needle || `${m.id} ${m.model} ${m.name ?? ""}`.toLowerCase().includes(needle)),
434
274
  )
435
- .sort(
436
- (a, b) =>
437
- Number(b.rungs.some((r) => r.enabled)) - Number(a.rungs.some((r) => r.enabled)) ||
438
- a.id.localeCompare(b.id) ||
439
- a.backend.localeCompare(b.backend),
440
- );
275
+ .sort((a, b) => a.id.localeCompare(b.id) || a.backend.localeCompare(b.backend));
441
276
  return { total: kept.length, models: kept.slice(0, f.limit) };
442
277
  }
278
+
279
+ export interface IndexStatus {
280
+ fetchedAt: string | null;
281
+ checkedAt: string | null;
282
+ pairs: number;
283
+ error: string | null;
284
+ }
285
+
286
+ /** How long a fetched index is trusted before the next automatic refresh. */
287
+ export const INDEX_TTL_MS = 3_600_000;
288
+
289
+ export function indexStatus(): IndexStatus {
290
+ const f = readIndexFile(indexPath());
291
+ return {
292
+ fetchedAt: f?.fetchedAt ?? null,
293
+ checkedAt: f?.checkedAt ?? null,
294
+ pairs: f?.models.length ?? 0,
295
+ error: f?.error?.message ?? null,
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Keeps `<data>/slopalytics.json` fresh: fetched when its last check is over an hour old (or `force`), the last
301
+ * good answer kept when the proxy fails and the error recorded beside it. Never throws. An `auto` refresh
302
+ * (MCP boot, `init`, run start) fetches nothing under `CATHERD_NO_SYNC=1`.
303
+ */
304
+ export async function refreshIndex(
305
+ o: { force?: boolean; auto?: boolean; fetch?: typeof fetch; now?: number } = {},
306
+ ): Promise<IndexStatus> {
307
+ if (o.auto && process.env.CATHERD_NO_SYNC === "1") return indexStatus();
308
+ const now = o.now ?? Date.now();
309
+ // read, fetch and write run under one lock: a refresh that fails reads the file as the one before it left it
310
+ try {
311
+ mkdirSync(dirname(indexPath()), { recursive: true, mode: PRIVATE_DIR });
312
+ await withFileLock(indexPath(), () => refreshLocked(o, now), { timeoutMs: 30_000 });
313
+ } catch (e) {
314
+ log("debug", "catalog", { index: errorMessage(e) });
315
+ }
316
+ return indexStatus();
317
+ }
318
+
319
+ async function refreshLocked(o: { force?: boolean; fetch?: typeof fetch }, now: number): Promise<void> {
320
+ const prev = readIndexFile(indexPath());
321
+ if (!o.force && prev && now - Date.parse(prev.checkedAt) < INDEX_TTL_MS) return;
322
+ const at = new Date(now).toISOString();
323
+ try {
324
+ const got = await fetchProxy({ fetch: o.fetch });
325
+ writeIndexFile(indexPath(), {
326
+ schema: 1,
327
+ checkedAt: at,
328
+ fetchedAt: got.fetchedAt,
329
+ error: null,
330
+ models: got.models,
331
+ });
332
+ } catch (e) {
333
+ const message = errorMessage(e);
334
+ log("debug", "catalog", { index: message });
335
+ writeIndexFile(indexPath(), {
336
+ schema: 1,
337
+ checkedAt: at,
338
+ fetchedAt: prev?.fetchedAt ?? null,
339
+ error: { at, message },
340
+ models: prev?.models ?? [],
341
+ });
342
+ }
343
+ }
@@ -1,23 +1,22 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { CatherdError, isCatherdError } from "../domain/errors.ts";
5
- import { log } from "../infra/log.ts";
5
+ import { withFileLockSync } from "../infra/filelock.ts";
6
6
  import { configDir } from "../infra/paths.ts";
7
7
  import { readVersioned, writeJsonAtomic } from "../infra/store.ts";
8
8
 
9
- // Spec §10.4 and 1.2 §9: `<config>/credentials.json`, mode 600, holds catherd's own keys: Jev's
10
- // (`typesafeApiKey`) and Artificial Analysis's (`artificialAnalysisApiKey`). The schema is a loose object,
11
- // so a key one catherd does not know survives another's rewrite.
9
+ // Spec §10.4: `<config>/credentials.json`, mode 600, holds catherd's own key, Jev's (`typesafeApiKey`). The
10
+ // schema is a loose object, so a key one catherd does not know (a 1.5 file's `artificialAnalysisApiKey`)
11
+ // survives another's rewrite.
12
12
 
13
13
  export const credentialsPath = (): string => join(configDir(), "credentials.json");
14
14
 
15
15
  const CredentialsSchema = z.looseObject({
16
16
  schema: z.literal(1).default(1),
17
17
  typesafeApiKey: z.string().optional(),
18
- artificialAnalysisApiKey: z.string().optional(),
19
18
  });
20
- export type CredentialField = "typesafeApiKey" | "artificialAnalysisApiKey";
19
+ export type CredentialField = "typesafeApiKey";
21
20
 
22
21
  /** How to repair a credentials file catherd cannot read: `init` alone refuses to overwrite it. */
23
22
  const credentialsFix = (): string =>
@@ -45,25 +44,15 @@ export function savedCredential(field: CredentialField): {
45
44
 
46
45
  /**
47
46
  * Keeps every other credential, and the file at mode 600 (spec §10.4). A missing file starts empty; an
48
- * unreadable or newer-schema one is refused (it throws) rather than overwritten.
47
+ * unreadable or newer-schema one is refused (it throws) rather than overwritten. The read and the write hold
48
+ * the file's lock (1.2 minor), so two concurrent `init`s never lose a key.
49
49
  */
50
50
  export function saveCredential(field: CredentialField, key: string): void {
51
- const cur: z.infer<typeof CredentialsSchema> = existsSync(credentialsPath())
52
- ? readCredentials()
53
- : { schema: 1 };
54
- writeJsonAtomic(credentialsPath(), { ...cur, schema: 1, [field]: key.trim() }, { mode: 0o600 });
51
+ mkdirSync(configDir(), { recursive: true });
52
+ withFileLockSync(credentialsPath(), () => {
53
+ const cur: z.infer<typeof CredentialsSchema> = existsSync(credentialsPath())
54
+ ? readCredentials()
55
+ : { schema: 1 };
56
+ writeJsonAtomic(credentialsPath(), { ...cur, schema: 1, [field]: key.trim() }, { mode: 0o600 });
57
+ });
55
58
  }
56
-
57
- /**
58
- * Spec 1.2 §9: `ARTIFICIAL_ANALYSIS_API_KEY`, else the saved key; null when neither has one. The key is
59
- * optional, so a credentials file that cannot be read means no key here; it is logged.
60
- */
61
- export function aaKey(env: Record<string, string | undefined> = process.env): string | null {
62
- const fromEnv = env.ARTIFICIAL_ANALYSIS_API_KEY?.trim();
63
- if (fromEnv) return fromEnv;
64
- const { key, problem } = savedCredential("artificialAnalysisApiKey");
65
- if (problem) log("warn", "sources", { error: `no Artificial Analysis key: ${problem.message}` });
66
- return key;
67
- }
68
-
69
- export const saveAaKey = (key: string): void => saveCredential("artificialAnalysisApiKey", key);