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,8 +1,6 @@
1
- import { billingKeyOf, type Catalog, DIMS, type Dim, rungInfo, scoresOf } from "./catalog.ts";
1
+ import { billingKeyOf, type Catalog, pairOf, rungInfo } from "./catalog.ts";
2
2
  import { type BillingMode, compareCost, type Cost, costOf, DEFAULT_BILLING } from "./cost.ts";
3
3
  import { type Rung, tryParseRung } from "./ids.ts";
4
- import { DIFFICULTIES, KINDS } from "./lane.ts";
5
- import { EFFORT_ORDER } from "./sources.ts";
6
4
 
7
5
  /**
8
6
  * Spec §7.1 and §4.5: a stand-in on the same quota would be out of quota too. The billing key names the
@@ -16,66 +14,35 @@ export function claudeBilled(rung: string): boolean {
16
14
  return r !== null && quotaOf(r) === "claude-code";
17
15
  }
18
16
 
19
- /** A rung's scores by dim, its own or borrowed through a treat-like; null when unscored or not a rung. */
20
- export function valuesOf(c: Catalog, rung: string): Partial<Record<Dim, number>> | null {
21
- try {
22
- return scoresOf(c, rungInfo(c, rung).canonical)?.values ?? null;
23
- } catch {
24
- return null;
25
- }
26
- }
17
+ /** A rung's intelligence index; null when it is not a rung or not indexed. */
18
+ export const intelligenceOf = (c: Catalog, rung: string): number | null =>
19
+ pairOf(c, rung)?.intelligence ?? null;
27
20
 
28
- /** The dims used by the routing bars (kind × difficulty) the rung clears: what makes it fit its lanes. */
29
- export function barDims(c: Catalog, rung: string): Dim[] {
30
- const v = valuesOf(c, rung);
31
- if (!v) return [];
32
- const used = new Set<Dim>();
33
- for (const kind of KINDS)
34
- for (const d of DIFFICULTIES) {
35
- const bar = Object.entries(c.bars[kind][d]).filter(([, min]) => min !== undefined) as [Dim, number][];
36
- if (bar.every(([dim, min]) => (v[dim] ?? Number.NEGATIVE_INFINITY) >= min))
37
- for (const [dim] of bar) used.add(dim);
38
- }
39
- return DIMS.filter((d) => used.has(d));
21
+ /** Spec 1.1 §11: `standIn` is a downgrade when the rung is indexed and the stand-in is not, or is less intelligent. */
22
+ export function downgrade(c: Catalog, rung: string, standIn: string): boolean {
23
+ const a = intelligenceOf(c, rung);
24
+ const b = intelligenceOf(c, standIn);
25
+ return a !== null && (b === null || b < a);
40
26
  }
41
27
 
42
- /**
43
- * Spec 1.1 §11: the dims, among those the rung's bars use, on which `standIn` scores below `rung` (a
44
- * missing score counts as below). Empty means the stand-in clears every bar the rung clears, at least as
45
- * well: no downgrade.
46
- */
47
- export function downgradeDims(c: Catalog, rung: string, standIn: string): Dim[] {
48
- const a = valuesOf(c, rung);
49
- if (!a) return [];
50
- const b = valuesOf(c, standIn) ?? {};
51
- return barDims(c, rung).filter((d) => (b[d] ?? Number.NEGATIVE_INFINITY) < (a[d] as number));
52
- }
53
-
54
- /**
55
- * Spec 1.1 §11 "the ladder goes down": the dims both rungs are scored on where `upper` (the later rung)
56
- * scores below `lower`, when it scores above it on none of them. Empty when it does not go down.
57
- */
58
- export function ladderDropDims(c: Catalog, lower: string, upper: string): Dim[] {
59
- const a = valuesOf(c, lower);
60
- const b = valuesOf(c, upper);
61
- if (!a || !b) return [];
62
- const shared = DIMS.filter((d) => a[d] !== undefined && b[d] !== undefined);
63
- if (shared.some((d) => (b[d] as number) > (a[d] as number))) return [];
64
- return shared.filter((d) => (b[d] as number) < (a[d] as number));
28
+ /** Spec 1.1 §11 "the ladder goes down": the later rung (`upper`) is less intelligent than `lower`. */
29
+ export function ladderDrop(c: Catalog, lower: string, upper: string): boolean {
30
+ const a = intelligenceOf(c, lower);
31
+ const b = intelligenceOf(c, upper);
32
+ return a !== null && b !== null && b < a;
65
33
  }
66
34
 
67
35
  const costFor = (c: Catalog, billing: Partial<Record<string, BillingMode>>, rung: string): Cost => {
68
36
  const info = rungInfo(c, rung);
69
- return costOf(info.family, info.parsed.effort, billing[info.key] ?? DEFAULT_BILLING[info.key]);
37
+ return costOf(info.family, info.pair, info.parsed.effort, billing[info.key] ?? DEFAULT_BILLING[info.key]);
70
38
  };
71
39
 
72
40
  /**
73
41
  * Spec 1.1 §11: the rungs of `pool` that can stand in for `rung`, best first. A stand-in is on another
74
- * quota, scored, paid from a plan or subscription under `billing` (a metered one spends money nobody chose
75
- * to), startable by `dispatch` (not a native `claude:` subagent), and no downgrade on the rung's bar dims.
76
- * Claude-billed stand-ins rank last, then the effort nearest the rung's own (a model's efforts carry one
77
- * another's values as `adjacent`, spec 1.2 §4.3, so the cheapest would otherwise be its lowest effort; plan 14
78
- * Ruling 4), then the cheapest.
42
+ * quota, indexed, paid from a plan or subscription under `billing` (a metered one spends money nobody chose
43
+ * to), startable by `dispatch` (not a native `claude:` subagent), and never less intelligent than the rung
44
+ * (a rung with no index of its own keeps every stand-in). Claude-billed stand-ins rank last, then the
45
+ * nearest intelligence above the rung's own, then the cheapest.
79
46
  */
80
47
  export function rankStandIns(
81
48
  c: Catalog,
@@ -85,13 +52,15 @@ export function rankStandIns(
85
52
  ): string[] {
86
53
  const from = tryParseRung(rung);
87
54
  if (!from) return [];
55
+ const own = intelligenceOf(c, rung);
88
56
  const fits = [...new Set(pool)].filter((x) => {
89
57
  const r = tryParseRung(x);
90
- if (!r || r.backend === "claude" || quotaOf(r) === quotaOf(from) || !valuesOf(c, x)) return false;
58
+ const i = r ? intelligenceOf(c, x) : null;
59
+ if (!r || r.backend === "claude" || quotaOf(r) === quotaOf(from) || i === null) return false;
91
60
  if (costFor(c, billing, x).tier === 1) return false;
92
- return downgradeDims(c, rung, x).length === 0;
61
+ return own === null || i >= own;
93
62
  });
94
- const gap = (x: string) => effortGap(from.effort, tryParseRung(x)?.effort ?? "");
63
+ const gap = (x: string) => (own === null ? 0 : (intelligenceOf(c, x) as number) - own);
95
64
  return fits.sort(
96
65
  (a, b) =>
97
66
  Number(claudeBilled(a)) - Number(claudeBilled(b)) ||
@@ -101,35 +70,28 @@ export function rankStandIns(
101
70
  );
102
71
  }
103
72
 
104
- /** How many effort steps apart two efforts are; an effort that is no effort word (`default`) is far from all. */
105
- function effortGap(a: string, b: string): number {
106
- const i = (EFFORT_ORDER as readonly string[]).indexOf(a);
107
- const j = (EFFORT_ORDER as readonly string[]).indexOf(b);
108
- if (a === b) return 0;
109
- return i < 0 || j < 0 ? EFFORT_ORDER.length : Math.abs(i - j);
110
- }
111
-
112
- /** How a family's `on` key or a model id's prefix maps to the backend that runs it. */
73
+ /** How a family's `on` key maps to the backend that runs it. */
113
74
  const BACKEND_OF_KEY: Record<string, string> = { "opencode-go": "opencode" };
114
75
 
115
76
  /**
116
- * Every rung the catalog knows how to name: each shipped family's model on each backend at each effort (at
117
- * `#default` when it has none),
118
- * each listed model at each effort, and each treat-like whose model is an opencode id (`opencode-go/…`,
119
- * `opencode/…`), such as the shipped Kimi K3 stand-in. Sorted, without duplicates.
77
+ * Every indexed rung catherd can name: each shipped family's model on each backend at each effort the index
78
+ * (or the backend's listing) gives it, and each listed model at each listed effort. Sorted, without duplicates.
120
79
  */
121
- export function catalogRungs(c: Catalog): string[] {
80
+ export function catalogPairs(c: Catalog): string[] {
122
81
  const out = new Set<string>();
82
+ const add = (backend: string, model: string, efforts: readonly string[]) => {
83
+ for (const e of [...efforts, "default"]) {
84
+ const rung = `${backend}:${model}#${e}`;
85
+ if (pairOf(c, rung)) out.add(rung);
86
+ }
87
+ };
123
88
  for (const f of c.families)
124
89
  for (const [key, m] of Object.entries(f.on)) {
125
90
  if (!m) continue;
126
- // a model with no effort has one rung, #default (Claude Code's Haiku, Cursor's Composer; spec 1.3 §7.1)
127
- for (const e of m.efforts.length ? m.efforts : ["default"])
128
- out.add(`${BACKEND_OF_KEY[key] ?? key}:${m.id}#${e}`);
91
+ const backend = BACKEND_OF_KEY[key] ?? key;
92
+ add(backend, m.id, rungInfo(c, `${backend}:${m.id}#default`).efforts);
129
93
  }
130
94
  for (const [backend, l] of Object.entries(c.listed))
131
- for (const m of l.models) for (const e of m.efforts) out.add(`${backend}:${m.id}#${e}`);
132
- for (const canonical of Object.keys(c.treatLike))
133
- if (/^opencode(-go)?\//.test(canonical)) out.add(`opencode:${canonical}`);
95
+ for (const m of l.models) add(backend, m.id, m.efforts);
134
96
  return [...out].sort();
135
97
  }
@@ -0,0 +1,47 @@
1
+ import type { Finding } from "./run-events.ts";
2
+
3
+ const LINE = /^\s*(?:[-*]\s+|\d+[.)]\s+)?\**(BLOCKER|BUG|NIT)\b\**\s*:?\s*(.*)$/;
4
+ /** What a reviewer writes where it has nothing to report: `BUG: none`, `NIT n/a`, a lone dash. */
5
+ const NOTHING = /^(?:none|n\/a|nil|no findings?|clean|[-—–]+)\.?\**$/i;
6
+ const PATH = /^[`'"(]*((?:\.{0,2}\/)?[\w@~.+-]+(?:\/[\w@~.+-]+)*)(?::\d+(?:[-:]\d+)?)?[`'"),;:]*$/;
7
+
8
+ /**
9
+ * The first `path:line` or `path` token of a finding's first words (a reviewer writes
10
+ * `BLOCKER file:line — problem — fix`), or null: a path has a `/` or an extension.
11
+ */
12
+ export function fileOf(text: string): string | null {
13
+ for (const tok of text.trim().split(/\s+/).slice(0, 4)) {
14
+ if (/^[—–-]+$/.test(tok)) return null;
15
+ const p = PATH.exec(tok)?.[1];
16
+ if (p && (p.includes("/") || /\.[A-Za-z]\w*$/.test(p))) return p;
17
+ }
18
+ return null;
19
+ }
20
+
21
+ /** A reviewer's reply as findings, in order: its BLOCKER, BUG and NIT lines, as written; "none" lines left out. */
22
+ export function parseFindings(reply: string): Finding[] {
23
+ const out: Finding[] = [];
24
+ for (const line of reply.split("\n")) {
25
+ const m = LINE.exec(line);
26
+ if (!m) continue;
27
+ const rest = (m[2] ?? "").trim();
28
+ if (!rest || NOTHING.test(rest)) continue;
29
+ out.push({ label: m[1] as Finding["label"], text: line.trim(), file: fileOf(rest) });
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /** The lines a failing verifier lists below its VERDICT line, as BLOCKERs: what the next fix round starts from. */
35
+ export function parseFailLines(reply: string, max = 12): Finding[] {
36
+ const lines = reply.split("\n").map((l) => l.trim());
37
+ const at = lines.findIndex((l) => l !== "");
38
+ return lines
39
+ .slice(at + 1)
40
+ .filter((l) => l !== "" && !/^STATUS:/i.test(l))
41
+ .slice(0, max)
42
+ .map((l) => ({
43
+ label: "BLOCKER" as const,
44
+ text: l.replace(/^(?:[-*]\s+|\d+[.)]\s+)/, ""),
45
+ file: fileOf(l.replace(/^(?:[-*]\s+|\d+[.)]\s+)/, "").replace(/^\**FAIL\**:?\s*/i, "")),
46
+ }));
47
+ }
@@ -0,0 +1,53 @@
1
+ // Plan 23: how a verifier runs a gate, which every verifier gets (its agent file, or its dispatch brief), and
2
+ // the run's own facts a dispatched verifier's brief adds: the items already recorded, the failed ones to
3
+ // re-check first, and the repo's gate environment.
4
+
5
+ /** A re-check's cap on each command, in minutes: a hung command fails its item, not the verifier. */
6
+ export const RECHECK_COMMAND_MIN = 10;
7
+
8
+ /** The rules of the gate, one paragraph each. */
9
+ export const VERIFIER_GATE_RULES = [
10
+ "Run the root gate (the milestone's full check) as one gate item and each per-service acceptance suite as its own item, never as one command: a failure then names its item, and a re-check reruns only that item. Acceptance that builds from HEAD (git archive, a commit's image) is yours: workers do not commit, so they never run it.",
11
+ "Run the milestone's full check with gate_run (or catherd gate run), on the committed HEAD, never by hand: catherd runs it detached under the verifier lock, reruns a failure with new lines once, scans the run's commits for secrets and records the result, and land reads that record. If it answers running, call it again. When it fails and no baseline exists, it says how to record the base's own failures from a checkout of the base.",
12
+ "Stay in the foreground until your verdict: no background watcher, and never end your turn while a command runs.",
13
+ `When the machine, not the work, stops an item (a VPN filter, a Docker client proxy, a dead registry, no DNS), probe it directly, twice, 5 s apart, and if it still fails, reply VERDICT: BLOCKED: environment — <the probe and its output>. That is a blocker for the owner, not a FAIL.`,
14
+ "Last, when you built Docker images, run docker image prune -f, so the gates of later milestones do not fill the disk.",
15
+ ];
16
+
17
+ /** The marker a verifier brief's catherd section starts with: added once. */
18
+ export const GATE_NOTES_HEADING = "## The gate (catherd)";
19
+
20
+ /** The run's facts for one verifier dispatch. */
21
+ export interface GateNotes {
22
+ milestone: string | null;
23
+ /** the milestone's recorded items, with whether each passed */
24
+ recorded: { item: string; passed: boolean }[];
25
+ /** the repo's gate environment, as `NAME=value` or `NAME=$FROM` */
26
+ env: string[];
27
+ }
28
+
29
+ /** The section a dispatched verifier's brief ends with (before the reply contract). */
30
+ export function gateNotes(n: GateNotes): string {
31
+ const failed = n.recorded.filter((r) => !r.passed).map((r) => r.item);
32
+ const lines = [GATE_NOTES_HEADING, "", ...VERIFIER_GATE_RULES.map((r) => `- ${r}`)];
33
+ if (n.milestone && n.recorded.length) {
34
+ lines.push(
35
+ `- Items already recorded for ${n.milestone}; reuse these names in gate_check, so what passed is carried over: ${n.recorded.map((r) => r.item).join(", ")}.`,
36
+ );
37
+ if (failed.length)
38
+ lines.push(
39
+ `- This is a re-check. Run the failed items first: ${failed.join(", ")}. Cap each command at ${RECHECK_COMMAND_MIN} minutes; a command that runs longer fails its item (say so), it never holds the gate.`,
40
+ );
41
+ }
42
+ if (n.env.length)
43
+ lines.push(
44
+ `- The repo's gate environment is already set in your env (a secret as $NAME of where it came from): ${n.env.join(", ")}.`,
45
+ );
46
+ return lines.join("\n");
47
+ }
48
+
49
+ /** `brief` with the gate notes appended, once: a brief that already has them is kept. */
50
+ export function withGateNotes(brief: string, n: GateNotes): string {
51
+ if (brief.includes(GATE_NOTES_HEADING)) return brief;
52
+ return `${brief.trimEnd()}\n\n${gateNotes(n)}\n`;
53
+ }
@@ -3,11 +3,22 @@ import type { RunRecord } from "./record.ts";
3
3
  /** Spec §4.4: what the orchestrator does next, read off one finished record. `dir` is the dispatch folder, relative to the run. */
4
4
  export function dispatchHints(r: RunRecord, owns: string[], dir: string): string[] {
5
5
  const h: string[] = [];
6
- if (r.status === "limit") h.push(`limit: ${r.backend} hit a usage limit on ${r.rung}`);
6
+ if (r.status === "limit")
7
+ h.push(
8
+ r.error?.code === "provider-unavailable"
9
+ ? `limit: ${r.rung}'s provider is unavailable (${r.error.message}); it fails over as on a usage limit`
10
+ : `limit: ${r.backend} hit a usage limit on ${r.rung}`,
11
+ );
7
12
  if (r.status === "cli-too-old") h.push(`cli-too-old: ${r.error?.message ?? `upgrade ${r.backend}`}`);
8
13
  if (r.status === "failed") h.push(`failed: read ${dir}/stderr`);
9
14
  if (r.gitUnavailable) h.push("git-unavailable: changed files unknown");
10
- if (r.replyStatus === "refused" || r.replyStatus === "blocked") h.push(`climb: ${r.replyStatus}`);
15
+ // plan 23: the environment stopped it, and a higher rung would stop the same way: fix it or surface it
16
+ if (r.environment) h.push(`environment: ${r.environment}`);
17
+ else if (r.replyStatus === "flaky")
18
+ h.push(
19
+ `flaky: ${r.replyWhy || "a check failed, then passed alone"}; rerun it alone, accept it, or climb: your call`,
20
+ );
21
+ else if (r.replyStatus === "refused" || r.replyStatus === "blocked") h.push(`climb: ${r.replyStatus}`);
11
22
  else if (
12
23
  r.status === "ok" &&
13
24
  !r.gitUnavailable &&
package/src/domain/ids.ts CHANGED
@@ -11,8 +11,8 @@ export function assertId(kind: string, value: string): string {
11
11
  return value;
12
12
  }
13
13
 
14
- /** The backends that run as a process through a `BackendAdapter` (spec 1.3: cursor, grok and antigravity). */
15
- export const ADAPTER_IDS = ["codex", "claude-code", "opencode", "cursor", "grok", "antigravity"] as const;
14
+ /** The backends that run as a process through a `BackendAdapter`. */
15
+ export const ADAPTER_IDS = ["codex", "claude-code", "opencode", "cursor"] as const;
16
16
 
17
17
  /** `claude` is the native Claude Code subagent path (no process); the rest are adapters. */
18
18
  const RUNG_BACKENDS = [...ADAPTER_IDS, "claude"] as const;
@@ -56,8 +56,19 @@ export function tryParseRung(s: string): Rung | null {
56
56
  }
57
57
  }
58
58
 
59
+ /** Reasoning efforts from least to most; a rung's effort that is none of these (`default`) cannot be ranked. */
60
+ export const EFFORT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"] as const;
61
+ export type EffortWord = (typeof EFFORT_ORDER)[number];
62
+
63
+ /** Index in `EFFORT_ORDER`; -1 when `e` is not an effort word. */
64
+ export const effortRank = (e: string): number => (EFFORT_ORDER as readonly string[]).indexOf(e);
65
+
59
66
  export const formatRung = (r: Rung): string => `${r.backend}:${r.model}#${r.effort}`;
60
67
 
68
+ /** Spec 2.0 decision 12: the daemon runs every role headless, so a native `claude:` rung runs as `claude-code:`. */
69
+ export const headlessRung = (rung: string): string =>
70
+ rung.startsWith("claude:") ? `claude-code:${rung.slice("claude:".length)}` : rung;
71
+
61
72
  const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
62
73
 
63
74
  let lastTime = -1;
package/src/domain/jev.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { BLOCKED_REASONS } from "./blocked.ts";
2
3
  import { type Difficulty, KINDS, type Kind, parseLaneHeader } from "./lane.ts";
3
4
  import { scrubKeyShapes, scrubSecretAssignments } from "./secrets.ts";
4
5
 
@@ -29,6 +30,12 @@ const RouteRuleSchema = z.object({
29
30
  /** exactly two levels each, in order: copy then build, logic then hard */
30
31
  trackA: z.array(z.string()).length(2),
31
32
  trackB: z.array(z.string()).length(2),
33
+ /** a noul answer at or above this is a yes */
34
+ noulMin: z.number().min(0).max(1),
35
+ /** a yes to any of these lifts the lane to at least logic */
36
+ floorLogic: z.array(z.string()),
37
+ /** a yes to any of these holds the lane at build; the floor wins */
38
+ capBuild: z.array(z.string()),
32
39
  });
33
40
  export type RouteRule = z.infer<typeof RouteRuleSchema>;
34
41
  const VerdictRuleSchema = z.object({ min: z.number().min(0).max(1), fallback: z.string() });
@@ -38,9 +45,16 @@ export const JevFileSchema = z.looseObject({
38
45
  schema: z.literal(1),
39
46
  model: z.string(),
40
47
  sets: z.object({
41
- "route-v2": z.object({ questions: z.record(z.string(), QuestionSchema), rule: RouteRuleSchema }),
48
+ "route-v2": z
49
+ .object({ questions: z.record(z.string(), QuestionSchema), rule: RouteRuleSchema })
50
+ .superRefine((set, ctx) => {
51
+ for (const id of [...set.rule.floorLogic, ...set.rule.capBuild])
52
+ if (set.questions[id]?.type !== "noul")
53
+ ctx.addIssue({ code: "custom", message: `${id} is no noul question of route-v2` });
54
+ }),
42
55
  finding: z.object({ questions: z.record(z.string(), QuestionSchema), rule: VerdictRuleSchema }),
43
56
  "same-defect": z.object({ questions: z.record(z.string(), QuestionSchema), rule: VerdictRuleSchema }),
57
+ stop: z.object({ questions: z.record(z.string(), QuestionSchema), rule: VerdictRuleSchema }),
44
58
  }),
45
59
  });
46
60
  export type JevFile = z.infer<typeof JevFileSchema>;
@@ -50,6 +64,7 @@ export type SetName = keyof JevFile["sets"];
50
64
  export const VERDICT_OPTIONS = {
51
65
  finding: ["design", "code", "unclear"],
52
66
  "same-defect": ["yes", "no"],
67
+ stop: [...BLOCKED_REASONS, "other"],
53
68
  } as const;
54
69
 
55
70
  /** JSON with object keys sorted at every depth, so equal data always hashes the same. */
@@ -199,7 +214,7 @@ export interface RouteJudgement {
199
214
  * different option counts; the rule reads probabilities instead: the kind at p_max ≥ kindMin, the track
200
215
  * when the summed probability of its levels reaches trackMin, and neither in between (the dead band).
201
216
  */
202
- export function judgeRoute(rule: RouteRule, answers: JevAnswers): RouteJudgement {
217
+ function decideRoute(rule: RouteRule, answers: JevAnswers): RouteJudgement {
203
218
  const k = answers.kind;
204
219
  const d = answers.difficulty;
205
220
  const nouls: Record<string, number> = {};
@@ -252,6 +267,36 @@ export function judgeRoute(rule: RouteRule, answers: JevAnswers): RouteJudgement
252
267
  };
253
268
  }
254
269
 
270
+ /**
271
+ * `decideRoute`, then Jev's yes/no answers (decision 11): a yes to a `floorLogic` question lifts the lane
272
+ * to logic (hard stays hard), else a yes to a `capBuild` question holds it at build (copy stays copy). The
273
+ * floor wins, and both apply in the dead band. The reason names the answer only when the difficulty moved.
274
+ */
275
+ export function judgeRoute(rule: RouteRule, answers: JevAnswers): RouteJudgement {
276
+ const j = decideRoute(rule, answers);
277
+ const yes = (ids: string[]) => ids.find((id) => (j.nouls[id] ?? 0) >= rule.noulMin);
278
+ const lift = yes(rule.floorLogic);
279
+ const hold = lift === undefined ? yes(rule.capBuild) : undefined;
280
+ const now: Difficulty | null =
281
+ lift !== undefined
282
+ ? j.difficulty === "hard"
283
+ ? "hard"
284
+ : "logic"
285
+ : hold !== undefined
286
+ ? j.difficulty === "copy"
287
+ ? "copy"
288
+ : "build"
289
+ : j.difficulty;
290
+ if (now === j.difficulty) return j;
291
+ const [what, id] = lift !== undefined ? ["floor logic", lift] : ["cap build", hold ?? ""];
292
+ return {
293
+ ...j,
294
+ track: now === "logic" || now === "hard" ? "B" : "A",
295
+ difficulty: now,
296
+ rule: `${j.rule}; ${what}: ${id} ${j.nouls[id]}`,
297
+ };
298
+ }
299
+
255
300
  export interface Verdict<T extends string> {
256
301
  value: T;
257
302
  /** the chosen answer's probability, when Jev answered */
@@ -0,0 +1,124 @@
1
+ import { z } from "zod";
2
+ import { BLOCKED_LINE } from "./blocked.ts";
3
+
4
+ export const JUDGE_KINDS = ["triage", "question", "unexpected", "rules"] as const;
5
+ export type JudgeKind = (typeof JUDGE_KINDS)[number];
6
+
7
+ /** What a judge call is given: its kind, the unit it is about, a context text and the numbered findings. */
8
+ export interface JudgeInput {
9
+ id: string;
10
+ kind: JudgeKind;
11
+ unit: string;
12
+ context: string;
13
+ findings: string[];
14
+ lanes: { id: string; owns: string[] }[];
15
+ }
16
+
17
+ const why = z.string();
18
+
19
+ /** A finished run's rules review proposes at most this many changes to the standing rules (plan 34). */
20
+ export const MAX_RULE_CHANGES = 5;
21
+ export const RuleChangeSchema = z.object({ op: z.enum(["add", "remove"]), text: z.string().min(1), why });
22
+ export type RuleChange = z.infer<typeof RuleChangeSchema>;
23
+
24
+ export const JudgeDecisionSchema = z.discriminatedUnion("verb", [
25
+ z.object({
26
+ verb: z.literal("route"),
27
+ routes: z.array(z.object({ finding: z.number().int().min(1), to: z.string(), why })),
28
+ }),
29
+ z.object({ verb: z.literal("answer"), answer: z.string() }),
30
+ z.object({ verb: z.literal("ask_owner"), question: z.string() }),
31
+ z.object({ verb: z.literal("park"), question: z.string() }),
32
+ z.object({ verb: z.literal("retry"), why }),
33
+ z.object({ verb: z.literal("climb"), why }),
34
+ z.object({ verb: z.literal("skip"), why }),
35
+ z.object({ verb: z.literal("rules"), changes: z.array(RuleChangeSchema) }),
36
+ ]);
37
+ export type JudgeDecision = z.infer<typeof JudgeDecisionSchema>;
38
+
39
+ export const judgeName = (id: string): string => `judge-${id}`;
40
+
41
+ /** The judge's reply contract, appended to every judge brief (spec 2.0 decision 3). */
42
+ export const JUDGE_CONTRACT = [
43
+ "Your first line is DECISION: <verb>, one of the verbs your brief lists. Then, by verb:",
44
+ "- route: one line per finding, ROUTE <n> -> <lane id>|architect|dismiss — <why>",
45
+ "- answer: ANSWER: <the answer the worker needs, from the plan and the code>",
46
+ "- ask_owner or park: QUESTION: <one question only the owner can answer>",
47
+ "- retry, climb or skip: WHY: <one line>",
48
+ "- rules: up to five lines, ADD <a one-line rule, with a [role, …] tag when it is not for every role> — <the event it comes from>, or REMOVE <the text of one line of knowledge.md> — <why>; no line when nothing should change",
49
+ `Decide only from what the brief and the run's files show. The last line of your reply is: STATUS: complete — <one line why>. ${BLOCKED_LINE}`,
50
+ ].join("\n");
51
+
52
+ /** The verbs each kind of call may answer with. */
53
+ export const JUDGE_VERBS: Record<JudgeKind, readonly JudgeDecision["verb"][]> = {
54
+ triage: ["route"],
55
+ question: ["answer", "ask_owner"],
56
+ unexpected: ["retry", "climb", "skip", "park"],
57
+ rules: ["rules"],
58
+ };
59
+
60
+ /** A line without the emphasis a model puts around it (`**DECISION: retry**`, backticks). */
61
+ const plain = (l: string): string => l.trim().replace(/^[*_`\s]+|[*_`\s]+$/g, "");
62
+ const STATUS = /^STATUS:\s*(complete|partial|blocked|refused|flaky)\b/i;
63
+ const RULE_LINE = /^\s*(?:[-*]|\d+[.)])?\s*\**(ADD|REMOVE)\**:?\s+(.+)$/;
64
+ const ROUTE = /^[-*\s]*ROUTE\s+(\d+)\s*(?:->|→)\s*(\S+)\s*(?:—|–|-|:)?\s*(.*)$/;
65
+
66
+ /** The text after `label:` on its line and the lines under it, up to the STATUS line. */
67
+ function section(lines: string[], label: string): string | null {
68
+ const at = lines.findIndex((l) => new RegExp(`^${label}:`, "i").test(plain(l)));
69
+ if (at < 0) return null;
70
+ const rest = [plain(lines[at] as string).slice(label.length + 1)];
71
+ for (const l of lines.slice(at + 1)) {
72
+ if (STATUS.test(plain(l))) break;
73
+ rest.push(l.trim());
74
+ }
75
+ return rest.join("\n").trim();
76
+ }
77
+
78
+ /** The judge's decision, or null when the reply is not one its kind allows (the daemon asks once more). */
79
+ export function parseJudgeReply(reply: string, kind: JudgeKind): JudgeDecision | null {
80
+ const lines = reply.split("\n");
81
+ const first = lines.map(plain).find(Boolean) ?? "";
82
+ const verb = /^DECISION:\s*(\w+)\s*$/i.exec(first)?.[1]?.toLowerCase();
83
+ if (!verb || !(JUDGE_VERBS[kind] as readonly string[]).includes(verb)) return null;
84
+ const decision = ((): unknown => {
85
+ switch (verb) {
86
+ case "route": {
87
+ const routes = lines.flatMap((l) => {
88
+ const m = ROUTE.exec(plain(l));
89
+ return m ? [{ finding: Number(m[1]), to: m[2] as string, why: (m[3] ?? "").trim() }] : [];
90
+ });
91
+ return routes.length ? { verb, routes } : null;
92
+ }
93
+ case "answer": {
94
+ const answer = section(lines, "ANSWER");
95
+ return answer ? { verb, answer } : null;
96
+ }
97
+ case "ask_owner":
98
+ case "park": {
99
+ const question = section(lines, "QUESTION");
100
+ return question ? { verb, question } : null;
101
+ }
102
+ case "rules": {
103
+ const changes = lines.flatMap((l) => {
104
+ const m = RULE_LINE.exec(l);
105
+ if (!m) return [];
106
+ const rest = (m[2] as string).trim();
107
+ const at = rest.lastIndexOf(" — ");
108
+ return [
109
+ {
110
+ op: (m[1] as string).toLowerCase(),
111
+ text: (at < 0 ? rest : rest.slice(0, at)).trim(),
112
+ why: at < 0 ? "" : rest.slice(at + 3).trim(),
113
+ },
114
+ ];
115
+ });
116
+ return { verb, changes: changes.slice(0, MAX_RULE_CHANGES) };
117
+ }
118
+ default:
119
+ return { verb, why: section(lines, "WHY") ?? "" };
120
+ }
121
+ })();
122
+ const parsed = JudgeDecisionSchema.safeParse(decision);
123
+ return parsed.success ? parsed.data : null;
124
+ }