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
@@ -1,30 +1,42 @@
1
1
  import type { OrchestrationHost } from "./host.ts";
2
+ import { type Bars, barInversions, barOf, effectiveBars, percentilesOf } from "./bars.ts";
3
+ import { type Catalog, capableFor, effortOffered, ROLE_NEEDS, rungInfo } from "./catalog.ts";
2
4
  import {
3
- type Catalog,
4
- type Dim,
5
- capableFor,
6
- effortOffered,
7
- ROLE_NEEDS,
8
- type RungInfo,
9
- rungInfo,
10
- scoresOf,
11
- } from "./catalog.ts";
12
- import {
13
- catalogRungs,
5
+ catalogPairs,
14
6
  claudeBilled,
15
- downgradeDims,
16
- ladderDropDims,
7
+ downgrade,
8
+ intelligenceOf,
9
+ ladderDrop,
17
10
  quotaOf,
18
11
  rankStandIns,
19
12
  } from "./failover.ts";
20
- import { tryParseRung } from "./ids.ts";
21
- import { DIFFICULTIES, KINDS } from "./lane.ts";
13
+ import { effortRank, tryParseRung } from "./ids.ts";
14
+ import { DIFFICULTIES, type Difficulty } from "./lane.ts";
22
15
  import { type Profile, type ProfileDoc, unknownValues } from "./profile.ts";
23
16
  import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
24
- import { candidates, clearsBar, type RoutingProfile } from "./select.ts";
17
+ import {
18
+ type Candidate,
19
+ candidates,
20
+ CODING_FLOOR,
21
+ CODING_ROLES,
22
+ clearsBar,
23
+ defaultLadder,
24
+ type RoutingProfile,
25
+ select,
26
+ } from "./select.ts";
25
27
 
26
28
  export { quotaOf };
27
29
 
30
+ /**
31
+ * `reach`: also warn about the difficulties no worker rung clears (spec 1.5 plan 24), which only an explicit
32
+ * `profile validate` asks for. `unindexed: "warning"` (doctor's) reads a rung with no Slopalytics index as a
33
+ * warning, since a profile can lose a pair overnight; everywhere else it is an error.
34
+ */
35
+ export interface ValidateOptions {
36
+ reach?: boolean;
37
+ unindexed?: "error" | "warning";
38
+ }
39
+
28
40
  /** One finding of `validate`: where in the profile, what is wrong, and the action that fixes it. */
29
41
  export interface Issue {
30
42
  path: string;
@@ -37,10 +49,8 @@ export interface Validation {
37
49
  warnings: Issue[];
38
50
  }
39
51
 
40
- const TREAT_LIKE_FIX = (rung: string) =>
41
- `map it with: catherd catalog treat-like ${rung} <a scored rung>; catalog_query lists them`;
42
- const SUGGEST_FIX = (rung: string) =>
43
- `confirm or replace it: catherd catalog treat-like --suggest ${rung}, then catherd catalog treat-like ${rung} <a rung>`;
52
+ const SYNC_FIX = "catherd catalog sync";
53
+ const unindexedMessage = (rung: string) => `${rung} has no Slopalytics intelligence index: routing skips it`;
44
54
 
45
55
  /** Spec 1.2 §6.2: two findings are the same one when they name the same path with the same message. */
46
56
  const sameIssue = (a: Issue, b: Issue): boolean => a.path === b.path && a.message === b.message;
@@ -57,89 +67,23 @@ export function repairs(before: Validation | null, after: Validation): boolean {
57
67
  return removed && !added;
58
68
  }
59
69
 
60
- /** Spec 1.2 §6.1 "stand-in to confirm": a rung whose values on some bar dimensions are an inferred stand-in's. */
61
- export interface StandInToConfirm {
62
- canonical: string;
63
- /** the profile's rungs that run it, and where the first one is */
64
- rungs: string[];
65
- path: string;
66
- dims: { dim: Dim; like: string }[];
67
- }
68
-
69
- /**
70
- * Spec 1.2 §6.1: the profile's rungs that lean on an inferred stand-in, one entry per canonical rung. Only
71
- * where bars choose: rungs of a role with more than one usable rung, and failover stand-ins; a role with one
72
- * rung runs it whatever the bars say (plan 14 Ruling 13).
73
- */
74
- export function standInsToConfirm(p: Profile, c: Catalog, backends: readonly string[]): StandInToConfirm[] {
75
- const seen = new Map<string, StandInToConfirm>();
76
- const add = (rung: string, path: string) => {
77
- const r = tryParseRung(rung);
78
- if (!r || !backends.includes(r.backend)) return;
79
- const info = rungInfo(c, rung);
80
- const s = scoresOf(c, info.canonical);
81
- if (!s || s.inferred.length === 0) return;
82
- const had = seen.get(info.canonical);
83
- if (had) {
84
- if (!had.rungs.includes(rung)) had.rungs.push(rung);
85
- return;
86
- }
87
- const dims = s.inferred.map((dim) => ({ dim, like: s.standIns[dim] as string }));
88
- seen.set(info.canonical, { canonical: info.canonical, rungs: [rung], path, dims });
89
- };
90
- for (const role of ROLES) {
91
- const rc = p.roles[role];
92
- if (!rc.enabled || candidates(c, routingProfileOf(p, role), role).length < 2) continue;
93
- for (const rung of rc.rungs) add(rung, `roles.${role}.rungs`);
94
- }
95
- for (const [from, to] of Object.entries(p.failover)) add(to, `failover.${from}`);
96
- return [...seen.values()];
97
- }
98
-
99
- /** One "stand-in to confirm" warning's words. */
100
- export function standInMessage(x: StandInToConfirm): string {
101
- const words = (xs: string[]) =>
102
- xs.length < 3 ? xs.join(" or ") : `${xs.slice(0, -1).join(", ")} or ${xs.at(-1)}`;
103
- const lent = x.dims.map((d) => `${d.like}'s ${d.dim}`).join(", ");
104
- const also = x.rungs.length > 1 ? ` (${x.rungs.join(", ")})` : "";
105
- return `stand-in to confirm: ${x.canonical}${also} has no ${words(x.dims.map((d) => d.dim))} value of its own; routing uses ${lent} (inferred)`;
106
- }
107
-
108
70
  export const routingProfileOf = (p: Profile, role: Role): RoutingProfile => ({
109
71
  objective: p.objective,
110
72
  billing: p.billing,
111
73
  role: p.roles[role],
74
+ maxEffort: p.maxEffort,
75
+ bars: { ...p.bars, ...p.roles[role].bars },
112
76
  });
113
77
 
114
- /**
115
- * Whether a rung's scores are catherd's guess: borrowed through a treat-like, filled by an inferred stand-in
116
- * (spec 1.2 §6.1), or only `inferred` ones. `note` says what a treat-like lends: "scores borrowed from X"
117
- * when the rung has no value of its own, else the dimensions it borrows (a rung a sync scored on some).
118
- */
119
- export function inferredScores(
120
- c: Catalog,
121
- info: RungInfo,
122
- ): { inferred: boolean; via: string | null; note: string | null } {
123
- const s = scoresOf(c, info.canonical);
124
- if (!s) return { inferred: false, via: null, note: null };
125
- const records = Object.values(s.records);
126
- const guessed =
127
- s.via !== null || s.inferred.length > 0 || records.every((r) => r.confidence === "inferred");
128
- const all = s.borrowed.length + s.inferred.length === records.length;
129
- const note = s.via ? `${all ? "scores" : s.borrowed.join(", ")} borrowed from ${s.via}` : null;
130
- return { inferred: guessed, via: s.via, note };
131
- }
132
-
133
78
  /**
134
79
  * Spec §7.1. Errors: the worker disabled; an enabled role with no usable rung; a failover stand-in on the
135
80
  * same quota; a rung whose backend catherd cannot run (`backends` lists those it can, `claude` included).
136
81
  * Everything else worth knowing is a warning: an access mode other than the role's default, an effort or
137
82
  * model the last listing does not offer, a stand-in that never runs, (spec 1.1 §11) a stand-in that
138
83
  * downgrades its rung, one that spends Claude quota while another plan could stand in, and a ladder that goes
139
- * down, and (spec 1.2 §6.1) an unscored rung or stand-in, never an error: a rung that leans on an inferred
140
- * stand-in is a "stand-in to confirm", and one with no value and no stand-in is skipped by routing. With the
141
- * stored `doc` `p` came from, a value this catherd does not know is also a warning, one that says how the
142
- * value is read.
84
+ * down. A rung or stand-in with no Slopalytics index is an error (spec 2.0 decision 12; a warning with
85
+ * `o.unindexed: "warning"`): routing skips it. With the stored `doc` `p` came from, a value this catherd does
86
+ * not know is also a warning, one that says how the value is read.
143
87
  */
144
88
  export function validateProfile(
145
89
  p: Profile,
@@ -147,6 +91,7 @@ export function validateProfile(
147
91
  backends: readonly string[],
148
92
  doc?: ProfileDoc,
149
93
  host: OrchestrationHost = "unknown",
94
+ o: ValidateOptions = {},
150
95
  ): Validation {
151
96
  const errors: Issue[] = [];
152
97
  const warnings: Issue[] = [];
@@ -163,6 +108,21 @@ export function validateProfile(
163
108
  fix: "catherd profile set roles.worker.enabled true",
164
109
  });
165
110
 
111
+ // a harder difficulty's percentile below an easier one's would route a harder lane to a weaker rung
112
+ const barOrder = (at: string, bars: Bars): void => {
113
+ const eff = effectiveBars({ bars });
114
+ for (const [easier, harder] of barInversions(eff))
115
+ errors.push({
116
+ path: at,
117
+ message: `the ${harder} bar (${eff[harder]}) is below the ${easier} bar (${eff[easier]})`,
118
+ fix: `catherd profile set ${at}.${harder} ${eff[easier]}, or lower ${at}.${easier} to ${eff[harder]} or less`,
119
+ });
120
+ };
121
+ barOrder("bars", p.bars);
122
+ for (const role of ROLES)
123
+ if (Object.keys(p.roles[role].bars ?? {}).length > 0)
124
+ barOrder(`roles.${role}.bars`, { ...p.bars, ...p.roles[role].bars });
125
+
166
126
  for (const role of ROLES) {
167
127
  const rc = p.roles[role];
168
128
  const at = `roles.${role}`;
@@ -172,6 +132,14 @@ export function validateProfile(
172
132
  message: `${role} runs ${rc.access}; catherd's default for it is ${DEFAULT_ACCESS[role]}`,
173
133
  });
174
134
  if (!rc.enabled) continue;
135
+ // spec §5: network: false takes a workspace-write role's network grants; read-only and full have none to take
136
+ if (rc.network === false && rc.access !== "workspace-write")
137
+ warnings.push({
138
+ path: `${at}.network`,
139
+ message: `network: false does nothing here: it applies to workspace-write roles, and ${role} runs ${rc.access}`,
140
+ fix: `catherd profile set ${at}.network null`,
141
+ });
142
+ let unindexed = 0;
175
143
  for (const rung of rc.rungs) {
176
144
  const native = nativeClaudeIssue(rung, host, `${at}.rungs`);
177
145
  if (native) errors.push(native);
@@ -184,6 +152,18 @@ export function validateProfile(
184
152
  });
185
153
  continue;
186
154
  }
155
+ const above = aboveCeiling(p.maxEffort, rung, `${at}.rungs`);
156
+ if (above) warnings.push(above);
157
+ if (
158
+ CODING_ROLES.includes(role) &&
159
+ effortRank(r.effort) >= 0 &&
160
+ effortRank(r.effort) < effortRank(CODING_FLOOR)
161
+ )
162
+ warnings.push({
163
+ path: `${at}.rungs`,
164
+ message: `${rung} is below ${CODING_FLOOR} effort: a coding worker never runs it`,
165
+ fix: `pick #${CODING_FLOOR} or above: catherd catalog list --role ${role}`,
166
+ });
187
167
  if (!backends.includes(r.backend)) {
188
168
  errors.push({
189
169
  path: `${at}.rungs`,
@@ -199,36 +179,31 @@ export function validateProfile(
199
179
  message: `${rung} cannot fill the ${role} role (it needs ${Object.keys(ROLE_NEEDS[role]).join(" + ")})`,
200
180
  fix: `catalog_query({ role: "${role}" }) lists the models that can`,
201
181
  });
202
- if (info.efforts.length > 0 && !effortOffered(info))
182
+ if (info.listed === true && !effortOffered(info))
203
183
  errors.push({
204
184
  path: `${at}.rungs`,
205
185
  message: `${r.model} has no effort "${r.effort}" on ${r.backend} (it has ${info.efforts.join(", ")})`,
206
186
  });
207
- else if (info.efforts.length === 0 && r.effort !== "default")
208
- warnings.push({
187
+ else if (!info.pair) {
188
+ unindexed++;
189
+ (o.unindexed === "warning" ? warnings : errors).push({
209
190
  path: `${at}.rungs`,
210
- message: `${rung}: catherd cannot check its effort until ${r.backend} lists its models`,
211
- fix: "catherd catalog refresh",
191
+ message: unindexedMessage(rung),
192
+ fix: c.indexedAt === null ? SYNC_FIX : `pick an indexed pair: catherd catalog list --role ${role}`,
212
193
  });
194
+ }
213
195
  if (info.listed === false)
214
196
  warnings.push({
215
197
  path: `${at}.rungs`,
216
198
  message: `${r.backend}'s last listing does not offer ${r.model}; routing skips it`,
217
199
  });
218
- if (!scoresOf(c, info.canonical))
219
- warnings.push({
220
- path: `${at}.rungs`,
221
- message: `${rung} is unscored and no rung is near enough to stand in for it: routing skips it`,
222
- fix: TREAT_LIKE_FIX(rung),
223
- });
224
200
  }
225
201
  rc.rungs.forEach((rung, i) => {
226
202
  const prev = rc.rungs[i - 1];
227
- const down = prev === undefined ? [] : ladderDropDims(c, prev, rung);
228
- if (down.length)
203
+ if (prev !== undefined && ladderDrop(c, prev, rung))
229
204
  warnings.push({
230
205
  path: `${at}.rungs`,
231
- message: `the ladder goes down at ${rung}: it scores below ${prev} on ${down.join(", ")}`,
206
+ message: `the ladder goes down at ${rung}: intelligence ${(intelligenceOf(c, rung) as number).toFixed(1)} below ${prev}'s ${(intelligenceOf(c, prev) as number).toFixed(1)}`,
232
207
  fix: `order ${at}.rungs weakest first`,
233
208
  });
234
209
  });
@@ -240,41 +215,62 @@ export function validateProfile(
240
215
  });
241
216
  const usable = candidates(c, routingProfileOf(p, role), role);
242
217
  if (usable.length === 0)
243
- errors.push({
218
+ // doctor's mode: rungs that are only unindexed are the same warning, so the role's total is one too, but
219
+ // only when an index exists: with none (not fetched, corrupt, empty) nothing can route, which is an error
220
+ (o.unindexed === "warning" &&
221
+ c.indexedAt !== null &&
222
+ Object.keys(c.index).length > 0 &&
223
+ unindexed > 0 &&
224
+ unindexed === rc.rungs.length
225
+ ? warnings
226
+ : errors
227
+ ).push({
244
228
  path: `${at}.rungs`,
245
229
  message: `the ${role} role has no usable rung`,
246
- fix: `enable a scored rung its backend offers, or turn the role off with: catherd profile set ${at}.enabled false`,
230
+ fix: `enable an indexed rung its backend offers, or turn the role off with: catherd profile set ${at}.enabled false`,
247
231
  });
248
- if (usable.length > 1)
249
- // Claude rungs have no honesty score, so they clear no shipped bar: select() starts on one only as the
250
- // default rung (when nothing clears) and never climbs onto it
251
- for (const x of usable)
252
- if (
253
- quotaOf(x.info.parsed) === "claude-code" &&
254
- !KINDS.some((k) => DIFFICULTIES.some((d) => clearsBar(c, x, k, d)))
255
- )
256
- warnings.push({
257
- path: `${at}.rungs`,
258
- message: `${x.rung} clears no routing bar, so a lane starts on it only as the role's default rung and never climbs onto it`,
259
- });
260
- // spec 1.2 §5.2: the logic and hard bars sit at the 60th and 75th percentiles, which a cost-minded ladder
261
- // may never reach, and those lanes start at the default rung and climb; only a kind whose every bar the
262
- // ladder misses routes blind (plan 14 Ruling 12)
232
+ // spec 1.5 plan 24: every difficulty no worker rung clears, in one warning; those lanes start at the
233
+ // default rung (or an easier difficulty's start) and climb only onto rungs at least as intelligent. Only an
234
+ // explicit validate says it (Ruling 3): the shipped bars put logic and hard above every Sol rung, the
235
+ // owner's call, so every save and doctor would repeat it
236
+ if (o.reach && role === "worker" && usable.length > 1) {
237
+ const unreached = unreachable(c, usable, percentilesOf(routingProfileOf(p, role)));
238
+ if (unreached.length)
239
+ warnings.push({
240
+ path: `${at}.rungs`,
241
+ message: `no worker rung clears ${unreached.join(", ")}: those lanes start at the default rung and climb only onto rungs at least as intelligent (route names the closest)`,
242
+ });
243
+ }
244
+ // spec 1.5 plan 24: a quota whose rungs never start a lane sits idle but for climbs (the identity run's Go).
245
+ // Only the worker routes lanes and climbs (Ruling 9); another role routes lane-less to one rung, so a rung
246
+ // on a second quota is a deliberate spare there, not an idle quota (final review Important 4, Ruling 8)
263
247
  if (role === "worker" && usable.length > 1) {
264
- const blind = KINDS.filter((k) => !DIFFICULTIES.some((d) => usable.some((x) => clearsBar(c, x, k, d))));
265
- if (blind.length)
248
+ const idle = idleQuotas(c, p, role, usable);
249
+ for (const [q, rungs] of idle) {
250
+ // the billing key, not the quota, is what billing is set on (`claude:` and `claude-code:` share one)
251
+ const keys = [...new Set(usable.filter((x) => rungs.includes(x.rung)).map((x) => x.info.key))];
266
252
  warnings.push({
267
253
  path: `${at}.rungs`,
268
- message: `no worker rung clears any bar for ${blind.join(", ")}; those lanes always start at the default rung`,
254
+ message: `${q} never starts a ${role} lane: ${rungs.join(", ")} ${rungs.length > 1 ? "run" : "runs"} only on a climb, since another rung starts every lane`,
255
+ fix: `check ${keys.map((k) => `billing.${k}`).join(" and ")} (a metered rung starts only where nothing paid from a plan clears the bar), or order ${at}.rungs so its rungs come first among equals`,
269
256
  });
257
+ }
270
258
  }
271
259
  }
272
260
 
273
261
  const ladders = new Set(ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs));
274
- // the stand-ins catherd could run here, for the fixes below
275
- const pool = catalogRungs(c).filter((x) => {
262
+ // the stand-ins the fixes below may suggest: on a backend catherd runs and the profile already names (a
263
+ // ladder or a failover entry), never one this machine may not have (Cursor on a machine without it); a
264
+ // native claude: rung names claude-code too, the same Claude Code CLI
265
+ const named = new Set(
266
+ [...ladders, ...Object.keys(p.failover), ...Object.values(p.failover)].flatMap((x) => {
267
+ const b = tryParseRung(x)?.backend;
268
+ return b === undefined ? [] : b === "claude" ? [b, "claude-code"] : [b];
269
+ }),
270
+ );
271
+ const pool = catalogPairs(c).filter((x) => {
276
272
  const r = tryParseRung(x);
277
- return r !== null && backends.includes(r.backend);
273
+ return r !== null && backends.includes(r.backend) && named.has(r.backend);
278
274
  });
279
275
  for (const [from, to] of Object.entries(p.failover)) {
280
276
  const at = `failover.${from}`;
@@ -290,15 +286,19 @@ export function validateProfile(
290
286
  });
291
287
  continue;
292
288
  }
289
+ const above = aboveCeiling(p.maxEffort, to, at);
290
+ if (above) warnings.push(above);
293
291
  if (!backends.includes(b.backend)) {
294
292
  errors.push({ path: at, message: `stand-in ${to}: catherd cannot run ${b.backend} yet` });
295
293
  continue;
296
294
  }
297
- if (!scoresOf(c, rungInfo(c, to).canonical))
298
- warnings.push({
295
+ const ranked = rankStandIns(c, p.billing, from, pool);
296
+ const unindexed = intelligenceOf(c, to) === null;
297
+ if (unindexed)
298
+ (o.unindexed === "warning" ? warnings : errors).push({
299
299
  path: at,
300
- message: `stand-in ${to} is unscored and no rung is near enough to stand in for it`,
301
- fix: TREAT_LIKE_FIX(to),
300
+ message: unindexedMessage(to),
301
+ fix: c.indexedAt === null ? SYNC_FIX : `catherd profile set failover.${from} ${ranked[0] ?? "null"}`,
302
302
  });
303
303
  if (quotaOf(a) === quotaOf(b))
304
304
  errors.push({
@@ -306,12 +306,11 @@ export function validateProfile(
306
306
  message: `stand-in ${to} draws on the same quota as ${from}, which is out when ${from} hits its limit`,
307
307
  fix: "name a stand-in on another backend or plan",
308
308
  });
309
- const ranked = rankStandIns(c, p.billing, from, pool);
310
- const down = downgradeDims(c, from, to);
311
- if (down.length)
309
+ // an unindexed stand-in has its own finding above: no "downgrade" besides
310
+ if (!unindexed && downgrade(c, from, to))
312
311
  warnings.push({
313
312
  path: at,
314
- message: `downgrade: ${to} stands in for ${from}, scoring below it on ${down.join(", ")}`,
313
+ message: `downgrade: ${to} stands in for ${from} at intelligence ${(intelligenceOf(c, to) as number).toFixed(1)}, below its ${(intelligenceOf(c, from) as number).toFixed(1)}`,
315
314
  fix: `catherd profile set failover.${from} ${ranked[0] ?? "null"}`,
316
315
  });
317
316
  const other = ranked.find((x) => !claudeBilled(x));
@@ -329,11 +328,49 @@ export function validateProfile(
329
328
  if (!ladders.has(from))
330
329
  warnings.push({ path: at, message: `${from} is on no enabled role's ladder, so this never runs` });
331
330
  }
332
- for (const x of standInsToConfirm(p, c, backends))
333
- warnings.push({ path: x.path, message: standInMessage(x), fix: SUGGEST_FIX(x.rungs[0] as string) });
334
331
  return { errors, warnings };
335
332
  }
336
333
 
334
+ /** Plan 33: a warning for a rung above its model's `maxEffort`, null for any other. */
335
+ function aboveCeiling(maxEffort: Readonly<Record<string, string>>, rung: string, path: string): Issue | null {
336
+ const r = tryParseRung(rung);
337
+ const key = r ? `${r.backend}:${r.model}` : "";
338
+ const cap = maxEffort[key];
339
+ if (!r || cap === undefined || effortRank(r.effort) <= effortRank(cap) || effortRank(r.effort) < 0)
340
+ return null;
341
+ return {
342
+ path,
343
+ message: `${rung} is above maxEffort ${cap} for ${key}: routing skips it`,
344
+ fix: `catherd profile set maxEffort.${key} ${r.effort}, or remove the rung`,
345
+ };
346
+ }
347
+
348
+ /** The difficulties no rung of `usable` clears, as `logic, hard`. */
349
+ function unreachable(c: Catalog, usable: Candidate[], at: Record<Difficulty, number>): string[] {
350
+ return DIFFICULTIES.filter((d) => !usable.some((x) => clearsBar(x, barOf(c, d, at))));
351
+ }
352
+
353
+ /**
354
+ * Spec 1.5 plan 24: each quota of the role's usable rungs that no kind and difficulty (nor a lane-less route)
355
+ * ever starts on, whichever quota the run has used least, with its rungs.
356
+ */
357
+ function idleQuotas(c: Catalog, p: Profile, role: Role, usable: Candidate[]): [string, string[]][] {
358
+ const quotas = [...new Set(usable.map((x) => quotaOf(x.info.parsed)))];
359
+ if (quotas.length < 2) return [];
360
+ const starts = new Set<string>();
361
+ // every quota gets its turn as the least used, so a tie it could win counts as a start
362
+ for (const fresh of [null, ...quotas]) {
363
+ const usage = Object.fromEntries(quotas.map((q) => [q, q === fresh ? 0 : 1]));
364
+ const rp = { ...routingProfileOf(p, role), usage };
365
+ starts.add(defaultLadder(c, rp, role).rung);
366
+ for (const d of DIFFICULTIES) starts.add(select(c, rp, role, d).rung);
367
+ }
368
+ const started = new Set(usable.filter((x) => starts.has(x.rung)).map((x) => quotaOf(x.info.parsed)));
369
+ return quotas
370
+ .filter((q) => !started.has(q))
371
+ .map((q) => [q, usable.filter((x) => quotaOf(x.info.parsed) === q).map((x) => x.rung)]);
372
+ }
373
+
337
374
  export function nativeClaudeIssue(rung: string, host: OrchestrationHost, path: string): Issue | null {
338
375
  const r = tryParseRung(rung);
339
376
  return host !== "claude-code" && r?.backend === "claude"