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
@@ -1,46 +1,48 @@
1
- import { deleteRef, headOf, setRef } from "../git/git.js";
2
- import { landingRefName, mergeWorktreePath, rejectedRefName, reviewRunSessionDir } from "../paths.js";
1
+ // vocabulary: plain
2
+ import { deleteRef, headOf, patchId, setRef } from "../git/git.js";
3
+ import { mergeRefName, mergeWorktreePath, rejectedRefName, reviewRunSessionDir } from "../paths.js";
3
4
  import { ensureDetachedWorktree } from "../git/worktree.js";
4
5
  import { useWorktree } from "../git/worktree-use.js";
5
- import { mergeToMain } from "./landing-merge.js";
6
- import { rebaseOntoMain } from "./landing-git.js";
7
- import { landingCheckRed, landingBlocked } from "./landing-check-failures.js";
6
+ import { mergeToMain } from "./merge-to-main.js";
7
+ import { rebaseOntoMain } from "./merge-git.js";
8
+ import { mergeCheckRed, mergeBlocked, noteMergeRetryPassed } from "./merge-check-failures.js";
8
9
  import { reviewAheadOfMain } from "../review/review.js";
9
10
  import { reviewRunConfig, modelSelectorField } from "../config/config-views.js";
10
11
  import { warnEventBestEffort, logEventBestEffort } from "../events/events.js";
11
12
  import { saveLoopState } from "../loop/loop-state.js";
12
- import { REVISION_LIMIT } from "../loop/revision.js";
13
+ import { REVISION_FINAL_ON_REPEAT, REVISION_BACKSTOP } from "../loop/revision.js";
14
+ import { priorObjectionNumber } from "../review/review-verdict.js";
13
15
  import { DIRECTOR_ROLE } from "../roles/roles.js";
14
16
  import { shortSha } from "../text/format.js";
15
- import { setLandingStage } from "./landing-slot.js";
17
+ import { setMergeStage } from "./merge-slot.js";
16
18
  /** Rebase a pinned change in its leased pool slot `wt` (checked out detached at `req.sha`) onto
17
19
  * main's CURRENT head before its review gate, and return the request that gate must judge.
18
20
  * The gate must review main's current tree, not the main the author started from: when main
19
- * moved after the pin (the common queued-landing case), a pre-check against the stale tree
20
- * fails on a failure main already fixed and every queued landing rejects in a cascade — and a
21
+ * moved after the pin (the common queued-merge case), a pre-check against the stale tree
22
+ * fails on a failure main already fixed and every queued merge rejects in a cascade — and a
21
23
  * reviewer whose checkout is behind main reads main's newer commits as the change deleting
22
24
  * them (BUGS.md 2026-09-23: d13cf2e, a sound feature, rejected for "reverting" five commits
23
- * that landed after its pin). A no-op when main has not moved; on a clean rebase the ref and
24
- * the request track the synced head so the strike-cap tell and the landing ref name the tree
25
- * that can actually land. On a conflict rebaseOntoMain has already aborted and restored the
26
- * detached pin — the gate reviews the pinned tree and mergeToMain's resolver lands it. Every
27
- * vet runs it before its gate (landing-batch.ts's vetRequest), so a gate never judges a stale
25
+ * that merged after its pin). A no-op when main has not moved; on a clean rebase the ref and
26
+ * the request track the synced head so the strike-cap tell and the merge ref name the tree
27
+ * that can actually merge. On a conflict rebaseOntoMain has already aborted and restored the
28
+ * detached pin — the gate reviews the pinned tree and mergeToMain's resolver merges it. Every
29
+ * verify runs it before its gate (merge-batch.ts's verifyRequest), so a gate never judges a stale
28
30
  * pin; each call rebases only its own worktree, onto main itself — never onto another queued
29
- * change — so concurrent vets in distinct pool slots cannot interfere. */
31
+ * change — so concurrent verifies in distinct pool slots cannot interfere. */
30
32
  export async function syncPinToMain(ctx, wt, req) {
31
33
  if (!(await rebaseOntoMain(wt, ctx.mainBranch)))
32
34
  return req;
33
35
  const syncedHead = await headOf(wt, "HEAD");
34
36
  if (syncedHead === req.sha)
35
37
  return req;
36
- await setRef(ctx.root, landingRefName(req.role), syncedHead);
38
+ await setRef(ctx.root, mergeRefName(req.role), syncedHead);
37
39
  return { ...req, sha: syncedHead };
38
40
  }
39
- /** The ReviewContext for judging landing request `req` in worktree `wt` — the single
40
- * home of that argument object, shared by reviewPinnedChange's pinned vet and
41
- * landApprovedChange's re-review of a resolved conflict (its recheckResolved callback), so the
41
+ /** The ReviewContext for judging merge request `req` in worktree `wt` — the single
42
+ * home of that argument object, shared by reviewPinnedChange's pinned verify and
43
+ * mergeApprovedChange's re-review of a resolved conflict (its recheckResolved callback), so the
42
44
  * two gate invocations cannot drift on which fields the reviewer sees. Both callers' contexts
43
- * (ReviewGateContext and LanderContext) carry the root/mainBranch/config/signal/runGatePi fields
45
+ * (ReviewGateContext and MergerContext) carry the root/mainBranch/config/signal/runGatePi fields
44
46
  * this reads. */
45
47
  function reviewContextFor(ctx, req, wt) {
46
48
  return {
@@ -50,8 +52,8 @@ function reviewContextFor(ctx, req, wt) {
50
52
  mainBranch: ctx.mainBranch,
51
53
  config: ctx.config,
52
54
  tick: req.tick,
53
- // One directory per review of one change: tick plus the pinned sha, so a re-vet of the
54
- // same sha reuses it and the vet and the conflict-resolved re-review agree.
55
+ // One directory per review of one change: tick plus the pinned sha, so a re-verify of the
56
+ // same sha reuses it and the verify and the conflict-resolved re-review agree.
55
57
  sessionDir: reviewRunSessionDir(ctx.root, req.role, `${req.tick}-${shortSha(req.sha)}`),
56
58
  signal: ctx.signal(),
57
59
  runGatePi: ctx.runGatePi,
@@ -60,29 +62,29 @@ function reviewContextFor(ctx, req, wt) {
60
62
  };
61
63
  }
62
64
  /** Run one pinned change through the review gate in its leased pool slot `wt` and handle the
63
- * immediate bookkeeping — the heart of every vet (landing-batch.ts's vetRequest). Persists the
65
+ * immediate bookkeeping — the heart of every verify (merge-batch.ts's verifyRequest). Persists the
64
66
  * verdict at once, folds the reviewer's usage, and routes
65
- * the three terminal outcomes: aborted (ref kept — fail closed, the next tick re-lands it),
67
+ * the three terminal outcomes: aborted (ref kept — fail closed, the next tick re-merges it),
66
68
  * rejected (ref deleted — final for this sha), and failed (a strike-cap discard — the gate
67
69
  * reports it as `discarded` — deletes the ref; an under-cap failure, a red main's included,
68
- * keeps it for the next re-land). Returns
69
- * the gate result only when the change may be landed, alongside the head to land it at. An
70
+ * keeps it for the next re-merge). Returns
71
+ * the gate result only when the change may be merged, alongside the head to merge it at. An
70
72
  * abort that has already fired is observed HERE, before the gate starts, not only by the
71
- * gate's pi runs: a gate that short-circuits on an already-approved head (a re-vetted change's)
73
+ * gate's pi runs: a gate that short-circuits on an already-approved head (a re-verified change's)
72
74
  * runs no pi at all, and one that does still spends its build pre-check first — so a stopping
73
- * landing (a restart hand-off past its deadline, BUGS.md 2026-09-23) would otherwise sail
75
+ * merge (a restart hand-off past its deadline, BUGS.md 2026-09-23) would otherwise sail
74
76
  * through its gate into the check. */
75
77
  export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
76
78
  const { root, config } = ctx;
77
79
  const { role } = req;
78
- const ref = landingRefName(role);
80
+ const ref = mergeRefName(role);
79
81
  // Both uses below — a rejection and a strike-cap discard — end a pinned change without
80
82
  // keeping it: drop the held-selector note and the pin ref together, so the two-part cleanup
81
- // cannot drift between them. The vet's checkout is a pooled slot (plans/worktree-pool.md,
82
- // "Vets and merges"): vetRequest releases the lease in its finally, and a slot is reused, so
83
+ // cannot drift between them. The verify's checkout is a pooled slot (plans/worktree-pool.md,
84
+ // "Vets and merges"): verifyRequest releases the lease in its finally, and a slot is reused, so
83
85
  // this path must not remove the worktree it ran in.
84
86
  const discardPin = async () => {
85
- state.landingReviewError = undefined;
87
+ state.mergeReviewError = undefined;
86
88
  await deleteRef(root, ref);
87
89
  };
88
90
  // Already stopping: the gate never starts, so nothing is persisted or folded — the ref stays
@@ -90,28 +92,30 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
90
92
  if (ctx.signal().aborted)
91
93
  return { kind: "result", result: "aborted" };
92
94
  const gate = await reviewAheadOfMain(reviewContextFor(ctx, req, wt), state, req.summary, req.body, req.highFriction);
93
- // The gate is over, whatever it decided: move the landing cell off the gate's stages (a no-op
94
- // outside a queued landing). What follows is the wait for the merge — the vet marks the change
95
- // `vetted` next, and the merge slot re-stages it `merging` when it takes it — and a finished
95
+ // The gate is over, whatever it decided: move the merge cell off the gate's stages (a no-op
96
+ // outside a queued merge). What follows is the wait for the merge — the verify marks the change
97
+ // `approved` next, and the merge slot re-stages it `merging` when it takes it — and a finished
96
98
  // reviewer's last turns left in the cell would accrue a false `no pi output` flag for as long
97
99
  // as the change waits.
98
- setLandingStage(root, role, "merging");
100
+ setMergeStage(root, role, "merging");
99
101
  // A rejection keeps the rejected commit alive while a revision is due (plans/revise-rejected.md):
100
102
  // point the rejected ref at the head the gate judged and record the round, so the author's next
101
- // tick re-applies it. Past REVISION_LIMIT the rejection is final: clear the state, delete the
102
- // ref, and log `exhausted`. Done before the save below so the round rides the persisted state.
103
+ // tick re-applies it. A rejection is final — clear the state, delete the ref, and log
104
+ // `exhausted` — when the same objection has survived two revisions or the chain hit the
105
+ // backstop (BUGS.md 2026-10-14). Done before the save below so the round rides the
106
+ // persisted state.
103
107
  if (gate.decision === "rejected") {
104
- await recordRejectedChange(root, role, state, state.lastReview?.head ?? req.sha, req.revisionRound);
108
+ await recordRejectedChange(root, role, state, state.lastReview?.head ?? req.sha, req.revisionRound, req.priorReview?.counts);
105
109
  }
106
110
  // Persist the verdict immediately, not at the tick's end save: the gate's bookkeeping is
107
111
  // cross-tick memory (a persisted "reject" injects a "your previous change was rejected"
108
- // note into the next prompt), and this tick's tail — the landing plus the still-to-come
112
+ // note into the next prompt), and this tick's tail — the merge plus the still-to-come
109
113
  // authoring run — can outlive a sudden death by hours. A mid-run crash (power loss,
110
114
  // kill -9) would otherwise roll the state file back to the last tick-boundary snapshot
111
115
  // and re-inject a superseded rejection even though its replacement is already on main.
112
116
  saveLoopState(root, state);
113
117
  // The reviewer's usage is folded exactly once per attempt: runGatePi folds a RETRIED first
114
- // attempt at retry time (stamping the fleet-wide rate-limit hold promptly, BUGS.md
118
+ // attempt at retry time (stamping the team-wide rate-limit hold promptly, BUGS.md
115
119
  // 2026-10-01), and the final run is folded HERE — not also inside the wiring, which would
116
120
  // count the reviewer twice.
117
121
  if (gate.run)
@@ -124,7 +128,7 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
124
128
  // produced it — one more line here, because a GateResult carries a third run now.
125
129
  if (gate.nudgeRun)
126
130
  foldUsage(gate.nudgeRun);
127
- // Shutdown/user abort mid-review: fail closed — the ref stays and the next tick re-lands it.
131
+ // Shutdown/user abort mid-review: fail closed — the ref stays and the next tick re-merges it.
128
132
  // The caller routes "aborted" through its own abort handling (which discards the pin too when
129
133
  // the abort was a deliberate user stop).
130
134
  if (gate.aborted)
@@ -136,7 +140,7 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
136
140
  }
137
141
  if (gate.decision === "failed" && gate.mainRed) {
138
142
  // Main is red at its tip: nothing judged this diff and nothing is wrong with the reviewer,
139
- // so it is main_red, not review_error — the pin stays for a re-land once main is green.
143
+ // so it is main_red, not review_error — the pin stays for a re-merge once main is green.
140
144
  state.lastError = `gate check failed: ${gate.detail}`;
141
145
  return { kind: "result", result: "main_red" };
142
146
  }
@@ -148,7 +152,7 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
148
152
  // 2026-10-06). A discarded pin is gone, so no hold is recorded for it.
149
153
  if (gate.configError && !gate.discarded) {
150
154
  const selector = modelSelectorField(reviewRunConfig(config)).model;
151
- state.landingReviewError = {
155
+ state.mergeReviewError = {
152
156
  sha: req.sha,
153
157
  ...(selector ? { selector } : {}),
154
158
  message: state.lastError,
@@ -157,7 +161,7 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
157
161
  }
158
162
  // Strike-cap discard: the gate says so directly (it reset the worktree off the pin) — the
159
163
  // commit is gone and the ref goes too. An under-cap failure — a dead reviewer — keeps the
160
- // pin as it is: the gate never commits, so the pin still names the tree the next re-land
164
+ // pin as it is: the gate never commits, so the pin still names the tree the next re-merge
161
165
  // reviews.
162
166
  if (gate.discarded) {
163
167
  await discardPin();
@@ -166,7 +170,7 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
166
170
  return { kind: "result", result: "review_error" };
167
171
  }
168
172
  // Approved or exempt: track the pin to the head the verdict judged, so a merge that cannot
169
- // finish (conflict/blocked) leaves recovery re-landing that tree, not a stale pin.
173
+ // finish (conflict/blocked) leaves recovery re-merging that tree, not a stale pin.
170
174
  // verifiedHead carries it when the pre-check ran green; otherwise the worktree head does. An
171
175
  // unreadable head keeps the old pin (fail closed).
172
176
  const sha = gate.verifiedHead ?? (await headOf(wt, "HEAD").catch(() => req.sha));
@@ -174,73 +178,92 @@ export async function reviewPinnedChange(ctx, req, wt, state, foldUsage) {
174
178
  await setRef(root, ref, sha);
175
179
  req = { ...req, sha };
176
180
  }
177
- // The change may land: nothing is held against the previous reviewer selector any more.
178
- state.landingReviewError = undefined;
181
+ // The change may merge: nothing is held against the previous reviewer selector any more.
182
+ state.mergeReviewError = undefined;
179
183
  return { kind: "gate", gate, sha };
180
184
  }
181
- /** The failed landing outcomes that KEEP the pin for another attempt: an under-cap review
182
- * failure, or a merge that could not be rebased/landed. They are exactly a landing's
185
+ /** The failed merge outcomes that KEEP the pin for another attempt: an under-cap review
186
+ * failure, or a merge that could not be rebased/merged. They are exactly a merge's
183
187
  * non-terminal failures (`rejected` is final and deletes the pin; `aborted` is a shutdown;
184
- * `changed` landed), so a run of them on the leftover-recovery path is what feeds the error
188
+ * `changed` merged), so a run of them on the leftover-recovery path is what feeds the error
185
189
  * streak — a dead reviewer backend raises the alarm instead of resetting it every tick
186
190
  * (BUGS.md 2026-09-21). */
187
- export const RETRIABLE_LANDING_RESULTS = new Set([
191
+ export const RETRIABLE_MERGE_RESULTS = new Set([
188
192
  "review_error",
189
193
  "merge_conflict",
190
194
  "merge_blocked",
191
195
  ]);
192
- /** Record a gate rejection for the revise-rejected feature (plans/revise-rejected.md): while
193
- * the round is within REVISION_LIMIT, point `refs/tumwater/rejected/<role>` at the head the gate
194
- * judged and set LoopState.revision so the author's next tick re-applies it; past the limit,
195
- * clear the revision, delete the ref and log `exhausted`. Shared by a pinned change's rejection
196
- * (reviewPinnedChange) and the in-lock re-review rejection of a resolved conflict
197
- * (landApprovedChange), so both send the change back for revision the same way. A rejection
198
- * with no `revisionRound` is a fresh change's first round (1). The director never revises. */
199
- async function recordRejectedChange(root, role, state, rejectedHead, revisionRound) {
196
+ /** Record a gate rejection for the revise-rejected feature (plans/revise-rejected.md; the
197
+ * repeat-finality policy in BUGS.md 2026-10-14): a rejection sends the change back for another
198
+ * round UNLESS the same objection has survived two revisions — a `(prior N)` restatement whose
199
+ * count reaches REVISION_FINAL_ON_REPEAT, counted from `priorCounts` — or the chain has reached
200
+ * the REVISION_BACKSTOP cap, when it ends even if the objections never repeat. While it keeps
201
+ * revising, point `refs/tumwater/rejected/<role>` at the head the gate judged and set
202
+ * LoopState.revision (with each reason's persistence count) so the author's next tick re-applies
203
+ * it; when final, clear the revision, delete the ref and log `exhausted`. `priorCounts` is the
204
+ * previous round's per-reason persistence counts, carried from the queued merge's PriorReview;
205
+ * absent for a fresh change's first rejection and on a recovery merge, a `(prior N)` tag then
206
+ * reads as a fresh objection. Shared by a pinned change's rejection (reviewPinnedChange) and the
207
+ * in-lock re-review rejection of a resolved conflict (mergeApprovedChange), so both send the
208
+ * change back for revision the same way. A rejection with no `revisionRound` is a fresh change's
209
+ * first round (1). The director never revises. */
210
+ async function recordRejectedChange(root, role, state, rejectedHead, revisionRound, priorCounts) {
200
211
  if (role === DIRECTOR_ROLE)
201
212
  return;
202
213
  const nextRound = (revisionRound ?? 0) + 1;
203
- if (nextRound <= REVISION_LIMIT) {
204
- await setRef(root, rejectedRefName(role), rejectedHead);
205
- state.revision = { sha: rejectedHead, round: nextRound, at: Date.now() };
214
+ const reasons = state.lastReview?.reasons ?? [];
215
+ // Each reason's persistence count: a new (untagged) objection starts at 0; a `(prior N)`
216
+ // restatement has survived one more revision than that objection's own count from the prior
217
+ // round, so an objection standing across two revisions reaches REVISION_FINAL_ON_REPEAT. An
218
+ // out-of-range or missing tag (a stale prior list, no prior round) reads as a fresh objection.
219
+ const prior = priorCounts ?? [];
220
+ const counts = reasons.map((r) => {
221
+ const n = priorObjectionNumber(r);
222
+ return n === null ? 0 : 1 + (prior[n - 1] ?? 0);
223
+ });
224
+ const repeatedFinal = counts.some((c) => c >= REVISION_FINAL_ON_REPEAT);
225
+ if (nextRound >= REVISION_BACKSTOP || repeatedFinal) {
226
+ state.revision = undefined;
227
+ await deleteRef(root, rejectedRefName(role));
228
+ // The next tick gets the plain rejected note, not a revision note; mark it final so that
229
+ // note says the rejected diff is gone rather than inviting another revision.
230
+ if (state.lastReview)
231
+ state.lastReview.exhausted = true;
232
+ logEventBestEffort(root, { source: role, type: "revision", action: "exhausted", round: nextRound, sha: rejectedHead });
206
233
  return;
207
234
  }
208
- state.revision = undefined;
209
- await deleteRef(root, rejectedRefName(role));
210
- // The next tick gets the plain rejected note, not a revision note; mark it final so that
211
- // note says the rejected diff is gone rather than inviting another revision.
212
- if (state.lastReview)
213
- state.lastReview.exhausted = true;
214
- logEventBestEffort(root, { loop: role, type: "revision", action: "exhausted", round: nextRound, sha: rejectedHead });
235
+ await setRef(root, rejectedRefName(role), rejectedHead);
236
+ state.revision = { sha: rejectedHead, round: nextRound, at: Date.now(), reasonCounts: counts };
215
237
  }
216
- /** Land a head that already passed its OWN gate in its vet — the merge slot's one-change landing
217
- * and each landing of an abandoned stack's one-at-a-time fallback (landing-batch.ts's landVetted)
238
+ /** Merge a head that already passed its OWN gate in its verify — the merge slot's one-change merge
239
+ * and each merge of an abandoned stack's one-at-a-time fallback (merge-batch.ts's mergeApproved)
218
240
  * — without a second gate. Re-gating would rebase first, and by then main has usually moved (a
219
- * fallback's earlier entries just landed): the rebase rewrites the approved sha, the gate's
220
- * exact-sha `lastApprovedHead` short-circuit misses, and every such landing paid a second build
241
+ * fallback's earlier entries just merged): the rebase rewrites the approved sha, the gate's
242
+ * exact-sha `lastApprovedHead` short-circuit misses, and every such merge paid a second build
221
243
  * check and model review of a change it had already approved (BUGS.md 2026-09-23). Here a clean
222
244
  * rebase of the approved head is accepted as-is: mergeToMain's own in-lock rebase moves it onto
223
245
  * main's tip, and because the pre-merge head it captures is the approved `req.sha`,
224
- * verifyLanding runs one bounded scope-`landing` check whenever that rebase rewrote anything
246
+ * verifyMerge runs one bounded scope-`merge` check whenever that rebase rewrote anything
225
247
  * (and skips only for the exact bytes the gate judged, seeding the red-main baseline when
226
248
  * `req.verifiedHead` names them). A conflicting rebase gets mergeToMain's resolver — the rule for
227
249
  * a pin whose pre-gate rebase conflicted. `lastApprovedHead` is neither read nor widened: the
228
- * caller vouches for `req.sha` (only approved/exempt changes are vetted). Owns the landing ref's
229
- * lifecycle from here: deleted on landing, KEPT on every non-terminal outcome (aborted,
230
- * merge_conflict, merge_blocked) for the next tick's leftover recovery. An abort that has
250
+ * caller vouches for `req.sha` (only changes the gate approved or exempted are merged). Owns
251
+ * the merge ref's lifecycle from here: deleted on merge, KEPT on every non-terminal outcome
252
+ * (aborted, merge_conflict, merge_blocked) for the next tick's leftover recovery. An abort that has
231
253
  * already fired returns "aborted" before anything starts, ref kept — the merge's step-boundary
232
254
  * stop (BUGS.md 2026-09-23): with no gate here, nothing else on this path would notice it
233
- * before the in-lock check and the merge. Never throws for a failed landing: git-level failures
255
+ * before the in-lock check and the merge. Never throws for a failed merge: git-level failures
234
256
  * propagate like any other failure. */
235
- export async function landApprovedChange(ctx, req) {
257
+ export async function mergeApprovedChange(ctx, req) {
236
258
  if (ctx.signal().aborted)
237
259
  return "aborted";
238
260
  // Hold the merge worktree from before ensureDetachedWorktree (its reset is part of the
239
261
  // use) through the merge (plans/disk-floor.md, part 2/4).
240
- return useWorktree(ctx.root, mergeWorktreePath(ctx.root), () => landApprovedChangeIn(ctx, req));
262
+ return useWorktree(ctx.root, mergeWorktreePath(ctx.root), () => mergeApprovedChangeIn(ctx, req));
241
263
  }
242
- async function landApprovedChangeIn(ctx, req) {
264
+ async function mergeApprovedChangeIn(ctx, req) {
243
265
  const wt = await ensureDetachedWorktree(ctx.root, mergeWorktreePath(ctx.root), req.sha);
266
+ const pinPatch = await patchId(wt, ctx.mainBranch, req.sha).catch(() => null);
244
267
  let red;
245
268
  let blocked;
246
269
  const result = await mergeToMain({
@@ -251,10 +274,10 @@ async function landApprovedChangeIn(ctx, req) {
251
274
  config: ctx.config,
252
275
  tick: req.tick,
253
276
  runPi: ctx.runPi,
254
- onLandingCheckRed: (check, outcome) => {
277
+ onMergeCheckRed: (check, outcome) => {
255
278
  red = { check, outcome };
256
279
  },
257
- onLandingBlocked: (reason) => {
280
+ onMergeBlocked: (reason) => {
258
281
  blocked = reason;
259
282
  },
260
283
  recheckResolved: async (resolvedWt) => {
@@ -262,7 +285,7 @@ async function landApprovedChangeIn(ctx, req) {
262
285
  return { verdict: "retry" };
263
286
  // A resolved diff that left the reviewed change's lines goes back through the same gate
264
287
  // the pin went through (BUGS.md 2026-10-01: a resolution that restored code main had
265
- // deliberately reverted landed on the build check alone). Same gate, same worktree
288
+ // deliberately reverted merged on the build check alone). Same gate, same worktree
266
289
  // convention as reviewPinnedChange: the reject path records the verdict, resets the
267
290
  // worktree to main and logs review_rejected itself; the ref and the lastError are ours.
268
291
  const gate = await reviewAheadOfMain(reviewContextFor(ctx, req, resolvedWt), ctx.state, req.summary, req.body, req.highFriction);
@@ -272,7 +295,7 @@ async function landApprovedChangeIn(ctx, req) {
272
295
  ctx.foldUsage(gate.followUpRun);
273
296
  if (gate.nudgeRun)
274
297
  ctx.foldUsage(gate.nudgeRun);
275
- setLandingStage(ctx.root, req.role, "merging");
298
+ setMergeStage(ctx.root, req.role, "merging");
276
299
  saveLoopState(ctx.root, ctx.state);
277
300
  if (gate.aborted)
278
301
  return { verdict: "retry" };
@@ -284,34 +307,35 @@ async function landApprovedChangeIn(ctx, req) {
284
307
  // author for revision (plans/revise-rejected.md), exactly as a pinned change's
285
308
  // rejection does, then drop this pin. Persist before returning — the state save above
286
309
  // predates this bookkeeping.
287
- await recordRejectedChange(ctx.root, req.role, ctx.state, ctx.state.lastReview?.head ?? req.sha, req.revisionRound);
310
+ await recordRejectedChange(ctx.root, req.role, ctx.state, ctx.state.lastReview?.head ?? req.sha, req.revisionRound, req.priorReview?.counts);
288
311
  saveLoopState(ctx.root, ctx.state);
289
- await deleteRef(ctx.root, landingRefName(req.role)); // the verdict is final for this pin
312
+ await deleteRef(ctx.root, mergeRefName(req.role)); // the verdict is final for this pin
290
313
  ctx.state.lastError = "merge failed: conflict resolution rejected on re-review";
291
314
  return { verdict: "rejected" };
292
315
  }
293
316
  // A failed gate (dead reviewer, aborted run) is no verdict about the tree: under the
294
- // strike cap the pin stays for recovery's re-land (the gate's own discard fires here as
317
+ // strike cap the pin stays for recovery's re-merge (the gate's own discard fires here as
295
318
  // "discarded", and then the commit is gone anyway).
296
319
  ctx.state.lastError = `merge failed: re-review failed: ${gate.detail}`;
297
320
  if (gate.discarded) {
298
- await deleteRef(ctx.root, landingRefName(req.role));
321
+ await deleteRef(ctx.root, mergeRefName(req.role));
299
322
  return { verdict: "rejected" };
300
323
  }
301
324
  return { verdict: "retry" };
302
325
  },
303
326
  }, wt, req.summary, req.verifiedHead);
304
327
  if (result === "changed") {
305
- await deleteRef(ctx.root, landingRefName(req.role)); // landed: the pin has done its job
328
+ await deleteRef(ctx.root, mergeRefName(req.role)); // merged: the pin has done its job
329
+ noteMergeRetryPassed(ctx, req.role, pinPatch);
306
330
  return result;
307
331
  }
308
332
  if (result === "merge_blocked" && red)
309
- return landingCheckRed(ctx, req.role, wt, red);
333
+ return mergeCheckRed(ctx, req.role, wt, red);
310
334
  if (result === "merge_blocked" && blocked !== undefined) {
311
- return landingBlocked(ctx, req.role, wt, blocked);
335
+ return mergeBlocked(ctx, req.role, wt, blocked);
312
336
  }
313
- // merge_conflict / merge_blocked: keep the ref — the next tick's recovery re-lands it.
314
- // (Cross-check blocks no longer come through here: landing-check-failures.ts's landingBlocked
337
+ // merge_conflict / merge_blocked: keep the ref — the next tick's recovery re-merges it.
338
+ // (Cross-check blocks no longer come through here: merge-check-failures.ts's mergeBlocked
315
339
  // counts them.)
316
340
  // A rejected recheck already wrote its own lastError and deleted its ref — leave it.
317
341
  if (result !== "rejected")
@@ -1,6 +1,6 @@
1
1
  import { increment } from "../collections.js";
2
2
  import { gitTry } from "../git/git-run.js";
3
- /** The landing-scope DIFF COMPARISON (see landing-merge.ts for the merge flow that consults
3
+ /** The merge-scope DIFF COMPARISON (see merge-to-main.ts for the merge flow that consults
4
4
  * it): whether a conflict-resolved tree's diff ahead of main stays inside the diff the review
5
5
  * gate approved. Pure multiset comparison over unified diffs, plus the git reads that produce
6
6
  * them — kept apart from the merge flow so the hunk-aware parsing (BUGS.md 2026-10-02) is
@@ -0,0 +1,212 @@
1
+ // vocabulary: plain
2
+ import { mergeApproved } from "./merge-batch.js";
3
+ import { removeMergeChange, resolveWaitStage, setMergeChangeStatus } from "./merge-slot.js";
4
+ import { seamConcurrencyKey } from "../config/config-views.js";
5
+ import { withProviderLease } from "../concurrency/provider-lease.js";
6
+ import { dropMerge, entryRevisionFields, queuedMergeFiles } from "./merge-queue.js";
7
+ import { errorMessage } from "../text/text.js";
8
+ import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
9
+ import { saveLoopState } from "../loop/loop-state.js";
10
+ import { abortOnShutdown, discardPinnedRefs, guardMergeTask, settleMergeOutcome, } from "./merge-pipeline.js";
11
+ import { drainVerify, VERIFY_TIER } from "./merge-verify.js";
12
+ /** The tier the merge slot's conflict-resolution runs wait at: ahead of any verification parked for
13
+ * a permit, because the merge is the one serial step every queued change waits on. (Nothing that
14
+ * holds a permit ever waits on the merge: no verification takes the merge lock, and the resolver
15
+ * runs outside it — merge-to-main.ts's mergeToMain — so this cannot deadlock.) */
16
+ const MERGE_TIER = VERIFY_TIER - 1;
17
+ /** One poll of the merge queue (the scheduler's WHEN: after the gates, never while a restart or
18
+ * a team hold is pending): start a verification for every queued change the pipeline does not
19
+ * already hold (drainVerify, in merge-verify.ts), and start the merge when the slot is free and
20
+ * something is approved (drainMerge). A queued merge is COMMITTED work awaiting completion, not
21
+ * a new tick, so the budget and user-pause gates deliberately do not hold it (pausing it would
22
+ * leave main behind while the interlock blocks that role's next tick forever). Starts tasks and
23
+ * returns; never awaits them — authors keep ticking behind them, which is the entire point. */
24
+ export async function drainMergeQueue(ctx, p) {
25
+ await drainVerify(ctx, p);
26
+ drainMerge(ctx, p);
27
+ }
28
+ /** Settle every approved change `tumwater abort --role` flagged (consumeAbortRequests over
29
+ * abortableMerges): it has no task to stop, so it ends here exactly as an aborted merge
30
+ * does — "aborted" written back, entry dropped, pin discarded (the deliberate stop throws the
31
+ * committed work away). The scheduler runs it every poll right after consuming the requests,
32
+ * held or not: a restart hold that skipped it would hand the flagged change, pin intact, to
33
+ * the next generation to merge.
34
+ *
35
+ * Unlike a verification or a merge, this settles in the poll body with nothing to guard it: a throw
36
+ * out of the outcome write — an unwritable state file — would end the team's run. It must also
37
+ * never leave the deliberately aborted change queued (an abandoned queue file would be
38
+ * re-verified and merged next poll, against the operator's intent) or keep its pin. So a failed
39
+ * write is reported and the abort honored by hand: queue file and marker dropped, pin
40
+ * discarded. */
41
+ export async function settleAbortedApproved(root, p) {
42
+ for (const [role, v] of [...p.approved]) {
43
+ if (!v.userAborted)
44
+ continue;
45
+ p.approved.delete(role);
46
+ try {
47
+ await settleMergeOutcome(root, v.entry, v.author.state, "aborted", Date.now() - v.startedAt, v.usage, v.file);
48
+ }
49
+ catch (err) {
50
+ warnEventBestEffort(root, TUMWATER_SOURCE, `the stopped merge for ${role} could not be settled: ${errorMessage(err)}`);
51
+ dropMerge(v.file);
52
+ removeMergeChange(root, role);
53
+ }
54
+ await discardPinnedRefs(root, [role]);
55
+ }
56
+ }
57
+ /** Start the merge when the slot is free and something is approved: every approved change, up to
58
+ * mergeBatchMax, in queue order among the approved — an unapproved entry ahead of them in the queue
59
+ * (a slow review) does not hold them back. An approved change whose queue entry is gone is
60
+ * forgotten (nothing in-process drops one; defensive). */
61
+ export function drainMerge(ctx, p) {
62
+ if (p.merge !== null || p.approved.size === 0)
63
+ return;
64
+ const queued = queuedMergeFiles(ctx.root);
65
+ const files = new Set(queued.map((q) => q.file));
66
+ for (const [role, v] of [...p.approved]) {
67
+ if (files.has(v.file))
68
+ continue;
69
+ p.approved.delete(role);
70
+ removeMergeChange(ctx.root, role);
71
+ }
72
+ const picks = queued.flatMap((q) => {
73
+ const v = p.approved.get(q.entry.role);
74
+ return v?.file === q.file ? [v] : [];
75
+ });
76
+ picks.splice(ctx.liveConfig.mergeBatchMax);
77
+ if (picks.length === 0)
78
+ return;
79
+ for (const v of picks)
80
+ p.approved.delete(v.entry.role);
81
+ p.merge = startMerge(ctx, p, picks);
82
+ }
83
+ /** The merge task — main's one writer in the pipeline: merge `picks` through mergeApproved on
84
+ * main's current tip — one change through mergeApprovedChange, two or more as one stack with one
85
+ * scope-`batch` check, one fast-forward, and 3d's largest-passing-prefix bisect — with no second
86
+ * review. An approved change whose main moved since its verification is re-checked on the tree that
87
+ * merges (the in-lock re-check, or the stack's check), so an approval that outlived its base never
88
+ * merges unverified. Each defined result is written back and its entry dropped; an unattempted
89
+ * change (behind a bisect's attributed one) goes back to `approved` for the next merge. A plumbing
90
+ * throw keeps every entry queued, but unapproved: each is verified afresh from its pin next poll. A
91
+ * user abort of any merged role stops the whole stack, and every change it had not merged ends
92
+ * "aborted" with its pin discarded; a shutdown keeps the pins. The task takes no permit of its
93
+ * own — its only model run is mergeToMain's conflict resolver, which takes a shared permit at
94
+ * MERGE_TIER for the run's length, outside the merge lock. */
95
+ function startMerge(ctx, p, picks) {
96
+ const { root, mainBranch, signal, roleConfig, semaphore } = ctx;
97
+ const merge = {
98
+ promise: Promise.resolve(), // Replaced below; the placeholder satisfies the type.
99
+ controller: new AbortController(),
100
+ roles: picks.map((v) => v.entry.role),
101
+ userAborted: false,
102
+ };
103
+ const detachShutdown = abortOnShutdown(signal, merge.controller);
104
+ const wiring = new Map(picks.map((v) => [
105
+ v.entry.role,
106
+ {
107
+ state: v.author.state,
108
+ foldUsage: v.foldUsage,
109
+ runGatePi: (opts) => v.author.runGatePi(opts),
110
+ runPi: async (w, prompt, s, cfg) => {
111
+ // A parked resolver names its wait (resolve-wait, then resolving once granted) the way
112
+ // a parked check does (withCheckPermit): on 2026-10-12 one waited 2.5 min for a permit
113
+ // with the merge queue behind it, reading as "merging". The lease is keyed on the
114
+ // resolver config's provider (the `cfg` it is handed), so the conflict resolution
115
+ // counts against the strong tier's provider cap like any other strong-tier run
116
+ // (plans/provider-concurrency.md §4). canLeaseAtOnce is acquireLease's own fast path
117
+ // decided synchronously, so a free permit shows no wait; MERGE_TIER ignores the
118
+ // maintenance reserve.
119
+ const key = cfg ? seamConcurrencyKey(cfg, ctx.piDefault) : undefined;
120
+ const hooks = resolveWaitStage(root, v.entry.role);
121
+ if (!semaphore.canLeaseAtOnce(MERGE_TIER, key))
122
+ hooks.waiting();
123
+ const lease = await semaphore.acquireLease(MERGE_TIER, key);
124
+ hooks.granted();
125
+ try {
126
+ // The resolver's lease rides in the provider-lease store for its run, so the run
127
+ // re-keys it to the provider it actually uses (plans/provider-concurrency.md §5).
128
+ return await withProviderLease(lease, semaphore, () => v.author.runMergePi(w, prompt, s, cfg), { piDefault: ctx.piDefault });
129
+ }
130
+ finally {
131
+ lease.release();
132
+ }
133
+ },
134
+ },
135
+ ]));
136
+ const task = (async () => {
137
+ let results;
138
+ let threw = false;
139
+ // A result reaches settleMergeOutcome the moment mergeApproved finalizes it, not after the
140
+ // whole batch; this set lets the end-of-batch loop skip what is already written back.
141
+ const settled = new Set();
142
+ const settleOne = async (s, result) => {
143
+ const v = picks[s];
144
+ settled.add(s);
145
+ await settleMergeOutcome(root, v.entry, v.author.state, result, Date.now() - v.startedAt, v.usage, v.file);
146
+ };
147
+ try {
148
+ results = await mergeApproved({
149
+ root,
150
+ mainBranch,
151
+ config: roleConfig,
152
+ signal: () => merge.controller.signal,
153
+ onChangeStatus: (role, status) => setMergeChangeStatus(root, role, status),
154
+ onResult: settleOne,
155
+ runGatePi: (opts) => picks[0].author.runGatePi(opts),
156
+ }, picks.map((v) => ({
157
+ role: v.entry.role,
158
+ sha: v.sha,
159
+ tick: v.entry.run,
160
+ summary: v.entry.summary,
161
+ body: v.entry.body,
162
+ highFriction: v.entry.highFriction,
163
+ ...entryRevisionFields(v.entry),
164
+ ...(v.verifiedHead !== undefined ? { verifiedHead: v.verifiedHead } : {}),
165
+ })), (role) => wiring.get(role));
166
+ }
167
+ catch (err) {
168
+ threw = true;
169
+ results = picks.map(() => undefined);
170
+ const head = picks[0].author;
171
+ head.state.lastError = errorMessage(err);
172
+ // The catch exists to keep every entry queued and unapproved when mergeApproved throws. A
173
+ // throw out of this write — an unwritable state file — must not skip the settle loop
174
+ // and the finally that frees the merge slot, or the pipeline wedges with p.merge set
175
+ // forever; the write is bookkeeping a later save retries, so warn and keep going.
176
+ try {
177
+ saveLoopState(root, head.state);
178
+ }
179
+ catch (writeErr) {
180
+ warnEventBestEffort(root, TUMWATER_SOURCE, `the merge error for ${head.state.role} could not be saved: ${errorMessage(writeErr)}`);
181
+ }
182
+ }
183
+ try {
184
+ for (let s = 0; s < picks.length; s++) {
185
+ const v = picks[s];
186
+ const { role } = v.entry;
187
+ if (settled.has(s))
188
+ continue; // already written back through onResult
189
+ const result = results[s] ?? (merge.userAborted ? "aborted" : undefined);
190
+ if (result === undefined && !threw) {
191
+ p.approved.set(role, v);
192
+ setMergeChangeStatus(root, role, "approved");
193
+ continue;
194
+ }
195
+ if (result !== undefined) {
196
+ await settleOne(s, result);
197
+ }
198
+ else {
199
+ removeMergeChange(root, role);
200
+ }
201
+ }
202
+ }
203
+ finally {
204
+ detachShutdown();
205
+ if (merge.userAborted)
206
+ await discardPinnedRefs(root, merge.roles);
207
+ p.merge = null;
208
+ }
209
+ })();
210
+ merge.promise = guardMergeTask(root, merge.roles.join(", "), task);
211
+ return merge;
212
+ }