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,837 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { mrCommand, unitMilestone } from "../domain/branch.ts";
4
+ import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
5
+ import { type BlockedReason, foreignFailure, parseBlockedReason } from "../domain/blocked.ts";
6
+ import { quotaOf } from "../domain/failover.ts";
7
+ import { parseFailLines, parseFindings } from "../domain/findings.ts";
8
+ import { headlessRung, tryParseRung } from "../domain/ids.ts";
9
+ import { type JudgeKind, judgeName, parseJudgeReply } from "../domain/judge.ts";
10
+ import { milestoneAfter, onlyAllowedHits, parseLaneHeader } from "../domain/lane.ts";
11
+ import { milestoneProblems } from "../domain/plan-lint.ts";
12
+ import { mayStartMilestone, memoryLimitsOf, memoryLoad, neededMb } from "../domain/milestone-memory.ts";
13
+ import { changedDecisions, reapprovalQuestion } from "../domain/plan-doc.ts";
14
+ import type { RunRecord } from "../domain/record.ts";
15
+ import { currentRoute } from "../domain/route.ts";
16
+ import type { Role } from "../domain/roles.ts";
17
+ import { type MilestonePlan, RUN_UNIT, type RunEventInput } from "../domain/run-events.ts";
18
+ import { type Action, unitOf } from "../domain/run-machine.ts";
19
+ import { lanesOf, milestoneOf, ownerOfFile, type RunState } from "../domain/run-state.ts";
20
+ import { endCollect, requestCancel, tryCollect } from "../infra/dispatch-dir.ts";
21
+ import { currentBranch, fetchBase, gitOrigin, gitRun, isAncestor } from "../infra/git.ts";
22
+ import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
23
+ import { log } from "../infra/log.ts";
24
+ import { admit } from "./admission.ts";
25
+ import { cancel, launcher } from "./dispatch-service.ts";
26
+ import { type Dispatch, listDispatches } from "./dispatches.ts";
27
+ import { finalizeDispatch, waitForFinish } from "./finalize.ts";
28
+ import { failoverRung } from "./failover-rungs.ts";
29
+ import { climb, land, routeLanes } from "./lane-service.ts";
30
+ import { blockedByEnvironment, landedCommitOf, replyVerdict } from "./milestones.ts";
31
+ import { machineGate } from "./milestone-check.ts";
32
+ import { branchPusher, claimTree, memoryReader, removeTree } from "./milestone-tree.ts";
33
+ import { sendRunNotice } from "./notify-service.ts";
34
+ import { planProblems } from "./plan-input.ts";
35
+ import type { Deps } from "./ports.ts";
36
+ import { checkEnvFor, preflight, runCheck } from "./preflight.ts";
37
+ import { park } from "./questions.ts";
38
+ import { briefFor, judgeBrief } from "./run-briefs.ts";
39
+ import { appendEvent, appendEvents, readEvents, replayRun } from "./run-events-store.ts";
40
+ import { writeRunReport } from "./run-report.ts";
41
+ import { runProfile } from "./run-pin.ts";
42
+ import { appendRoute, readRecords, readRoutes, type Run, runPaths } from "./run-store.ts";
43
+ import { treeOf } from "./tree-path.ts";
44
+
45
+ /**
46
+ * What carries the machine's actions out. `run` appends the events the action produces (a long one when it is
47
+ * done), never throws: a failure becomes an `effect.failed` event.
48
+ */
49
+ export interface Effects {
50
+ run(a: Action, state: RunState): Promise<void>;
51
+ }
52
+
53
+ /** Limits a test shortens. */
54
+ export const effectLimits = { checkMs: 600_000, commitMs: 300_000 };
55
+
56
+ /** The lane a finding's file belongs to, by the longest `Owns:` entry. */
57
+ export const ownerOf = ownerOfFile;
58
+
59
+ const read = (file: string): string => {
60
+ try {
61
+ return readFileSync(file, "utf8");
62
+ } catch {
63
+ return "";
64
+ }
65
+ };
66
+
67
+ /** A lane's `Owns:` entries, and those of the other lanes of its milestone; empty when a lane file is gone. */
68
+ function laneOwnsOf(run: Run, lane: string): { own: string[]; siblings: string[] } {
69
+ const dir = runPaths(run.dir).lanes;
70
+ const prefix = lane.slice(0, lane.indexOf(".") + 1);
71
+ const owns = (id: string): string[] => parseLaneHeader(read(join(dir, `${id}.md`))).owns;
72
+ const ids = existsSync(dir)
73
+ ? readdirSync(dir)
74
+ .filter((f) => f.endsWith(".md") && f.startsWith(prefix))
75
+ .map((f) => f.slice(0, -3))
76
+ : [];
77
+ return { own: owns(lane), siblings: ids.filter((id) => id !== lane).flatMap(owns) };
78
+ }
79
+
80
+ /** The sibling entries a failed lane check blames when it blames only them: the lane counts as done. */
81
+ function foreignOf(run: Run, lane: string, log: string): string[] {
82
+ const { own, siblings } = laneOwnsOf(run, lane);
83
+ return foreignFailure(log, own, siblings);
84
+ }
85
+
86
+ const FIRST_LINE = /^VERDICT:\s*FAIL\b/i;
87
+ const PASS_LINE = /^VERDICT: PASS\b/;
88
+
89
+ /**
90
+ * The `dispatch.ended` event of a finished dispatch: the record's status, the reply's findings (a reviewer's
91
+ * BLOCKER, BUG and NIT lines, a failing verifier's lines) and the verifier's verdict, the reason a blocked reply
92
+ * names, the HEAD a verifier checked, and the sibling files a `check_broken` lane blames.
93
+ */
94
+ export function endedEvent(run: Run, record: RunRecord, unit: string): RunEventInput {
95
+ const reply = record.replyPath ? read(join(run.dir, record.replyPath)) : "";
96
+ let verdict: "pass" | "fail" | "blocked" | null = null;
97
+ let probe: string | null = null;
98
+ let findings = record.role === "reviewer" || record.role === "ui-reviewer" ? parseFindings(reply) : [];
99
+ if (record.role === "verifier") {
100
+ const first = replyVerdict(run, record) ?? "";
101
+ probe = blockedByEnvironment(first);
102
+ if (probe !== null) verdict = "blocked";
103
+ else if (PASS_LINE.test(first)) {
104
+ verdict = "pass";
105
+ // plan 34: a PASS lands with the NITs it lists as known issues
106
+ findings = parseFindings(reply).filter((f) => f.label === "NIT");
107
+ }
108
+ // a reply with no readable verdict is not a pass: the next fix round starts from what it said
109
+ else if (record.status === "ok") verdict = "fail";
110
+ if (verdict === "fail") {
111
+ findings = parseFailLines(reply);
112
+ if (!FIRST_LINE.test(first) && findings.length === 0)
113
+ findings = [{ label: "BLOCKER", text: `no VERDICT line: ${first || "empty reply"}`, file: null }];
114
+ }
115
+ }
116
+ const stopped = record.replyStatus === "blocked" || record.replyStatus === "refused";
117
+ const reason = stopped ? (parseBlockedReason(record.replyWhy)?.reason ?? null) : null;
118
+ return {
119
+ type: "dispatch.ended",
120
+ dispatchId: record.dispatchId,
121
+ name: record.name,
122
+ unit,
123
+ status: record.status,
124
+ replyStatus: record.replyStatus,
125
+ reason,
126
+ why: record.replyWhy ?? record.error?.message ?? "",
127
+ thread: record.thread,
128
+ head: record.head ?? null,
129
+ environment: record.environment ?? (probe ? probe : null),
130
+ verdict,
131
+ findings,
132
+ foreign: reason === "check_broken" && record.lane ? foreignOf(run, record.lane, reply) : [],
133
+ replyPath: record.replyPath,
134
+ };
135
+ }
136
+
137
+ const ID = /^M[\w-]*\.\S+$/;
138
+ const HEADING = /^##\s+(M\d+)\b\s*(?:[—–:.-]\s*)?(.*)$/;
139
+ const AFTER = /^\s*[*_]*After[*_]*\s*:[*_]*\s*(.*)$/i;
140
+
141
+ const ROLE_OF_KIND: Record<string, Role> = { prose: "writer", research: "researcher" };
142
+
143
+ /** The plan the architect wrote: milestones from plan.md's `## M<n> …` headings, their lanes from lanes/*.md. */
144
+ export function planOf(run: Run): MilestonePlan[] {
145
+ const dir = runPaths(run.dir).lanes;
146
+ const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".md")) : [];
147
+ const natural = (a: string, b: string): number => a.localeCompare(b, "en", { numeric: true });
148
+ const lanes = files
149
+ .map((f) => f.slice(0, -3))
150
+ .filter((id) => ID.test(id))
151
+ .sort(natural)
152
+ .map((id) => {
153
+ const h = parseLaneHeader(read(join(dir, `${id}.md`)));
154
+ return {
155
+ id,
156
+ milestone: id.slice(0, id.indexOf(".")),
157
+ role: (h.kind ? ROLE_OF_KIND[h.kind] : undefined) ?? ("worker" as Role),
158
+ kind: h.kind,
159
+ after: h.after,
160
+ owns: h.owns,
161
+ };
162
+ });
163
+ if (lanes.length === 0) throw new CatherdError("E_LANE_INVALID", "the architect wrote no lane files");
164
+ const heads: { id: string; title: string; after: string[] }[] = [];
165
+ for (const line of read(runPaths(run.dir).plan).split("\n")) {
166
+ const h = HEADING.exec(line);
167
+ if (h) heads.push({ id: h[1] as string, title: (h[2] ?? "").trim() || (h[1] as string), after: [] });
168
+ else if (line.startsWith("## ")) heads.push({ id: "", title: "", after: [] });
169
+ else if (heads.length) {
170
+ const a = AFTER.exec(line)?.[1];
171
+ if (a) (heads.at(-1) as { after: string[] }).after.push(...(a.match(/\bM\d+\b/g) ?? []));
172
+ }
173
+ }
174
+ const known = heads.filter((h) => h.id);
175
+ const ids = [...new Set([...known.map((h) => h.id), ...lanes.map((l) => l.milestone).sort(natural)])];
176
+ return ids.flatMap((id) => {
177
+ const mine = lanes.filter((l) => l.milestone === id);
178
+ if (mine.length === 0) return [];
179
+ const head = known.find((h) => h.id === id);
180
+ return [
181
+ {
182
+ id,
183
+ title: head?.title ?? id,
184
+ // a plan.md heading's After: line, and the milestones the milestone's lanes name (plan 35 D7)
185
+ after: [
186
+ ...new Set([
187
+ ...(head?.after ?? []).filter((a) => a !== id),
188
+ ...milestoneAfter(
189
+ lanes.map((l) => ({ id: l.id, after: l.after })),
190
+ id,
191
+ ),
192
+ ]),
193
+ ].sort(natural),
194
+ lanes: mine.map(({ milestone: _m, ...l }) => l),
195
+ },
196
+ ];
197
+ });
198
+ }
199
+
200
+ const lower = (s: string): string => s.charAt(0).toLowerCase() + s.slice(1);
201
+
202
+ /** The last `n` lines of what a command printed. */
203
+ const tailOf = (text: string, n = 40): string =>
204
+ text
205
+ .split("\n")
206
+ .filter((l) => l.trim())
207
+ .slice(-n)
208
+ .join("\n");
209
+
210
+ /** The effects that carry a run's actions out through the existing services. */
211
+ export function realEffects(deps: Deps, run: Run): Effects {
212
+ const at = (): Date => new Date(deps.now());
213
+ const append = (e: RunEventInput) => appendEvent(run, e, at());
214
+ const watching = new Set<string>();
215
+
216
+ const heldQuotas = (s: RunState): string[] =>
217
+ Object.entries(s.holds)
218
+ .filter(([, until]) => until > s.at)
219
+ .map(([q]) => q);
220
+
221
+ /** Plan 33: every failover, chosen or none, is a row in routes.jsonl; a failed append never fails the dispatch. */
222
+ function recordFailover(
223
+ a: { name: string; role: Role; lane: string | null },
224
+ from: string,
225
+ rung: string | null,
226
+ s: RunState,
227
+ ): void {
228
+ try {
229
+ appendRoute(run, {
230
+ at: at().toISOString(),
231
+ lane: a.lane,
232
+ role: a.role,
233
+ name: a.name,
234
+ source: "failover",
235
+ from,
236
+ rung,
237
+ why: rung
238
+ ? `failed over from ${from} to ${rung}`
239
+ : `no stand-in for ${from}: every candidate is held or below it`,
240
+ held: heldQuotas(s).sort(),
241
+ });
242
+ } catch (e) {
243
+ log("warn", "routes", { failover: errorMessage(e) });
244
+ }
245
+ }
246
+
247
+ /** The rung a dispatch runs on, or a refusal's code and message. */
248
+ async function rungFor(
249
+ a: { name: string; role: Role; lane: string | null; rung: string | null; failoverFrom: string | null },
250
+ s: RunState,
251
+ ): Promise<{ rung: string } | { code: string; message: string }> {
252
+ const profile = runProfile(deps, run);
253
+ let rung = a.failoverFrom ?? a.rung;
254
+ if (!rung && a.lane) rung = currentRoute(readRoutes(run), a.lane)?.rung ?? null;
255
+ if (!rung && a.role === "judge") rung = profile.roles.judge?.rungs[0] ?? null;
256
+ if (!rung) {
257
+ const [routed] = await routeLanes(deps, { run: run.id, laneFiles: [undefined], role: a.role });
258
+ rung = routed?.rung ?? null;
259
+ }
260
+ if (!rung) throw new CatherdError("E_ADMIT_RUNG", `no rung for the ${a.role}`);
261
+ const parsed = tryParseRung(rung);
262
+ const held = heldQuotas(s);
263
+ if (a.failoverFrom || (parsed !== null && held.includes(quotaOf(parsed)))) {
264
+ const stand = failoverRung(deps, run, a.role, rung, held);
265
+ recordFailover(a, a.failoverFrom ?? rung, stand, s);
266
+ if (!stand)
267
+ return {
268
+ code: "E_ALL_HELD",
269
+ message: `every rung of the ${a.role} is on a held quota (${held.join(", ")})`,
270
+ };
271
+ rung = stand;
272
+ }
273
+ return { rung: headlessRung(rung) };
274
+ }
275
+
276
+ /** Waits for a dispatch to end, then appends what it said: a judge's decision first, then `dispatch.ended`. */
277
+ async function settle(
278
+ d: Dispatch,
279
+ unit: string,
280
+ judge: { id: string; kind: JudgeKind } | null,
281
+ ): Promise<void> {
282
+ const id = d.admit.dispatchId;
283
+ if (watching.has(id)) return;
284
+ watching.add(id);
285
+ try {
286
+ await waitForFinish(d, { pollMs: deps.pollMs, now: deps.now });
287
+ const record = await finalizeDispatch(run, d);
288
+ if (replayRun(run).dispatches[id]?.ended) return;
289
+ // a daemon that died between judge.decided and dispatch.ended decided it already
290
+ const decidedBefore = readEvents(run).some((e) => e.type === "judge.decided" && e.dispatchId === id);
291
+ if (judge && record.status !== "limit" && !decidedBefore) {
292
+ const reply = record.replyPath ? read(join(run.dir, record.replyPath)) : "";
293
+ const decision = record.status === "ok" ? parseJudgeReply(reply, judge.kind) : null;
294
+ await append(
295
+ decision
296
+ ? { type: "judge.decided", id: judge.id, unit, kind: judge.kind, decision, dispatchId: id }
297
+ : {
298
+ type: "judge.failed",
299
+ id: judge.id,
300
+ unit,
301
+ why:
302
+ record.status === "ok"
303
+ ? "the reply holds no decision its kind allows"
304
+ : `the judge ${record.status}`,
305
+ },
306
+ );
307
+ }
308
+ await append(endedEvent(run, record, unit));
309
+ // catherd announces nothing about a role of a daemon run: the run's reports do
310
+ if (await tryCollect(d.dir)) endCollect(d.dir);
311
+ } finally {
312
+ watching.delete(id);
313
+ }
314
+ }
315
+
316
+ const judgeOf = (name: string, s: RunState): { id: string; kind: JudgeKind } | null => {
317
+ const id = name.replace(/^judge-/, "");
318
+ return name.startsWith("judge-") ? { id, kind: s.judges[id]?.kind ?? "unexpected" } : null;
319
+ };
320
+
321
+ /** An admitted dispatch of `name` that the log never heard of. */
322
+ const orphanOf = (name: string, s: RunState): Dispatch | undefined =>
323
+ listDispatches(run).find((d) => d.admit.name === name && !s.dispatches[d.admit.dispatchId]);
324
+
325
+ async function start(
326
+ a: {
327
+ unit: string;
328
+ name: string;
329
+ role: Role;
330
+ lane: string | null;
331
+ rung: string | null;
332
+ thread: string | null;
333
+ failoverFrom: string | null;
334
+ },
335
+ brief: string,
336
+ s: RunState,
337
+ judge: { id: string; kind: JudgeKind } | null,
338
+ ): Promise<void> {
339
+ const refuse = (code: string, message: string) =>
340
+ append({ type: "dispatch.refused", name: a.name, unit: a.unit, code, message });
341
+ // the daemon died between its admission and its event: the dispatch is recorded and watched, never launched twice
342
+ const adopt = async (found: Dispatch): Promise<void> => {
343
+ await append({
344
+ type: "dispatch.started",
345
+ dispatchId: found.admit.dispatchId,
346
+ name: a.name,
347
+ role: a.role,
348
+ unit: a.unit,
349
+ lane: a.lane,
350
+ rung: found.admit.rung,
351
+ thread: found.admit.thread,
352
+ ...(judge ? { judgeKind: judge.kind } : {}),
353
+ });
354
+ await settle(found, a.unit, judge);
355
+ };
356
+ const orphan = orphanOf(a.name, s);
357
+ if (orphan) return adopt(orphan);
358
+ const picked = await rungFor(a, s);
359
+ if ("code" in picked) return void (await refuse(picked.code, picked.message));
360
+ let admitted: Awaited<ReturnType<typeof admit>>;
361
+ try {
362
+ admitted = await admit(deps, run, {
363
+ role: a.role,
364
+ name: a.name,
365
+ brief,
366
+ rung: picked.rung,
367
+ thread: a.thread,
368
+ lane: a.lane,
369
+ failoverFrom: a.failoverFrom,
370
+ // plan 35: a base run's milestone works in its own worktree
371
+ cwd: treeOf(run, unitMilestone(a.unit)),
372
+ });
373
+ } catch (e) {
374
+ if (!isCatherdError(e)) throw e;
375
+ if (e.code === "E_ADMIT_DUPLICATE") {
376
+ const orphan = orphanOf(a.name, s);
377
+ if (orphan) return await adopt(orphan);
378
+ }
379
+ return void (await refuse(e.code, e.message));
380
+ }
381
+ const { d, specPath } = admitted;
382
+ // a cancel that landed while this start was in flight: the role must not start after the run is cancelled
383
+ const now = replayRun(run);
384
+ if (now.cancelling || now.phase === "ended") {
385
+ requestCancel(d.dir);
386
+ await finalizeDispatch(run, d);
387
+ if (await tryCollect(d.dir)) endCollect(d.dir);
388
+ return void (await refuse("E_RUN_CANCELLED", "the run was cancelled before the role started"));
389
+ }
390
+ try {
391
+ launcher.launch(d, specPath);
392
+ } catch (e) {
393
+ // watched all the same: a dispatch that never started is recorded as lost once its start grace passes
394
+ log("warn", "daemon", { run: run.id, name: a.name, launch: errorMessage(e) });
395
+ }
396
+ await append({
397
+ type: "dispatch.started",
398
+ dispatchId: d.admit.dispatchId,
399
+ name: a.name,
400
+ role: a.role,
401
+ unit: a.unit,
402
+ lane: a.lane,
403
+ rung: d.admit.rung,
404
+ thread: a.thread,
405
+ ...(judge ? { judgeKind: judge.kind } : {}),
406
+ });
407
+ await settle(d, a.unit, judge);
408
+ }
409
+
410
+ /**
411
+ * The architect's plan into `plan.ready`, once the plan backs every lane. A gap is `effect.failed`: its architect
412
+ * is sent back with it. An edit to a decision the owner approved asks them again before the plan is read.
413
+ */
414
+ async function readPlan(): Promise<void> {
415
+ const milestones = planOf(run);
416
+ const problems = [...planProblems(run), ...milestoneProblems(milestones)];
417
+ if (problems.length > 0) {
418
+ await append({
419
+ type: "effect.failed",
420
+ action: "readPlan",
421
+ unit: RUN_UNIT,
422
+ message: `the plan needs: ${problems.join("; ")}`,
423
+ });
424
+ return;
425
+ }
426
+ const approved = read(join(run.dir, "plan-approved.md"));
427
+ const changed = approved ? changedDecisions(approved, read(runPaths(run.dir).plan)) : [];
428
+ // one write: a crash between the two would lose the owner's change request
429
+ await appendEvents(
430
+ run,
431
+ [
432
+ ...(changed.length > 0
433
+ ? [
434
+ {
435
+ type: "question.parked" as const,
436
+ unit: RUN_UNIT,
437
+ question: reapprovalQuestion(run.id, changed),
438
+ reason: "approval" as const,
439
+ },
440
+ ]
441
+ : []),
442
+ { type: "plan.ready", milestones },
443
+ ],
444
+ at(),
445
+ );
446
+ }
447
+
448
+ async function route(a: Extract<Action, { type: "route" }>, s: RunState): Promise<void> {
449
+ const lanes = lanesOf(s, a.milestone);
450
+ const roles = [...new Set(lanes.map((l) => l.role))];
451
+ const routed: { lane: string; rung: string; ladder: string[] }[] = [];
452
+ for (const role of roles) {
453
+ const ids = lanes.filter((l) => l.role === role).map((l) => l.id);
454
+ const out = await routeLanes(deps, { run: run.id, laneFiles: ids.map((id) => `lanes/${id}.md`), role });
455
+ for (const r of out) if (r.lane) routed.push({ lane: r.lane, rung: r.rung, ladder: r.ladder });
456
+ }
457
+ await append({ type: "lanes.routed", milestone: a.milestone, lanes: routed });
458
+ const rep = await preflight(deps, { run: run.id, milestone: a.milestone, confirmed: a.confirmed });
459
+ await append(
460
+ rep.needsConfirmation
461
+ ? {
462
+ type: "preflight.done",
463
+ milestone: a.milestone,
464
+ blocking: [],
465
+ confirm: rep.commands.map((c) => `${c.lane}: ${c.check ?? "(no fast check)"}`),
466
+ lint: rep.lint,
467
+ }
468
+ : {
469
+ type: "preflight.done",
470
+ milestone: a.milestone,
471
+ blocking: rep.results
472
+ .filter((r) => r.outcome === "cannot-start")
473
+ .map((r) => ({
474
+ lane: r.lane,
475
+ note: r.note ?? (tailOf(r.tail.join("\n"), 3) || "cannot start"),
476
+ })),
477
+ confirm: null,
478
+ lint: rep.lint,
479
+ },
480
+ );
481
+ }
482
+
483
+ async function fastCheck(a: Extract<Action, { type: "fastCheck" }>): Promise<void> {
484
+ const header = parseLaneHeader(read(join(runPaths(run.dir).lanes, `${a.lane}.md`)));
485
+ if (!header.fastCheck) {
486
+ await append({
487
+ type: "check.ran",
488
+ lane: a.lane,
489
+ exit: 0,
490
+ tail: "the lane has no fast check",
491
+ foreign: [],
492
+ });
493
+ return;
494
+ }
495
+ const check = header.fastCheck;
496
+ const cwd = treeOf(run, unitMilestone(a.lane));
497
+ const env = await checkEnvFor(run.meta.repo);
498
+ const slots = heavySlots(runProfile(deps, run).heavy);
499
+ const r = await withHeavySlot(
500
+ slots,
501
+ (_slot, tmp) => runCheck(cwd, check, effectLimits.checkMs, { ...env, TMPDIR: tmp, GOTMPDIR: tmp }),
502
+ { tmpBase: process.env.TMPDIR },
503
+ );
504
+ // an absence check whose only hits are the lane's Allow: exceptions has passed
505
+ const allowed = r.code !== 0 && !r.timedOut && onlyAllowedHits(r.lines, header.allow);
506
+ const failed = !allowed && r.code !== 0;
507
+ await append({
508
+ type: "check.ran",
509
+ lane: a.lane,
510
+ exit: allowed ? 0 : r.code,
511
+ tail: r.tail.join("\n"),
512
+ // a check that fails only in a sibling's files still counts: the sibling is not done writing them
513
+ foreign: failed ? foreignOf(run, a.lane, (r.lines.length ? r.lines : r.tail).join("\n")) : [],
514
+ });
515
+ }
516
+
517
+ /** Why a stop gave no reason: Jev reads the end of the reply; null when it is off, unsure or says other. */
518
+ async function classify(a: Extract<Action, { type: "classify" }>): Promise<void> {
519
+ const record = readRecords(run).records.find((r) => r.dispatchId === a.dispatchId);
520
+ let reason: BlockedReason | null = null;
521
+ if (record) {
522
+ const reply = record.replyPath ? read(join(run.dir, record.replyPath)) : "";
523
+ try {
524
+ const v = await deps.routing.stopCause(
525
+ run.dir,
526
+ `${record.status}${record.replyStatus ? `, STATUS ${record.replyStatus}` : ""}`,
527
+ reply,
528
+ runProfile(deps, run).jev.use,
529
+ );
530
+ if (v.source === "jev" && v.value !== "other") reason = v.value;
531
+ } catch (e) {
532
+ log("warn", "daemon", { run: run.id, action: "classify", error: errorMessage(e) });
533
+ }
534
+ }
535
+ await append({ type: "stop.classified", dispatchId: a.dispatchId, reason });
536
+ }
537
+
538
+ async function commit(a: Extract<Action, { type: "commit" }>, s: RunState): Promise<void> {
539
+ const m = milestoneOf(s, a.milestone);
540
+ const lanes = lanesOf(s, a.milestone);
541
+ const ids = new Set(lanes.map((l) => l.id));
542
+ const paths = new Set(lanes.flatMap((l) => l.owns));
543
+ for (const r of readRecords(run).records)
544
+ if (r.lane && ids.has(r.lane)) for (const p of [...r.changedOwned, ...r.violations]) paths.add(p);
545
+ // plan 35: a base run's milestone is committed in its own worktree, on its own branch
546
+ const repo = treeOf(run, a.milestone);
547
+ const failed = (r: { out: string; err: string; timedOut: boolean }) =>
548
+ append({
549
+ type: "commit.failed",
550
+ milestone: a.milestone,
551
+ round: a.round,
552
+ tail: r.timedOut ? "git timed out" : tailOf(`${r.out}\n${r.err}`),
553
+ });
554
+ // a merge of the base is open: the files it left in conflict are resolved now, and the merge itself is committed
555
+ const merging = (m?.integrateFiles.length ?? 0) > 0;
556
+ for (const f of merging ? (m?.integrateFiles ?? []) : []) paths.add(f);
557
+ for (const p of paths) {
558
+ const r = await gitRun(repo, ["--literal-pathspecs", "add", "-A", "--", p], effectLimits.commitMs);
559
+ // an Owns: entry the lane never created is no error
560
+ if (r.code !== 0 && !/did not match any files/.test(r.err)) return void (await failed(r));
561
+ }
562
+ if (merging) {
563
+ // a file the worker left unresolved must not become the milestone's commit
564
+ for (const f of m?.integrateFiles ?? []) {
565
+ const text = existsSync(join(repo, f)) ? readFileSync(join(repo, f), "utf8") : "";
566
+ if (/^<{7} /m.test(text) && /^>{7} /m.test(text))
567
+ return void (await failed({ out: "", err: `${f} still holds conflict markers`, timedOut: false }));
568
+ }
569
+ // an open merge is committed whole: a partial commit is refused while one is in progress
570
+ const c = await gitRun(repo, ["commit", "--no-edit"], effectLimits.commitMs);
571
+ if (c.code !== 0) return void (await failed(c));
572
+ } else {
573
+ // only what the milestone owns: work staged elsewhere stays staged and out of the commit
574
+ const staged = paths.size
575
+ ? await gitRun(
576
+ repo,
577
+ ["--literal-pathspecs", "diff", "--cached", "--name-only", "-z", "--", ...paths],
578
+ effectLimits.commitMs,
579
+ )
580
+ : { code: 0, out: "", err: "", timedOut: false };
581
+ if (staged.code !== 0) return void (await failed(staged));
582
+ const files = staged.out.split("\0").filter(Boolean);
583
+ if (files.length) {
584
+ const title = lower(m?.title ?? a.milestone);
585
+ const suffix = a.round === 0 ? "" : `, review round ${a.round}`;
586
+ const subject = `${`${a.round === 0 ? "feat" : "fix"}: ${title}`.slice(0, 100 - suffix.length)}${suffix}`;
587
+ const c = await gitRun(
588
+ repo,
589
+ ["--literal-pathspecs", "commit", "--only", "-m", subject, "--", ...files],
590
+ effectLimits.commitMs,
591
+ );
592
+ if (c.code !== 0) return void (await failed(c));
593
+ }
594
+ }
595
+ const head = await gitRun(repo, ["rev-parse", "HEAD"]);
596
+ if (head.code !== 0) return void (await failed(head));
597
+ await append({ type: "commit.made", milestone: a.milestone, round: a.round, commit: head.out.trim() });
598
+ }
599
+
600
+ async function landIt(a: Extract<Action, { type: "land" }>, s: RunState): Promise<void> {
601
+ const m = milestoneOf(s, a.milestone);
602
+ if (!m?.commit) throw new CatherdError("E_RUN_COMMIT", `${a.milestone} has no commit to land`);
603
+ const issues = a.knownIssues.length ? `; ${a.knownIssues.length} known issue(s)` : "";
604
+ // a base run lands only what origin's base is behind; a base that moved is merged in first (spec 2.0, decision 21)
605
+ let stale = "";
606
+ if (s.base !== null) {
607
+ const repo = run.meta.repo;
608
+ const fetched = await fetchBase(repo, s.base);
609
+ if ((await isAncestor(repo, `origin/${s.base}`, m.commit)) === false)
610
+ return void (await append({ type: "base.behind", milestone: a.milestone }));
611
+ if (!fetched) stale = `; fetch of origin/${s.base} failed: using the copy from the last fetch`;
612
+ }
613
+ try {
614
+ const r = await land(deps, {
615
+ run: run.id,
616
+ milestone: a.milestone,
617
+ what: m.title,
618
+ commit: m.commit,
619
+ evidence: `verifier-${a.milestone} PASS on ${m.commit.slice(0, 12)}${issues}${stale}`,
620
+ next: `the daemon continues after ${a.milestone}`,
621
+ });
622
+ await append({
623
+ type: "milestone.landed",
624
+ milestone: a.milestone,
625
+ commit: m.commit,
626
+ knownIssues: a.knownIssues,
627
+ digestPath: r.digestPath,
628
+ ...(s.base !== null ? { branch: m.branch ?? (await currentBranch(treeOf(run, a.milestone))) } : {}),
629
+ });
630
+ } catch (e) {
631
+ if (!isCatherdError(e)) throw e;
632
+ await append({
633
+ type: "land.refused",
634
+ milestone: a.milestone,
635
+ code: e.code,
636
+ message: e.message,
637
+ fix: e.fix ?? "",
638
+ });
639
+ }
640
+ }
641
+
642
+ /** Merges origin's base into the run's branch: done with the merge commit, or the files left in conflict. */
643
+ async function integrate(a: Extract<Action, { type: "integrate" }>, s: RunState): Promise<void> {
644
+ const repo = treeOf(run, a.milestone);
645
+ const base = s.base;
646
+ if (base === null) throw new CatherdError("E_RUN_COMMIT", `${a.milestone} has no base branch to merge`);
647
+ await fetchBase(repo, base);
648
+ const branch = milestoneOf(s, a.milestone)?.branch ?? (await currentBranch(repo)) ?? "HEAD";
649
+ const r = await gitRun(
650
+ repo,
651
+ ["merge", "--no-ff", "-m", `Merge origin/${base} into ${branch}`, `origin/${base}`],
652
+ effectLimits.commitMs,
653
+ );
654
+ if (r.code === 0) {
655
+ const head = await gitRun(repo, ["rev-parse", "HEAD"]);
656
+ if (head.code !== 0) throw new Error(`git rev-parse HEAD failed: ${tailOf(head.err)}`);
657
+ return void (await append({ type: "integrate.done", milestone: a.milestone, commit: head.out.trim() }));
658
+ }
659
+ const unmerged = await gitRun(repo, ["diff", "--name-only", "--diff-filter=U"]);
660
+ const files = unmerged.out.split("\n").filter(Boolean);
661
+ if (unmerged.code !== 0 || files.length === 0)
662
+ throw new Error(`merging origin/${base} failed: ${tailOf(`${r.out}\n${r.err}`, 5)}`);
663
+ await append({ type: "integrate.conflict", milestone: a.milestone, files });
664
+ }
665
+
666
+ /**
667
+ * Whether the milestone is merged: origin's base holds its landed commit, or the owner's answer counted it so. A
668
+ * merged milestone's clean worktree goes (a dirty one stays, logged), and then `milestone.merged` follows; silent
669
+ * while it is not merged, or cannot be told.
670
+ */
671
+ async function checkMerged(a: Extract<Action, { type: "checkMerged" }>, s: RunState): Promise<void> {
672
+ if (s.base === null) return;
673
+ const commit = landedCommitOf(run, a.milestone);
674
+ let merged = milestoneOf(s, a.milestone)?.status === "merged";
675
+ if (!merged && commit) {
676
+ await fetchBase(run.meta.repo, s.base);
677
+ merged = (await isAncestor(run.meta.repo, commit, `origin/${s.base}`)) === true;
678
+ }
679
+ if (!merged) return;
680
+ const gone = await removeTree(run, a.milestone);
681
+ if (gone.hint) log("warn", "trees", { run: run.id, milestone: a.milestone, hint: gone.hint });
682
+ await append({ type: "milestone.merged", milestone: a.milestone });
683
+ }
684
+
685
+ /** A memory check, then the milestone's worktree: `tree.deferred` while the machine has no room for another. */
686
+ async function claim(a: Extract<Action, { type: "claimTree" }>, s: RunState): Promise<void> {
687
+ const limits = memoryLimitsOf(runProfile(deps, run));
688
+ const load = memoryLoad(s.milestones, deps.now(), limits);
689
+ const availableMb = memoryReader.read();
690
+ if (!mayStartMilestone({ availableMb, ...load }, limits))
691
+ return void (await append({
692
+ type: "tree.deferred",
693
+ milestone: a.milestone,
694
+ availableMb,
695
+ needMb: neededMb(load.ramping, limits),
696
+ }));
697
+ const c = await claimTree(deps, run, a.milestone, {
698
+ known: milestoneOf(s, a.milestone)?.branch != null,
699
+ });
700
+ for (const hint of c.hints) log("warn", "trees", { run: run.id, milestone: a.milestone, hint });
701
+ await append({
702
+ type: "tree.ready",
703
+ milestone: a.milestone,
704
+ tree: c.tree,
705
+ branch: c.branch,
706
+ base: c.base,
707
+ hints: c.hints,
708
+ });
709
+ }
710
+
711
+ /** The owner's `push` answer: the one push catherd makes, of this milestone's own branch. It opens no MR. */
712
+ async function pushBranch(a: Extract<Action, { type: "pushBranch" }>, s: RunState): Promise<void> {
713
+ const m = milestoneOf(s, a.milestone);
714
+ if (!m?.tree || !m.branch || s.base === null)
715
+ throw new CatherdError("E_TREE", `${a.milestone} has no branch to push`);
716
+ await branchPusher.push(m.tree, m.branch);
717
+ const mr = mrCommand({
718
+ originUrl: (await gitOrigin(run.meta.repo)) ?? "",
719
+ base: s.base,
720
+ branch: m.branch,
721
+ body: m.digestPath ?? "",
722
+ });
723
+ await append({ type: "branch.pushed", milestone: a.milestone, branch: m.branch, remote: "origin", mr });
724
+ }
725
+
726
+ async function perform(a: Action, s: RunState): Promise<void> {
727
+ switch (a.type) {
728
+ case "dispatch":
729
+ return start(a, briefFor(run, s, a), s, null);
730
+ case "judge":
731
+ return start(
732
+ {
733
+ unit: a.input.unit,
734
+ name: judgeName(a.input.id),
735
+ role: "judge",
736
+ lane: null,
737
+ rung: null,
738
+ thread: null,
739
+ failoverFrom: null,
740
+ },
741
+ judgeBrief(run, a.input),
742
+ s,
743
+ { id: a.input.id, kind: a.input.kind },
744
+ );
745
+ case "watch": {
746
+ const v = s.dispatches[a.dispatchId];
747
+ if (!v || v.ended) return;
748
+ const d = listDispatches(run).find((x) => x.admit.dispatchId === a.dispatchId);
749
+ if (!d) throw new Error(`dispatch ${a.dispatchId} has no folder`);
750
+ return settle(d, v.unit, v.role === "judge" ? judgeOf(v.name, s) : null);
751
+ }
752
+ case "readPlan":
753
+ return readPlan();
754
+ case "route":
755
+ return route(a, s);
756
+ case "fastCheck":
757
+ return fastCheck(a);
758
+ case "commit":
759
+ return commit(a, s);
760
+ case "climb": {
761
+ const r = await climb(deps, { run: run.id, lane: a.lane, reason: a.reason });
762
+ return void (await append({ type: "lane.climbed", lane: a.lane, rung: r.rung, top: r.top }));
763
+ }
764
+ case "park":
765
+ // the push question is the daemon's own: a 1.5 question would hold the run's pause and ask twice
766
+ if (milestoneOf(s, a.unit) && a.reason !== "push")
767
+ await park(deps, { run: run.id, milestone: a.unit, question: a.question });
768
+ return void (await append({
769
+ type: "question.parked",
770
+ unit: a.unit,
771
+ question: a.question,
772
+ reason: a.reason,
773
+ }));
774
+ case "land":
775
+ return landIt(a, s);
776
+ case "cancel": {
777
+ for (const id of a.dispatchIds) {
778
+ const d = listDispatches(run).find((x) => x.admit.dispatchId === id);
779
+ if (!d) continue;
780
+ try {
781
+ await cancel(deps, run.id, d.admit.name);
782
+ } catch (e) {
783
+ if (!(isCatherdError(e) && e.code === "E_RUN_NOT_LIVE")) throw e;
784
+ }
785
+ }
786
+ return;
787
+ }
788
+ case "report":
789
+ return void (await append({ type: "report.posted", kind: a.kind, unit: a.unit, text: a.text }));
790
+ case "notify":
791
+ // the phone; the session hears the run's reports through its notifier
792
+ await sendRunNotice(deps, run, { unit: a.unit, moment: a.moment });
793
+ return;
794
+ case "finish": {
795
+ // what each landed milestone's check passed on only through the base or a rerun
796
+ const audits: Record<string, string[]> = {};
797
+ for (const m of s.milestones) {
798
+ if (!m.commit || (m.status !== "landed" && m.status !== "merged")) continue;
799
+ const g = await machineGate(run, m.id, m.commit).catch(() => null);
800
+ if (g?.audit.length) audits[m.id] = g.audit;
801
+ }
802
+ const reportPath = writeRunReport(run, s, readEvents(run), audits, a.proposals);
803
+ return void (await append({ type: "run.ended", outcome: a.outcome, reportPath }));
804
+ }
805
+ case "classify":
806
+ return classify(a);
807
+ case "checkMerged":
808
+ return checkMerged(a, s);
809
+ case "integrate":
810
+ return integrate(a, s);
811
+ case "claimTree":
812
+ return claim(a, s);
813
+ case "pushBranch":
814
+ return pushBranch(a, s);
815
+ }
816
+ }
817
+
818
+ return {
819
+ async run(a, s) {
820
+ try {
821
+ await perform(a, s);
822
+ } catch (e) {
823
+ log("warn", "daemon", { run: run.id, action: a.type, error: errorMessage(e) });
824
+ try {
825
+ await append({
826
+ type: "effect.failed",
827
+ action: a.type,
828
+ unit: unitOf(a, s),
829
+ message: errorMessage(e),
830
+ });
831
+ } catch (inner) {
832
+ log("error", "daemon", { run: run.id, action: a.type, lost: errorMessage(inner) });
833
+ }
834
+ }
835
+ },
836
+ };
837
+ }