catherd-cli 1.5.0 → 2.0.0-beta.20261022113309

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/README.md +164 -208
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -247
  5. package/package.json +6 -8
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/commands/catherd.md +2 -2
  10. package/plugin/skills/catherd/SKILL.md +97 -421
  11. package/plugin/skills/catherd-setup/SKILL.md +20 -19
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +1 -2
  14. package/src/adapters/cursor/home.ts +1 -1
  15. package/src/adapters/cursor/index.ts +4 -4
  16. package/src/cli.ts +22 -27
  17. package/src/domain/bars.ts +33 -107
  18. package/src/domain/blocked.ts +72 -0
  19. package/src/domain/branch.ts +51 -0
  20. package/src/domain/brief.ts +21 -6
  21. package/src/domain/catalog.ts +69 -324
  22. package/src/domain/cost.ts +15 -30
  23. package/src/domain/errors.ts +2 -2
  24. package/src/domain/fail-lines.ts +26 -0
  25. package/src/domain/failover.ts +37 -87
  26. package/src/domain/findings.ts +47 -0
  27. package/src/domain/gate-brief.ts +1 -0
  28. package/src/domain/ids.ts +13 -2
  29. package/src/domain/jev.ts +47 -2
  30. package/src/domain/judge.ts +124 -0
  31. package/src/domain/lane.ts +22 -3
  32. package/src/domain/milestone-memory.ts +53 -0
  33. package/src/domain/notice.ts +25 -8
  34. package/src/domain/notify.ts +32 -0
  35. package/src/domain/plan-doc.ts +198 -0
  36. package/src/domain/plan-lint.ts +141 -0
  37. package/src/domain/profile-rules.ts +101 -160
  38. package/src/domain/profile.ts +116 -63
  39. package/src/domain/record.ts +53 -2
  40. package/src/domain/role-prompts.ts +131 -45
  41. package/src/domain/role-scope.ts +4 -34
  42. package/src/domain/role-tools.ts +2 -2
  43. package/src/domain/roles.ts +2 -0
  44. package/src/domain/route.ts +17 -0
  45. package/src/domain/rules.ts +33 -0
  46. package/src/domain/run-events.ts +298 -0
  47. package/src/domain/run-machine.ts +668 -0
  48. package/src/domain/run-state.ts +1310 -0
  49. package/src/domain/scorecard.ts +197 -0
  50. package/src/domain/select.ts +151 -146
  51. package/src/domain/slopalytics.ts +72 -0
  52. package/src/entry/answer-command.ts +39 -0
  53. package/src/entry/catalog-command.ts +25 -174
  54. package/src/entry/check-bin.ts +17 -0
  55. package/src/entry/cli-kit.ts +1 -1
  56. package/src/entry/daemon-bin.ts +22 -0
  57. package/src/entry/glyphs.ts +15 -2
  58. package/src/entry/init-command.ts +18 -68
  59. package/src/entry/knowledge-command.ts +45 -4
  60. package/src/entry/lock-command.ts +6 -7
  61. package/src/entry/mcp/role-server.ts +18 -0
  62. package/src/entry/mcp/server.ts +13 -40
  63. package/src/entry/mcp/session-tools.ts +149 -0
  64. package/src/entry/panel/openapi.ts +189 -0
  65. package/src/entry/panel/server.ts +410 -0
  66. package/src/entry/panel-command.ts +68 -0
  67. package/src/entry/profile-command.ts +10 -14
  68. package/src/entry/role-cli-command.ts +47 -3
  69. package/src/entry/runs-command.ts +3 -19
  70. package/src/entry/scorecard-command.ts +187 -0
  71. package/src/entry/supervise-bin.ts +1 -1
  72. package/src/infra/env.ts +0 -2
  73. package/src/infra/git.ts +64 -0
  74. package/src/infra/heavy-lock.ts +45 -7
  75. package/src/infra/launch.ts +63 -7
  76. package/src/infra/memory.ts +45 -0
  77. package/src/infra/ntfy.ts +40 -0
  78. package/src/infra/open-url.ts +42 -0
  79. package/src/infra/paths.ts +2 -2
  80. package/src/infra/slopalytics.ts +30 -0
  81. package/src/infra/store.ts +6 -1
  82. package/src/services/admission.ts +24 -9
  83. package/src/services/backends.ts +4 -6
  84. package/src/services/capture.ts +1 -22
  85. package/src/services/catalog-service.ts +155 -262
  86. package/src/services/credentials.ts +4 -20
  87. package/src/services/daemon.ts +181 -0
  88. package/src/services/dispatch-service.ts +5 -1
  89. package/src/services/doctor-backends.ts +1 -1
  90. package/src/services/doctor-checks.ts +47 -0
  91. package/src/services/doctor.ts +4 -9
  92. package/src/services/failover-rungs.ts +109 -0
  93. package/src/services/finalize.ts +12 -0
  94. package/src/services/gate-env.ts +1 -2
  95. package/src/services/gate-service.ts +21 -6
  96. package/src/services/jev-service.ts +2 -4
  97. package/src/services/lane-service.ts +34 -3
  98. package/src/services/milestone-check.ts +433 -0
  99. package/src/services/milestone-tree.ts +170 -0
  100. package/src/services/milestones.ts +35 -8
  101. package/src/services/notifier.ts +69 -2
  102. package/src/services/notify-service.ts +26 -0
  103. package/src/services/panel-setup.ts +116 -0
  104. package/src/services/plan-input.ts +151 -0
  105. package/src/services/ports.ts +30 -4
  106. package/src/services/preflight.ts +82 -8
  107. package/src/services/profile-service.ts +30 -3
  108. package/src/services/profile-store.ts +8 -3
  109. package/src/services/protocol.ts +4 -0
  110. package/src/services/provenance.ts +31 -90
  111. package/src/services/questions.ts +9 -0
  112. package/src/services/reconcile.ts +11 -0
  113. package/src/services/routing-service.ts +26 -15
  114. package/src/services/run-briefs.ts +477 -0
  115. package/src/services/run-client.ts +217 -0
  116. package/src/services/run-effects.ts +837 -0
  117. package/src/services/run-events-store.ts +174 -0
  118. package/src/services/run-evidence.ts +1 -1
  119. package/src/services/run-read.ts +221 -0
  120. package/src/services/run-report.ts +176 -0
  121. package/src/services/run-service.ts +55 -6
  122. package/src/services/run-store.ts +54 -7
  123. package/src/services/scorecard-metrics.ts +86 -0
  124. package/src/services/scorecard.ts +382 -0
  125. package/src/services/summary.ts +27 -3
  126. package/src/services/tree-path.ts +27 -0
  127. package/src/services/workspace-admission.ts +5 -13
  128. package/src/services/workspace-service.ts +5 -0
  129. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  130. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  131. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  132. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  133. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  134. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  135. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  136. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  137. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  138. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  139. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  140. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  141. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  142. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  143. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  144. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  145. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  146. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  147. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  148. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  149. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  150. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  151. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  152. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  153. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  154. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  155. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  156. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  157. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  158. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  159. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  160. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  161. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  162. package/web/dist/assets/index-C5IbylvM.css +2 -0
  163. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  164. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  165. package/web/dist/assets/label-DFSYBc62.js +1 -0
  166. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  167. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  168. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  169. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  170. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  171. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  172. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  173. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  174. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  175. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  176. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  177. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  178. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  179. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  180. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  181. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  183. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  184. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  185. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  186. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  187. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  188. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  189. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  190. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  191. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  192. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  193. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  194. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  195. package/web/dist/assets/time--9kSQbbp.js +1 -0
  196. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  197. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  198. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  199. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  200. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  201. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  202. package/web/dist/index.html +15 -0
  203. package/catalog/ATTRIBUTION.md +0 -44
  204. package/catalog/scores.json +0 -3202
  205. package/catalog/sources.json +0 -97
  206. package/src/adapters/antigravity/events.ts +0 -131
  207. package/src/adapters/antigravity/home.ts +0 -46
  208. package/src/adapters/antigravity/index.ts +0 -335
  209. package/src/adapters/antigravity/models.ts +0 -37
  210. package/src/adapters/grok/events.ts +0 -112
  211. package/src/adapters/grok/home.ts +0 -187
  212. package/src/adapters/grok/index.ts +0 -358
  213. package/src/adapters/grok/models.ts +0 -48
  214. package/src/domain/calibration.ts +0 -132
  215. package/src/domain/sources.ts +0 -144
  216. package/src/entry/mcp/dispatch-tools.ts +0 -52
  217. package/src/entry/mcp/lane-tools.ts +0 -131
  218. package/src/entry/mcp/protocol-tools.ts +0 -63
  219. package/src/entry/mcp/push-tools.ts +0 -16
  220. package/src/entry/mcp/run-tools.ts +0 -164
  221. package/src/entry/mcp/setup-tools.ts +0 -109
  222. package/src/entry/mcp/workspace-tools.ts +0 -142
  223. package/src/entry/tui/commands.ts +0 -563
  224. package/src/entry/tui/effects.ts +0 -385
  225. package/src/entry/tui/fixtures.ts +0 -637
  226. package/src/entry/tui/profile-edits.ts +0 -146
  227. package/src/entry/tui/profile-tree.ts +0 -429
  228. package/src/entry/tui/providers/app.tsx +0 -162
  229. package/src/entry/tui/providers/boundary.tsx +0 -57
  230. package/src/entry/tui/providers/data.tsx +0 -160
  231. package/src/entry/tui/providers/keymap.tsx +0 -166
  232. package/src/entry/tui/providers/root.tsx +0 -45
  233. package/src/entry/tui/providers/theme.tsx +0 -17
  234. package/src/entry/tui/providers/toast.tsx +0 -77
  235. package/src/entry/tui/run.tsx +0 -114
  236. package/src/entry/tui/state.ts +0 -400
  237. package/src/entry/tui/stories.ts +0 -114
  238. package/src/entry/tui/text.ts +0 -130
  239. package/src/entry/tui/theme.ts +0 -140
  240. package/src/entry/tui/views/app.tsx +0 -208
  241. package/src/entry/tui/views/dialogs.tsx +0 -41
  242. package/src/entry/tui/views/profile-actions.ts +0 -331
  243. package/src/entry/tui/views/profiles.tsx +0 -520
  244. package/src/entry/tui/views/runs.tsx +0 -619
  245. package/src/entry/tui/views/save-dialog.tsx +0 -344
  246. package/src/entry/tui/views/status.tsx +0 -213
  247. package/src/entry/tui/widgets/chrome.tsx +0 -114
  248. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  249. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  250. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  251. package/src/entry/tui/widgets/dialog.tsx +0 -68
  252. package/src/entry/tui/widgets/line.tsx +0 -92
  253. package/src/entry/tui/widgets/list.tsx +0 -197
  254. package/src/entry/tui/widgets/spinner.tsx +0 -49
  255. package/src/entry/tui/widgets/toast.tsx +0 -62
  256. package/src/infra/sources/arena.ts +0 -95
  257. package/src/infra/sources/artificial-analysis.ts +0 -119
  258. package/src/infra/sources/cache.ts +0 -126
  259. package/src/infra/sources/csv.ts +0 -38
  260. package/src/infra/sources/epoch.ts +0 -82
  261. package/src/infra/sources/http.ts +0 -75
  262. package/src/infra/sources/litellm.ts +0 -58
  263. package/src/infra/sources/models-dev.ts +0 -59
  264. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  265. package/src/infra/sources/openrouter-models.ts +0 -60
  266. package/src/infra/sources/rows.ts +0 -38
  267. package/src/infra/sources/vectara.ts +0 -69
  268. package/src/infra/sources/zip.ts +0 -48
  269. package/src/services/catalog-refresh.ts +0 -201
  270. package/src/services/doctor-sources.ts +0 -76
  271. package/src/services/runs-page.ts +0 -290
  272. package/src/services/source-derive.ts +0 -397
  273. package/src/services/source-sync.ts +0 -312
  274. package/src/services/standins.ts +0 -238
  275. package/src/services/treat-likes.ts +0 -113
@@ -0,0 +1,1310 @@
1
+ import type { BlockedReason } from "./blocked.ts";
2
+ import { quotaOf } from "./failover.ts";
3
+ import { MEMORY_LIMITS } from "./milestone-memory.ts";
4
+ import { tryParseRung } from "./ids.ts";
5
+ import { type JudgeKind, judgeName, type RuleChange } from "./judge.ts";
6
+ import type { ReplyStatus, RunStatus } from "./record.ts";
7
+ import type { Role } from "./roles.ts";
8
+ import type { NotifyMoment } from "./profile.ts";
9
+ import { isPushAnswer } from "./branch.ts";
10
+ import type { ClimbReason } from "./route.ts";
11
+ import {
12
+ type Finding,
13
+ issueLine,
14
+ type MilestonePlan,
15
+ type ParkReason,
16
+ type ReportKind,
17
+ RUN_UNIT,
18
+ type RunEvent,
19
+ } from "./run-events.ts";
20
+
21
+ /** A quota that hit a limit holds this long, or less once a role on it finishes cleanly (spec 2.0 decision 6). */
22
+ export const HOLD_MS = 30 * 60_000;
23
+ /** A paused machine, or a refusal that clears itself, is tried again after this long. */
24
+ export const REFUSAL_WAIT_MS = 5 * 60_000;
25
+ /** A lane parked this many times at the top rung stops asking the judge and waits for the owner. */
26
+ export const TOP_RUNG_JUDGES = 3;
27
+ /** A name refused as a duplicate this many times in a row parks its unit. */
28
+ export const DUPLICATE_LIMIT = 3;
29
+ /** A role that fails this many times without a limit parks its unit. */
30
+ export const MAX_TRIES = 2;
31
+ /** A plan the check refused goes back to its architect this many times before the run parks (plan 32). */
32
+ export const PLAN_REVISIONS = 2;
33
+
34
+ /** Spec 2.0 decision 5: a soft cap of max(3, 2 + ⌈lanes/3⌉) fix rounds. */
35
+ export const softCap = (lanes: number): number => Math.max(3, 2 + Math.ceil(lanes / 3));
36
+
37
+ /** A finding's identity across rounds: its file and its first words, without line numbers (t3herd `findingKey`). */
38
+ export const findingKey = (f: string): string =>
39
+ f
40
+ .replace(/^[\W\d]*(BLOCKER|BUG)\**\s*/i, "")
41
+ .replace(/:\d+(-\d+)?/g, "")
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9/._ ]+/g, " ")
44
+ .split(/\s+/)
45
+ .filter(Boolean)
46
+ .slice(0, 8)
47
+ .join(" ");
48
+
49
+ export type MilestoneStatus =
50
+ | "waiting"
51
+ | "routing"
52
+ | "lanes"
53
+ | "committing"
54
+ | "review"
55
+ | "fixing"
56
+ | "verifying"
57
+ | "integrating"
58
+ | "landing"
59
+ | "landed"
60
+ | "merged"
61
+ | "parked";
62
+ export type LaneStatus = "pending" | "running" | "checking" | "done" | "parked";
63
+
64
+ /** What a lane's dispatch said when it ended, for the part of the state it changes. */
65
+ interface LaneEnd {
66
+ rung: string;
67
+ status: RunStatus;
68
+ replyStatus: ReplyStatus | null;
69
+ thread: string | null;
70
+ why: string;
71
+ foreign: string[];
72
+ }
73
+
74
+ export interface LaneState {
75
+ id: string;
76
+ milestone: string;
77
+ role: Role;
78
+ kind: string | null;
79
+ after: string[];
80
+ owns: string[];
81
+ status: LaneStatus;
82
+ /** the lane's current route (after any climb) */
83
+ rung: string | null;
84
+ /** the thread its CLI session is on, resumed by a check retry and a fix round; null starts fresh */
85
+ thread: string | null;
86
+ /** its last climb reached the top rung */
87
+ top: boolean;
88
+ /** failed or partial tries since the last ok end or climb */
89
+ attempts: number;
90
+ checkFails: number;
91
+ /** the rung whose quota hit a limit: the next dispatch takes a stand-in */
92
+ failoverFrom: string | null;
93
+ lastReason: BlockedReason | null;
94
+ /** how many times the lane stood at the top rung (the judge is asked each time, up to TOP_RUNG_JUDGES) */
95
+ sameReason: number;
96
+ notes: string[];
97
+ /** the review round whose fix this lane was armed for (a survived finding climbs first) */
98
+ fixedRound: number | null;
99
+ /** a climb the lane waits for before it runs again */
100
+ climbReason: ClimbReason | null;
101
+ /** how many stops in a row named `lastReason` (a provider_error is retried once, a check_broken waits once) */
102
+ stopRun: number;
103
+ /** a provider_error that repeated: the next dispatch fails over although no limit was hit */
104
+ failoverNow: boolean;
105
+ /** the lane's pending climb is for a too_hard stop: at the top rung the owner is asked, not the judge */
106
+ tooHard: boolean;
107
+ /** what the judge is asked about the parked lane: an owner-level question, or a lane out of rungs */
108
+ awaiting: "question" | "unexpected" | null;
109
+ /** an unexplained stop waiting for its classification: nothing else happens to the lane until it is in */
110
+ classify: (LaneEnd & { dispatchId: string }) | null;
111
+ /** when the lane last climbed: a sibling "untried since" is judged against it */
112
+ climbedAt: string | null;
113
+ }
114
+
115
+ export interface MilestoneState {
116
+ id: string;
117
+ title: string;
118
+ after: string[];
119
+ status: MilestoneStatus;
120
+ parkedFrom: MilestoneStatus | null;
121
+ /** the fix round of the latest commit (0 for the lanes' own) */
122
+ round: number;
123
+ /** open BLOCKER and BUG lines per completed review round */
124
+ counts: number[];
125
+ /** the findings still to be acted on: the round so far, then the ones no lane owns until triaged */
126
+ open: Finding[];
127
+ keys: string[][];
128
+ knownIssues: string[];
129
+ /** plan 34: the NIT lines of the latest review round; a landing keeps them as known issues */
130
+ nits: string[];
131
+ commit: string | null;
132
+ verifierRuns: number;
133
+ landRefusals: number;
134
+ /** the reviewers this review round still owes (reviewer-M, ui-reviewer-M, reviewer-M-2) */
135
+ awaiting: string[];
136
+ /** failures per milestone-level role name since the milestone last moved on */
137
+ tries: Record<string, number>;
138
+ /** findings the judge sent to the architect: the plan's next reading takes them */
139
+ delta: string[];
140
+ /** the owner confirmed the preflight commands */
141
+ confirmed: boolean;
142
+ /** what the owner answered while the milestone was parked: the next briefs of its roles carry it */
143
+ notes: string[];
144
+ /** what the next verifier is told: a refused landing's message and fix, or the commit to verify */
145
+ verifierNotes: string[];
146
+ /** verifier ends in a row that said check_broken */
147
+ checkBroken: number;
148
+ /** a base run: the files the merge of the base left in conflict (none until it did); non-empty means committing resolves it */
149
+ integrateFiles: string[];
150
+ /** a base run: when to look again whether origin's base holds the landed commit */
151
+ mergeDue: string | null;
152
+ /** the branch the landed commit is on, for the owner's push question */
153
+ branch: string | null;
154
+ /** plan 35, a base run: the milestone's worktree once `tree.ready` happened */
155
+ tree: string | null;
156
+ /** plan 35: when the worktree became ready, for the memory a young milestone is still to take */
157
+ treeAt: string | null;
158
+ /** plan 35: a memory deferral: no new claim before this time */
159
+ claimAfter: string | null;
160
+ /** plan 35: the owner answered `push` and catherd has not pushed the branch yet */
161
+ pushing: boolean;
162
+ /** plan 35: the digest file the owner's MR takes as its body */
163
+ digestPath: string | null;
164
+ /** plan 35: the MR command of the branch catherd pushed, named in the question that asks again */
165
+ mr: string | null;
166
+ }
167
+
168
+ export interface DispatchView {
169
+ dispatchId: string;
170
+ name: string;
171
+ role: Role;
172
+ unit: string;
173
+ lane: string | null;
174
+ rung: string;
175
+ thread: string | null;
176
+ startedAt: string;
177
+ ended: {
178
+ at: string;
179
+ status: RunStatus;
180
+ replyStatus: ReplyStatus | null;
181
+ reason: BlockedReason | null;
182
+ } | null;
183
+ }
184
+
185
+ export interface OpenQuestion {
186
+ unit: string;
187
+ question: string;
188
+ reason: ParkReason;
189
+ at: string;
190
+ }
191
+
192
+ /** A report and phone notice the owner is owed: set by the event, cleared by its `report.posted`. */
193
+ export interface OwedReport {
194
+ kind: ReportKind;
195
+ unit: string;
196
+ text: string;
197
+ moment: NotifyMoment;
198
+ }
199
+
200
+ export interface RunState {
201
+ v: 1;
202
+ run: string;
203
+ seq: number;
204
+ at: string;
205
+ phase: "planning" | "approval" | "running" | "ended";
206
+ outcome: "done" | "cancelled" | "failed" | null;
207
+ title: string;
208
+ base: string | null;
209
+ aLines: string[];
210
+ plan: string[];
211
+ /** the roles the profile turns off */
212
+ rolesOff: Role[];
213
+ /** what the owner answered at run level (a parked planning role): its retried briefs carry it */
214
+ notes: string[];
215
+ milestones: MilestoneState[];
216
+ lanes: Record<string, LaneState>;
217
+ dispatches: Record<string, DispatchView>;
218
+ questions: Record<string, OpenQuestion>;
219
+ /** quota → until */
220
+ holds: Record<string, string>;
221
+ judges: Record<string, { unit: string; kind: JudgeKind; tries: number }>;
222
+ /** plan 34: the finished run's rules review: what the judge proposed, or that it gave no readable answer */
223
+ rulesReview: { changes: RuleChange[]; failed: boolean } | null;
224
+ failedEffects: Record<string, number>;
225
+ cancelling: boolean;
226
+ wakeAt: string | null;
227
+ /** an architect ended and the plan was not read since */
228
+ planDirty: boolean;
229
+ /** what the next architect dispatch must change in plan.md (the owner's request, a problem the plan check found); the plan's next reading clears it */
230
+ revise: string[];
231
+ /** dispatch name → until: refused for now (paused, every quota held) */
232
+ waiting: Record<string, string>;
233
+ /** unit → what the owner must be asked: the machine parks it */
234
+ stuck: Record<string, { reason: ParkReason; question: string }>;
235
+ /** `kind:unit` → a report not posted yet, so a daemon that died before it sends it again */
236
+ owed: Record<string, OwedReport>;
237
+ }
238
+
239
+ export function emptyState(run: string): RunState {
240
+ return {
241
+ v: 1,
242
+ run,
243
+ seq: 0,
244
+ at: "1970-01-01T00:00:00.000Z",
245
+ phase: "planning",
246
+ outcome: null,
247
+ title: "",
248
+ base: null,
249
+ aLines: [],
250
+ plan: [],
251
+ rolesOff: [],
252
+ notes: [],
253
+ milestones: [],
254
+ lanes: {},
255
+ dispatches: {},
256
+ questions: {},
257
+ holds: {},
258
+ judges: {},
259
+ rulesReview: null,
260
+ failedEffects: {},
261
+ cancelling: false,
262
+ wakeAt: null,
263
+ planDirty: false,
264
+ revise: [],
265
+ waiting: {},
266
+ stuck: {},
267
+ owed: {},
268
+ };
269
+ }
270
+
271
+ /** How often a landed milestone waits between two looks at origin's base. */
272
+ export const MERGE_CHECK_MS = 10 * 60_000;
273
+
274
+ /** The lane-less worker that resolves the conflicts of merging the base into the run's branch. */
275
+ export const integrateName = (milestone: string): string => `worker-${milestone}-integrate`;
276
+
277
+ export const isDone = (m: MilestoneState): boolean => m.status === "landed" || m.status === "merged";
278
+ /** In a base run a milestone is finished when origin's base holds it; otherwise when it landed. */
279
+ export const isFinished = (s: RunState, m: MilestoneState): boolean =>
280
+ s.base === null ? isDone(m) : m.status === "merged";
281
+ export const lanesOf = (s: RunState, milestone: string): LaneState[] =>
282
+ Object.values(s.lanes).filter((l) => l.milestone === milestone);
283
+ export const liveDispatches = (s: RunState): DispatchView[] =>
284
+ Object.values(s.dispatches).filter((d) => d.ended === null);
285
+ export const milestoneOf = (s: RunState, id: string): MilestoneState | undefined =>
286
+ s.milestones.find((m) => m.id === id);
287
+
288
+ /**
289
+ * The milestones the machine works on. A run without base: the first one not landed, merged or parked whose `after`
290
+ * landed. A base run (plan 35): every such one whose `after` milestones are merged and that has a tree, plus the
291
+ * first of them without one, so trees are claimed one at a time in plan order.
292
+ */
293
+ export function activeMilestones(s: RunState): MilestoneState[] {
294
+ const finished = (id: string) => {
295
+ const m = milestoneOf(s, id);
296
+ return m === undefined || isFinished(s, m);
297
+ };
298
+ const open = s.milestones.filter((m) => !isDone(m) && m.status !== "parked" && m.after.every(finished));
299
+ if (s.base === null) return open.slice(0, 1);
300
+ let claiming = false;
301
+ return open.filter((m) => m.tree !== null || (!claiming && (claiming = true)));
302
+ }
303
+
304
+ const bare = (p: string) => p.replace(/\/+$/, "");
305
+
306
+ /** The lane owning `file` by its longest `Owns:` entry, or null (no file, or none owns it). */
307
+ export function ownerOfFile(file: string | null, lanes: { id: string; owns: string[] }[]): string | null {
308
+ if (!file) return null;
309
+ let best: { id: string; len: number } | null = null;
310
+ for (const l of lanes)
311
+ for (const o of l.owns) {
312
+ const e = bare(o);
313
+ if ((file === e || file.startsWith(`${e}/`)) && (best === null || e.length > best.len))
314
+ best = { id: l.id, len: e.length };
315
+ }
316
+ return best?.id ?? null;
317
+ }
318
+
319
+ const lastOf = (s: RunState, name: string): DispatchView | undefined =>
320
+ Object.values(s.dispatches)
321
+ .filter((d) => d.name === name)
322
+ .at(-1);
323
+
324
+ /** The rung of the latest dispatch of `name` when it ended on a usage limit, else null. */
325
+ export function limitedRung(s: RunState, name: string): string | null {
326
+ const d = lastOf(s, name);
327
+ return d?.ended?.status === "limit" ? d.rung : null;
328
+ }
329
+
330
+ /** The owner gave the run its plan: files in `run.started` or a `plan:` A-line. */
331
+ export const hasPlanInput = (s: RunState): boolean =>
332
+ s.plan.length > 0 || s.aLines.some((l) => /^\s*plan:\s*\S/i.test(l));
333
+
334
+ const hasJudge = (s: RunState, unit: string): boolean => Object.values(s.judges).some((j) => j.unit === unit);
335
+
336
+ const isoPlus = (at: string, ms: number): string => new Date(Date.parse(at) + ms).toISOString();
337
+
338
+ function recomputeWake(s: RunState): void {
339
+ const merges = s.milestones.flatMap((m) => (m.status === "landed" && m.mergeDue ? [m.mergeDue] : []));
340
+ const claims = s.milestones.flatMap((m) => (m.claimAfter ? [m.claimAfter] : []));
341
+ const all = [...Object.values(s.holds), ...Object.values(s.waiting), ...merges, ...claims].sort();
342
+ s.wakeAt = all[0] ?? null;
343
+ }
344
+
345
+ function mergePlan(s: RunState, plan: MilestonePlan[]): void {
346
+ const next: MilestoneState[] = [];
347
+ for (const mp of plan) {
348
+ const old = milestoneOf(s, mp.id);
349
+ const m: MilestoneState = old ?? {
350
+ id: mp.id,
351
+ title: mp.title,
352
+ after: mp.after,
353
+ status: "waiting",
354
+ parkedFrom: null,
355
+ round: 0,
356
+ counts: [],
357
+ open: [],
358
+ keys: [],
359
+ knownIssues: [],
360
+ nits: [],
361
+ commit: null,
362
+ verifierRuns: 0,
363
+ landRefusals: 0,
364
+ awaiting: [],
365
+ tries: {},
366
+ delta: [],
367
+ confirmed: false,
368
+ notes: [],
369
+ verifierNotes: [],
370
+ checkBroken: 0,
371
+ integrateFiles: [],
372
+ mergeDue: null,
373
+ branch: null,
374
+ tree: null,
375
+ treeAt: null,
376
+ claimAfter: null,
377
+ pushing: false,
378
+ digestPath: null,
379
+ mr: null,
380
+ };
381
+ m.title = mp.title;
382
+ m.after = mp.after;
383
+ m.delta = [];
384
+ for (const lp of mp.lanes) {
385
+ const l = s.lanes[lp.id];
386
+ if (l) {
387
+ l.role = lp.role;
388
+ l.kind = lp.kind;
389
+ l.after = lp.after;
390
+ l.owns = lp.owns;
391
+ } else
392
+ s.lanes[lp.id] = {
393
+ id: lp.id,
394
+ milestone: mp.id,
395
+ role: lp.role,
396
+ kind: lp.kind,
397
+ after: lp.after,
398
+ owns: lp.owns,
399
+ status: "pending",
400
+ rung: null,
401
+ thread: null,
402
+ top: false,
403
+ attempts: 0,
404
+ checkFails: 0,
405
+ failoverFrom: null,
406
+ lastReason: null,
407
+ sameReason: 0,
408
+ notes: [],
409
+ fixedRound: null,
410
+ climbReason: null,
411
+ stopRun: 0,
412
+ failoverNow: false,
413
+ tooHard: false,
414
+ awaiting: null,
415
+ classify: null,
416
+ climbedAt: null,
417
+ };
418
+ }
419
+ next.push(m);
420
+ }
421
+ for (const m of s.milestones) if (!next.some((n) => n.id === m.id)) next.push(m);
422
+ s.milestones = next;
423
+ }
424
+
425
+ const reviewersOf = (s: RunState, m: MilestoneState): string[] => [
426
+ ...(s.rolesOff.includes("reviewer") ? [] : [`reviewer-${m.id}`]),
427
+ ...(!s.rolesOff.includes("ui-reviewer") && lanesOf(s, m.id).some((l) => l.kind === "ui")
428
+ ? [`ui-reviewer-${m.id}`]
429
+ : []),
430
+ ];
431
+
432
+ /** What a milestone's lanes and fix round have settled: the move to a commit, once nothing is left to do. */
433
+ function settle(s: RunState, m: MilestoneState): void {
434
+ const lanes = lanesOf(s, m.id);
435
+ const allDone = lanes.every((l) => l.status === "done");
436
+ if (m.status === "lanes" && allDone) m.status = "committing";
437
+ else if (
438
+ m.status === "fixing" &&
439
+ allDone &&
440
+ m.open.length === 0 &&
441
+ m.delta.length === 0 &&
442
+ !s.planDirty &&
443
+ !hasJudge(s, m.id) &&
444
+ !s.stuck[m.id] &&
445
+ !Object.values(s.dispatches).some((d) => d.ended === null && d.name.startsWith(`architect-${m.id}-`))
446
+ )
447
+ m.status = "committing";
448
+ }
449
+
450
+ function laneDone(s: RunState, l: LaneState): void {
451
+ l.status = "done";
452
+ l.notes = [];
453
+ l.checkFails = 0;
454
+ l.attempts = 0;
455
+ l.failoverFrom = null;
456
+ l.climbReason = null;
457
+ l.sameReason = 0;
458
+ l.stopRun = 0;
459
+ l.lastReason = null;
460
+ l.failoverNow = false;
461
+ l.tooHard = false;
462
+ l.awaiting = null;
463
+ l.classify = null;
464
+ const m = milestoneOf(s, l.milestone);
465
+ if (m) settle(s, m);
466
+ }
467
+
468
+ const nitLines = (findings: Finding[]): string[] => findings.filter((f) => f.label === "NIT").map(issueLine);
469
+
470
+ /** The review round's end: counts, keys and what happens next (a fix round, the verifier, or a stop). */
471
+ function completeRound(s: RunState, m: MilestoneState): void {
472
+ const seen = new Set<string>();
473
+ const found = m.open.filter((f) => {
474
+ if ((f.label !== "BLOCKER" && f.label !== "BUG") || seen.has(f.text)) return false;
475
+ seen.add(f.text);
476
+ return true;
477
+ });
478
+ const n = found.length;
479
+ m.counts = [...m.counts, n];
480
+ m.keys = [...m.keys, found.map((f) => findingKey(f.text))];
481
+ const r = m.counts.length;
482
+ m.awaiting = [];
483
+ m.tries = {};
484
+ m.verifierNotes = [];
485
+ m.checkBroken = 0;
486
+ if (n === 0) {
487
+ m.open = [];
488
+ m.status = "verifying";
489
+ return;
490
+ }
491
+ const lanes = lanesOf(s, m.id);
492
+ // no progress means not fewer than a round that had findings: a clean review before a verifier FAIL is no baseline
493
+ const before = m.counts[r - 2] ?? 0;
494
+ const stop = r >= softCap(lanes.length) || (r >= 2 && before > 0 && n >= before);
495
+ if (stop) {
496
+ m.open = found;
497
+ if (found.some((f) => f.label === "BLOCKER")) {
498
+ m.status = "review";
499
+ s.stuck[m.id] = {
500
+ reason: "blocker",
501
+ question: `${m.id} still has a BLOCKER after ${r} review round(s):\n${found.map((f) => f.text).join("\n")}`,
502
+ };
503
+ return;
504
+ }
505
+ m.knownIssues = found.map((f) => f.text);
506
+ m.open = [];
507
+ m.status = "verifying";
508
+ return;
509
+ }
510
+ const previous = new Set(m.keys[r - 2] ?? []);
511
+ const unowned: Finding[] = [];
512
+ const survived = new Set<string>();
513
+ for (const f of found) {
514
+ const owner = ownerOfFile(f.file, lanes);
515
+ const lane = owner ? s.lanes[owner] : undefined;
516
+ if (!lane) {
517
+ unowned.push(f);
518
+ continue;
519
+ }
520
+ lane.notes.push(f.text);
521
+ lane.status = "pending";
522
+ lane.attempts = 0;
523
+ lane.checkFails = 0;
524
+ lane.fixedRound = r;
525
+ if (previous.has(findingKey(f.text))) survived.add(lane.id);
526
+ }
527
+ for (const id of survived) (s.lanes[id] as LaneState).climbReason = "same-defect";
528
+ m.open = unowned;
529
+ m.status = "fixing";
530
+ settle(s, m);
531
+ }
532
+
533
+ function failRole(
534
+ s: RunState,
535
+ e: Extract<RunEvent, { type: "dispatch.ended" }>,
536
+ m: MilestoneState | undefined,
537
+ ): void {
538
+ const bad = e.status !== "ok" && e.status !== "limit";
539
+ const refused = e.status === "ok" && (e.replyStatus === "blocked" || e.replyStatus === "refused");
540
+ if (!bad && !refused) return;
541
+ if (m) {
542
+ m.tries[e.name] = refused ? MAX_TRIES : (m.tries[e.name] ?? 0) + 1;
543
+ if ((m.tries[e.name] as number) >= MAX_TRIES)
544
+ s.stuck[m.id] = {
545
+ reason: e.name.startsWith("verifier-") ? "verifier" : "effect",
546
+ question: `${e.name} could not finish (${e.status}${e.replyStatus ? `, ${e.replyStatus}` : ""}): ${e.why}`,
547
+ };
548
+ return;
549
+ }
550
+ const key = `ended:${e.name}`;
551
+ s.failedEffects[key] = refused ? MAX_TRIES : (s.failedEffects[key] ?? 0) + 1;
552
+ if ((s.failedEffects[key] as number) >= MAX_TRIES)
553
+ s.stuck[e.unit] = {
554
+ reason: "effect",
555
+ question: `${e.name} could not finish (${e.status}${e.replyStatus ? `, ${e.replyStatus}` : ""}): ${e.why}`,
556
+ };
557
+ }
558
+
559
+ /** A stop that names no reason (plan 30b Ruling 7): the machine asks Jev, and with Jev off acts as 1.5 did. */
560
+ function unexplained(e: Extract<RunEvent, { type: "dispatch.ended" }>): boolean {
561
+ if (e.reason !== null) return false;
562
+ if (e.status === "ok")
563
+ return e.replyStatus === null || e.replyStatus === "blocked" || e.replyStatus === "refused";
564
+ return (e.status === "failed" || e.status === "timeout") && e.environment === null;
565
+ }
566
+
567
+ const stopQuestion = (l: LaneState, reason: BlockedReason, why: string): string =>
568
+ `${l.id} stopped (${reason})${why.trim() ? `: ${why.trim()}` : ""}`;
569
+
570
+ /** A lane's stop that names `reason` (spec 2.0 decision 8): the reason decides what happens next. */
571
+ function laneStopped(s: RunState, l: LaneState, x: LaneEnd, reason: BlockedReason): void {
572
+ l.stopRun = l.lastReason === reason ? l.stopRun + 1 : 1;
573
+ l.lastReason = reason;
574
+ l.tooHard = false;
575
+ switch (reason) {
576
+ case "needs_user":
577
+ l.thread = x.thread ?? l.thread;
578
+ l.status = "parked";
579
+ l.awaiting = "question";
580
+ return;
581
+ case "too_hard":
582
+ l.thread = null;
583
+ l.status = "pending";
584
+ l.climbReason = "blocked";
585
+ l.tooHard = true;
586
+ return;
587
+ case "provider_error":
588
+ l.thread = null;
589
+ l.status = "pending";
590
+ // a second one in a row fails over, with no quota hold since no limit was hit; a third parks the lane
591
+ if (l.stopRun === 2) {
592
+ l.failoverFrom = x.rung;
593
+ l.failoverNow = true;
594
+ } else if (l.stopRun >= 3) {
595
+ l.status = "parked";
596
+ s.stuck[l.id] = { reason, question: stopQuestion(l, reason, x.why) };
597
+ }
598
+ return;
599
+ case "check_broken":
600
+ // blames only a sibling's files: the lane did its part
601
+ if (x.foreign.length > 0) return laneDone(s, l);
602
+ l.thread = x.thread ?? l.thread;
603
+ l.status = "pending";
604
+ if (l.stopRun >= 2) {
605
+ l.status = "parked";
606
+ s.stuck[l.id] = { reason, question: stopQuestion(l, reason, x.why) };
607
+ }
608
+ return;
609
+ case "no_access":
610
+ case "out_of_scope":
611
+ l.thread = x.thread ?? l.thread;
612
+ l.status = "parked";
613
+ s.stuck[l.id] = { reason, question: stopQuestion(l, reason, x.why) };
614
+ }
615
+ }
616
+
617
+ /** One lane dispatch's end, once its stop has a reason (or none): what 1.5 did with it, or the reason's rule. */
618
+ function laneEnded(s: RunState, l: LaneState, x: LaneEnd, reason: BlockedReason | null): void {
619
+ if (x.status === "limit") {
620
+ l.status = "pending";
621
+ l.failoverFrom = x.rung;
622
+ l.thread = null;
623
+ return;
624
+ }
625
+ if (reason !== null) return laneStopped(s, l, x, reason);
626
+ l.lastReason = null;
627
+ l.stopRun = 0;
628
+ if (x.status === "ok" && (x.replyStatus === "blocked" || x.replyStatus === "refused")) {
629
+ // no reason, Jev off or unsure: 1.5 climbed a stuck lane
630
+ l.thread = x.thread ?? l.thread;
631
+ l.status = "pending";
632
+ l.climbReason = "blocked";
633
+ } else if (x.status === "ok" && x.replyStatus !== "partial") {
634
+ l.attempts = 0;
635
+ l.thread = x.thread ?? l.thread;
636
+ l.failoverFrom = null;
637
+ l.failoverNow = false;
638
+ if (l.kind === "research") laneDone(s, l);
639
+ else l.status = "checking";
640
+ } else {
641
+ l.attempts += 1;
642
+ l.thread = null;
643
+ l.status = "pending";
644
+ if (l.attempts >= MAX_TRIES) l.climbReason = "unchanged";
645
+ }
646
+ }
647
+
648
+ /** The base is merged into the milestone's branch: a fresh verifier checks the merge commit. */
649
+ function mergedIn(m: MilestoneState, commit: string): void {
650
+ m.commit = commit;
651
+ m.integrateFiles = [];
652
+ m.tries = {};
653
+ m.verifierNotes = [
654
+ `The base branch was merged into the branch at ${commit.slice(0, 12)}: verify that commit.`,
655
+ ];
656
+ m.status = "verifying";
657
+ }
658
+
659
+ /** The rules review is advisory: whatever stops its judge, the run ends without one and is never parked or held. */
660
+ function failRulesReview(s: RunState): void {
661
+ delete s.judges.rules;
662
+ s.rulesReview = { changes: [], failed: true };
663
+ }
664
+
665
+ function onEnded(s: RunState, e: Extract<RunEvent, { type: "dispatch.ended" }>): void {
666
+ const dv = s.dispatches[e.dispatchId];
667
+ if (!dv) return;
668
+ dv.thread = e.thread ?? dv.thread;
669
+ dv.ended = { at: e.at, status: e.status, replyStatus: e.replyStatus, reason: e.reason };
670
+ const rung = tryParseRung(dv.rung);
671
+ if (rung) {
672
+ const quota = quotaOf(rung);
673
+ if (e.status === "limit") s.holds[quota] = isoPlus(e.at, HOLD_MS);
674
+ else if (e.status === "ok" && e.replyStatus === "complete") delete s.holds[quota];
675
+ }
676
+ recomputeWake(s);
677
+ if (dv.role === "judge") {
678
+ if (e.status === "limit" && dv.name === judgeName("rules")) failRulesReview(s);
679
+ return;
680
+ }
681
+ const lane = dv.lane ? s.lanes[dv.lane] : undefined;
682
+ if (lane) {
683
+ const x: LaneEnd = {
684
+ rung: dv.rung,
685
+ status: e.status,
686
+ replyStatus: e.replyStatus,
687
+ thread: e.thread,
688
+ why: e.why,
689
+ foreign: e.foreign,
690
+ };
691
+ // a stop that names no reason waits for its classification: nothing else happens to the lane meanwhile
692
+ if (e.status !== "limit" && unexplained(e)) {
693
+ lane.classify = { dispatchId: e.dispatchId, ...x };
694
+ return;
695
+ }
696
+ laneEnded(s, lane, x, e.reason);
697
+ return;
698
+ }
699
+ const m = milestoneOf(s, e.unit);
700
+ if (dv.role === "architect") {
701
+ if (e.status === "ok" && e.replyStatus !== "blocked" && e.replyStatus !== "refused") s.planDirty = true;
702
+ else failRole(s, e, m);
703
+ return;
704
+ }
705
+ if (!m) {
706
+ failRole(s, e, undefined);
707
+ return;
708
+ }
709
+ if (e.name === integrateName(m.id) && m.status === "integrating") {
710
+ const refused = e.replyStatus === "blocked" || e.replyStatus === "refused";
711
+ if (e.status === "ok" && !refused) m.status = "committing";
712
+ else failRole(s, e, m);
713
+ return;
714
+ }
715
+ if ((dv.role === "reviewer" || dv.role === "ui-reviewer") && m.status === "review") {
716
+ if (e.status === "ok" && e.replyStatus !== "blocked" && e.replyStatus !== "refused") {
717
+ m.open.push(...e.findings);
718
+ m.nits.push(...nitLines(e.findings));
719
+ if (e.replyStatus === "partial" && e.name === `reviewer-${m.id}`)
720
+ m.awaiting = m.awaiting.map((n) => (n === e.name ? `reviewer-${m.id}-2` : n));
721
+ else m.awaiting = m.awaiting.filter((n) => n !== e.name);
722
+ if (m.awaiting.length === 0) completeRound(s, m);
723
+ } else failRole(s, e, m);
724
+ return;
725
+ }
726
+ if (dv.role === "verifier" && m.status === "verifying") {
727
+ // a check that cannot run: one fresh verifier more, then the owner (a BLOCKED: environment verdict parks first)
728
+ if (e.status === "ok" && e.reason === "check_broken" && e.verdict !== "blocked") {
729
+ m.checkBroken += 1;
730
+ if (m.checkBroken >= 2)
731
+ s.stuck[m.id] = {
732
+ reason: "check_broken",
733
+ question: `${e.name} could not run the check twice: ${e.why || "no reason given"}`,
734
+ };
735
+ } else if (e.status === "ok" && e.verdict === "pass") {
736
+ m.verifierRuns += 1;
737
+ m.checkBroken = 0;
738
+ // a PASS about another commit than the milestone's is no verdict on it: a fresh verifier checks the right one
739
+ if (m.commit !== null && e.head !== null && m.commit.startsWith(e.head)) {
740
+ m.knownIssues = [...new Set([...m.knownIssues, ...m.nits, ...nitLines(e.findings)])];
741
+ m.tries = {};
742
+ m.verifierNotes = [];
743
+ m.status = "landing";
744
+ } else {
745
+ m.tries[e.name] = (m.tries[e.name] ?? 0) + 1;
746
+ const at = (m.commit ?? "HEAD").slice(0, 12);
747
+ m.verifierNotes = [
748
+ `verify ${at}: your last verdict was about ${e.head ? e.head.slice(0, 12) : "no commit"}; check commit ${at} and end your reply with HEAD: <the full sha you checked>.`,
749
+ ];
750
+ if ((m.tries[e.name] as number) >= MAX_TRIES)
751
+ s.stuck[m.id] = {
752
+ reason: "verifier",
753
+ question: `${e.name} passed ${e.head ? e.head.slice(0, 12) : "no commit"} twice, and the milestone is at ${at}`,
754
+ };
755
+ }
756
+ } else if (e.status === "ok" && e.verdict === "fail") {
757
+ m.verifierRuns += 1;
758
+ m.open = e.findings.length
759
+ ? e.findings.map((f) => ({ ...f, label: "BLOCKER" as const }))
760
+ : [{ label: "BLOCKER", text: `FAIL: ${e.why || "the verifier failed the milestone"}`, file: null }];
761
+ completeRound(s, m);
762
+ } else if (e.status === "ok" && e.verdict === "blocked") {
763
+ m.verifierRuns += 1;
764
+ s.stuck[m.id] = {
765
+ reason: "environment",
766
+ question: `${e.name} is blocked by the environment: ${e.environment || e.why || "no probe named"}`,
767
+ };
768
+ } else failRole(s, e, m);
769
+ }
770
+ }
771
+
772
+ function onClimbed(s: RunState, e: Extract<RunEvent, { type: "lane.climbed" }>): void {
773
+ const l = s.lanes[e.lane];
774
+ if (!l) return;
775
+ const tooHard = l.tooHard;
776
+ l.rung = e.rung;
777
+ l.top = e.top;
778
+ l.thread = null;
779
+ l.attempts = 0;
780
+ l.checkFails = 0;
781
+ l.failoverFrom = null;
782
+ l.failoverNow = false;
783
+ l.climbReason = null;
784
+ l.tooHard = false;
785
+ l.stopRun = 0;
786
+ l.climbedAt = e.at;
787
+ if (e.top) {
788
+ l.status = "parked";
789
+ l.sameReason += 1;
790
+ if (tooHard)
791
+ // a stronger model was the only fix, and there is none: the owner decides
792
+ s.stuck[l.id] = {
793
+ reason: "too_hard",
794
+ question: `${l.id} is too hard for its top rung (${e.rung}); no stronger model is left`,
795
+ };
796
+ else {
797
+ l.awaiting = "unexpected";
798
+ if (l.sameReason >= TOP_RUNG_JUDGES)
799
+ s.stuck[l.id] = {
800
+ reason: "too_hard",
801
+ question: `${l.id} is at the top rung and still does not get through; the judge was asked ${l.sameReason} times`,
802
+ };
803
+ }
804
+ } else l.status = "pending";
805
+ const m = milestoneOf(s, l.milestone);
806
+ if (m?.status === "fixing") l.fixedRound = m.counts.length;
807
+ }
808
+
809
+ function onJudged(s: RunState, e: Extract<RunEvent, { type: "judge.decided" }>): void {
810
+ delete s.judges[e.id];
811
+ const d = e.decision;
812
+ const lane = s.lanes[e.unit];
813
+ const m = milestoneOf(s, e.unit);
814
+ switch (d.verb) {
815
+ case "route": {
816
+ if (!m) return;
817
+ const lanes = lanesOf(s, m.id);
818
+ for (const r of d.routes) {
819
+ const f = m.open[r.finding - 1];
820
+ if (!f) continue;
821
+ const target = lanes.find((l) => l.id === r.to);
822
+ if (target) {
823
+ target.notes.push(f.text);
824
+ target.status = "pending";
825
+ target.attempts = 0;
826
+ target.fixedRound = m.counts.length;
827
+ } else if (r.to === "architect") m.delta.push(f.text);
828
+ }
829
+ m.open = [];
830
+ settle(s, m);
831
+ return;
832
+ }
833
+ case "answer":
834
+ if (lane) {
835
+ lane.notes.push(`The judge answered: ${d.answer}`);
836
+ lane.status = "pending";
837
+ lane.awaiting = null;
838
+ }
839
+ return;
840
+ case "ask_owner":
841
+ case "park":
842
+ s.stuck[e.unit] = { reason: d.verb === "park" ? "judge" : "needs_user", question: d.question };
843
+ return;
844
+ case "retry":
845
+ if (lane) {
846
+ lane.status = "pending";
847
+ lane.awaiting = null;
848
+ }
849
+ return;
850
+ case "climb":
851
+ if (lane) {
852
+ lane.status = "pending";
853
+ lane.awaiting = null;
854
+ lane.climbReason = "blocked";
855
+ }
856
+ return;
857
+ case "skip":
858
+ if (lane) {
859
+ const lm = milestoneOf(s, lane.milestone);
860
+ lm?.knownIssues.push(`${lane.id} skipped: ${d.why}`);
861
+ laneDone(s, lane);
862
+ }
863
+ return;
864
+ case "rules":
865
+ s.rulesReview = { changes: d.changes, failed: false };
866
+ }
867
+ }
868
+
869
+ function onAnswered(s: RunState, e: Extract<RunEvent, { type: "question.answered" }>): void {
870
+ const q = s.questions[e.unit];
871
+ delete s.questions[e.unit];
872
+ delete s.stuck[e.unit];
873
+ for (const k of Object.keys(s.failedEffects))
874
+ if (k.endsWith(`@${e.unit}`) || k === `ended:${e.unit}`) delete s.failedEffects[k];
875
+ const lane = s.lanes[e.unit];
876
+ if (lane) {
877
+ lane.notes.push(`The owner answered: ${e.answer}`);
878
+ lane.status = "pending";
879
+ lane.attempts = 0;
880
+ lane.checkFails = 0;
881
+ lane.sameReason = 0;
882
+ lane.stopRun = 0;
883
+ lane.lastReason = null;
884
+ lane.failoverNow = false;
885
+ lane.awaiting = null;
886
+ return;
887
+ }
888
+ const m = milestoneOf(s, e.unit);
889
+ if (!m) {
890
+ for (const k of Object.keys(s.failedEffects)) if (k.startsWith("ended:")) delete s.failedEffects[k];
891
+ if (e.unit === RUN_UNIT) {
892
+ if (q?.reason === "approval") s.revise = [`The owner asked for these changes to plan.md: ${e.answer}`];
893
+ else s.notes.push(`The owner answered: ${e.answer}`);
894
+ }
895
+ return;
896
+ }
897
+ // the owner's word that a push question's milestone is merged: how a squash merge unblocks its dependants
898
+ // plan 35: the word `push` is no merge: catherd pushes the branch and asks again
899
+ if (q?.reason === "push") {
900
+ if (m.status !== "landed") return;
901
+ if (isPushAnswer(e.answer)) m.pushing = true;
902
+ else m.status = "merged";
903
+ return;
904
+ }
905
+ m.notes.push(`The owner answered: ${e.answer}`);
906
+ const from = m.parkedFrom ?? "waiting";
907
+ m.parkedFrom = null;
908
+ m.tries = {};
909
+ m.checkBroken = 0;
910
+ if (from === "routing") {
911
+ m.status = "waiting";
912
+ m.confirmed = true;
913
+ } else if (q?.reason === "blocker") {
914
+ m.knownIssues = m.open.map((f) => f.text);
915
+ m.open = [];
916
+ m.status = "verifying";
917
+ } else m.status = from;
918
+ }
919
+
920
+ /** One event applied to `s` in place. */
921
+ function apply(s: RunState, e: RunEvent): void {
922
+ s.seq = e.seq;
923
+ s.at = e.at;
924
+ const ok = (key: string) => delete s.failedEffects[key];
925
+ switch (e.type) {
926
+ case "run.started":
927
+ s.title = e.title;
928
+ s.base = e.base;
929
+ s.aLines = e.aLines;
930
+ s.plan = e.plan;
931
+ s.rolesOff = e.rolesOff ?? [];
932
+ return;
933
+ case "plan.ready":
934
+ mergePlan(s, e.milestones);
935
+ // a plan the owner did not give waits for the owner's approval before its first lane
936
+ if (s.phase === "planning") s.phase = hasPlanInput(s) ? "running" : "approval";
937
+ s.planDirty = false;
938
+ s.revise = [];
939
+ ok(`readPlan@${RUN_UNIT}`);
940
+ for (const m of s.milestones) settle(s, m);
941
+ return;
942
+ case "plan.approved":
943
+ if (s.phase === "approval") s.phase = "running";
944
+ if (s.questions[RUN_UNIT]?.reason === "approval") delete s.questions[RUN_UNIT];
945
+ s.revise = [];
946
+ return;
947
+ case "lanes.routed": {
948
+ for (const r of e.lanes) {
949
+ const l = s.lanes[r.lane];
950
+ if (l) l.rung = r.rung;
951
+ }
952
+ const m = milestoneOf(s, e.milestone);
953
+ if (m?.status === "waiting") m.status = "routing";
954
+ return;
955
+ }
956
+ case "preflight.done": {
957
+ ok(`route@${e.milestone}`);
958
+ const m = milestoneOf(s, e.milestone);
959
+ if (!m || m.status !== "routing") return;
960
+ if (e.blocking.length > 0)
961
+ s.stuck[m.id] = {
962
+ reason: "preflight",
963
+ question: `Preflight cannot start these lanes:\n${e.blocking.map((b) => `${b.lane}: ${b.note}`).join("\n")}`,
964
+ };
965
+ else if (e.confirm && e.confirm.length > 0)
966
+ s.stuck[m.id] = {
967
+ reason: "confirm",
968
+ question: `Confirm these fast checks may run:\n${e.confirm.join("\n")}`,
969
+ };
970
+ else {
971
+ m.status = "lanes";
972
+ settle(s, m);
973
+ }
974
+ return;
975
+ }
976
+ case "dispatch.started": {
977
+ s.dispatches[e.dispatchId] = {
978
+ dispatchId: e.dispatchId,
979
+ name: e.name,
980
+ role: e.role,
981
+ unit: e.unit,
982
+ lane: e.lane,
983
+ rung: e.rung,
984
+ thread: e.thread,
985
+ startedAt: e.at,
986
+ ended: null,
987
+ };
988
+ delete s.waiting[e.name];
989
+ recomputeWake(s);
990
+ ok(`dispatch@${e.unit}`);
991
+ ok(`duplicate:${e.name}@${e.unit}`);
992
+ if (e.role === "judge") {
993
+ const id = e.name.replace(/^judge-/, "");
994
+ s.judges[id] = { unit: e.unit, kind: e.judgeKind ?? "unexpected", tries: s.judges[id]?.tries ?? 0 };
995
+ }
996
+ const l = e.lane ? s.lanes[e.lane] : undefined;
997
+ if (l && (l.status === "pending" || l.status === "checking")) {
998
+ l.status = "running";
999
+ l.climbReason = null;
1000
+ }
1001
+ return;
1002
+ }
1003
+ case "dispatch.refused": {
1004
+ const wait = (until: string) => {
1005
+ s.waiting[e.name] = until;
1006
+ recomputeWake(s);
1007
+ };
1008
+ if (e.code === "E_RUN_CANCELLED") return;
1009
+ if (e.name === judgeName("rules") && e.unit === RUN_UNIT) return failRulesReview(s);
1010
+ if (e.code === "E_RUN_BUDGET")
1011
+ s.stuck[RUN_UNIT] = { reason: "budget", question: `The run budget is spent: ${e.message}` };
1012
+ else if (e.code === "E_ADMIT_PAUSED") wait(isoPlus(e.at, REFUSAL_WAIT_MS));
1013
+ else if (e.code === "E_ALL_HELD") {
1014
+ const l = s.lanes[e.unit];
1015
+ const heldUntil = Object.values(s.holds)
1016
+ .filter((u) => u > e.at)
1017
+ .sort()[0];
1018
+ // a repeated provider_error holds no quota: with no stand-in free either, waiting would never end
1019
+ if (l?.failoverNow && heldUntil === undefined) {
1020
+ l.status = "parked";
1021
+ s.stuck[l.id] = {
1022
+ reason: "provider_error",
1023
+ question: stopQuestion(l, "provider_error", "no stand-in rung is free"),
1024
+ };
1025
+ } else wait(heldUntil ?? isoPlus(e.at, REFUSAL_WAIT_MS));
1026
+ } else if (e.code === "E_ADMIT_DUPLICATE") {
1027
+ const key = `duplicate:${e.name}@${e.unit}`;
1028
+ s.failedEffects[key] = (s.failedEffects[key] ?? 0) + 1;
1029
+ if ((s.failedEffects[key] as number) >= DUPLICATE_LIMIT)
1030
+ s.stuck[e.unit] = { reason: "effect", question: `${e.name} was refused (${e.code}): ${e.message}` };
1031
+ else wait(isoPlus(e.at, REFUSAL_WAIT_MS));
1032
+ } else
1033
+ s.stuck[e.unit] = { reason: "effect", question: `${e.name} was refused (${e.code}): ${e.message}` };
1034
+ return;
1035
+ }
1036
+ case "dispatch.ended":
1037
+ onEnded(s, e);
1038
+ return;
1039
+ case "check.ran": {
1040
+ ok(`fastCheck@${e.lane}`);
1041
+ const l = s.lanes[e.lane];
1042
+ if (!l) return;
1043
+ // a check that fails only in a sibling's files has nothing to say about this lane
1044
+ if (e.exit === 0 || e.foreign.length > 0) laneDone(s, l);
1045
+ else {
1046
+ l.checkFails += 1;
1047
+ l.notes = [
1048
+ ...l.notes.filter((n) => !n.startsWith("The fast check failed")),
1049
+ `The fast check failed (exit ${e.exit ?? "timeout"}); its last lines:\n${e.tail}`,
1050
+ ];
1051
+ l.status = "pending";
1052
+ if (l.checkFails >= MAX_TRIES) l.climbReason = "check-failed-twice";
1053
+ }
1054
+ return;
1055
+ }
1056
+ case "commit.made": {
1057
+ ok(`commit@${e.milestone}`);
1058
+ const m = milestoneOf(s, e.milestone);
1059
+ if (!m) return;
1060
+ // the commit that finishes a merge of the base: reviews stay (by time), a fresh verifier gates the merge
1061
+ if (m.integrateFiles.length > 0) return mergedIn(m, e.commit);
1062
+ m.status = "review";
1063
+ m.round = e.round;
1064
+ m.commit = e.commit;
1065
+ m.open = [];
1066
+ m.nits = [];
1067
+ m.tries = {};
1068
+ m.awaiting = reviewersOf(s, m);
1069
+ if (m.awaiting.length === 0) completeRound(s, m);
1070
+ return;
1071
+ }
1072
+ case "commit.failed": {
1073
+ ok(`commit@${e.milestone}`);
1074
+ s.stuck[e.milestone] = {
1075
+ reason: "commit",
1076
+ question: `Committing ${e.milestone} failed (round ${e.round}):\n${e.tail}`,
1077
+ };
1078
+ return;
1079
+ }
1080
+ case "lane.climbed":
1081
+ ok(`climb@${e.lane}`);
1082
+ onClimbed(s, e);
1083
+ return;
1084
+ case "judge.decided":
1085
+ onJudged(s, e);
1086
+ return;
1087
+ case "judge.failed": {
1088
+ const j = s.judges[e.id];
1089
+ if (!j) return;
1090
+ j.tries += 1;
1091
+ if (j.tries >= MAX_TRIES) {
1092
+ delete s.judges[e.id];
1093
+ // the rules review is advisory: a judge that cannot answer ends the run without one, never parks it
1094
+ if (j.kind === "rules") s.rulesReview = { changes: [], failed: true };
1095
+ else
1096
+ s.stuck[e.unit] = { reason: "judge", question: `The judge could not decide ${e.unit}: ${e.why}` };
1097
+ }
1098
+ return;
1099
+ }
1100
+ case "question.parked": {
1101
+ s.questions[e.unit] = { unit: e.unit, question: e.question, reason: e.reason, at: e.at };
1102
+ delete s.stuck[e.unit];
1103
+ for (const [id, j] of Object.entries(s.judges)) if (j.unit === e.unit) delete s.judges[id];
1104
+ s.owed[`parked:${e.unit}`] = {
1105
+ kind: "parked",
1106
+ unit: e.unit,
1107
+ text: `${e.unit} is parked (${e.reason}): ${e.question}`,
1108
+ moment: "blocked",
1109
+ };
1110
+ const l = s.lanes[e.unit];
1111
+ const m = milestoneOf(s, e.unit);
1112
+ // an architect change that edits an approved decision asks the owner again: no new lane until they answer
1113
+ if (e.unit === RUN_UNIT && e.reason === "approval" && s.phase === "running") s.phase = "approval";
1114
+ if (l) l.status = "parked";
1115
+ else if (m && e.reason !== "push" && m.status !== "parked") {
1116
+ m.parkedFrom = m.status;
1117
+ m.status = "parked";
1118
+ }
1119
+ return;
1120
+ }
1121
+ case "question.answered":
1122
+ onAnswered(s, e);
1123
+ return;
1124
+ case "milestone.landed": {
1125
+ ok(`land@${e.milestone}`);
1126
+ const m = milestoneOf(s, e.milestone);
1127
+ if (m) {
1128
+ m.status = "landed";
1129
+ m.commit = e.commit;
1130
+ m.knownIssues = e.knownIssues;
1131
+ m.open = [];
1132
+ m.awaiting = [];
1133
+ m.branch = e.branch ?? null;
1134
+ m.digestPath = e.digestPath;
1135
+ m.mergeDue = s.base === null ? null : isoPlus(e.at, MERGE_CHECK_MS);
1136
+ recomputeWake(s);
1137
+ }
1138
+ s.owed[`landed:${e.milestone}`] = {
1139
+ kind: "landed",
1140
+ unit: e.milestone,
1141
+ text: `${e.milestone} landed at ${e.commit.slice(0, 7)}${
1142
+ e.knownIssues.length ? `, with ${e.knownIssues.length} known issue(s)` : ""
1143
+ }: ${m?.title ?? ""}`.trimEnd(),
1144
+ moment: "milestone",
1145
+ };
1146
+ return;
1147
+ }
1148
+ case "land.refused": {
1149
+ ok(`land@${e.milestone}`);
1150
+ const m = milestoneOf(s, e.milestone);
1151
+ if (m) m.landRefusals += 1;
1152
+ // the first refusal is most often a verifier that skipped gate_run: a fresh one with the message; the second parks
1153
+ if (m?.status === "landing" && m.landRefusals === 1) {
1154
+ m.status = "verifying";
1155
+ m.tries = {};
1156
+ m.verifierNotes = [
1157
+ `A landing of this milestone was refused (${e.code}): ${e.message}`,
1158
+ `Fix: ${e.fix}`,
1159
+ ];
1160
+ return;
1161
+ }
1162
+ s.stuck[e.milestone] = {
1163
+ reason: "land",
1164
+ question: `Landing ${e.milestone} was refused (${e.code}): ${e.message}\nFix: ${e.fix}`,
1165
+ };
1166
+ return;
1167
+ }
1168
+ case "milestone.merged": {
1169
+ const m = milestoneOf(s, e.milestone);
1170
+ if (m) {
1171
+ m.status = "merged";
1172
+ m.mergeDue = null;
1173
+ }
1174
+ if (s.questions[e.milestone]?.reason === "push") delete s.questions[e.milestone];
1175
+ recomputeWake(s);
1176
+ return;
1177
+ }
1178
+ case "tree.ready": {
1179
+ ok(`claimTree@${e.milestone}`);
1180
+ const m = milestoneOf(s, e.milestone);
1181
+ if (m) {
1182
+ m.tree = e.tree;
1183
+ m.branch = e.branch;
1184
+ m.treeAt = e.at;
1185
+ m.claimAfter = null;
1186
+ }
1187
+ recomputeWake(s);
1188
+ return;
1189
+ }
1190
+ case "tree.deferred": {
1191
+ ok(`claimTree@${e.milestone}`);
1192
+ const m = milestoneOf(s, e.milestone);
1193
+ if (m) m.claimAfter = isoPlus(e.at, MEMORY_LIMITS.recheckMs);
1194
+ recomputeWake(s);
1195
+ return;
1196
+ }
1197
+ case "branch.pushed": {
1198
+ ok(`pushBranch@${e.milestone}`);
1199
+ const m = milestoneOf(s, e.milestone);
1200
+ if (m) {
1201
+ m.pushing = false;
1202
+ m.mr = e.mr;
1203
+ }
1204
+ return;
1205
+ }
1206
+ case "cancel.requested":
1207
+ s.cancelling = true;
1208
+ return;
1209
+ case "effect.failed": {
1210
+ if (e.action === "readPlan") s.planDirty = false;
1211
+ if (e.action === "classify") {
1212
+ // a classification that cannot be made is none: the lane goes on as in 1.5
1213
+ const l = s.lanes[e.unit];
1214
+ if (l?.classify) {
1215
+ const { dispatchId: _id, ...x } = l.classify;
1216
+ l.classify = null;
1217
+ laneEnded(s, l, x, null);
1218
+ }
1219
+ return;
1220
+ }
1221
+ const key = `${e.action}@${e.unit}`;
1222
+ s.failedEffects[key] = (s.failedEffects[key] ?? 0) + 1;
1223
+ // only the rules review's judge acts on the run itself, once every milestone is finished
1224
+ if (
1225
+ e.action === "judge" &&
1226
+ e.unit === RUN_UNIT &&
1227
+ s.milestones.length > 0 &&
1228
+ s.milestones.every((m) => isFinished(s, m))
1229
+ ) {
1230
+ if ((s.failedEffects[key] as number) >= MAX_TRIES) failRulesReview(s);
1231
+ return;
1232
+ }
1233
+ // a plan the check refused: its architect fixes it, PLAN_REVISIONS times, before the run parks
1234
+ const planning = e.action === "readPlan" && (s.phase === "planning" || s.phase === "approval");
1235
+ if (planning) s.revise = [e.message];
1236
+ // running: the architect of a milestone's fix round is re-dispatched from its delta, so the gap goes there
1237
+ else if (e.action === "readPlan")
1238
+ for (const m of s.milestones)
1239
+ if (m.delta.length > 0 && !m.delta.includes(e.message)) m.delta.push(e.message);
1240
+ const limit = planning ? PLAN_REVISIONS + 1 : MAX_TRIES;
1241
+ if ((s.failedEffects[key] as number) >= limit && e.action !== "report" && e.action !== "notify")
1242
+ s.stuck[e.unit] = { reason: "effect", question: `${e.action} on ${e.unit} failed: ${e.message}` };
1243
+ return;
1244
+ }
1245
+ case "tick":
1246
+ for (const [k, until] of Object.entries(s.holds)) if (until <= e.at) delete s.holds[k];
1247
+ for (const [k, until] of Object.entries(s.waiting)) if (until <= e.at) delete s.waiting[k];
1248
+ for (const m of s.milestones) {
1249
+ if (m.mergeDue !== null && m.mergeDue <= e.at) m.mergeDue = isoPlus(e.at, MERGE_CHECK_MS);
1250
+ if (m.claimAfter !== null && m.claimAfter <= e.at) m.claimAfter = null;
1251
+ }
1252
+ recomputeWake(s);
1253
+ return;
1254
+ case "run.ended":
1255
+ s.phase = "ended";
1256
+ s.outcome = e.outcome;
1257
+ return;
1258
+ case "report.posted":
1259
+ delete s.owed[`${e.kind}:${e.unit}`];
1260
+ return;
1261
+ case "stop.classified": {
1262
+ const l = Object.values(s.lanes).find((x) => x.classify?.dispatchId === e.dispatchId);
1263
+ if (!l?.classify) return;
1264
+ const { dispatchId: _id, ...x } = l.classify;
1265
+ l.classify = null;
1266
+ laneEnded(s, l, x, e.reason);
1267
+ return;
1268
+ }
1269
+ case "base.behind": {
1270
+ // the commit under verification is behind origin's base: merge the base first
1271
+ const m = milestoneOf(s, e.milestone);
1272
+ ok(`land@${e.milestone}`);
1273
+ if (m && !isDone(m)) {
1274
+ m.status = "integrating";
1275
+ m.integrateFiles = [];
1276
+ }
1277
+ return;
1278
+ }
1279
+ case "integrate.done": {
1280
+ ok(`integrate@${e.milestone}`);
1281
+ const m = milestoneOf(s, e.milestone);
1282
+ if (m?.status === "integrating") mergedIn(m, e.commit);
1283
+ return;
1284
+ }
1285
+ case "integrate.conflict": {
1286
+ ok(`integrate@${e.milestone}`);
1287
+ const m = milestoneOf(s, e.milestone);
1288
+ if (m?.status === "integrating") {
1289
+ m.integrateFiles = e.files;
1290
+ m.tries = {};
1291
+ }
1292
+ return;
1293
+ }
1294
+ case "daemon.started":
1295
+ return;
1296
+ }
1297
+ }
1298
+
1299
+ /** A pure fold step: `state` untouched, the next one returned. */
1300
+ export function reduce(state: RunState, e: RunEvent): RunState {
1301
+ const s = structuredClone(state);
1302
+ apply(s, e);
1303
+ return s;
1304
+ }
1305
+
1306
+ export function replay(events: RunEvent[], run = ""): RunState {
1307
+ const s = emptyState(run);
1308
+ for (const e of events) apply(s, e);
1309
+ return s;
1310
+ }