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
@@ -0,0 +1,197 @@
1
+ import { z } from "zod";
2
+ import { CatherdError } from "./errors.ts";
3
+
4
+ export const SCORECARD_TASKS = [
5
+ "raqam-myanmar",
6
+ "catherd-findings",
7
+ "sanitell-payment-3",
8
+ "injected-lao",
9
+ ] as const;
10
+ export type ScorecardTaskId = (typeof SCORECARD_TASKS)[number];
11
+ export const INJECTED_TASK: ScorecardTaskId = "injected-lao";
12
+
13
+ /** The spec's "no worse than 1.5", read as the four tasks' summed wall-clock within 10 % (plan 28, ruling 6). */
14
+ export const WALL_TOLERANCE = 0.1;
15
+
16
+ const text = z.string().min(1);
17
+
18
+ export const ScorecardTaskSchema = z.strictObject({
19
+ schema: z.literal(1),
20
+ id: z.enum(SCORECARD_TASKS),
21
+ title: text,
22
+ goal: text,
23
+ source: text.nullable(),
24
+ commit: z.string().regex(/^[0-9a-f]{40}$/),
25
+ plan: z.union([z.strictObject({ file: text }), z.strictObject({ repoPath: text })]),
26
+ inject: z
27
+ .strictObject({
28
+ message: text,
29
+ edits: z.array(z.strictObject({ path: text, find: text, replace: z.string() })).min(1),
30
+ })
31
+ .nullable()
32
+ .default(null),
33
+ limits: z.number().int().min(0).default(0),
34
+ setup: z.array(text).default([]),
35
+ acceptance: text,
36
+ acceptanceTimeoutMin: z.number().int().positive(),
37
+ acceptFiles: z.array(z.strictObject({ from: text, to: text })).default([]),
38
+ prompt: z.string().includes("{plan}"),
39
+ });
40
+ export type ScorecardTask = z.infer<typeof ScorecardTaskSchema>;
41
+
42
+ export interface Interventions {
43
+ parks: number;
44
+ answers: number;
45
+ takeovers: number;
46
+ manualDispatches: number;
47
+ pauses: number;
48
+ superseded: number;
49
+ chat: number;
50
+ /** the owner's cancel requests (a daemon run) */
51
+ cancels: number;
52
+ /** the owner's plan approvals, not a plan given as input (a daemon run) */
53
+ approvals: number;
54
+ total: number;
55
+ }
56
+
57
+ export interface QuotaRow {
58
+ backend: string;
59
+ runs: number;
60
+ input: number;
61
+ cached: number;
62
+ output: number;
63
+ limits: number;
64
+ }
65
+
66
+ export interface TaskScore {
67
+ task: ScorecardTaskId;
68
+ run: string;
69
+ catherdVersion: string;
70
+ startCommit: string;
71
+ head: string | null;
72
+ milestones: string[];
73
+ landed: string[];
74
+ finished: boolean;
75
+ interventions: Interventions;
76
+ reviewRounds: number;
77
+ lanes: { total: number; landed: number; climbs: number };
78
+ wallMinutes: number;
79
+ pausedMinutes: number;
80
+ quota: { backends: QuotaRow[]; claudeAgentTokens: number };
81
+ acceptance: { passed: boolean; code: number | null; timedOut: boolean; tail: string[] };
82
+ firstTry: boolean;
83
+ injected: { limits: number; standInOk: boolean } | null;
84
+ recordedAt: string;
85
+ }
86
+
87
+ export interface ScorecardFile {
88
+ schema: 1;
89
+ label: string;
90
+ tasks: Partial<Record<ScorecardTaskId, TaskScore>>;
91
+ }
92
+
93
+ export const ScorecardFileSchema = z.looseObject({
94
+ schema: z.literal(1),
95
+ label: z.string(),
96
+ tasks: z.record(z.string(), z.unknown()),
97
+ });
98
+
99
+ const count = z.number().int().min(0);
100
+
101
+ /** The fields `passBar` and the comparison table read; the rest of a row is carried untouched. */
102
+ const TaskScoreRowSchema = z.looseObject({
103
+ catherdVersion: z.string(),
104
+ finished: z.boolean(),
105
+ interventions: z.looseObject({
106
+ parks: count,
107
+ answers: count,
108
+ takeovers: count,
109
+ manualDispatches: count,
110
+ pauses: count,
111
+ superseded: count,
112
+ chat: count,
113
+ total: count,
114
+ }),
115
+ reviewRounds: count,
116
+ wallMinutes: z.number().min(0),
117
+ quota: z.looseObject({
118
+ backends: z.array(z.looseObject({ input: z.number().min(0), output: z.number().min(0) })),
119
+ }),
120
+ firstTry: z.boolean(),
121
+ injected: z.looseObject({ limits: count, standInOk: z.boolean() }).nullable(),
122
+ });
123
+
124
+ /** Checks every row `passBar` consumes; a malformed one fails naming the label, the task and the field. */
125
+ export function assertScorecardRows(f: z.infer<typeof ScorecardFileSchema>, file: string): ScorecardFile {
126
+ for (const [task, row] of Object.entries(f.tasks)) {
127
+ const r = TaskScoreRowSchema.safeParse(row);
128
+ const issue = r.success ? null : r.error.issues[0];
129
+ const field = issue ? issue.path.join(".") || "row" : null;
130
+ if (!(SCORECARD_TASKS as readonly string[]).includes(task) || issue)
131
+ throw new CatherdError(
132
+ "E_INPUT_INVALID",
133
+ `scorecard ${f.label} (${file}): ${task}: ${field ?? "unknown task"}: ${issue?.message ?? "not one of the four tasks"}`,
134
+ { fix: `fix or delete ${file}, then record the task again` },
135
+ );
136
+ }
137
+ return f as unknown as ScorecardFile;
138
+ }
139
+
140
+ export function assertLabel(label: string): string {
141
+ if (!/^[a-z0-9][a-z0-9.-]{0,40}$/.test(label)) {
142
+ throw new CatherdError("E_INPUT_INVALID", `\`${label}\` is not a scorecard label`, {
143
+ fix: "a version or plan name: 1.5.0, plan-29, 2.0.0-beta.1",
144
+ });
145
+ }
146
+ return label;
147
+ }
148
+
149
+ /** Answers are shown, not added: each one closes a park that is already counted. */
150
+ export function interventionTotal(i: Omit<Interventions, "total">): number {
151
+ return (
152
+ i.parks + i.takeovers + i.manualDispatches + i.pauses + i.superseded + i.chat + i.cancels + i.approvals
153
+ );
154
+ }
155
+
156
+ /** The injected task recovered by itself: a limit hit, a stand-in that ended ok, and nobody stepped in. */
157
+ export function recovered(s: TaskScore): boolean {
158
+ return (
159
+ s.injected !== null &&
160
+ s.injected.limits > 0 &&
161
+ s.injected.standInOk &&
162
+ s.finished &&
163
+ s.interventions.total === 0
164
+ );
165
+ }
166
+
167
+ export interface Verdict {
168
+ pass: boolean;
169
+ failures: string[];
170
+ wall: { now: number; baseline: number | null };
171
+ }
172
+
173
+ const sumWall = (f: ScorecardFile): number =>
174
+ SCORECARD_TASKS.reduce((n, t) => n + (f.tasks[t]?.wallMinutes ?? 0), 0);
175
+
176
+ /** The spec's pass bar. Quota, review rounds and the first try are reported, never gated. */
177
+ export function passBar(current: ScorecardFile, baseline: ScorecardFile | null): Verdict {
178
+ const failures: string[] = [];
179
+ for (const t of SCORECARD_TASKS) {
180
+ const s = current.tasks[t];
181
+ if (!s) failures.push(`${t}: not recorded`);
182
+ else if (!s.finished) failures.push(`${t}: not finished`);
183
+ else if (s.interventions.total > 0) failures.push(`${t}: ${s.interventions.total} intervention(s)`);
184
+ }
185
+ const injected = current.tasks[INJECTED_TASK];
186
+ if (injected && !recovered(injected)) failures.push(`${INJECTED_TASK}: did not recover by itself`);
187
+ const now = sumWall(current);
188
+ const full = baseline !== null && SCORECARD_TASKS.every((t) => baseline.tasks[t] !== undefined);
189
+ const base = full ? sumWall(baseline) : null;
190
+ if (base === null) failures.push("no full baseline to compare wall-clock against");
191
+ else if (now > base * (1 + WALL_TOLERANCE)) {
192
+ failures.push(
193
+ `wall-clock ${Math.round(now)} min against the baseline's ${Math.round(base)} min (more than 10 % slower)`,
194
+ );
195
+ }
196
+ return { pass: failures.length === 0, failures, wall: { now, baseline: base } };
197
+ }
@@ -1,136 +1,293 @@
1
- import {
2
- type Catalog,
3
- capableFor,
4
- type Dim,
5
- effortOffered,
6
- type RungInfo,
7
- rungInfo,
8
- scoresOf,
9
- } from "./catalog.ts";
1
+ import { type Bars, barOf, percentilesOf } from "./bars.ts";
2
+ import { type Catalog, capableFor, effortOffered, type RungInfo, rungInfo } from "./catalog.ts";
10
3
  import { type BillingMode, type Cost, compareCost, costOf, DEFAULT_BILLING } from "./cost.ts";
11
4
  import { CatherdError } from "./errors.ts";
12
- import { DIFFICULTIES, type Difficulty, type Kind } from "./lane.ts";
13
- import type { Role } from "./roles.ts";
5
+ import { quotaOf } from "./failover.ts";
6
+ import { type EffortWord, effortRank, tryParseRung } from "./ids.ts";
7
+ import { DIFFICULTIES, type Difficulty } from "./lane.ts";
8
+ import { ROLES, type Role } from "./roles.ts";
14
9
 
15
10
  /** What routing reads from a profile for one role. */
16
11
  export interface RoutingProfile {
17
12
  objective: "cost" | "speed";
18
13
  billing: Partial<Record<string, BillingMode>>;
19
14
  role: { enabled: boolean; rungs: string[]; defaultRung?: string };
15
+ /**
16
+ * spec 1.5 plan 24: the run's dispatches so far per quota (`quotaOf`), the headroom a tie between rungs of
17
+ * equal intelligence is broken on; absent, every quota counts 0 and the candidates' order breaks it
18
+ */
19
+ usage?: Partial<Record<string, number>>;
20
+ /** `<backend>:<model>` → the highest effort routing may start (plan 33); a rung above it is skipped */
21
+ maxEffort?: Readonly<Record<string, string>>;
22
+ /** this role's bar percentiles over the profile's, laid over the shipped ones (`percentilesOf`) */
23
+ bars?: Bars;
24
+ }
25
+
26
+ /** What a start check reads of a profile: `ProfileView` and the resolved `Profile` both satisfy it. */
27
+ export interface ProfileLike {
28
+ objective: "cost" | "speed";
29
+ billing: Partial<Record<string, BillingMode>>;
30
+ roles: Partial<Record<Role, { enabled: boolean; rungs: string[]; defaultRung?: string }>>;
31
+ maxEffort?: Readonly<Record<string, string>>;
32
+ }
33
+
34
+ /** Spec 2.0 decision 22: the roles that write code. The worker runs repo_code, terminal and ui lanes and every fix round. */
35
+ export const CODING_ROLES: readonly Role[] = ["worker"];
36
+ /** A coding role never runs below this effort, whatever the objective or the budget. */
37
+ export const CODING_FLOOR: EffortWord = "medium";
38
+
39
+ /**
40
+ * False for a rung above its model's `maxEffort`, or below `CODING_FLOOR` for a coding role. A rung that does not
41
+ * parse, one whose effort is no effort word (`default`), and a ceiling that is no effort word all allow it.
42
+ */
43
+ export function effortAllowed(
44
+ maxEffort: Readonly<Record<string, string>>,
45
+ role: Role,
46
+ rung: string,
47
+ ): boolean {
48
+ const r = tryParseRung(rung);
49
+ const rank = r ? effortRank(r.effort) : -1;
50
+ if (!r || rank < 0) return true;
51
+ if (CODING_ROLES.includes(role) && rank < effortRank(CODING_FLOOR)) return false;
52
+ const cap = effortRank(maxEffort[`${r.backend}:${r.model}`] ?? "");
53
+ return cap < 0 || rank <= cap;
20
54
  }
21
55
 
22
56
  export interface Candidate {
23
57
  rung: string;
24
58
  info: RungInfo;
25
- scores: Partial<Record<Dim, number>>;
59
+ intelligence: number;
26
60
  cost: Cost;
27
- /** median seconds over the user's own runs (≥ 5 samples), else null */
28
- secs: number | null;
61
+ /** the pair's tokens per second; null when Slopalytics has none */
62
+ speed: number | null;
29
63
  }
30
64
 
31
65
  export interface Pick {
32
66
  rung: string;
33
67
  ladder: string[];
68
+ /** no rung clears the difficulty's bar (`no rung clears hard (bar 47.1); best is …`) */
69
+ noClear?: string;
70
+ /** spec 1.5 plan 24: rungs of equal intelligence competed for the start, and how the tie was broken */
71
+ tie?: string;
72
+ }
73
+
74
+ /** The enabled roles, in `ROLES` order, that the catalog can place no rung for. */
75
+ export function rolesWithoutRung(c: Catalog, v: ProfileLike): Role[] {
76
+ return ROLES.filter((role) => {
77
+ const r = v.roles[role];
78
+ return (
79
+ r?.enabled === true &&
80
+ candidates(
81
+ c,
82
+ {
83
+ objective: v.objective,
84
+ billing: v.billing,
85
+ role: r,
86
+ ...(v.maxEffort ? { maxEffort: v.maxEffort } : {}),
87
+ },
88
+ role,
89
+ ).length === 0
90
+ );
91
+ });
34
92
  }
35
93
 
36
94
  const byNull = (a: number | null, b: number | null) =>
37
95
  a === null || b === null ? (a === null ? 1 : 0) - (b === null ? 1 : 0) : a - b;
38
96
 
39
- function secsOf(c: Catalog, canonical: string, kind: Kind | null): number | null {
40
- return c.secs[`${canonical}|${kind ?? "*"}`] ?? c.secs[`${canonical}|*`] ?? null;
41
- }
42
-
43
97
  /**
44
- * The role's enabled rungs that the catalog can place: parseable, offered by their model, listed by
45
- * the backend when it has a listing, capable for the role, and scored (their own or a treat-like).
46
- * Ordered by cost (spec §5.3), or by measured speed with cost breaking ties; with fewer than 5
47
- * samples a rung has no speed, and cost orders it, which within a model is effort order.
98
+ * The role's enabled rungs that the catalog can place: parseable, indexed, offered by their model, listed by
99
+ * the backend when it has a listing, and capable for the role. Ordered by cost (spec §5.3), or by speed with
100
+ * cost breaking ties; the profile's ladder order breaks every remaining tie (spec 1.5 plan 24), so equal rungs
101
+ * start in the order the user wrote.
48
102
  */
49
- export function candidates(c: Catalog, p: RoutingProfile, role: Role, kind: Kind | null = null): Candidate[] {
103
+ export function candidates(c: Catalog, p: RoutingProfile, role: Role): Candidate[] {
50
104
  if (!p.role.enabled) return [];
51
105
  const out: Candidate[] = [];
52
- for (const rung of new Set(p.role.rungs)) {
106
+ const order = [...new Set(p.role.rungs)];
107
+ for (const rung of order) {
108
+ if (!effortAllowed(p.maxEffort ?? {}, role, rung)) continue;
53
109
  let info: RungInfo;
54
110
  try {
55
111
  info = rungInfo(c, rung);
56
112
  } catch {
57
113
  continue;
58
114
  }
59
- if (!effortOffered(info) || info.listed === false || !capableFor(c, info, role)) continue;
60
- const s = scoresOf(c, info.canonical);
61
- if (!s) continue;
115
+ if (!info.pair || !effortOffered(info) || info.listed === false || !capableFor(c, info, role)) continue;
62
116
  const mode = p.billing[info.key] ?? DEFAULT_BILLING[info.key];
63
117
  out.push({
64
118
  rung,
65
119
  info,
66
- scores: s.values,
67
- cost: costOf(info.family, info.parsed.effort, mode),
68
- secs: secsOf(c, info.canonical, kind),
120
+ intelligence: info.pair.intelligence,
121
+ cost: costOf(info.family, info.pair, info.parsed.effort, mode),
122
+ speed: info.pair.speed,
69
123
  });
70
124
  }
71
- const cost = (a: Candidate, b: Candidate) => compareCost(a.cost, b.cost) || byNull(a.secs, b.secs);
72
- const speed = (a: Candidate, b: Candidate) => byNull(a.secs, b.secs) || compareCost(a.cost, b.cost);
125
+ const written = (a: Candidate, b: Candidate) => order.indexOf(a.rung) - order.indexOf(b.rung);
126
+ const cost = (a: Candidate, b: Candidate) => compareCost(a.cost, b.cost) || written(a, b);
127
+ // fastest first, a pair with no speed last
128
+ const speed = (a: Candidate, b: Candidate) =>
129
+ byNull(a.speed === null ? null : -a.speed, b.speed === null ? null : -b.speed) ||
130
+ compareCost(a.cost, b.cost) ||
131
+ written(a, b);
73
132
  return out.sort(p.objective === "speed" ? speed : cost);
74
133
  }
75
134
 
76
- export function clearsBar(c: Catalog, cand: Candidate, kind: Kind, difficulty: Difficulty): boolean {
77
- return Object.entries(c.bars[kind][difficulty]).every(
78
- ([dim, min]) => min === undefined || (cand.scores[dim as Dim] ?? Number.NEGATIVE_INFINITY) >= min,
79
- );
80
- }
135
+ export const clearsBar = (cand: Candidate, bar: number): boolean => cand.intelligence >= bar;
81
136
 
82
137
  function noRung(role: Role): CatherdError {
83
- return new CatherdError("E_CONFIG_INVALID", `role "${role}" has no enabled, capable, scored rung`, {
84
- fix: "run profile_validate, then enable a scored rung or map one with catherd catalog treat-like",
138
+ return new CatherdError("E_CONFIG_INVALID", `role "${role}" has no enabled, capable, indexed rung`, {
139
+ fix: `pick indexed pairs: catherd catalog list --role ${role}`,
85
140
  });
86
141
  }
87
142
 
143
+ /** Spec 1.5 plan 24: how many of `rungs` (a run's dispatched rungs) drew on each quota. */
144
+ export function quotaUsage(rungs: readonly string[]): Record<string, number> {
145
+ const out: Record<string, number> = {};
146
+ for (const rung of rungs) {
147
+ const r = tryParseRung(rung);
148
+ if (r) out[quotaOf(r)] = (out[quotaOf(r)] ?? 0) + 1;
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /** Equal intelligence: a tie for the start. */
154
+ const same = (a: Candidate, b: Candidate): boolean => a.intelligence === b.intelligence;
155
+
156
+ const quota = (x: Candidate): string => quotaOf(x.info.parsed);
157
+
88
158
  /**
89
- * The role's default rung and every candidate above it (the whole list when it has no default), in cost
90
- * order under either objective: speed order would put slower, not stronger, rungs above the default.
159
+ * Spec 1.5 plan 24: when rungs on other quotas are exactly as intelligent as `start`, the start goes to the
160
+ * quota with the most headroom (the fewest of the run's dispatches so far), the candidates' order (cost, then
161
+ * the profile's ladder order) breaking an equal count. `tie` says what decided. Rungs of one quota that tie
162
+ * are no tie (cost already orders them), nor is a rung of another cost tier.
163
+ */
164
+ function breakTie(
165
+ start: Candidate,
166
+ order: Candidate[],
167
+ usage: Partial<Record<string, number>>,
168
+ ): { start: Candidate; tie?: string } {
169
+ // a metered rung never wins a tie over a plan's: headroom is free only within the start's cost tier
170
+ const rivals = order.filter(
171
+ (x) => x !== start && quota(x) !== quota(start) && x.cost.tier === start.cost.tier && same(x, start),
172
+ );
173
+ if (rivals.length === 0) return { start };
174
+ const used = (x: Candidate) => usage[quota(x)] ?? 0;
175
+ const tied = [start, ...rivals];
176
+ const pick = [...tied].sort((a, b) => used(a) - used(b))[0] as Candidate;
177
+ const others = tied.filter((x) => x !== pick).map((x) => x.rung);
178
+ const counts = [...new Set(tied.map(quota))].map((q) => `${q} ${usage[q] ?? 0}`).join(", ");
179
+ const why = tied.every((x) => used(x) === used(pick))
180
+ ? `equal headroom (${used(pick)} dispatches each); the profile's ladder order and cost decided`
181
+ : `${quota(pick)} has the most headroom (dispatches in this run: ${counts})`;
182
+ return {
183
+ start: pick,
184
+ tie: `tie on intelligence with ${others.join(", ")}: started on ${pick.rung}; ${why}`,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Spec 1.5 plan 24, "climb ladders only go up": the start (after a tie on equal intelligence goes to the quota
190
+ * with the most headroom), then, in `order`'s order, every other candidate at least as intelligent as the last
191
+ * rung kept. A stronger rung is on the ladder wherever cost puts it, and one weaker than the rung before it
192
+ * never is, so a climb never lands on a rung below the one it leaves.
193
+ */
194
+ function ladderFrom(first: Candidate, order: Candidate[], usage: Partial<Record<string, number>> = {}): Pick {
195
+ const { start, tie } = breakTie(first, order, usage);
196
+ const kept = [start];
197
+ for (const x of order)
198
+ if (x !== start && x.intelligence >= (kept.at(-1) as Candidate).intelligence) kept.push(x);
199
+ return { rung: start.rung, ladder: kept.map((x) => x.rung), ...(tie ? { tie } : {}) };
200
+ }
201
+
202
+ /**
203
+ * The role's default rung and every candidate at least as intelligent (the whole list when it has no default),
204
+ * in cost order under either objective: speed order would put faster, not stronger, rungs above the default.
91
205
  */
92
206
  export function defaultLadder(c: Catalog, p: RoutingProfile, role: Role): Pick {
93
- const all = candidates(c, { ...p, objective: "cost" }, role).map((x) => x.rung);
207
+ const all = candidates(c, { ...p, objective: "cost" }, role);
94
208
  if (all.length === 0) throw noRung(role);
95
- const i = Math.max(0, all.indexOf(p.role.defaultRung ?? ""));
96
- return { rung: all[i] as string, ladder: all.slice(i) };
209
+ const start = all.find((x) => x.rung === p.role.defaultRung) ?? (all[0] as Candidate);
210
+ return ladderFrom(start, all, p.usage);
97
211
  }
98
212
 
99
213
  /**
100
- * The role's default difficulty for a kind: the hardest difficulty whose bar the role's default rung clears
101
- * (`build` when it clears none, or is no candidate: never `copy`, whose start is the cheapest rung, below
102
- * what the `default` source would pick). A route sure of the kind but not the difficulty starts there.
214
+ * The role's default difficulty: the hardest difficulty whose bar the role's default rung clears (`build` when
215
+ * it clears none, or is no candidate: never `copy`, whose start is the cheapest rung). A route sure of the
216
+ * lane but not its difficulty starts there.
103
217
  */
104
- export function defaultDifficulty(c: Catalog, p: RoutingProfile, role: Role, kind: Kind): Difficulty {
218
+ export function defaultDifficulty(c: Catalog, p: RoutingProfile, role: Role): Difficulty {
105
219
  const start = defaultLadder(c, p, role).rung;
106
- const cand = candidates(c, p, role, kind).find((x) => x.rung === start);
107
- const cleared = cand ? DIFFICULTIES.filter((d) => clearsBar(c, cand, kind, d)) : [];
220
+ const cand = candidates(c, p, role).find((x) => x.rung === start);
221
+ const at = percentilesOf(p);
222
+ const cleared = cand ? DIFFICULTIES.filter((d) => clearsBar(cand, barOf(c, d, at))) : [];
108
223
  return cleared.at(-1) ?? "build";
109
224
  }
110
225
 
111
226
  /**
112
- * Under `objective: "speed"` the objective picks only the start (the fastest bar-clearing rung); the
113
- * ladder above it is the other bar-clearing rungs at least as strong on the kind's primary dimension,
114
- * weakest first, cost breaking ties, so a lane never climbs onto a weaker rung. Ported from 0.x.
227
+ * Under `objective: "speed"` the objective picks only the start (the fastest rung that clears the bar); the
228
+ * ladder above it is the other rungs at least as intelligent, least intelligent first, cost breaking ties, so a
229
+ * lane never climbs onto a weaker rung.
230
+ */
231
+ function speedLadder(start: Candidate, all: Candidate[], p: RoutingProfile): Pick {
232
+ const order = [...all].sort((a, b) => a.intelligence - b.intelligence || compareCost(a.cost, b.cost));
233
+ return ladderFrom(start, order, p.usage);
234
+ }
235
+
236
+ /**
237
+ * Spec 1.5 plan 24: when no rung clears a bar, the most intelligent one (the candidates' order breaking a tie).
238
+ */
239
+ const bestOf = (all: Candidate[]): Candidate =>
240
+ [...all].sort((a, b) => b.intelligence - a.intelligence)[0] as Candidate;
241
+
242
+ /** `no rung clears hard (bar 47.1); best is codex:gpt-6-sol#xhigh (44.2)`, and where a raised start came from. */
243
+ export function noClearLine(all: Candidate[], bar: number, d: Difficulty, raisedFrom?: Difficulty): string {
244
+ const best = bestOf(all);
245
+ return `no rung clears ${d} (bar ${bar.toFixed(1)}); best is ${best.rung} (${best.intelligence.toFixed(1)})${raisedFrom ? `; started at ${raisedFrom}'s start` : ""}`;
246
+ }
247
+
248
+ /**
249
+ * Spec §5.4: the start rung and ladder for a lane of this difficulty. The start is the first rung in objective
250
+ * order that clears the bar (under speed the fastest one with a speed, else the cheapest). When none does, it is
251
+ * the default rung, raised to an easier difficulty's start when that one is at least as intelligent (spec 1.5
252
+ * plan 24: `logic` starts no lower than `build`), and `noClear` says so. The ladder above the start only goes up.
115
253
  */
116
- function speedLadder(clearing: Candidate[], kind: Kind): Pick {
117
- const start = clearing[0] as Candidate;
118
- const dim: Dim = kind === "terminal" ? "terminal" : "repo_code";
119
- const strength = (x: Candidate) => x.scores[dim] ?? Number.NEGATIVE_INFINITY;
120
- const rest = clearing
121
- .filter((x) => x !== start && strength(x) >= strength(start))
122
- .sort((a, b) => strength(a) - strength(b) || compareCost(a.cost, b.cost));
123
- return { rung: start.rung, ladder: [start, ...rest].map((x) => x.rung) };
124
- }
125
-
126
- /** Spec §5.4: the start rung and ladder for a lane of this kind and difficulty (0.x `select`). */
127
- export function select(c: Catalog, p: RoutingProfile, role: Role, kind: Kind, difficulty: Difficulty): Pick {
128
- const all = candidates(c, p, role, kind);
254
+ export function select(c: Catalog, p: RoutingProfile, role: Role, difficulty: Difficulty): Pick {
255
+ const all = candidates(c, p, role);
129
256
  if (all.length === 0) throw noRung(role);
130
- if (all.length === 1) return { rung: all[0]?.rung as string, ladder: [all[0]?.rung as string] };
131
- const clearing = all.filter((x) => clearsBar(c, x, kind, difficulty));
132
- if (clearing.length === 0) return defaultLadder(c, p, role);
133
- return p.objective === "speed"
134
- ? speedLadder(clearing, kind)
135
- : { rung: clearing[0]?.rung as string, ladder: clearing.map((x) => x.rung) };
257
+ const at = percentilesOf(p);
258
+ const bar = barOf(c, difficulty, at);
259
+ if (all.length === 1) {
260
+ const only = all[0] as Candidate;
261
+ const one = { rung: only.rung, ladder: [only.rung] };
262
+ return clearsBar(only, bar) ? one : { ...one, noClear: noClearLine(all, bar, difficulty) };
263
+ }
264
+ const byCost = p.objective === "speed" ? candidates(c, { ...p, objective: "cost" }, role) : all;
265
+ if (p.objective === "speed") {
266
+ // the fastest clearing pair that has a speed, else the cheapest clearing one
267
+ const rung = (
268
+ all.find((x) => x.speed !== null && clearsBar(x, bar)) ?? byCost.find((x) => clearsBar(x, bar))
269
+ )?.rung;
270
+ const first = all.find((x) => x.rung === rung);
271
+ if (first) return speedLadder(first, all, p);
272
+ } else {
273
+ const first = all.find((x) => clearsBar(x, bar));
274
+ if (first) return ladderFrom(first, all, p.usage);
275
+ }
276
+ const fallback = defaultLadder(c, p, role).rung;
277
+ const base = byCost.find((x) => x.rung === fallback) ?? (byCost[0] as Candidate);
278
+ let start = base;
279
+ let raisedFrom: Difficulty | undefined;
280
+ for (const easier of DIFFICULTIES.slice(0, DIFFICULTIES.indexOf(difficulty)).reverse()) {
281
+ const s = byCost.find((x) => clearsBar(x, barOf(c, easier, at)));
282
+ if (!s) continue;
283
+ if (s.intelligence >= start.intelligence) {
284
+ start = s;
285
+ if (s !== base) raisedFrom = easier;
286
+ }
287
+ break;
288
+ }
289
+ return {
290
+ ...ladderFrom(start, byCost, p.usage),
291
+ noClear: noClearLine(all, bar, difficulty, raisedFrom),
292
+ };
136
293
  }
@@ -0,0 +1,72 @@
1
+ import { z } from "zod";
2
+ import { CatherdError } from "./errors.ts";
3
+
4
+ /** One model at one effort, as the Slopalytics proxy indexes it: its intelligence and the facts that come with it. */
5
+ export interface SlopRecord {
6
+ family: string;
7
+ effort: string;
8
+ intelligence: number;
9
+ inputPrice: number | null;
10
+ outputPrice: number | null;
11
+ speed: number | null;
12
+ context: number | null;
13
+ }
14
+
15
+ const RecordSchema = z.object({
16
+ family: z.string(),
17
+ effort: z.string(),
18
+ intelligence: z.number(),
19
+ inputPrice: z.number().nullable(),
20
+ outputPrice: z.number().nullable(),
21
+ speed: z.number().nullable(),
22
+ context: z.number().nullable(),
23
+ });
24
+
25
+ /** `<data>/slopalytics.json`: the last good answer, when it was fetched and checked, and the last failure. */
26
+ export const IndexFileSchema = z.looseObject({
27
+ schema: z.literal(1),
28
+ checkedAt: z.iso.datetime(),
29
+ fetchedAt: z.string().nullable(),
30
+ error: z.object({ at: z.string(), message: z.string() }).nullable(),
31
+ models: z.array(RecordSchema),
32
+ });
33
+ export type IndexFile = z.infer<typeof IndexFileSchema>;
34
+
35
+ /** A finite number or a finite numeric string (the proxy sends some prices as strings), else null. */
36
+ const num = (v: unknown): number | null => {
37
+ const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
38
+ return typeof n === "number" && Number.isFinite(n) ? n : null;
39
+ };
40
+
41
+ /** The proxy's answer as the records catherd ranks on: only a record with a positive intelligence is a pair. */
42
+ export function parseProxy(body: unknown): { fetchedAt: string | null; models: SlopRecord[] } {
43
+ const b = body as { fetchedAt?: unknown; models?: unknown } | null;
44
+ if (typeof b !== "object" || b === null || !Array.isArray(b.models))
45
+ throw new CatherdError("E_INPUT_INVALID", "Slopalytics answered without a models list");
46
+ const models: SlopRecord[] = [];
47
+ for (const raw of b.models) {
48
+ const r = (raw ?? {}) as Record<string, unknown>;
49
+ const intelligence = num(r.intelligence);
50
+ if (typeof r.family !== "string" || !r.family) continue;
51
+ if (typeof r.effort !== "string" || !r.effort) continue;
52
+ if (intelligence === null || intelligence <= 0) continue;
53
+ const context = num(r.context);
54
+ models.push({
55
+ family: r.family,
56
+ effort: r.effort,
57
+ intelligence,
58
+ inputPrice: num(r.inputPrice),
59
+ outputPrice: num(r.outputPrice),
60
+ speed: num(r.speed),
61
+ context: context !== null && Number.isInteger(context) && context > 0 ? context : null,
62
+ });
63
+ }
64
+ return { fetchedAt: typeof b.fetchedAt === "string" ? b.fetchedAt : null, models };
65
+ }
66
+
67
+ /** A model's Slopalytics family key: its provider dropped, lower case, dots as dashes (`gpt-6.1-sol` → `gpt-6-1-sol`). */
68
+ export const slopKey = (model: string): string =>
69
+ model
70
+ .slice(model.lastIndexOf("/") + 1)
71
+ .toLowerCase()
72
+ .replaceAll(".", "-");
@@ -16,6 +16,8 @@ export interface StateView {
16
16
  dirty: { path: string; owner: string | null }[];
17
17
  running: { name: string; rung: string; thread: string | null; since: string; brief: string }[];
18
18
  lastCheck: string | null;
19
+ /** spec 1.5 "Pinned per run": what changed since the pin; dispatch keeps the pinned values */
20
+ pinChanges?: string[];
19
21
  next: string;
20
22
  /** spec 1.1 §10: the protocol's next step, derived from the run's files; always the last line */
21
23
  protocol: string;
@@ -40,6 +42,9 @@ export function renderState(s: StateView): string {
40
42
  : ["- none"]),
41
43
  "",
42
44
  `Last check: ${s.lastCheck ?? "none"}`,
45
+ ...(s.pinChanges?.length
46
+ ? [`Pinned: ${s.pinChanges.join("; ")} (dispatch keeps the pinned values; run_pin re-pins)`]
47
+ : []),
43
48
  "",
44
49
  // 1.1: no tool waits; each running role's record arrives as a catherd message
45
50
  `Next: ${waiting.length ? `running ${waiting.join(", ")} (results arrive as catherd messages; peek to check); then ${s.next}` : s.next}`,