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,668 @@
1
+ import { type JudgeInput, type JudgeKind, judgeName, type RuleChange } from "./judge.ts";
2
+ import { overlaps } from "./lane.ts";
3
+ import { approvalQuestion } from "./plan-doc.ts";
4
+ import { quotaOf } from "./failover.ts";
5
+ import { tryParseRung } from "./ids.ts";
6
+ import type { NotifyMoment } from "./profile.ts";
7
+ import type { Role } from "./roles.ts";
8
+ import type { ClimbReason } from "./route.ts";
9
+ import { ACTION_TYPES, type ParkReason, type ReportKind, RUN_UNIT, type RunEvent } from "./run-events.ts";
10
+ import {
11
+ activeMilestones,
12
+ integrateName,
13
+ isFinished,
14
+ type LaneState,
15
+ lanesOf,
16
+ limitedRung,
17
+ liveDispatches,
18
+ MAX_TRIES,
19
+ MERGE_CHECK_MS,
20
+ hasPlanInput,
21
+ type MilestoneState,
22
+ milestoneOf,
23
+ type RunState,
24
+ } from "./run-state.ts";
25
+
26
+ export type BriefKind = "dossier" | "architect" | "lane" | "fix" | "review" | "recheck" | "verify" | "judge";
27
+
28
+ export type Action =
29
+ | {
30
+ type: "dispatch";
31
+ unit: string;
32
+ name: string;
33
+ role: Role;
34
+ lane: string | null;
35
+ rung: string | null;
36
+ thread: string | null;
37
+ failoverFrom: string | null;
38
+ brief: BriefKind;
39
+ notes: string[];
40
+ }
41
+ | { type: "watch"; dispatchId: string }
42
+ | { type: "readPlan" }
43
+ | { type: "route"; milestone: string; confirmed: boolean }
44
+ | { type: "fastCheck"; lane: string }
45
+ | { type: "commit"; milestone: string; round: number }
46
+ | { type: "climb"; lane: string; reason: ClimbReason }
47
+ | { type: "judge"; input: JudgeInput }
48
+ | { type: "classify"; dispatchId: string }
49
+ | { type: "park"; unit: string; question: string; reason: ParkReason }
50
+ | { type: "land"; milestone: string; knownIssues: string[] }
51
+ | { type: "checkMerged"; milestone: string }
52
+ | { type: "integrate"; milestone: string }
53
+ | { type: "claimTree"; milestone: string }
54
+ | { type: "pushBranch"; milestone: string }
55
+ | { type: "cancel"; dispatchIds: string[] }
56
+ | { type: "report"; kind: ReportKind; unit: string; text: string }
57
+ | { type: "notify"; unit: string; moment: NotifyMoment }
58
+ | { type: "finish"; outcome: "done" | "cancelled" | "failed"; proposals?: RuleChange[] };
59
+
60
+ // every action type is one `effect.failed` can name, and the other way round
61
+ type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
62
+ export const ACTIONS_MATCH_EVENTS: Same<Action["type"], (typeof ACTION_TYPES)[number]> = true;
63
+
64
+ /** The key an issued action is remembered by until an event confirms it; the daemon never issues one twice. */
65
+ export function actionKey(a: Action): string {
66
+ switch (a.type) {
67
+ case "dispatch":
68
+ return `dispatch:${a.name}`;
69
+ case "watch":
70
+ case "classify":
71
+ return `${a.type}:${a.dispatchId}`;
72
+ case "readPlan":
73
+ return "readPlan";
74
+ case "route":
75
+ case "land":
76
+ case "checkMerged":
77
+ case "integrate":
78
+ case "claimTree":
79
+ case "pushBranch":
80
+ return `${a.type}:${a.milestone}`;
81
+ case "fastCheck":
82
+ case "climb":
83
+ return `${a.type}:${a.lane}`;
84
+ case "commit":
85
+ return `commit:${a.milestone}:${a.round}`;
86
+ case "judge":
87
+ return `judge:${a.input.unit}:${a.input.kind}`;
88
+ case "park":
89
+ return `park:${a.unit}`;
90
+ case "cancel":
91
+ return "cancel:run";
92
+ case "report":
93
+ return `report:${a.kind}:${a.unit}`;
94
+ case "notify":
95
+ return `notify:${a.unit}:${a.moment}`;
96
+ case "finish":
97
+ return "finish:run";
98
+ }
99
+ }
100
+
101
+ /** The unit an action works on: what `effect.failed` names it by. */
102
+ export function unitOf(a: Action, s?: RunState): string {
103
+ switch (a.type) {
104
+ case "dispatch":
105
+ case "park":
106
+ case "report":
107
+ case "notify":
108
+ return a.unit;
109
+ case "watch":
110
+ case "classify":
111
+ return s?.dispatches[a.dispatchId]?.unit ?? RUN_UNIT;
112
+ case "route":
113
+ case "land":
114
+ case "checkMerged":
115
+ case "integrate":
116
+ case "claimTree":
117
+ case "pushBranch":
118
+ case "commit":
119
+ return a.milestone;
120
+ case "fastCheck":
121
+ case "climb":
122
+ return a.lane;
123
+ case "judge":
124
+ return a.input.unit;
125
+ case "readPlan":
126
+ case "cancel":
127
+ case "finish":
128
+ return RUN_UNIT;
129
+ }
130
+ }
131
+
132
+ /** What `effect.failed` carries to name an action: its type and unit. The daemon frees the matching key. */
133
+ export const failKey = (a: Action, s?: RunState): string => `${a.type}@${unitOf(a, s)}`;
134
+
135
+ /** The keys an event settles: what the daemon forgets, so a still-ready action may be issued again. */
136
+ export function confirms(e: RunEvent): string[] {
137
+ switch (e.type) {
138
+ case "dispatch.started":
139
+ return [
140
+ `dispatch:${e.name}`,
141
+ ...(e.role === "judge" && e.judgeKind ? [`judge:${e.unit}:${e.judgeKind}`] : []),
142
+ ];
143
+ case "dispatch.refused":
144
+ return [`dispatch:${e.name}`];
145
+ case "dispatch.ended":
146
+ return [`watch:${e.dispatchId}`, `classify:${e.dispatchId}`, "cancel:run"];
147
+ case "stop.classified":
148
+ return [`classify:${e.dispatchId}`];
149
+ case "plan.ready":
150
+ return ["readPlan"];
151
+ case "preflight.done":
152
+ return [`route:${e.milestone}`];
153
+ case "check.ran":
154
+ return [`fastCheck:${e.lane}`];
155
+ case "commit.made":
156
+ case "commit.failed":
157
+ return [`commit:${e.milestone}:${e.round}`];
158
+ case "lane.climbed":
159
+ return [`climb:${e.lane}`];
160
+ case "judge.decided":
161
+ case "judge.failed":
162
+ return (["triage", "question", "unexpected", "rules"] as const).map((k) => `judge:${e.unit}:${k}`);
163
+ case "question.parked":
164
+ return [`park:${e.unit}`];
165
+ case "milestone.landed":
166
+ case "land.refused":
167
+ case "base.behind":
168
+ return [`land:${e.milestone}`];
169
+ case "integrate.done":
170
+ case "integrate.conflict":
171
+ return [`integrate:${e.milestone}`];
172
+ case "milestone.merged":
173
+ return [`checkMerged:${e.milestone}`];
174
+ case "tree.ready":
175
+ case "tree.deferred":
176
+ return [`claimTree:${e.milestone}`];
177
+ case "branch.pushed":
178
+ return [`pushBranch:${e.milestone}`];
179
+ case "run.ended":
180
+ return ["finish:run", "cancel:run"];
181
+ case "report.posted":
182
+ return [`report:${e.kind}:${e.unit}`];
183
+ case "effect.failed":
184
+ return [`${e.action}@${e.unit}`];
185
+ default:
186
+ return [];
187
+ }
188
+ }
189
+
190
+ const held = (s: RunState, rung: string | null): boolean => {
191
+ const r = rung ? tryParseRung(rung) : null;
192
+ const until = r ? s.holds[quotaOf(r)] : undefined;
193
+ return until !== undefined && until > s.at;
194
+ };
195
+ const waiting = (s: RunState, name: string): boolean => (s.waiting[name] ?? "") > s.at;
196
+ const hasLive = (s: RunState, name: string): boolean => liveDispatches(s).some((d) => d.name === name);
197
+ const judgeOpen = (s: RunState, unit: string): boolean =>
198
+ Object.values(s.judges).some((j) => j.unit === unit);
199
+
200
+ /** The brief and the dispatch of a lane-less role: its rung is routed by the effect, a limit gives a stand-in. */
201
+ function roleDispatch(
202
+ s: RunState,
203
+ o: { unit: string; name: string; role: Role; brief: BriefKind; notes?: string[]; thread?: string | null },
204
+ ): Action[] {
205
+ if (hasLive(s, o.name) || waiting(s, o.name)) return [];
206
+ const limited = limitedRung(s, o.name);
207
+ return [
208
+ {
209
+ type: "dispatch",
210
+ unit: o.unit,
211
+ name: o.name,
212
+ role: o.role,
213
+ lane: null,
214
+ rung: null,
215
+ thread: limited ? null : (o.thread ?? null),
216
+ failoverFrom: limited && held(s, limited) ? limited : null,
217
+ brief: o.brief,
218
+ notes: o.notes ?? [],
219
+ },
220
+ ];
221
+ }
222
+
223
+ const laneName = (l: LaneState): string => `${l.role}-${l.id}`;
224
+
225
+ /** The input of one judge call, rebuilt from the state: the daemon asks the same question again from it. */
226
+ export function judgeInputFor(s: RunState, id: string, kind: JudgeKind, unit: string): JudgeInput {
227
+ if (kind === "rules")
228
+ return {
229
+ id,
230
+ kind,
231
+ unit,
232
+ context: s.milestones
233
+ .map(
234
+ (m) =>
235
+ `${m.id} ${m.title}: ${m.counts.length} review round(s), ${m.knownIssues.length} known issue(s)`,
236
+ )
237
+ .join("\n"),
238
+ findings: [],
239
+ lanes: [],
240
+ };
241
+ const lane = s.lanes[unit];
242
+ const m = lane
243
+ ? s.milestones.find((x) => x.id === lane.milestone)
244
+ : s.milestones.find((x) => x.id === unit);
245
+ const lanes = (m ? lanesOf(s, m.id) : Object.values(s.lanes)).map((l) => ({ id: l.id, owns: l.owns }));
246
+ if (kind === "triage")
247
+ return {
248
+ id,
249
+ kind,
250
+ unit,
251
+ context: `Milestone ${unit}, review round ${m?.counts.length ?? 0}: findings no lane owns. Send each to the lane that should fix it, to the architect when the plan itself is wrong, or dismiss it.`,
252
+ findings: (m?.open ?? []).map((f) => f.text),
253
+ lanes,
254
+ };
255
+ const last = Object.values(s.dispatches)
256
+ .filter((d) => d.lane === unit)
257
+ .at(-1);
258
+ if (kind === "question")
259
+ return {
260
+ id,
261
+ kind,
262
+ unit,
263
+ context: last
264
+ ? `Lane ${unit}: ${last.name} stopped with STATUS ${last.ended?.replyStatus ?? "none"}.\nreply: roles/${last.name}/${last.dispatchId}/reply.md`
265
+ : `Lane ${unit} stopped and asked for a decision.`,
266
+ findings: [],
267
+ lanes,
268
+ };
269
+ return {
270
+ id,
271
+ kind,
272
+ unit,
273
+ context:
274
+ `Lane ${unit} is at the top rung of its ladder (${lane?.rung ?? "unknown"}) and did not get through; it has stood there ${lane?.sameReason ?? 0} time(s). Decide: retry once more, climb, skip it as a known issue, or park it for the owner.\n${lane?.notes.join("\n") ?? ""}`.trimEnd(),
275
+ findings: [],
276
+ lanes,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * A lane whose check was broken by something outside its task waits while a sibling of its milestone is still
282
+ * working or has not tried yet: the sibling may be what breaks (or fixes) the check. A sibling that waits on this
283
+ * lane, or that tried and stopped, never holds it.
284
+ */
285
+ function waitsForSibling(s: RunState, l: LaneState): boolean {
286
+ if (l.lastReason !== "check_broken" || l.stopRun !== 1) return false;
287
+ return lanesOf(s, l.milestone).some((o) => {
288
+ if (o.id === l.id || o.after.includes(l.id)) return false;
289
+ if (o.status === "running") return true;
290
+ if (o.status !== "pending") return false;
291
+ const tried = Object.values(s.dispatches).filter((d) => d.lane === o.id);
292
+ const latest = tried.at(-1);
293
+ return latest === undefined || (o.climbedAt !== null && latest.startedAt < o.climbedAt);
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Whether every lane `l` must follow has finished. In a base run (plan 35) a lane of the same milestone must be done,
299
+ * and another milestone's lane, or a whole milestone, must be merged: the milestones work side by side.
300
+ */
301
+ function afterReady(s: RunState, l: LaneState): boolean {
302
+ return l.after.every((a) => {
303
+ if (s.base !== null) {
304
+ const dep = a.split(".")[0] as string;
305
+ if (dep !== l.milestone) {
306
+ const m = milestoneOf(s, dep);
307
+ return m === undefined || isFinished(s, m);
308
+ }
309
+ }
310
+ return s.lanes[a] === undefined || s.lanes[a]?.status === "done";
311
+ });
312
+ }
313
+
314
+ function laneActions(s: RunState, m: MilestoneState): Action[] {
315
+ const out: Action[] = [];
316
+ const lanes = lanesOf(s, m.id);
317
+ const live = liveDispatches(s);
318
+ for (const l of lanes) {
319
+ if (s.stuck[l.id] || s.questions[l.id]) continue;
320
+ if (l.classify) {
321
+ out.push({ type: "classify", dispatchId: l.classify.dispatchId });
322
+ continue;
323
+ }
324
+ if (l.status === "checking" && !hasLive(s, laneName(l))) out.push({ type: "fastCheck", lane: l.id });
325
+ else if (l.status === "parked") {
326
+ if (!judgeOpen(s, l.id))
327
+ out.push(...onLaneStopped(s, l, l.awaiting ?? (l.top ? "unexpected" : "question")));
328
+ } else if (l.status === "pending") {
329
+ const name = laneName(l);
330
+ if (hasLive(s, name) || waiting(s, name)) continue;
331
+ if (waitsForSibling(s, l)) continue;
332
+ if (!afterReady(s, l)) continue;
333
+ // lanes that own the same paths wait for each other, as admission would refuse them anyway
334
+ const clash = live.some((d) => {
335
+ const other = d.lane && d.lane !== l.id ? s.lanes[d.lane] : undefined;
336
+ return other !== undefined && overlaps(l.owns, other.owns).length > 0;
337
+ });
338
+ if (clash) continue;
339
+ if (l.climbReason) {
340
+ out.push({ type: "climb", lane: l.id, reason: l.climbReason });
341
+ continue;
342
+ }
343
+ // a limit fails over while its quota is held; a provider_error that repeated does so with no hold
344
+ const failoverFrom = l.failoverNow
345
+ ? (l.failoverFrom ?? l.rung)
346
+ : held(s, l.rung)
347
+ ? l.rung
348
+ : l.failoverFrom && held(s, l.failoverFrom)
349
+ ? l.failoverFrom
350
+ : null;
351
+ out.push({
352
+ type: "dispatch",
353
+ unit: l.id,
354
+ name,
355
+ role: l.role,
356
+ lane: l.id,
357
+ rung: l.rung,
358
+ thread: failoverFrom ? null : l.thread,
359
+ failoverFrom,
360
+ brief: l.notes.length ? "fix" : "lane",
361
+ notes: l.notes,
362
+ });
363
+ }
364
+ }
365
+ return out;
366
+ }
367
+
368
+ /**
369
+ * A lane that asked a question (needs_user: the judge answers it from the plan and the code, or asks the owner) or
370
+ * ran out of rungs: the judge decides. The other stop reasons never reach it; the state maps them to a transition.
371
+ */
372
+ export function onLaneStopped(s: RunState, lane: LaneState, kind: "question" | "unexpected"): Action[] {
373
+ return [{ type: "judge", input: judgeInputFor(s, String(s.seq), kind, lane.id) }];
374
+ }
375
+
376
+ /** A limit: the unit went back to pending with its rung to fail over from (reduce did it); the dispatch takes the stand-in. */
377
+ export function onLimit(_s: RunState, _e: RunEvent): Action[] {
378
+ return [];
379
+ }
380
+
381
+ /**
382
+ * A verifier's verdict is in the milestone's status (reduce did it): landing only for a PASS that names the
383
+ * milestone's commit, a fresh verifier for a PASS that names another or a check that could not run, a fix round
384
+ * for a FAIL, a park for BLOCKED: environment. The dispatch of a fresh verifier is milestoneActions'.
385
+ */
386
+ export function onVerifier(_s: RunState, _m: MilestoneState, _e: RunEvent): Action[] {
387
+ return [];
388
+ }
389
+
390
+ /** A judge call whose answer was unreadable is asked again, under the same id, until the state gives up on it. */
391
+ function judgeRetries(s: RunState): Action[] {
392
+ return Object.entries(s.judges)
393
+ .filter(([id]) => !hasLive(s, judgeName(id)))
394
+ .map(([id, j]) => ({ type: "judge", input: judgeInputFor(s, id, j.kind, j.unit) }));
395
+ }
396
+
397
+ function fixActions(s: RunState, m: MilestoneState): Action[] {
398
+ const out: Action[] = [];
399
+ if (s.stuck[m.id]) return out;
400
+ if (m.open.length > 0 && !judgeOpen(s, m.id))
401
+ out.push({ type: "judge", input: judgeInputFor(s, String(s.seq), "triage", m.id) });
402
+ if (m.delta.length > 0 && !s.planDirty) {
403
+ const r = m.counts.length;
404
+ if (!Object.values(s.dispatches).some((d) => !d.ended && d.name.startsWith(`architect-${m.id}-`)))
405
+ out.push(
406
+ ...roleDispatch(s, {
407
+ unit: m.id,
408
+ name: `architect-${m.id}-${r}`,
409
+ role: "architect",
410
+ brief: "architect",
411
+ notes: m.delta,
412
+ }),
413
+ );
414
+ }
415
+ return out;
416
+ }
417
+
418
+ function reviewActions(s: RunState, m: MilestoneState): Action[] {
419
+ const out: Action[] = [];
420
+ for (const name of m.awaiting) {
421
+ if ((m.tries[name] ?? 0) >= MAX_TRIES) continue;
422
+ const second = name === `reviewer-${m.id}-2`;
423
+ const ui = name === `ui-reviewer-${m.id}`;
424
+ const prior = Object.values(s.dispatches)
425
+ .filter((d) => d.name === name && d.ended?.status === "ok")
426
+ .at(-1);
427
+ out.push(
428
+ ...roleDispatch(s, {
429
+ unit: m.id,
430
+ name,
431
+ role: ui ? "ui-reviewer" : "reviewer",
432
+ brief: second || (m.round > 0 && !ui) ? "recheck" : "review",
433
+ thread: second ? null : (prior?.thread ?? null),
434
+ }),
435
+ );
436
+ }
437
+ return out;
438
+ }
439
+
440
+ function milestoneActions(s: RunState, m: MilestoneState): Action[] {
441
+ if (s.stuck[m.id] || s.questions[m.id]) return [];
442
+ switch (m.status) {
443
+ case "waiting":
444
+ // a base run's milestone first needs its worktree, claimed one at a time (a memory deferral waits for its time)
445
+ if (s.base !== null && m.tree === null)
446
+ return m.claimAfter === null || m.claimAfter <= s.at ? [{ type: "claimTree", milestone: m.id }] : [];
447
+ return [{ type: "route", milestone: m.id, confirmed: m.confirmed }];
448
+ case "routing":
449
+ // preflight did not end (a crash, a lost effect): route again; the key keeps one in flight
450
+ return [{ type: "route", milestone: m.id, confirmed: m.confirmed }];
451
+ case "lanes":
452
+ return laneActions(s, m);
453
+ case "fixing":
454
+ return [...laneActions(s, m), ...fixActions(s, m)];
455
+ case "committing":
456
+ return [{ type: "commit", milestone: m.id, round: m.counts.length }];
457
+ case "review":
458
+ return reviewActions(s, m);
459
+ case "verifying":
460
+ return (m.tries[`verifier-${m.id}`] ?? 0) >= MAX_TRIES
461
+ ? []
462
+ : roleDispatch(s, {
463
+ unit: m.id,
464
+ name: `verifier-${m.id}`,
465
+ role: "verifier",
466
+ brief: "verify",
467
+ notes: m.verifierNotes,
468
+ });
469
+ case "landing":
470
+ return [{ type: "land", milestone: m.id, knownIssues: m.knownIssues }];
471
+ case "integrating":
472
+ // the merge first; its conflicts, once known, go to a worker, whose clean end is a commit (reduce)
473
+ if (m.integrateFiles.length === 0) return [{ type: "integrate", milestone: m.id }];
474
+ return roleDispatch(s, {
475
+ unit: m.id,
476
+ name: integrateName(m.id),
477
+ role: "worker",
478
+ brief: "fix",
479
+ notes: m.integrateFiles,
480
+ });
481
+ default:
482
+ return [];
483
+ }
484
+ }
485
+
486
+ /** The architect of the run's plan, again: on the thread of its last dispatch when there is something to change. */
487
+ function architectAgain(s: RunState): Action[] {
488
+ const thread = Object.values(s.dispatches)
489
+ .filter((d) => d.name === "architect")
490
+ .at(-1)?.thread;
491
+ return roleDispatch(s, {
492
+ unit: RUN_UNIT,
493
+ name: "architect",
494
+ role: "architect",
495
+ brief: "architect",
496
+ notes: s.revise,
497
+ thread: s.revise.length > 0 ? (thread ?? null) : null,
498
+ });
499
+ }
500
+
501
+ function planningActions(s: RunState): Action[] {
502
+ if (s.planDirty) return [{ type: "readPlan" }];
503
+ const planned = hasPlanInput(s);
504
+ const dossierOk = Object.values(s.dispatches).some(
505
+ (d) => d.name === "researcher-dossier" && d.ended?.status === "ok" && d.ended.replyStatus !== "blocked",
506
+ );
507
+ if (planned || dossierOk) return architectAgain(s);
508
+ return roleDispatch(s, {
509
+ unit: RUN_UNIT,
510
+ name: "researcher-dossier",
511
+ role: "researcher",
512
+ brief: "dossier",
513
+ });
514
+ }
515
+
516
+ /**
517
+ * The owner has not approved the plan yet: read what the architect wrote, let it revise on a change request, and
518
+ * otherwise ask. No lane and no route runs meanwhile.
519
+ */
520
+ function approvalActions(s: RunState): Action[] {
521
+ if (s.planDirty) return [{ type: "readPlan" }];
522
+ if (hasLive(s, "architect")) return [];
523
+ if (s.revise.length > 0) return architectAgain(s);
524
+ return [{ type: "park", unit: RUN_UNIT, question: approvalQuestion(s.run), reason: "approval" }];
525
+ }
526
+
527
+ /** The check the owner runs right before merging an MR: the branch must contain origin's base. */
528
+ const precheck = (s: RunState, m: MilestoneState): string => {
529
+ const base = s.base ?? "the base";
530
+ const tree = m.tree ?? ".";
531
+ return `git -C ${tree} fetch -q origin ${base} && git -C ${tree} merge-base --is-ancestor origin/${base} HEAD`;
532
+ };
533
+
534
+ /**
535
+ * A landed milestone of a base run is the owner's to merge; catherd pushes its branch only when the owner answers
536
+ * `push` (plan 35), and then asks again with the MR command. It never opens an MR and never merges.
537
+ */
538
+ function pushQuestion(s: RunState, m: MilestoneState): string {
539
+ const branch = m.branch ?? "the run's branch";
540
+ if (m.mr !== null)
541
+ return `${m.id}'s branch ${branch} is pushed to origin. Open its MR: ${m.mr}, and merge it with a merge commit; right before the merge run ${precheck(s, m)}. Answer once it is merged.`;
542
+ const tree = m.tree ?? ".";
543
+ return `${m.id} landed on ${branch} at ${(m.commit ?? "").slice(0, 12)}. Answer push and catherd pushes it (git -C ${tree} push -u origin ${branch}); or push it yourself. Then open its MR with ${m.digestPath ?? "its digest"} as the body, and merge it with a merge commit; right before the merge run ${precheck(s, m)}. Answer once it is merged; in a repo that squash-merges, catherd cannot see the merge, so your answer is what unblocks the milestones after it.`;
544
+ }
545
+
546
+ /**
547
+ * Plan 34: a run whose milestones all finished asks a judge to review the repo's standing rules, then finishes with
548
+ * its proposals. The run never waits on the owner for it, and a judge that cannot answer ends the run without one.
549
+ */
550
+ function rulesReviewActions(s: RunState): Action[] {
551
+ const review = s.rulesReview;
552
+ if (review)
553
+ return [{ type: "finish", outcome: "done", ...(review.failed ? {} : { proposals: review.changes }) }];
554
+ if (Object.values(s.dispatches).some((d) => d.name === judgeName("rules"))) return judgeRetries(s);
555
+ return [{ type: "judge", input: judgeInputFor(s, "rules", "rules", RUN_UNIT) }];
556
+ }
557
+
558
+ function advance(s: RunState): Action[] {
559
+ if (s.phase === "ended") return [];
560
+ const live = liveDispatches(s);
561
+ if (s.cancelling)
562
+ return live.length > 0
563
+ ? [{ type: "cancel", dispatchIds: live.map((d) => d.dispatchId) }]
564
+ : [{ type: "finish", outcome: "cancelled" }];
565
+ const parks: Action[] = Object.entries(s.stuck)
566
+ .filter(([unit]) => !s.questions[unit])
567
+ .map(([unit, q]) => ({ type: "park", unit, question: q.question, reason: q.reason }));
568
+ if (s.base !== null)
569
+ for (const m of s.milestones)
570
+ if (m.status === "landed" && !s.questions[m.id] && !s.stuck[m.id])
571
+ parks.push(
572
+ m.pushing
573
+ ? { type: "pushBranch", milestone: m.id }
574
+ : { type: "park", unit: m.id, question: pushQuestion(s, m), reason: "push" },
575
+ );
576
+ if (s.stuck[RUN_UNIT] || s.questions[RUN_UNIT]) return parks;
577
+ if (s.phase === "planning") return [...parks, ...planningActions(s)];
578
+ if (s.phase === "approval") return [...parks, ...approvalActions(s)];
579
+ if (s.planDirty) return [...parks, { type: "readPlan" }];
580
+ if (s.milestones.length > 0 && s.milestones.every((m) => isFinished(s, m)))
581
+ return [...parks, ...rulesReviewActions(s)];
582
+ return [...parks, ...judgeRetries(s), ...activeMilestones(s).flatMap((m) => milestoneActions(s, m))];
583
+ }
584
+
585
+ function react(s: RunState, e: RunEvent): Action[] {
586
+ switch (e.type) {
587
+ case "daemon.started":
588
+ return liveDispatches(s).map((d) => ({ type: "watch", dispatchId: d.dispatchId }));
589
+ case "question.parked":
590
+ return [
591
+ {
592
+ type: "report",
593
+ kind: "parked",
594
+ unit: e.unit,
595
+ text: `${e.unit} is parked (${e.reason}): ${e.question}`,
596
+ },
597
+ { type: "notify", unit: e.unit, moment: "blocked" },
598
+ ];
599
+ case "milestone.landed":
600
+ return [
601
+ {
602
+ type: "report",
603
+ kind: "landed",
604
+ unit: e.milestone,
605
+ text: `${e.milestone} landed at ${e.commit.slice(0, 7)}${
606
+ e.knownIssues.length ? `, with ${e.knownIssues.length} known issue(s)` : ""
607
+ }: ${s.milestones.find((m) => m.id === e.milestone)?.title ?? ""}`.trimEnd(),
608
+ },
609
+ { type: "notify", unit: e.milestone, moment: "milestone" },
610
+ ...(s.base !== null ? [{ type: "checkMerged" as const, milestone: e.milestone }] : []),
611
+ ];
612
+ case "question.answered": {
613
+ // the owner's word that a landed milestone is merged: its clean worktree goes (checkMerged removes it)
614
+ const m = s.base !== null ? s.milestones.find((x) => x.id === e.unit) : undefined;
615
+ return m?.status === "merged" && m.tree !== null ? [{ type: "checkMerged", milestone: m.id }] : [];
616
+ }
617
+ case "tick":
618
+ // a landed milestone whose merge check this tick re-armed (reduce set its next look MERGE_CHECK_MS ahead)
619
+ return s.milestones
620
+ .filter(
621
+ (m) =>
622
+ m.status === "landed" && m.mergeDue === new Date(Date.parse(e.at) + MERGE_CHECK_MS).toISOString(),
623
+ )
624
+ .map((m) => ({ type: "checkMerged" as const, milestone: m.id }));
625
+ case "run.ended":
626
+ return [
627
+ {
628
+ type: "report",
629
+ kind: e.outcome === "done" ? "finished" : e.outcome,
630
+ unit: RUN_UNIT,
631
+ text: `The run ${e.outcome === "done" ? "finished" : e.outcome}${e.reportPath ? `: ${e.reportPath}` : ""}${
632
+ s.rulesReview?.changes.length
633
+ ? `; ${s.rulesReview.changes.length} proposed change(s) to the standing rules wait in the report`
634
+ : ""
635
+ }`,
636
+ },
637
+ { type: "notify", unit: RUN_UNIT, moment: "finish" },
638
+ ];
639
+ default:
640
+ return [];
641
+ }
642
+ }
643
+
644
+ /** The reports and notices still owed: a park's and a landing's, until their `report.posted`. */
645
+ function owedActions(s: RunState): Action[] {
646
+ return Object.values(s.owed)
647
+ .filter((o) => (s.failedEffects[`report@${o.unit}`] ?? 0) < MAX_TRIES)
648
+ .flatMap((o): Action[] => [
649
+ { type: "report", kind: o.kind, unit: o.unit, text: o.text },
650
+ { type: "notify", unit: o.unit, moment: o.moment },
651
+ ]);
652
+ }
653
+
654
+ const sameLine = (s: RunState, a: Action): boolean => a.type === "dispatch" && hasLive(s, a.name);
655
+
656
+ /**
657
+ * The machine: the reactions to `e`, then every step the state is ready for (`state` is the fold including `e`).
658
+ * Level-triggered, so the daemon drops what it issued already (`actionKey`) until an event confirms it.
659
+ */
660
+ export function next(state: RunState, e: RunEvent): Action[] {
661
+ const seen = new Set<string>();
662
+ return [...react(state, e), ...owedActions(state), ...advance(state)].filter((a) => {
663
+ const key = actionKey(a);
664
+ if (seen.has(key) || sameLine(state, a)) return false;
665
+ seen.add(key);
666
+ return true;
667
+ });
668
+ }