tumwater 0.1.2 → 0.2.0

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 (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -6,12 +6,13 @@ import { currentBranch, gitLines, headOf } from "../git/git.js";
6
6
  import { unquotePorcelainPath } from "../git/git-diff.js";
7
7
  import { CONFIG_BASENAME, configPath, primaryWorktreeLockPath } from "../paths.js";
8
8
  import { withStateLockAsync } from "../concurrency/lock.js";
9
- /** Git plumbing for the landing flow: rebasing a worktree branch onto main, inspecting and
10
- * finishing a conflicted rebase, and fast-forwarding main — the mechanics, with no landing policy
11
- * of their own. Landing policy (lock → rebase → verify → ff, conflict resolution, batch ff)
12
- * lives in landing-merge.ts on top of these. Moved here from landing-merge.ts: the cluster started
13
- * as landing-merge.ts-only helpers (organize tick 78, moving them out of git/git.ts), but the
14
- * lander and the batch lander grew to call them directly, leaving landing-merge.ts exporting
9
+ import { errorMessage } from "../text/text.js";
10
+ /** Git plumbing for the merge flow: rebasing a worktree branch onto main, inspecting and
11
+ * finishing a conflicted rebase, and fast-forwarding main — the mechanics, with no merge policy
12
+ * of their own. Merge policy (lock → rebase → verify → ff, conflict resolution, batch ff)
13
+ * lives in merge-to-main.ts on top of these. Moved here from merge-to-main.ts: the cluster started
14
+ * as merge-to-main.ts-only helpers (organize tick 78, moving them out of git/git.ts), but the
15
+ * merger and the batch merger grew to call them directly, leaving merge-to-main.ts exporting
15
16
  * generic rebase/ff plumbing that had nothing to do with merging. */
16
17
  /** Paths currently in conflict (unmerged) in the worktree. C-quoted names are decoded to
17
18
  * real paths — a non-ASCII conflicted file arrives as `"h\303\251llo.ts"` (core.quotePath is on
@@ -60,7 +61,7 @@ export async function mainCommitsTouching(wt, since, mainBranch, files) {
60
61
  * cleaning up: "rebased" (including already up to date), "conflict" (the rebase stopped on
61
62
  * unmerged paths, which remain in the worktree for a resolver), or "other" (any other
62
63
  * failure). Shared by the two rebase wrappers below, which differ only in cleanup policy.
63
- * Rebase — not merge — keeps main's history linear: each tick lands as its own commit on top
64
+ * Rebase — not merge — keeps main's history linear: each tick merges as its own commit on top
64
65
  * of whatever main holds. */
65
66
  async function attemptRebase(wt, mainBranch) {
66
67
  try {
@@ -93,6 +94,10 @@ export async function rebaseOntoMainLeaveConflicts(wt, mainBranch) {
93
94
  await gitTry(wt, "rebase", "--abort");
94
95
  return state === "rebased" ? "clean" : state === "conflict" ? "conflict" : "failed";
95
96
  }
97
+ /** A conflict block's start or end marker line: seven `<` or `>`, then a space or the line's end.
98
+ * The one marker definition hasConflictMarkers and conflictHunkRanges share, so the ranges a
99
+ * conflict prompt names are exactly the blocks the marker check later rejects. */
100
+ const MARKER_EDGE = /^(<{7}|>{7})( |$)/m;
96
101
  /** True if any of the given files still contains a git conflict marker.
97
102
  * A deleted file counts as resolved (the resolver chose the deletion). */
98
103
  export function hasConflictMarkers(wt, files) {
@@ -101,21 +106,65 @@ export function hasConflictMarkers(wt, files) {
101
106
  // characters), and flagging it would reject clean resolutions forever. A resolver that
102
107
  // leaves only a separator line behind is treated as resolved; its stray line is content the
103
108
  // project's own tests can catch.
104
- const marker = /^(<{7}|>{7})( |$)/m;
105
109
  return files.some((f) => {
106
110
  const p = path.join(wt, f);
107
111
  try {
108
- return marker.test(fs.readFileSync(p, "utf8"));
112
+ return MARKER_EDGE.test(fs.readFileSync(p, "utf8"));
109
113
  }
110
114
  catch {
111
115
  return false;
112
116
  }
113
117
  });
114
118
  }
119
+ /** How many conflict hunks a conflict prompt lists per file: every hunk of a typical backlog or
120
+ * code conflict, and a bound on a file pasted over wholesale. */
121
+ export const MAX_LISTED_HUNKS = 10;
122
+ /** The 1-based inclusive line ranges of the conflict hunks in `text`, each from a start marker
123
+ * line through the next end marker line. Markers are matched by line prefix alone, so a hunk
124
+ * inside a markdown code fence counts too: git writes its markers wherever the conflict falls.
125
+ * A second start marker inside an open hunk stays part of it, and an unterminated hunk runs to
126
+ * the last line. */
127
+ export function conflictHunkRanges(text) {
128
+ const lines = text.split("\n");
129
+ const ranges = [];
130
+ let start;
131
+ lines.forEach((line, i) => {
132
+ if (!MARKER_EDGE.test(line))
133
+ return;
134
+ if (line.startsWith("<"))
135
+ start ??= i + 1;
136
+ else if (start !== undefined) {
137
+ ranges.push({ start, end: i + 1 });
138
+ start = undefined;
139
+ }
140
+ });
141
+ const lastLine = text.endsWith("\n") ? lines.length - 1 : lines.length;
142
+ if (start !== undefined)
143
+ ranges.push({ start, end: lastLine });
144
+ return ranges;
145
+ }
146
+ /** Each of `files` (repo-relative, under `wt`) with its conflict hunks' line ranges, for the
147
+ * conflict prompts to name, so whoever resolves them reads the hunks instead of opening the file
148
+ * from line 1. Over 2026-10-17..19 the merge's conflict resolvers spent 2.0M chars reading the
149
+ * conflicted PLANS.md from its top, 16-47K chars a read because its lines are long. Any text
150
+ * file counts, backlog or code; a deleted, unreadable or binary (NUL-bearing) file lists none. */
151
+ export function conflictedFileHunks(wt, files) {
152
+ return files.map((f) => {
153
+ let text = "";
154
+ try {
155
+ text = fs.readFileSync(path.join(wt, f), "utf8");
156
+ }
157
+ catch {
158
+ // Deleted or unreadable: no ranges to name.
159
+ }
160
+ const all = text.includes("\0") ? [] : conflictHunkRanges(text);
161
+ return { path: f, hunks: all.slice(0, MAX_LISTED_HUNKS), more: Math.max(0, all.length - MAX_LISTED_HUNKS) };
162
+ });
163
+ }
115
164
  /** The reason naming every changed file that still carries a conflict marker, or undefined when
116
165
  * none do — the one home of the wording, shared by the stage check's pre-queue finding
117
166
  * (stage-check.ts) and the review gate's hard rejection (review.ts), so the two surfaces cannot
118
- * name the same leftover marker differently. Committing or landing one would put raw `<<<<<<<`
167
+ * name the same leftover marker differently. Committing or merge one would put raw `<<<<<<<`
119
168
  * lines on main (PLANS.md "Robust conflict landing, part 2/2"). Builds on hasConflictMarkers
120
169
  * above, the shared definition of what a marker is. */
121
170
  export function conflictMarkerReason(wt, files) {
@@ -126,14 +175,14 @@ export function conflictMarkerReason(wt, files) {
126
175
  * GIT_EDITOR=true so `rebase --continue` can never block on a commit-message prompt. A
127
176
  * resolution that leaves no unique content (the branch's change was fully superseded by
128
177
  * main) is skipped automatically by git, finishing the rebase cleanly. Throws when the
129
- * rebase stops again — e.g. on a second conflict from an extra commit pi authored during
130
- * the tick; the caller aborts and reports merge_conflict. */
178
+ * rebase stops again — e.g. on a second conflict from a later commit the resolution run
179
+ * added; the caller aborts and reports merge_conflict. */
131
180
  export async function continueRebase(wt) {
132
181
  await git(wt, "add", "-A");
133
182
  await runGit(wt, [...COMMIT_IDENT, "rebase", "--continue"], { GIT_EDITOR: "true" });
134
183
  return headOf(wt, "HEAD");
135
184
  }
136
- /** The live config's bytes, when the landing about to fast-forward `ref` would delete it
185
+ /** The live config's bytes, when the merge about to fast-forward `ref` would delete it
137
186
  * (plans/portability.md §4b/7): the config file exists, is tracked in the current index (and
138
187
  * HEAD — a staged-or-dirty config makes `git merge --ff-only` refuse below, so the two agree
139
188
  * on every merge that can succeed), and is absent from the incoming tree. Anything else —
@@ -168,24 +217,48 @@ export function restoreConfigBytes(root, saved) {
168
217
  * post-rebase HEAD — a bare sha, which both arms accept (`merge --ff-only <sha>` and
169
218
  * `push . <sha>:<main>`). Uses a working-tree merge when the primary checkout is on main (so its
170
219
  * files update), otherwise a local ref push. Returns true on success. The working-tree arm
171
- * preserves the live config across a landing that untracks it (plans/portability.md §4b/7):
172
- * without the write-back, the commit that removes the tracked config would delete the fleet's
220
+ * preserves the live config across a merge that untracks it (plans/portability.md §4b/7):
221
+ * without the write-back, the commit that removes the tracked config would delete the team's
173
222
  * running config out from under it and the next config poll would fall back to defaults. */
174
- export async function ffMainTo(root, ref, mainBranch) {
223
+ export async function ffMainToOrWhy(root, ref, mainBranch) {
175
224
  const primaryBranch = await currentBranch(root);
176
225
  if (primaryBranch === mainBranch) {
177
226
  // The working-tree arm rewrites the same tracked files the CLI backlog writers read and
178
227
  // rewrite, so it shares their primary-worktree lock (paths.ts): without it a `tumwater bug`
179
- // that read before this merge can rename its stale whole-file write over the landed change.
228
+ // that read before this merge can rename its stale whole-file write over the merged change.
180
229
  // The ref-push arm below touches no working tree and needs no lock; the save/merge/restore
181
230
  // stays one short section, never held across a build re-check.
182
231
  return withStateLockAsync(primaryWorktreeLockPath(root), async () => {
183
232
  const saved = await configBytesToPreserve(root, ref);
184
- if ((await gitTry(root, "merge", "--ff-only", ref)) === null)
185
- return false;
233
+ try {
234
+ await git(root, "merge", "--ff-only", ref);
235
+ }
236
+ catch (err) {
237
+ return ffFailureReason(root, ref, mainBranch, err);
238
+ }
186
239
  restoreConfigBytes(root, saved);
187
- return true;
240
+ return null;
188
241
  });
189
242
  }
190
- return (await gitTry(root, "push", ".", `${ref}:${mainBranch}`)) !== null;
243
+ try {
244
+ await git(root, "push", ".", `${ref}:${mainBranch}`);
245
+ return null;
246
+ }
247
+ catch (err) {
248
+ return ffFailureReason(root, ref, mainBranch, err);
249
+ }
250
+ }
251
+ /** Why a fast-forward to `ref` failed: main having moved off `ref`'s base (a commit that did not
252
+ * take the merge lock) is named with its new head and subject; any other cause reports git's own
253
+ * error, e.g. a dirty primary checkout. */
254
+ async function ffFailureReason(root, ref, mainBranch, err) {
255
+ if ((await gitTry(root, "merge-base", "--is-ancestor", mainBranch, ref)) === null) {
256
+ const now = await gitTry(root, "log", "-1", "--format=%h %s", mainBranch);
257
+ return `main moved outside the merge lock (now ${now?.trim() ?? mainBranch})`;
258
+ }
259
+ return `fast-forward failed: ${errorMessage(err).trim().split("\n")[0]}`;
260
+ }
261
+ /** Boolean form of ffMainToOrWhy for callers that need no reason. */
262
+ export async function ffMainTo(root, ref, mainBranch) {
263
+ return (await ffMainToOrWhy(root, ref, mainBranch)) === null;
191
264
  }
@@ -0,0 +1,99 @@
1
+ import { runOnAbort } from "../process/process.js";
2
+ import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
3
+ import { errorMessage } from "../text/text.js";
4
+ import { deleteRef } from "../git/git.js";
5
+ import { removeMergeChange, writeMergeOutcome } from "./merge-slot.js";
6
+ import { mergeRefName, rejectedRefName } from "../paths.js";
7
+ /** A fresh pipeline: nothing in flight anywhere — no verification running, no approved change
8
+ * waiting, no merge holding main. The orchestrator builds one per team start (orchestrator.ts). */
9
+ export function newMergePipeline() {
10
+ return { verifying: new Map(), approved: new Map(), merge: null };
11
+ }
12
+ /** Every merge task in flight — each verification holding its permit, and the merge — for the
13
+ * shutdown and restart waits. A parked verification is not one of them: it has started nothing, and
14
+ * a shutdown (its controller) or a closed start gate (at its grant) settles it without a run. */
15
+ export function mergeTasks(p) {
16
+ return [...[...p.verifying.values()].filter((v) => !v.parked), ...(p.merge ? [p.merge] : [])];
17
+ }
18
+ /** Everything `tumwater abort --role` can reach: every task in flight, plus each approved change
19
+ * waiting with no task of its own. A parked verification is out of reach, as a plainly queued entry
20
+ * always was: its change has not started merging. */
21
+ export function abortableMerges(p) {
22
+ return [...mergeTasks(p), ...p.approved.values()];
23
+ }
24
+ /** Wire harness shutdown to one task's own controller — through the shared register/abort
25
+ * race helper, so an already-fired signal aborts at once. Returns the detach function. The
26
+ * team shutdown signal outlives every merge, so each task that settles while the signal is
27
+ * still live must detach in its own finally; without that, the one long-lived signal
28
+ * accumulates a listener (and the retained controller behind it) for every merge the team
29
+ * ever runs, growing without bound until the shutdown it was waiting for. */
30
+ export function abortOnShutdown(signal, controller) {
31
+ return runOnAbort(signal, () => controller.abort());
32
+ }
33
+ /** Wrap a merge task's stored promise so it can never reject: the tasks are fire-and-forget
34
+ * during normal operation (only the shutdown/restart drain awaits them), and an unexpected
35
+ * throw out of their bookkeeping — an unwritable state file, event feed, or marker while
36
+ * settling an outcome — would otherwise surface as an unhandled rejection and take the
37
+ * orchestrator down. The throw is reported best-effort; the task's own finally has already
38
+ * released its slot and controller, and the surviving queue entry and pin recover on the next
39
+ * poll. */
40
+ export function guardMergeTask(root, role, promise) {
41
+ return promise.catch((err) => {
42
+ warnEventBestEffort(root, TUMWATER_SOURCE, `merge task for ${role} could not settle: ${errorMessage(err)}`);
43
+ });
44
+ }
45
+ /** Take a permit at `tier` (on `key` when given) unless `signal` fires first: resolves to its
46
+ * release, or to null when the signal won — a grant that arrives after that is handed straight
47
+ * back (a hop, never a leak), so a parked verification that a shutdown settles holds nothing. When
48
+ * `key` is set the permit is a keyed lease, so the verification also counts against its provider's
49
+ * cap (plans/provider-concurrency.md §4). */
50
+ export async function acquireUnlessAborted(semaphore, tier, signal, key) {
51
+ if (signal.aborted)
52
+ return null;
53
+ const granted = semaphore.acquireLease(tier, key);
54
+ const stopped = new Promise((resolve) => signal.addEventListener("abort", () => resolve(false), { once: true }));
55
+ if (await Promise.race([granted, stopped])) {
56
+ return granted;
57
+ }
58
+ void granted.then((lease) => lease.release());
59
+ return null;
60
+ }
61
+ /** The roles the pipeline holds right now — verifying (parked or not), approved, or merging. */
62
+ export function liveRoles(p) {
63
+ return new Set([...p.verifying.keys(), ...p.approved.keys(), ...(p.merge?.roles ?? [])]);
64
+ }
65
+ /** Discard the pinned merge refs of every role in a deliberately-aborted merge.
66
+ * `tumwater abort --role` throws the committed work away, and the pin is what would otherwise
67
+ * recover it, so the ref must go; a shutdown abort leaves `userAborted` unset and every ref
68
+ * survives for recovery. Shared by every task's settling code so their discard semantics
69
+ * cannot drift. A ref that is already gone is not an error. */
70
+ export async function discardPinnedRefs(root, roles) {
71
+ for (const role of roles) {
72
+ try {
73
+ await deleteRef(root, mergeRefName(role));
74
+ }
75
+ catch {
76
+ /* already gone */
77
+ }
78
+ }
79
+ }
80
+ /** Settle a merge queue entry that reached a terminal outcome — the one home of the
81
+ * write-plus-drop pairing every settled merge goes through: the result lands in the role's
82
+ * state and the merge_done/merge_failed event (writeMergeOutcome), then the change's marker
83
+ * record is dropped (removeMergeChange — merge-slot leaves the marker to the caller). Shared by
84
+ * settleAbortedApproved, the verification's final-verdict branch, and the merge's per-change
85
+ * write-back, so no settled entry can keep a stale marker or lose its outcome write. The pinned-ref
86
+ * discard stays with the callers: only a user abort throws work away, and the merge discards its
87
+ * whole stack at once rather than change by change. */
88
+ export async function settleMergeOutcome(root, entry, state, result, durationMs, usage, file) {
89
+ writeMergeOutcome(root, entry, state, result, durationMs, usage, file);
90
+ // A revision that merges ends the rejected change's life: drop the rejected ref (plans/revise-
91
+ // rejected.md). A rejection repoints or deletes it in merge-core.ts, so this is the only
92
+ // ref cleanup a merged revision needs here. Guarded on the entry's revisionRound, not just a
93
+ // `changed` result: a fresh change can merge while a different rejection's revision is still
94
+ // pending, and its merge must leave that pending revision's ref alone.
95
+ if (result === "changed" && entry.revisionRound !== undefined) {
96
+ await deleteRef(root, rejectedRefName(entry.role));
97
+ }
98
+ removeMergeChange(root, entry.role);
99
+ }
@@ -1,16 +1,19 @@
1
1
  import { openQuestions } from "../backlog/backlog.js";
2
2
  import { logEventBestEffort } from "../events/events.js";
3
3
  /** Emit one `question_posted` event per entry QUESTIONS.md's ## Open gained since `before` —
4
- * the capture-and-diff both merge paths record alongside their `merged` events (landing-merge.ts's
5
- * tryMerge and landing-stack.ts's ffStackToMain). The capture (`openQuestions(root)` under the
4
+ * the capture-and-diff both merge paths record alongside their `merged` events (merge-to-main.ts's
5
+ * tryMerge and merge-stack.ts's ffStackToMain). The capture (`openQuestions(root)` under the
6
6
  * merge lock, before the ff) stays with the callers: the lock window is theirs to define, and the
7
7
  * diff is only exact while the capture and this call share it. Best-effort: the questions have
8
8
  * already been posted to QUESTIONS.md and main already fast-forwarded, so an unwritable feed
9
- * must not reject the landing around it (landing-merge.ts's tryMerge, landing-stack.ts). */
9
+ * must not reject the merge around it. `position` is the entry's 1-based place in ## Open (the
10
+ * `<n>` `tumwater questions answer` takes now) and `open` is the list's length, so the page can
11
+ * name the answering command (merge-to-main.ts's tryMerge, merge-stack.ts). */
10
12
  export function logNewQuestions(root, before, role) {
11
- for (const question of openQuestions(root)) {
13
+ const titles = openQuestions(root);
14
+ titles.forEach((question, index) => {
12
15
  if (!before.includes(question)) {
13
- logEventBestEffort(root, { loop: role, type: "question_posted", question });
16
+ logEventBestEffort(root, { source: role, type: "question_posted", question, position: index + 1, open: titles.length });
14
17
  }
15
- }
18
+ });
16
19
  }
@@ -0,0 +1,75 @@
1
+ // vocabulary: plain
2
+ /** The renderer behind `tumwater queue` — the operator-facing text for the merge queue view the
3
+ * reader gathers (merge-queue.ts's readMergeQueue, beside it): one line per queued merge
4
+ * in execution order, plus the count of queue files the reader skipped. Pure function of the
5
+ * collected view and a `now` instant — no I/O, no clock reads — so the age it prints can be
6
+ * pinned by a test. */
7
+ import { humanSeconds, secondsSince } from "../text/datetime.js";
8
+ import { roleLabel } from "../roles/role-labels.js";
9
+ import { shortSha } from "../text/format.js";
10
+ import { plural } from "../text/phrases.js";
11
+ import { mergeChanges, MERGE_STAGE_LABELS } from "./merge-slot.js";
12
+ /** The bracketed-label text for one queue entry, in the wording the loop rows use (status-model's
13
+ * loopPhase): `merge <elapsed> · <stage>` while a verify or the merge holds the change,
14
+ * `approved, awaiting merge` once approved, and "" when the marker is absent, names no record for
15
+ * `sha`, or the record is `done`. Reads the marker through mergeChanges, so an older-generation
16
+ * single-change marker counts as one `merge` record. */
17
+ export function queueEntryMergeLabel(sha, inFlight, now) {
18
+ if (!inFlight)
19
+ return "";
20
+ const change = mergeChanges(inFlight).find((c) => c.sha === sha);
21
+ if (!change || change.status === "done")
22
+ return "";
23
+ if (change.status === "approved")
24
+ return "approved, waiting to merge";
25
+ let label = "merging";
26
+ if (change.startedAt)
27
+ label += ` ${humanSeconds(secondsSince(change.startedAt, now))}`;
28
+ if (change.stage)
29
+ label += ` · ${MERGE_STAGE_LABELS[change.stage]}`;
30
+ if (change.stageSince !== undefined)
31
+ label += ` ${humanSeconds(secondsSince(change.stageSince, now))}`;
32
+ return label;
33
+ }
34
+ /** One re-submitted change's revision note (plans/revise-rejected.md): `revision <round>`,
35
+ * plus ` · <n> prior objection(s)` when the change answered any prior review objections
36
+ * (its `priorReview.reasons`). "" for a fresh change with no revision-round field, so a
37
+ * first submission's queue line is byte-identical to before. */
38
+ export function revisionNote(entry) {
39
+ if (entry.revisionRound === undefined)
40
+ return "";
41
+ let note = "revision " + entry.revisionRound;
42
+ const reasons = entry.priorReview?.reasons;
43
+ if (Array.isArray(reasons) && reasons.length > 0)
44
+ note += " · " + plural(reasons.length, "prior objection");
45
+ return note;
46
+ }
47
+ /** Render the queue as the text `tumwater queue` prints: a `merge queue (<depth>):` header then
48
+ * one 1-based line per readable entry, oldest first, carrying the loop, tick number, short
49
+ * pinned sha, age, and summary. An empty queue (no entries and nothing skipped) prints
50
+ * `merge queue empty`; a non-zero skip count appends one `(+N unreadable queue file(s) skipped)`
51
+ * line so a torn or foreign file the reader dropped is visible rather than silent. An entry the
52
+ * optional in-flight marker has a live record for carries ` · [<label>]` after its age
53
+ * (queueEntryMergeLabel). */
54
+ export function renderMergeQueue(view, now, inFlight) {
55
+ const depth = view.entries.length + view.unreadable;
56
+ const lines = [];
57
+ if (depth === 0) {
58
+ lines.push("merge queue empty");
59
+ }
60
+ else {
61
+ lines.push(`merge queue (${depth}):`);
62
+ view.entries.forEach((e, i) => {
63
+ const age = humanSeconds(secondsSince(e.enqueuedAt, now));
64
+ const label = queueEntryMergeLabel(e.sha, inFlight, now);
65
+ const tag = label ? ` · [${label}]` : "";
66
+ const note = revisionNote(e);
67
+ const noteTag = note ? ` · ${note}` : "";
68
+ lines.push(` ${i + 1}. ${roleLabel(e.role)} run #${e.run} · ${shortSha(e.sha)} · ${age} ago${tag}${noteTag} · ${e.summary}`);
69
+ });
70
+ }
71
+ if (view.unreadable > 0) {
72
+ lines.push(` (+${plural(view.unreadable, "unreadable queue file")} skipped)`);
73
+ }
74
+ return lines.join("\n");
75
+ }
@@ -1,41 +1,91 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { listQueueFiles, queueFileName, removeQueueFile } from "../files/file-queue.js";
3
4
  import { isJsonObject, nonNegativeNumber } from "../files/json-object.js";
4
5
  import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
5
6
  import { cachedByStat } from "../files/stat-cache.js";
6
- import { landQueueDir } from "../paths.js";
7
+ import { mergeQueueDir, legacyLandQueueDir } from "../paths.js";
8
+ import { canonicalRoleId } from "../roles/legacy-role-ids.js";
7
9
  let seq = 0;
8
- /** Append one landing to the queue as a single timestamped file (creating the queue dir if
10
+ /** Append one merge to the queue as a single timestamped file (creating the queue dir if
9
11
  * needed). The filename orders entries across processes by wall-clock time; the per-process
10
12
  * counter and pid break ties within one process. Written atomically (tmp + rename): the
11
13
  * enqueue runs while the process can be hard-killed mid-write (the tick deadline's
12
14
  * process-group kill, a shutdown), and a torn entry the crash left behind is not always
13
15
  * recoverable — the drain repairs only an unreadable HEAD (staleHeadFile), so a torn file
14
16
  * that sorts after a healthy entry would sit in the queue dir unread forever. */
15
- export function enqueueLanding(root, entry) {
16
- const dir = landQueueDir(root);
17
+ export function enqueueMerge(root, entry) {
18
+ const dir = mergeQueueDir(root);
17
19
  const name = queueFileName(entry.enqueuedAt, seq++, ".json");
18
20
  writeJsonAtomic(path.join(dir, name), entry);
19
21
  }
20
- /** Every queue file in execution order (oldest first) — the same filename sort queuedLandings,
21
- * headLanding, and the orchestrator's per-poll queued-roles set read. A missing queue dir
22
+ /** The revision fields of a queue entry, serialized only when present — the shape merge
23
+ * surfaces (drain, verify) and the tick-stage enqueue re-emit when they build their own
24
+ * entry/request objects, so the `revisionRound`/`priorReview` names live in one place and
25
+ * cannot drift. */
26
+ export function entryRevisionFields(e) {
27
+ return {
28
+ ...(e.revisionRound !== undefined ? { revisionRound: e.revisionRound } : {}),
29
+ ...(e.priorReview !== undefined ? { priorReview: e.priorReview } : {}),
30
+ };
31
+ }
32
+ /** Every queue file in execution order (oldest first) — the same filename sort queuedMerges,
33
+ * headMerge, and the orchestrator's per-poll queued-roles set read. A missing queue dir
22
34
  * reads as an empty queue. */
23
35
  function queueFiles(root) {
24
- return listQueueFiles(landQueueDir(root), ".json");
36
+ // Files an old build enqueued into the legacy directory still count until the startup
37
+ // migration moves them; the sort is by basename because the queue order lives in the name.
38
+ const all = [...listQueueFiles(mergeQueueDir(root), ".json"), ...listQueueFiles(legacyLandQueueDir(root), ".json")];
39
+ return all.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
40
+ }
41
+ /** The startup migration step: move every `*.json` from the legacy queue directory into the
42
+ * current one, keeping the basename (the order lives in it) and the contents. A basename already
43
+ * present in the new directory stays put and is noted; the old directory is removed once empty.
44
+ * A file that vanished mid-move (a concurrent run) is skipped. */
45
+ export function legacyMigrateLandQueueDir(root) {
46
+ const from = legacyLandQueueDir(root);
47
+ const to = mergeQueueDir(root);
48
+ const notes = [];
49
+ const files = listQueueFiles(from, ".json");
50
+ if (files.length > 0)
51
+ fs.mkdirSync(to, { recursive: true });
52
+ for (const file of files) {
53
+ const name = path.basename(file);
54
+ const dest = path.join(to, name);
55
+ if (fs.existsSync(dest)) {
56
+ notes.push(`${name} already in merge-queue, left in place`);
57
+ continue;
58
+ }
59
+ try {
60
+ fs.renameSync(file, dest);
61
+ notes.push(`moved ${name}`);
62
+ }
63
+ catch (err) {
64
+ if (err.code !== "ENOENT")
65
+ throw err;
66
+ }
67
+ }
68
+ try {
69
+ fs.rmdirSync(from);
70
+ }
71
+ catch {
72
+ // Missing, or still holding a file left in place or placed since: nothing to remove.
73
+ }
74
+ return notes;
25
75
  }
26
- /** Number of landings currently queued — a directory listing only; no file content is read.
76
+ /** Number of merges currently queued — a directory listing only; no file content is read.
27
77
  * A missing queue dir reads as 0, like every other reader here. */
28
78
  export function queueDepth(root) {
29
79
  return queueFiles(root).length;
30
80
  }
31
81
  // Per-poll entry-content cache (stat-cache.cachedByStat): dashboard polls must not re-parse
32
- // every queued entry — each file is written once by enqueueLanding and only deleted on drop,
82
+ // every queued entry — each file is written once by enqueueMerge and only deleted on drop,
33
83
  // the same write-once discipline the inbox caches for prompts.
34
84
  const entryCache = new Map();
35
- /** True when `v` carries every field a LandingEntry requires, with the right type. The land
85
+ /** True when `v` carries every field a MergeEntry requires, with the right type. The merge
36
86
  * queue is one JSON object per file, so a file that parses to anything else — a scalar, an
37
87
  * array, an object missing a required field — is foreign or torn and reads as no entry.
38
- * Checking only `role` and `sha` left the `as LandingEntry` cast unsound: a foreign file that
88
+ * Checking only `role` and `sha` left the `as MergeEntry` cast unsound: a foreign file that
39
89
  * happened to carry those two fields was handed downstream with `tick` and `summary`
40
90
  * undefined, which surface as bogus session names (`tumwater-<role>-undefined-review`) and
41
91
  * `undefined` in the reviewer's prompt. Optional fields are checked only when present.
@@ -49,15 +99,16 @@ function isPriorReview(v) {
49
99
  return false;
50
100
  return (typeof v.sha === "string" &&
51
101
  Array.isArray(v.reasons) &&
52
- v.reasons.every((r) => typeof r === "string"));
102
+ v.reasons.every((r) => typeof r === "string") &&
103
+ (v.counts === undefined || (Array.isArray(v.counts) && v.counts.every((c) => nonNegativeNumber(c, null) !== null))));
53
104
  }
54
- function isLandingEntry(v) {
105
+ function isMergeEntry(v) {
55
106
  if (!isJsonObject(v))
56
107
  return false;
57
108
  const e = v;
58
109
  return (typeof e.role === "string" &&
59
110
  typeof e.sha === "string" &&
60
- nonNegativeNumber(e.tick, null) !== null &&
111
+ nonNegativeNumber(e.run, null) !== null &&
61
112
  typeof e.summary === "string" &&
62
113
  nonNegativeNumber(e.enqueuedAt, null) !== null &&
63
114
  (e.body === undefined || typeof e.body === "string") &&
@@ -67,22 +118,30 @@ function isLandingEntry(v) {
67
118
  }
68
119
  /** Read one queue file through the stat-keyed cache: null when it vanishes mid-listing (a
69
120
  * concurrent drop — the inbox.ts race policy), is unreadable, or fails to parse or to carry
70
- * every required LandingEntry field (a foreign or torn file is skipped, never thrown on). A
121
+ * every required MergeEntry field (a foreign or torn file is skipped, never thrown on). A
71
122
  * shallow copy out: the entry's fields are all scalars and callers treat the result as
72
123
  * read-only. */
73
124
  function readEntry(file) {
74
125
  return cachedByStat(entryCache, file, file, () => {
75
126
  // readJsonFile is the shared "missing or torn reads as no data" policy; a foreign shape
76
- // is still rejected by isLandingEntry.
77
- const parsed = readJsonFile(file);
78
- return isLandingEntry(parsed) ? parsed : null;
127
+ // is still rejected by isMergeEntry.
128
+ let parsed = readJsonFile(file);
129
+ // An entry an old build wrote carries `tick` where the current shape has `run`.
130
+ if (isJsonObject(parsed) && parsed.run === undefined && parsed.tick !== undefined) {
131
+ const { tick, ...rest } = parsed;
132
+ parsed = { ...rest, run: tick };
133
+ }
134
+ if (!isMergeEntry(parsed))
135
+ return null;
136
+ const role = canonicalRoleId(parsed.role);
137
+ return role === parsed.role ? parsed : { ...parsed, role };
79
138
  }, (e) => ({ ...e }));
80
139
  }
81
- /** Every queued landing in execution order (oldest first) paired with the file to drop on
82
- * completion. The batch drain (merge queue 5/5) reads this slice because `dropLanding` needs
140
+ /** Every queued merge in execution order (oldest first) paired with the file to drop on
141
+ * completion. The batch drain (merge queue 5/5) reads this slice because `dropMerge` needs
83
142
  * the file, which the bare-entry readers never return. A missing queue directory reads as an
84
143
  * empty queue. */
85
- export function queuedLandingFiles(root) {
144
+ export function queuedMergeFiles(root) {
86
145
  const out = [];
87
146
  for (const file of queueFiles(root)) {
88
147
  const entry = readEntry(file);
@@ -91,21 +150,21 @@ export function queuedLandingFiles(root) {
91
150
  }
92
151
  return out;
93
152
  }
94
- /** All queued landings in execution order (oldest first). A missing queue directory reads as
153
+ /** All queued merges in execution order (oldest first). A missing queue directory reads as
95
154
  * an empty queue. */
96
- export function queuedLandings(root) {
97
- return queuedLandingFiles(root).map((q) => q.entry);
155
+ export function queuedMerges(root) {
156
+ return queuedMergeFiles(root).map((q) => q.entry);
98
157
  }
99
- /** The roles currently holding a queued landing — the maintenance quota's and the permit
158
+ /** The roles currently holding a queued merge — the maintenance quota's and the permit
100
159
  * reserve's "already in flight" input, and the scheduler's per-poll merge-queue interlock.
101
160
  * A missing queue directory reads as an empty set, like every reader here. */
102
- export function queuedLandingRoles(root) {
103
- return new Set(queuedLandingFiles(root).map((q) => q.entry.role));
161
+ export function queuedMergeRoles(root) {
162
+ return new Set(queuedMergeFiles(root).map((q) => q.entry.role));
104
163
  }
105
164
  /** Read every queue file once through the stat-keyed cache, collecting the readable entries
106
165
  * (filename order) and counting the rest — the torn/foreign file policy readEntry already
107
166
  * applies, surfacing the skip count instead of hiding it. */
108
- export function readLandingQueue(root) {
167
+ export function readMergeQueue(root) {
109
168
  const entries = [];
110
169
  let unreadable = 0;
111
170
  for (const file of queueFiles(root)) {
@@ -119,7 +178,7 @@ export function readLandingQueue(root) {
119
178
  }
120
179
  /** The head of the queue — its oldest entry — plus the file to drop on completion, or null when
121
180
  * the queue is empty or its oldest file is a vanished/torn entry. */
122
- export function headLanding(root) {
181
+ export function headMerge(root) {
123
182
  const file = queueFiles(root)[0];
124
183
  if (!file)
125
184
  return null;
@@ -127,23 +186,23 @@ export function headLanding(root) {
127
186
  return entry ? { entry, file } : null;
128
187
  }
129
188
  /** The oldest queue file when the queue is non-empty but its head is unreadable — torn or
130
- * foreign (a partial write an older build left, a hand-placed file) — i.e. when headLanding
189
+ * foreign (a partial write an older build left, a hand-placed file) — i.e. when headMerge
131
190
  * reads null while the queue holds files; null when the queue is empty or its head is healthy.
132
- * The drain drops this file with a warning so the queue can drain: headLanding reads null for
191
+ * The drain drops this file with a warning so the queue can drain: headMerge reads null for
133
192
  * it forever and nothing else will remove it, stranding every live entry behind it (each
134
193
  * author's interlock, the per-poll queued-roles set, pins its ticks along with them). The
135
- * crashed entry's commit, if any, still lives in its landing ref — next-tick leftover recovery
136
- * re-lands it — so the drop loses nothing recoverable from the file (BUGS.md 2026-09-16). */
194
+ * crashed entry's commit, if any, still lives in its merge ref — next-tick leftover recovery
195
+ * re-merges it — so the drop loses nothing recoverable from the file (BUGS.md 2026-09-16). */
137
196
  export function staleHeadFile(root) {
138
197
  const file = queueFiles(root)[0];
139
198
  if (!file)
140
199
  return null;
141
200
  return readEntry(file) === null ? file : null;
142
201
  }
143
- /** Remove one queued landing's file after its landing outcome — terminal or not (a
144
- * non-terminal outcome keeps the landing ref; the retry is the role's next fresh tick, not a
202
+ /** Remove one queued merge's file after its merge outcome — terminal or not (a
203
+ * non-terminal outcome keeps the merge ref; the retry is the role's next fresh tick, not a
145
204
  * queue re-drain). ENOENT is a no-op: a concurrent drop between listing and removal is a
146
205
  * normal race (the inbox.ts policy), not an error. */
147
- export function dropLanding(file) {
206
+ export function dropMerge(file) {
148
207
  removeQueueFile(file);
149
208
  }