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
@@ -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,80 +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
- // a run whose files cannot be read gives no timings; it never fails a route (1.2 minor)
103
- try {
104
- const routes = readRoutes(run);
105
- for (const r of readRecords(run).records)
106
- if (r.status === "ok") count(routes, r.rung, r.secs, r.lane, r.startedAt);
107
- for (const a of readAgentRuns(run)) {
108
- if (a.status !== "ok" || a.secs === null) continue;
109
- // a row is written when the subagent ends; it started `secs` earlier
110
- const lane = typeof a.lane === "string" ? a.lane : null;
111
- const end = Date.parse(a.at);
112
- // a duration past the Date range (record_agent_run takes any) keeps the end, not a RangeError
113
- const start = new Date(end - a.secs * 1000);
114
- const startedAt = Number.isNaN(start.getTime()) ? a.at : start.toISOString();
115
- count(routes, a.rung, a.secs, lane, startedAt);
116
- }
117
- } catch (e) {
118
- log("debug", "catalog", { run: run.id, timings: errorMessage(e) });
119
- }
120
- }
121
- const secs: Catalog["secs"] = {};
122
- for (const [k, xs] of groups) if (xs.length >= MIN_SAMPLES) secs[k] = median(xs) as number;
123
- 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
+ });
124
65
  }
125
66
 
126
67
  /**
127
- * The shipped catalog with every listing (in `repo` for a backend listed per repository), the user's
128
- * override (or `override` in its place: what a change to it would make) and, unless `timings: false`, own
129
- * 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.
130
70
  */
131
- export function loadCatalog(o: { timings?: boolean; repo?: string; override?: Override } = {}): Catalog {
132
- // spec 1.2 §3.2: whatever the last sync derived; without one (first run, offline), the shipped values alone
133
- const synced = readDerived();
134
- // spec 1.2 §6.1: every value a rung lacks on a dimension the bars use comes from its nearest stand-in
135
- const base = withStandIns(
136
- buildCatalog({
137
- models: shippedModels(),
138
- scores: shippedScores(),
139
- synced: synced?.scores,
140
- facts: synced?.facts,
141
- features: synced?.features,
142
- override: o.override ?? readOverride(),
143
- listed: listedModels(o.repo),
144
- }),
145
- );
146
- 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);
147
80
  }
148
81
 
149
82
  export interface Refreshed {
@@ -233,145 +166,19 @@ export async function freshenDiscovery(rungs: string[], now = Date.now(), repo?:
233
166
  });
234
167
  }
235
168
 
236
- /** A rung given as `backend:model#effort` or as a canonical `model#effort`, in canonical form. */
237
- function canonicalOf(c: Catalog, rung: string): string {
238
- let canonical = rung;
239
- try {
240
- if (rung.includes(":")) canonical = rungInfo(c, rung).canonical;
241
- } catch {
242
- // a rung the catalog cannot resolve stays as given; the canonical check below refuses it
243
- }
244
- // a backend's own model id without its backend: the family that backend runs it for names it
245
- const hash = canonical.lastIndexOf("#");
246
- const model = canonical.slice(0, hash);
247
- if (hash > 0 && !c.families.some((f) => f.id === model)) {
248
- const family = c.families.find((f) => Object.values(f.on).some((m) => m?.id === model));
249
- if (family) canonical = `${family.id}${canonical.slice(hash)}`;
250
- }
251
- if (!CanonicalRung.safeParse(canonical).success)
252
- throw new CatherdError("E_INPUT_INVALID", `"${rung}" is not a rung`, {
253
- fix: "name it as backend:model#effort or model#effort, e.g. codex:gpt-6-sol#high",
254
- });
255
- return canonical;
256
- }
257
-
258
- /** Spec §5.2 "treat like": the user maps an unscored rung onto a scored one in catalog.override.json. */
259
- export async function saveTreatLike(rung: string, like: string): Promise<{ rung: string; like: string }> {
260
- const c = loadCatalog({ timings: false });
261
- const from = canonicalOf(c, rung);
262
- const to = canonicalOf(c, like);
263
- if (!c.scores[to])
264
- throw new CatherdError("E_CONFIG_INVALID", `${like} has no scores of its own to lend`, {
265
- fix: "treat it like a scored rung; catalog_query lists them",
266
- });
267
- if (from === to)
268
- throw new CatherdError("E_CONFIG_INVALID", `${rung} cannot be treated like itself`, {
269
- fix: "name a different, scored rung",
270
- });
271
- // a rung a sync scored on some dimensions may still borrow the others; one with every value `to` lends may not
272
- const own = c.scores[from] ?? {};
273
- if (DIMS.filter((d) => c.scores[to]?.[d]).every((d) => own[d]))
274
- throw new CatherdError(
275
- "E_CONFIG_INVALID",
276
- `${from} has scores of its own; a treat-like would not change it`,
277
- {
278
- fix: "treat-like maps only unscored rungs; catalog_query shows each rung's scores",
279
- },
280
- );
281
- ensurePrivateDir(dirname(overridePath()));
282
- await withFileLock(overridePath(), () => {
283
- const cur = readOverride();
284
- const next = { ...cur, schema: 1, treatLike: { ...cur.treatLike, [from]: to } };
285
- const checked = OverrideSchema.safeParse(next);
286
- if (!checked.success)
287
- throw new CatherdError(
288
- "E_CONFIG_INVALID",
289
- `the treat-like ${from} → ${to} would not be a valid override`,
290
- {
291
- fix: checked.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
292
- },
293
- );
294
- writeJsonAtomic(overridePath(), next);
295
- });
296
- return { rung: from, like: to };
297
- }
298
-
299
- /** A rung given as `backend:model#effort` or `model#effort`, as the canonical rung the override keys it by. */
300
- export const canonicalRung = (rung: string): string => canonicalOf(loadCatalog({ timings: false }), rung);
301
-
302
- /**
303
- * Spec 1.2 §6.4 `treat-like --clear|--reset`: removes the user's treat-likes for `rungs` (canonical), or every
304
- * one with `"all"`, from catalog.override.json; the shipped ones are not the user's to remove. Returns the
305
- * mappings removed. Leaves the override's other fields as they are.
306
- */
307
- export async function removeTreatLikes(rungs: string[] | "all"): Promise<[string, string][]> {
308
- if (!existsSync(overridePath())) return [];
309
- let removed: [string, string][] = [];
310
- await withFileLock(overridePath(), () => {
311
- const cur = readOverride();
312
- const gone = Object.entries(cur.treatLike).filter(([r]) => rungs === "all" || rungs.includes(r));
313
- removed = gone;
314
- if (gone.length === 0) return;
315
- const treatLike = Object.fromEntries(
316
- Object.entries(cur.treatLike).filter(([r]) => !gone.some(([g]) => g === r)),
317
- );
318
- writeJsonAtomic(overridePath(), { ...cur, schema: 1, treatLike });
319
- });
320
- return removed;
321
- }
322
-
323
169
  /** The backend a billing or harness key bills or isolates: `opencode-go` is a provider of the opencode backend. */
324
170
  export const backendOfKey = (key: string) => (key === "opencode-go" ? "opencode" : key);
325
171
 
326
- function rungRows(
327
- c: Catalog,
328
- backend: string,
329
- model: string,
330
- efforts: string[],
331
- billing: Partial<Record<string, BillingMode>>,
332
- evidence: EvidenceTable,
333
- ) {
334
- return (efforts.length ? efforts : ["default"]).map((effort) => {
335
- const rung = `${backend}:${model}#${effort}`;
336
- const info = rungInfo(c, rung);
337
- const s = scoresOf(c, info.canonical);
338
- const scored: Record<
339
- string,
340
- {
341
- value: number;
342
- benchmark: string;
343
- confidence: string;
344
- source: string;
345
- date: string;
346
- /** spec 1.2 §5.3: the rung a borrowed or inferred value belongs to */
347
- from?: string;
348
- /** what lent it: a treat-like's mapping (`like X`) or an inferred stand-in's guess (1.2 minor) */
349
- lent?: "treat-like" | "stand-in";
350
- }
351
- > = {};
352
- for (const v of valuesUsed(c, info.canonical))
353
- scored[v.dim] = {
354
- value: v.value,
355
- benchmark: v.benchmark,
356
- // a value lent by a treat-like or a stand-in is catherd's guess for this rung, whatever its own confidence
357
- confidence: v.inferred ? "inferred" : v.confidence,
358
- source: v.source,
359
- date: v.date,
360
- ...(v.from ? { from: v.from } : {}),
361
- ...(v.lent ? { lent: v.lent } : {}),
362
- };
363
- const like = c.treatLike[info.canonical] ?? null;
364
- return {
365
- rung,
366
- enabled: s !== null,
367
- ...(s === null ? { why: "unscored: map it with catherd catalog treat-like <rung> <scored rung>" } : {}),
368
- scores: scored,
369
- treatLike: s?.via ? like : null,
370
- cost: costOf(info.family, effort, billing[info.key] ?? DEFAULT_BILLING[info.key]),
371
- /** spec 1.2 §8: catherd's own runs on it, over every kind; never used for routing */
372
- evidence: evidenceLine(evidenceOf(evidence, info.canonical)),
373
- };
374
- });
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;
375
182
  }
376
183
 
377
184
  export interface CatalogModel {
@@ -381,70 +188,156 @@ export interface CatalogModel {
381
188
  model: string;
382
189
  billing: string;
383
190
  efforts: string[];
384
- context: number | null;
385
191
  capabilities: Family["capabilities"] | null;
386
192
  roles: Role[];
387
193
  listed: boolean | null;
388
194
  notes: Record<string, string>;
389
- /** spec 1.2 §4.1: the family's API price, dollars per million tokens, and its speed facts */
390
- price: Family["price"] | null;
391
- speed: Record<string, number>;
392
- 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);
393
226
  }
394
227
 
395
228
  /**
396
- * Spec §4.8 `catalog_query`: shipped families on each backend, plus listed models catherd cannot score;
397
- * 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.
398
232
  */
399
233
  export function catalogQuery(
400
234
  f: CatalogFilter,
401
235
  billing: Partial<Record<string, BillingMode>> = {},
402
236
  ): { total: number; models: CatalogModel[] } {
403
- const c = loadCatalog({ timings: false, repo: f.repo });
237
+ const c = loadCatalog({ repo: f.repo });
404
238
  const evidence = runEvidence(c);
405
239
  const rows: CatalogModel[] = [];
406
240
  const known = new Set<string>();
407
- 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;
408
244
  const probe = rungInfo(c, `${backend}:${model}#default`);
409
- return {
245
+ rows.push({
410
246
  id: fam?.id ?? model,
411
247
  name: fam?.name ?? null,
412
248
  backend,
413
249
  model,
414
250
  billing: probe.key,
415
- efforts: probe.efforts,
416
- context: probe.context,
251
+ efforts: rungs.map((r) => r.rung.slice(r.rung.lastIndexOf("#") + 1)),
417
252
  capabilities: fam?.capabilities ?? null,
418
253
  roles: ROLES.filter((r) => capableFor(c, probe, r)),
419
254
  listed: probe.listed,
420
255
  notes: fam?.notes ?? {},
421
- price: fam?.price ?? null,
422
- speed: fam?.speed ?? {},
423
- rungs: rungRows(c, backend, model, probe.efforts, billing, evidence),
424
- };
256
+ rungs,
257
+ });
425
258
  };
426
259
  for (const fam of c.families)
427
260
  for (const [key, on] of Object.entries(fam.on)) {
428
261
  const backends = key === "claude-code" ? ["claude", "claude-code"] : [backendOfKey(key)];
429
- for (const b of backends) rows.push(entry(b, on.id, fam));
262
+ for (const b of backends) entry(b, on.id, fam);
430
263
  known.add(`${backendOfKey(key)}:${on.id}`);
431
264
  }
432
265
  for (const [backend, l] of Object.entries(c.listed))
433
- 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);
434
267
  const needle = f.text?.toLowerCase();
435
268
  const kept = rows
436
269
  .filter(
437
270
  (m) =>
438
271
  (!f.role || m.roles.includes(f.role)) &&
439
272
  (!f.backend || m.backend === f.backend || m.billing === f.backend) &&
440
- (!needle || `${m.id} ${m.model} ${m.name ?? ""}`.toLowerCase().includes(needle)) &&
441
- (!f.scoredOnly || m.rungs.some((r) => r.enabled)),
273
+ (!needle || `${m.id} ${m.model} ${m.name ?? ""}`.toLowerCase().includes(needle)),
442
274
  )
443
- .sort(
444
- (a, b) =>
445
- Number(b.rungs.some((r) => r.enabled)) - Number(a.rungs.some((r) => r.enabled)) ||
446
- a.id.localeCompare(b.id) ||
447
- a.backend.localeCompare(b.backend),
448
- );
275
+ .sort((a, b) => a.id.localeCompare(b.id) || a.backend.localeCompare(b.backend));
449
276
  return { total: kept.length, models: kept.slice(0, f.limit) };
450
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
+ }
@@ -3,22 +3,20 @@ import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { CatherdError, isCatherdError } from "../domain/errors.ts";
5
5
  import { withFileLockSync } from "../infra/filelock.ts";
6
- import { log } from "../infra/log.ts";
7
6
  import { configDir } from "../infra/paths.ts";
8
7
  import { readVersioned, writeJsonAtomic } from "../infra/store.ts";
9
8
 
10
- // Spec §10.4 and 1.2 §9: `<config>/credentials.json`, mode 600, holds catherd's own keys: Jev's
11
- // (`typesafeApiKey`) and Artificial Analysis's (`artificialAnalysisApiKey`). The schema is a loose object,
12
- // 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.
13
12
 
14
13
  export const credentialsPath = (): string => join(configDir(), "credentials.json");
15
14
 
16
15
  const CredentialsSchema = z.looseObject({
17
16
  schema: z.literal(1).default(1),
18
17
  typesafeApiKey: z.string().optional(),
19
- artificialAnalysisApiKey: z.string().optional(),
20
18
  });
21
- export type CredentialField = "typesafeApiKey" | "artificialAnalysisApiKey";
19
+ export type CredentialField = "typesafeApiKey";
22
20
 
23
21
  /** How to repair a credentials file catherd cannot read: `init` alone refuses to overwrite it. */
24
22
  const credentialsFix = (): string =>
@@ -58,17 +56,3 @@ export function saveCredential(field: CredentialField, key: string): void {
58
56
  writeJsonAtomic(credentialsPath(), { ...cur, schema: 1, [field]: key.trim() }, { mode: 0o600 });
59
57
  });
60
58
  }
61
-
62
- /**
63
- * Spec 1.2 §9: `ARTIFICIAL_ANALYSIS_API_KEY`, else the saved key; null when neither has one. The key is
64
- * optional, so a credentials file that cannot be read means no key here; it is logged.
65
- */
66
- export function aaKey(env: Record<string, string | undefined> = process.env): string | null {
67
- const fromEnv = env.ARTIFICIAL_ANALYSIS_API_KEY?.trim();
68
- if (fromEnv) return fromEnv;
69
- const { key, problem } = savedCredential("artificialAnalysisApiKey");
70
- if (problem) log("warn", "sources", { error: `no Artificial Analysis key: ${problem.message}` });
71
- return key;
72
- }
73
-
74
- export const saveAaKey = (key: string): void => saveCredential("artificialAnalysisApiKey", key);