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
@@ -0,0 +1,340 @@
1
+ // vocabulary: plain
2
+ import { dropMerge } from "./merge-queue.js";
3
+ import { runCostIsReal } from "../pi/pi-run-result.js";
4
+ import { reviewRunConfig, seamHoldKey, seamPairName } from "../config/config-views.js";
5
+ import { saveLoopState } from "../loop/loop-state.js";
6
+ import { applyMergeOutcome, ERROR_STREAK_WARN } from "../tick/tick-apply.js";
7
+ import { observeClaudeAccount } from "../agent/claude/claude-account.js";
8
+ import { logEventBestEffort, usageFragment, warnEventBestEffort } from "../events/events.js";
9
+ import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
10
+ import { isJsonObject, nonNegativeNumber } from "../files/json-object.js";
11
+ import { removeQuiet } from "../files/files.js";
12
+ import { mergeStatePath } from "../paths.js";
13
+ import { consecutiveFailuresWarning } from "../text/phrases.js";
14
+ import { canonicalRoleId } from "../roles/legacy-role-ids.js";
15
+ /** How each merge stage names itself in the merge cell. */
16
+ export const MERGE_STAGE_LABELS = {
17
+ rebasing: "rebasing",
18
+ "check-wait": "waiting to check",
19
+ "build-check": "checking",
20
+ reviewing: "reviewing",
21
+ "resolve-wait": "waiting to resolve conflicts",
22
+ resolving: "resolving conflicts",
23
+ "provider-wait": "waiting for the provider",
24
+ merging: "finishing the merge",
25
+ };
26
+ /** Publish the in-flight merge marker — the one place its shape is constructed, so the
27
+ * interface is enforced here rather than at each caller's inline `writeJsonFile`: every
28
+ * writer names a stage. Atomic (tmp + rename) because the marker is rewritten mid-merge at
29
+ * every stage and per-change status transition while observers poll it every second — a plain
30
+ * overwrite would let a poll read a torn file as "no merge" and flicker the merge row back
31
+ * to its idle label. */
32
+ export function writeMergeMarker(root, marker) {
33
+ writeJsonAtomic(mergeStatePath(root), marker);
34
+ }
35
+ /** Read the in-flight merge marker; null when missing or unreadable. Never throws —
36
+ * observers poll it every second, and a torn write (a crash mid-write) must not take them
37
+ * down (the readOrchestratorInfo precedent). */
38
+ export function readMergeMarker(root) {
39
+ const marker = readJsonFile(mergeStatePath(root));
40
+ if (!marker || typeof marker.role !== "string")
41
+ return marker;
42
+ const out = { ...marker, role: canonicalRoleId(marker.role) };
43
+ if (Array.isArray(marker.changes)) {
44
+ out.changes = marker.changes.map((c) => (c && typeof c.role === "string" ? { ...c, role: canonicalRoleId(c.role) } : c));
45
+ }
46
+ return out;
47
+ }
48
+ /** Rewrite the marker after one of its records changed: re-point the top level at the first
49
+ * change in `merging` (see MergeInFlight — left as is while none is), then write it. Never
50
+ * throws: the marker is display-only, and a failed write must not fail a merge (the
51
+ * observers keep the previous frame's record until the next write). */
52
+ function rewriteMarker(root, marker) {
53
+ const headline = marker.changes.find((c) => isMergeChange(c) && c.status === "merging");
54
+ if (headline) {
55
+ marker.role = headline.role;
56
+ marker.sha = headline.sha;
57
+ marker.summary = headline.summary;
58
+ marker.startedAt = headline.startedAt ?? marker.startedAt;
59
+ marker.stage = headline.stage ?? "merging";
60
+ }
61
+ try {
62
+ writeMergeMarker(root, { ...marker, stage: marker.stage ?? "merging" });
63
+ }
64
+ catch {
65
+ /* display-only — see above */
66
+ }
67
+ }
68
+ /** Read-modify-write one change's record in the live marker — the skeleton setMergeChangeStatus
69
+ * and setMergeStage share: read the marker off disk, hand `apply` the change named by `role`,
70
+ * and write the marker back through rewriteMarker. A read-modify-write of the file, not a
71
+ * rewrite from memory, so the stage a gate last set (the same process) survives. A no-op when
72
+ * there is no marker or it has no record for `role`; `apply` returns whether to write the
73
+ * marker — true when it changed anything, false to skip the write (setMergeStage's guard
74
+ * against re-staging an unchanged stage). Never throws (rewriteMarker). */
75
+ function updateMergeChange(root, role, apply) {
76
+ const marker = readMergeMarker(root);
77
+ // A `changes` field that is not an array is as unusable as an absent one — treat it as no
78
+ // per-change records (the legacy shape) and leave the marker untouched, rather than reading
79
+ // `.find` off a scalar and throwing into a live merge.
80
+ if (!marker || !Array.isArray(marker.changes))
81
+ return;
82
+ const change = marker.changes.find((c) => isMergeChange(c) && c.role === role);
83
+ if (!change)
84
+ return;
85
+ if (!apply(change))
86
+ return;
87
+ rewriteMarker(root, { ...marker, changes: marker.changes });
88
+ }
89
+ /** Advance one change's record in the live marker — the verification's move to `approved` and the
90
+ * merge's per-change status hook (BatchContext.onChangeStatus). A read-modify-write of the file,
91
+ * not a rewrite from memory, so the stage a gate last set (setMergeStage, the same process)
92
+ * survives. Each move to `merging` resets the change's stage to `merging` and stamps its
93
+ * `startedAt` if it has none. A no-op when there is no marker or it has no record for `role`.
94
+ * Never throws (rewriteMarker). */
95
+ export function setMergeChangeStatus(root, role, status) {
96
+ updateMergeChange(root, role, (change) => {
97
+ change.status = status;
98
+ if (status === "merging") {
99
+ change.startedAt ??= Date.now();
100
+ change.stage = "merging";
101
+ }
102
+ return true;
103
+ });
104
+ }
105
+ /** Add one change's record as its verification starts: each change is its own task, so records come
106
+ * and go one at a time. The new record starts `merging` at `rebasing` with its own `startedAt` (a
107
+ * verification opens with its checkout and rebase onto main). Every other record is kept only while
108
+ * its role is in `live` — the roles the pipeline holds right now — so a record a crashed generation
109
+ * left behind never rides along. Never throws (rewriteMarker). */
110
+ export function addMergeChange(root, entry, live) {
111
+ const marker = readMergeMarker(root);
112
+ const kept = marker ? mergeChanges(marker).filter((c) => c.role !== entry.role && live.has(c.role)) : [];
113
+ const startedAt = Date.now();
114
+ const change = {
115
+ role: entry.role,
116
+ sha: entry.sha,
117
+ summary: entry.summary,
118
+ status: "merging",
119
+ startedAt,
120
+ stage: "rebasing",
121
+ stageSince: startedAt,
122
+ };
123
+ rewriteMarker(root, {
124
+ role: entry.role,
125
+ sha: entry.sha,
126
+ summary: entry.summary,
127
+ startedAt,
128
+ stage: "rebasing",
129
+ stageSince: startedAt,
130
+ changes: [...kept, change],
131
+ });
132
+ }
133
+ /** Remove one change's record once the pipeline has written its outcome (or dropped it as
134
+ * already on main), deleting the marker when it was the last. A no-op when the marker names no
135
+ * such role. Never throws. */
136
+ export function removeMergeChange(root, role) {
137
+ const marker = readMergeMarker(root);
138
+ if (!marker)
139
+ return;
140
+ const all = mergeChanges(marker);
141
+ const changes = all.filter((c) => c.role !== role);
142
+ if (changes.length === all.length)
143
+ return;
144
+ if (changes.length === 0) {
145
+ removeQuiet(mergeStatePath(root));
146
+ return;
147
+ }
148
+ rewriteMarker(root, { ...marker, changes });
149
+ }
150
+ /** Advance a merge's stage — called by the merge path at each transition: review.ts before
151
+ * its pre-check (`build-check`) and before its reviewer run (`reviewing`), merge-to-main.ts
152
+ * around its conflict resolver run (`resolving`, then `merging`), merge-core.ts's
153
+ * reviewPinnedChange once the gate returns (`merging`, whatever it decided — a finished
154
+ * reviewer's last turns must not sit in the cell accruing a false `no pi output` flag while the
155
+ * change waits for its merge), and merge-stack.ts around a stack's shared check. It stages
156
+ * `role`'s own record — every concurrent verification advances its own change's cell. A no-op for a
157
+ * role with no record (a gate run outside the pipeline, as the unit tests drive it). Only the stage
158
+ * changes: sha and startedAt are what the snapshot cross-check and the merge's elapsed read. */
159
+ export function setMergeStage(root, role, stage) {
160
+ updateMergeChange(root, role, (change) => {
161
+ if (change.stage === stage)
162
+ return false; // already there — keep the marker as written
163
+ change.stage = stage;
164
+ change.stageSince = Date.now();
165
+ return true;
166
+ });
167
+ }
168
+ /** The merge cell's save-point hooks one of the pipeline's permit waits parks on: `wait` (the
169
+ * stage shown while the caller is parked) set on each of `roles`' records, and `granted` (the
170
+ * stage once the permit arrives). The three permit waits the pipeline uses — the build check's,
171
+ * the conflict resolver's, and the re-key verification's — share this one pair of callbacks,
172
+ * which differ only in their two stage names, so a fourth wait reuses it instead of re-copying
173
+ * the waiting/granted shape. A no-op for a role with no record, like setMergeStage. */
174
+ function waitStageHooks(root, roles, wait, granted) {
175
+ return {
176
+ waiting: () => roles.forEach((role) => setMergeStage(root, role, wait)),
177
+ granted: () => roles.forEach((role) => setMergeStage(root, role, granted)),
178
+ };
179
+ }
180
+ /** The merge cell's hooks around a build check's wait for its permit (runScopedBuildCheck's
181
+ * `permitWait`): `check-wait` on each of `roles`' records while parked, `build-check` once the
182
+ * permit arrives. Called only when the permit was not free, so an unqueued check never
183
+ * flickers. */
184
+ export function checkWaitStage(root, roles) {
185
+ return waitStageHooks(root, roles, "check-wait", "build-check");
186
+ }
187
+ /** The merge cell's hooks around the merge's conflict resolver waiting for its agent permit
188
+ * (merge-drain.ts's merge wiring): `resolve-wait` while parked, `resolving` once the permit
189
+ * arrives — checkWaitStage's shape for the maxConcurrent semaphore, so a resolver queued behind
190
+ * busy agents never reads as one running. Called only when the permit was not free. */
191
+ export function resolveWaitStage(root, role) {
192
+ return waitStageHooks(root, [role], "resolve-wait", "resolving");
193
+ }
194
+ /** The verification's hooks around a re-key onto a full provider (plans/provider-concurrency.md §5,
195
+ * "A re-key wait is visible"): `provider-wait` while the verification's lease parks on the target
196
+ * provider's full cap, `reviewing` once the key frees and the review proceeds. resolveWaitStage's
197
+ * shape for the re-key path — a verification that normally shares its review seam never
198
+ * re-keys, so the stage fires only when a run actually moves to a different, full provider. */
199
+ export function providerWaitStage(root, role) {
200
+ return waitStageHooks(root, [role], "provider-wait", "reviewing");
201
+ }
202
+ /** The marker's per-change records, whichever shape it has: `changes` as written, or — for a
203
+ * marker an older generation wrote with one top-level change — that change, `merging` since the
204
+ * marker's start at the marker's stage. Reads both shapes into records, so addMergeChange's
205
+ * stale-record filtering and status-model's per-role cell cannot disagree about which changes a
206
+ * marker names. */
207
+ export function mergeChanges(marker) {
208
+ return Array.isArray(marker.changes)
209
+ ? marker.changes.filter(isMergeChange)
210
+ : [
211
+ {
212
+ role: marker.role,
213
+ sha: marker.sha,
214
+ summary: marker.summary,
215
+ status: "merging",
216
+ startedAt: marker.startedAt,
217
+ stage: marker.stage,
218
+ ...(marker.stageSince !== undefined ? { stageSince: marker.stageSince } : {}),
219
+ },
220
+ ];
221
+ }
222
+ /** True when a persisted `changes` entry is a record the pipeline and the observers can act on:
223
+ * a role, sha and summary string and one of the three statuses. A hand-edited, torn, or foreign
224
+ * entry (a `null`/scalar, or one missing `status`) is dropped by mergeChanges rather than read
225
+ * field-by-field — without this, `c.role` on a `null` entry threw `Cannot read properties of
226
+ * null` straight into setMergeChangeStatus and the every-second status poll. */
227
+ export function isMergeChange(value) {
228
+ return (isJsonObject(value) &&
229
+ typeof value.role === "string" &&
230
+ typeof value.sha === "string" &&
231
+ typeof value.summary === "string" &&
232
+ (value.status === "merging" || value.status === "approved" || value.status === "done") &&
233
+ (value.stageSince === undefined || nonNegativeNumber(value.stageSince, null) !== null));
234
+ }
235
+ /** Fold one merge's outcome into its role's state and drop its queue entry — the 3/5
236
+ * write-back every pipeline outcome goes through (a verification's final verdict, a merged change,
237
+ * an aborted approved change): apply the result to the live state object (paired with the entry's
238
+ * change for the last-result cell — applyMergeOutcome), persist it, log merge_done/merge_failed
239
+ * with the merge's own duration (from its verification's start) and usage (omitted when zero — the
240
+ * 4/5 idiom, so review-exempt merges render bare), and drop the entry: EVERY defined result
241
+ * drops. Both event writes are best-effort: an unwritable feed (ENOSPC, EACCES) must not
242
+ * skip the drop, or the settled entry would stay queued and be re-verified and re-merged on
243
+ * the next drain — the queue's "drop after EVERY outcome" contract (merge-queue.ts). The
244
+ * change's marker record is the caller's concern (removeMergeChange). */
245
+ export function writeMergeOutcome(root, entry, state, result, durationMs, usage, file) {
246
+ applyMergeOutcome(state, result, entry);
247
+ saveLoopState(root, state);
248
+ // One warning per error episode, merge side (BUGS.md 2026-09-30): a review rejection
249
+ // feeds the same streak the tick-side warn reads (src/tick/tick-finalize.ts), but it resolves
250
+ // AFTER the tick's end-save, so the crossing warn belongs here — same shape, same bar.
251
+ if (result === "rejected" && (state.consecutiveErrors ?? 0) === ERROR_STREAK_WARN) {
252
+ warnEventBestEffort(root, entry.role, consecutiveFailuresWarning(state.consecutiveErrors ?? 0, state.lastError ?? "review rejected"));
253
+ }
254
+ // `merged` still fires from merge-to-main.ts itself — these events mark the QUEUE's
255
+ // bookkeeping: the slot picked the entry up (merge_queued, logged at enqueue) and finished
256
+ // with or without merge.
257
+ logEventBestEffort(root, {
258
+ source: entry.role,
259
+ type: result === "changed" ? "merge_done" : "merge_failed",
260
+ commit: entry.sha,
261
+ result,
262
+ summary: entry.summary,
263
+ durationMs,
264
+ // Enqueue-to-outcome span, including the slot's own durationMs. A leftover-recovery
265
+ // re-enqueue writes a new entry with its own enqueuedAt, so a retried change counts from
266
+ // its last enqueue.
267
+ queuedMs: Math.max(0, Date.now() - entry.enqueuedAt),
268
+ ...usageFragment(usage.tokens, usage.cost, usage.notionalCost),
269
+ // The merge's per-provider spend, keyed to its reviewer seam (the same reviewProvider
270
+ // key the author-side foldMergeUsage charges), so the report's `Cost by provider` line
271
+ // sees a merge's spend under its reviewer's provider. Omitted when empty (a merge
272
+ // whose runs carried no provider), the same omit-when-empty idiom the usage fields use.
273
+ ...(Object.keys(usage.providerCost).length > 0 ? { providerCost: usage.providerCost } : {}),
274
+ });
275
+ dropMerge(file);
276
+ }
277
+ /** A fresh usage accumulator for one merge plus the foldUsage callback that charges each of
278
+ * its pi runs to BOTH the authoring runner's live state (foldMergeUsage: reviewer and
279
+ * conflict-resolution spend belongs to the authoring role) and the merge's own accumulator
280
+ * (the merge_done/merge_failed event's usage). Every verification builds its wiring through this,
281
+ * and its merge reuses it, so the accounting — and any future change to it — lives in one place.
282
+ * `reviewProvider` is this merge's reviewer seam hold key (seamHoldKey of reviewRunConfig),
283
+ * folded onto the rate-limit and backend-failure stamps this fold writes: a 429 on a claude
284
+ * reviewer must hold `claude`, not the pi author's provider (PLANS.md "Mixed claude + pi
285
+ * fleets, part 2/4"). The reviewer's final runs are folded HERE (merge-core.ts's
286
+ * reviewPinnedChange), not inside LoopPi — loop-pi.ts stamps the retried first attempt; this
287
+ * stamps the final run, which ends on a different seam when the reviewer runs on a
288
+ * non-pi backend. */
289
+ /** Fetch-or-create a provider's accumulator entry in `usage.providerCost` and add `amount`
290
+ * to one of its two fields — the single home of the real/notional provider bump both
291
+ * branches of foldUsage do, so a future change to the entry shape (or the
292
+ * noUncheckedIndexedAccess seed) is merged once instead of twice. */
293
+ function addProviderCost(usage, provider, amount, notional) {
294
+ const e = usage.providerCost[provider] ?? { costUsd: 0, notionalCostUsd: 0 };
295
+ if (notional)
296
+ e.notionalCostUsd += amount;
297
+ else
298
+ e.costUsd += amount;
299
+ usage.providerCost[provider] = e;
300
+ }
301
+ export function mergeUsage(author, root) {
302
+ const usage = { tokens: 0, cost: 0, providerCost: {} };
303
+ const reviewConfig = reviewRunConfig(author.config);
304
+ const reviewProvider = seamHoldKey(reviewConfig);
305
+ const reviewPair = seamPairName(reviewConfig);
306
+ return {
307
+ usage,
308
+ foldUsage: (run) => {
309
+ author.foldMergeUsage(run, reviewProvider, reviewPair);
310
+ // A claude reviewer's or resolver's billing/window readings reach the account file the
311
+ // moment this run folds, not at the author's next tick end — so a pi-author team's
312
+ // claude reviewer still updates the five-hour window before the author's next tick
313
+ // starts (PLANS.md "Mixed claude + pi fleets, part 3/4"). Best-effort like tick-finalize.ts's
314
+ // call; the file is a cache and an unwritable state dir must not fail the merge.
315
+ if (run.billing !== undefined || run.rateWindows !== undefined) {
316
+ try {
317
+ observeClaudeAccount(root, { billing: run.billing, rateWindows: run.rateWindows });
318
+ }
319
+ catch {
320
+ // The next reporting run rewrites it; nothing this merge depends on is lost.
321
+ }
322
+ }
323
+ usage.tokens += run.outputTokens;
324
+ if (runCostIsReal(run)) {
325
+ usage.cost += run.costUsd;
326
+ // Only a resolved reviewer seam bears a provider: a pi reviewer whose provider was
327
+ // never pinned (reviewProvider undefined) attributes nothing, like the author side.
328
+ if (reviewProvider !== undefined) {
329
+ addProviderCost(usage, reviewProvider, run.costUsd, false);
330
+ }
331
+ }
332
+ else {
333
+ usage.notionalCost = (usage.notionalCost ?? 0) + run.costUsd;
334
+ if (reviewProvider !== undefined) {
335
+ addProviderCost(usage, reviewProvider, run.costUsd, true);
336
+ }
337
+ }
338
+ },
339
+ };
340
+ }
@@ -1,42 +1,43 @@
1
- /** The stack lander — the merge-half machinery of a multi-change batch (extracted from
2
- * landing-batch.ts, whose header scopes that file to the vet and merge halves): assemble the
3
- * vetted changes as a cherry-picked stack on main's current tip, run ONE scope-`batch` build
1
+ // vocabulary: plain
2
+ /** The stack merger — the merge-half machinery of a multi-change batch (extracted from
3
+ * merge-batch.ts, whose header scopes that file to the verify and merge halves): assemble the
4
+ * approved changes as a cherry-picked stack on main's current tip, run ONE scope-`batch` build
4
5
  * check over the combined tree, and fast-forward main through the captured shas — with the
5
6
  * re-stack loop for a main that moves mid-check, the doc-only exemption and its cross-checks,
6
- * and the per-attempt abort observation. The bisect over a red stack (landVetted, in
7
- * landing-batch.ts) drives this file's landStack one prefix at a time. */
7
+ * and the per-attempt abort observation. The bisect over a red stack (mergeApproved, in
8
+ * merge-batch.ts) drives this file's mergeStack one prefix at a time. */
8
9
  import { COMMIT_IDENT, gitTry } from "../git/git-run.js";
9
10
  import { branchHead, deleteRef, gitLines, headOf } from "../git/git.js";
10
- import { landingRefName, mergeLockDir } from "../paths.js";
11
+ import { mergeRefName, mergeLockDir } from "../paths.js";
11
12
  import { ensureDetachedWorktree } from "../git/worktree.js";
12
13
  import { useWorktree } from "../git/worktree-use.js";
13
- import { exemptSkipBlockReason } from "./landing-merge.js";
14
- import { logNewQuestions } from "./landing-questions.js";
14
+ import { exemptSkipBlockReason } from "./merge-to-main.js";
15
+ import { logNewQuestions } from "./merge-questions.js";
15
16
  import { openQuestions } from "../backlog/backlog.js";
16
- import { logEventBestEffort } from "../events/events.js";
17
- import { ffMainTo } from "./landing-git.js";
17
+ import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
18
+ import { ffMainToOrWhy } from "./merge-git.js";
18
19
  import { syncRootInstall } from "../build/dep-install.js";
19
20
  import { withLock } from "../concurrency/lock.js";
20
21
  import { runScopedBuildCheck } from "../build/build-check-scoped.js";
21
22
  import { noteGreenBaseline } from "../baseline/main-baseline.js";
22
23
  import { isExemptDiff } from "../review/exemptions.js";
23
- import { checkWaitStage, setLandingStage } from "./landing-slot.js";
24
+ import { checkWaitStage, setMergeStage } from "./merge-slot.js";
24
25
  /** How many times a batch whose fast-forward lost the race to a moved main re-stacks onto the
25
26
  * new tip and goes round again before handing every change to leftover recovery as
26
27
  * `merge_blocked`. The race window is the whole batch check, and main still has writers
27
- * outside the land queue (a human commit, or a second fleet on the same repo), so a
28
+ * outside the merge queue (a human commit, or a second team on the same repo), so a
28
29
  * lost race is routine and one re-stack almost always wins it — the second is headroom for a
29
30
  * busy stretch. The bound keeps a main that moves faster than a check completes from holding
30
- * the merge slot (and every vetted landing behind it) indefinitely: each re-stack
31
+ * the merge slot (and every approved merge behind it) indefinitely: each re-stack
31
32
  * whose new tree is not doc-only pays one more full check. Past it the per-change path takes
32
- * over, whose in-lock re-check another harness landing cannot race. */
33
+ * over, whose in-lock re-check another harness merge cannot race. */
33
34
  export const BATCH_RESTACK_ATTEMPTS = 2;
34
- /** Assemble a batch's stack in `wtPath` (the shared `_merge` checkout a landing drain passes)
35
+ /** Assemble a batch's stack in `wtPath` (the shared `_merge` checkout a merge drain passes)
35
36
  * on main's CURRENT tip, once
36
37
  * per attempt — a re-stack after a lost fast-forward race is the same assembly on the tip that
37
- * won. `entries` carry each change's head to land (its synced pin); the result carries that
38
+ * won. `entries` carry each change's head to merge (its synced pin); the result carries that
38
39
  * base (`base` — the tip the stack's own delta is measured against) plus each change's
39
- * captured post-pick sha, in queue order — the stack ffStackToMain lands. Returns null when
40
+ * captured post-pick sha, in queue order — the stack ffStackToMain merges. Returns null when
40
41
  * the stack cannot be assembled on this tip: main is unreadable, or a pick conflicts (or
41
42
  * applies nothing — the crash-window re-drain); the caller abandons to one-at-a-time. */
42
43
  async function assembleStack(root, mainBranch, wtPath, entries) {
@@ -45,21 +46,21 @@ async function assembleStack(root, mainBranch, wtPath, entries) {
45
46
  // main the FIRST batch already moved; stacking from S[0].sha there would put the ff
46
47
  // against diverged history on every attempt. When main hasn't moved since the pins were
47
48
  // created (the common single-batch case) the picks reconstruct the same tree and the ff
48
- // lands the same tip. Every entry — the head included — carries its captured post-pick sha
49
+ // merges the same tip. Every entry — the head included — carries its captured post-pick sha
49
50
  // into the ff and the per-change merged events.
50
51
  const base = await branchHead(root, mainBranch);
51
52
  if (base === null)
52
53
  return null; // main unreadable: cannot stack
53
54
  // One idempotent ensure at the fresh base covers the worktree-a-moment-ago case (the
54
- // change's vet, or the previous attempt's assembly).
55
+ // change's verify, or the previous attempt's assembly).
55
56
  const wt = await ensureDetachedWorktree(root, wtPath, base);
56
- const landed = [];
57
+ const mergeDone = [];
57
58
  for (const entry of entries) {
58
- // Cherry-pick the whole RANGE from main's tip to the entry's head to land — `base..sha`,
59
+ // Cherry-pick the whole RANGE from main's tip to the entry's head to merge — `base..sha`,
59
60
  // every commit ahead of main, in queue order. A pin is normally one commit, so the range is
60
61
  // that commit; picking the range rather than the head alone keeps a pin that carries more
61
- // from landing only its last diff and orphaning the work beneath it. (Not a
62
- // rebase: after 2/5 the role branches sit at main and each landing lives only in its
62
+ // from merging only its last diff and orphaning the work beneath it. (Not a
63
+ // rebase: after 2/5 the role branches sit at main and each merge lives only in its
63
64
  // pinned ref — there is nothing to rebase.)
64
65
  const pick = await gitTry(wt, ...COMMIT_IDENT, "cherry-pick", `${base}..${entry.sha}`);
65
66
  if (pick === null) {
@@ -69,44 +70,44 @@ async function assembleStack(root, mainBranch, wtPath, entries) {
69
70
  await gitTry(wt, "cherry-pick", "--abort");
70
71
  return null;
71
72
  }
72
- landed.push({ role: entry.role, sha: await headOf(wt, "HEAD"), summary: entry.summary });
73
+ mergeDone.push({ role: entry.role, sha: await headOf(wt, "HEAD"), summary: entry.summary });
73
74
  }
74
- return { base, landed };
75
+ return { base, mergeDone };
75
76
  }
76
77
  /** True when the tree at `to` differs from the tree at `from` only in review-exempt paths —
77
78
  * the gate's own doc-only test (isExemptDiff over config.review.exemptPaths). A re-stack whose
78
79
  * new commits from main were doc-only rebuilds the checked tree with nothing but doc bytes
79
- * changed, and a doc-only delta cannot break the build: the reasoning verifyLanding
80
- * (src/landing/landing-merge.ts) applies when it skips its in-lock re-check for a moved
81
- * doc-only landing. Its false-fix cross-check has no counterpart here — the delta is
82
- * commits main already landed through their own gate, not a claim this batch makes.
80
+ * changed, and a doc-only delta cannot break the build: the reasoning verifyMerge
81
+ * (src/merge/merge-to-main.ts) applies when it skips its in-lock re-check for a moved
82
+ * doc-only merge. Its false-fix cross-check has no counterpart here — the delta is
83
+ * commits main already merged through their own gate, not a claim this batch makes.
83
84
  * --no-renames so a rename out of a code path lists the deleted source, not only its exempt
84
85
  * destination. An unreadable diff is not exempt: the caller runs the check. */
85
86
  async function exemptTreeDelta(root, from, to, exemptPaths) {
86
87
  const out = await gitTry(root, "diff", "--no-renames", "--name-only", from, to);
87
88
  return out !== null && isExemptDiff(gitLines(out), exemptPaths);
88
89
  }
89
- /** Land `entries` — the whole stack, or one bisect prefix of it — as ONE fast-forward on its
90
+ /** Merge `entries` — the whole stack, or one bisect prefix of it — as ONE fast-forward on its
90
91
  * own green check: assemble them on main's current tip in `wtPath`, run ONE scope-`batch`
91
92
  * check over the combined tree, and ff main through the captured shas (ffStackToMain) with
92
93
  * nothing rewritten in between — the in-lock invariant. A lost race re-stacks on the tip that
93
94
  * won, up to BATCH_RESTACK_ATTEMPTS times, and a re-stack whose tree is the checked tree plus
94
95
  * doc-only commits skips its re-check (exemptTreeDelta). A stack whose whole delta ahead of
95
96
  * its assembly base is review-exempt skips the check too — a doc-only delta cannot break the
96
- * build (the reasoning verifyLanding's exempt arm applies) — and runs the same cross-checks
97
+ * build (the reasoning verifyMerge's exempt arm applies) — and runs the same cross-checks
97
98
  * that arm runs, the backlog heading structure and the false-fix claim; one that fails either
98
99
  * returns `conflict`, so the per-change fallback applies the gate's own verdict per change.
99
100
  * On `landed` every entry's ref is deleted and the red-main baseline is seeded with the tip
100
- * when a check PASSED on exactly it — a skipped check seeds nothing, like verifyLanding's
101
+ * when a check PASSED on exactly it — a skipped check seeds nothing, like verifyMerge's
101
102
  * exempt arm. The check's events log under the first entry's role. */
102
- export async function landStack(ctx, wtPath, entries) {
103
+ export async function mergeStack(ctx, wtPath, entries) {
103
104
  // Hold the merge checkout from before assembleStack (its reset is part of the use) through
104
105
  // the ff (plans/disk-floor.md, part 2/4).
105
- return useWorktree(ctx.root, wtPath, () => landStackIn(ctx, wtPath, entries));
106
+ return useWorktree(ctx.root, wtPath, () => mergeStackIn(ctx, wtPath, entries));
106
107
  }
107
- async function landStackIn(ctx, wtPath, entries) {
108
+ async function mergeStackIn(ctx, wtPath, entries) {
108
109
  // The last stacked tip a build check actually ran on: a re-stack whose tree differs from it
109
- // only in doc-only paths lands on that run's verdict instead of paying another.
110
+ // only in doc-only paths merges on that run's verdict instead of paying another.
110
111
  let checkedTip = null;
111
112
  const exemptPaths = ctx.config.review.exemptPaths;
112
113
  for (let attempt = 0; attempt <= BATCH_RESTACK_ATTEMPTS; attempt++) {
@@ -118,10 +119,10 @@ async function landStackIn(ctx, wtPath, entries) {
118
119
  const assembled = await assembleStack(ctx.root, ctx.mainBranch, wtPath, entries);
119
120
  if (assembled === null)
120
121
  return { kind: "conflict" }; // main unreadable or a pick conflicted
121
- const { base, landed } = assembled;
122
- const tip = landed.at(-1).sha;
122
+ const { base, mergeDone } = assembled;
123
+ const tip = mergeDone.at(-1).sha;
123
124
  // The expensive deterministic half, shared: ONE run over the combined tree. Outcome
124
- // routing — null: no declared check, land directly; "failed": red or a merge-scope
125
+ // routing — null: no declared check, merge directly; "failed": red or a merge-scope
125
126
  // timeout, the caller bisects; "skipped": no npm / broken toolchain (the helper warned),
126
127
  // proceed — never fail-closed; "passed": green. A re-stack skips the run only when its
127
128
  // tree is the checked tree plus doc-only changes (exemptTreeDelta).
@@ -129,13 +130,13 @@ async function landStackIn(ctx, wtPath, entries) {
129
130
  const docOnlyRestack = checkedTip !== null && (await exemptTreeDelta(ctx.root, checkedTip, tip, exemptPaths));
130
131
  if (!docOnlyRestack) {
131
132
  // The stack's own delta ahead of its assembly base — main here is the tip assembleStack
132
- // stacked on — decides like verifyLanding does: doc-only cannot break the build, so no
133
- // check runs (a doc-only stack, or a bisect's size-1 doc-only remainder, lands on the
133
+ // stacked on — decides like verifyMerge does: doc-only cannot break the build, so no
134
+ // check runs (a doc-only stack, or a bisect's size-1 doc-only remainder, merges on the
134
135
  // exemption instead of paying a suite run no code change asked for).
135
136
  const stackDiff = await gitTry(ctx.root, "diff", "--no-renames", "--name-only", base, tip);
136
137
  const stackFiles = stackDiff === null ? null : gitLines(stackDiff);
137
138
  if (stackFiles !== null && isExemptDiff(stackFiles, exemptPaths)) {
138
- // The same cross-checks verifyLanding's exempt arm runs (landing-merge.ts's
139
+ // The same cross-checks verifyMerge's exempt arm runs (merge-to-main.ts's
139
140
  // exemptSkipBlockReason): the skip must not wave through an md-only edit the gate would
140
141
  // have rejected. A failure here cannot name a red check (none ran), so it hands the
141
142
  // stack to the one-at-a-time fallback, whose exempt arm blocks or rejects each change
@@ -144,14 +145,14 @@ async function landStackIn(ctx, wtPath, entries) {
144
145
  return { kind: "conflict" };
145
146
  }
146
147
  else {
147
- // The landing cell names the check while it runs, then the merge steps after it — the ff
148
+ // The merge cell names the check while it runs, then the merge steps after it — the ff
148
149
  // or what the caller does next — on every stacked change's own record.
149
150
  const roles = entries.map((e) => e.role);
150
151
  for (const role of roles)
151
- setLandingStage(ctx.root, role, "build-check");
152
+ setMergeStage(ctx.root, role, "build-check");
152
153
  const check = await runScopedBuildCheck(ctx.root, roles[0], "batch", wtPath, ctx.config, undefined, undefined, undefined, checkWaitStage(ctx.root, roles));
153
154
  for (const e of entries)
154
- setLandingStage(ctx.root, e.role, "merging");
155
+ setMergeStage(ctx.root, e.role, "merging");
155
156
  if (check !== null && check.outcome.status === "failed")
156
157
  return { kind: "red", ...check };
157
158
  checkedTip = tip;
@@ -159,57 +160,60 @@ async function landStackIn(ctx, wtPath, entries) {
159
160
  seed = tip;
160
161
  }
161
162
  }
162
- if ((await ffStackToMain(ctx.root, ctx.mainBranch, landed)) === "changed") {
163
+ if ((await ffStackToMain(ctx.root, ctx.mainBranch, mergeDone)) === "changed") {
163
164
  // A PASSED stack check ran on exactly the future main tip — seed the red-main
164
165
  // baseline after (not before) the successful ff, so a merge_blocked stack seeds
165
166
  // nothing; a skipped check seeds nothing either (skips never seed), and neither does
166
- // a doc-only re-stack (like verifyLanding's exempt arm: nothing ran on this tip).
167
+ // a doc-only re-stack (like verifyMerge's exempt arm: nothing ran on this tip).
167
168
  if (seed !== undefined)
168
169
  noteGreenBaseline(seed);
169
170
  for (const e of entries)
170
- await deleteRef(ctx.root, landingRefName(e.role));
171
- return { kind: "landed" };
171
+ await deleteRef(ctx.root, mergeRefName(e.role));
172
+ return { kind: "landed" }; // vocabulary-ok: persisted state value or event type, renamed by a later series
172
173
  }
173
174
  // Main moved under the batch while the check ran (a human commit): diverged history, ff
174
175
  // failed. Re-stack on the tip that won.
175
176
  }
176
177
  return { kind: "blocked" };
177
178
  }
178
- /** Fast-forward main through a WHOLE batch of already-reviewed landings in one (merge queue
179
- * 5/5): `landed` is the stack in queue order, each entry the change's captured post-pick sha
179
+ /** Fast-forward main through a WHOLE batch of already-reviewed merges in one (merge queue
180
+ * 5/5): `mergeDone` is the stack in queue order, each entry the change's captured post-pick sha
180
181
  * plus its own role and summary. Under the merge lock: one `ffMainTo` to the stacked tip (the
181
182
  * LAST entry's sha — a single ff through N stacked commits), one `merged` event PER entry so
182
183
  * the report counts the batch as N commits, and the question_posted diff around that single ff
183
184
  * (one-shot, as tryMerge's). No rebase and no in-lock re-check inside this helper — that is
184
185
  * what makes the batch's one shared stack check sufficient (the design invariant): nothing
185
- * rewrites between the lander's green check and this ff, so a successful ff makes main
186
+ * rewrites between the merger's green check and this ff, so a successful ff makes main
186
187
  * byte-identical to the checked tip (or, after a doc-only re-stack, to that tip plus the
187
- * doc-only commits main gained). `noteGreenBaseline` stays out of it too — the lander
188
+ * doc-only commits main gained). `noteGreenBaseline` stays out of it too — the merger
188
189
  * seeds the stacked tip, because it alone knows whether its own check passed. A failed ff
189
190
  * (main moved under the batch — diverged history) returns "merge_blocked" with NO events and
190
- * no ref changes: the lander re-stacks onto the new tip and calls this again (bounded by
191
+ * no ref changes: the merger re-stacks onto the new tip and calls this again (bounded by
191
192
  * BATCH_RESTACK_ATTEMPTS), and only a race lost on every attempt keeps every
192
193
  * change's ref for one-at-a-time recovery, whose tryMerge carries the in-lock check. The
193
194
  * single-change path is untouched. */
194
- export async function ffStackToMain(root, mainBranch, landed) {
195
- const tip = landed.at(-1);
195
+ export async function ffStackToMain(root, mainBranch, mergeDone) {
196
+ const tip = mergeDone.at(-1);
196
197
  if (!tip)
197
- return "changed"; // Empty stack: nothing to fast-forward (never called in production — the lander stacks at least one change).
198
+ return "changed"; // Empty stack: nothing to fast-forward (never called in production — the merger stacks at least one change).
198
199
  return withLock(mergeLockDir(root), async () => {
199
200
  const before = openQuestions(root);
200
- if (!(await ffMainTo(root, tip.sha, mainBranch)))
201
+ const ffFailure = await ffMainToOrWhy(root, tip.sha, mainBranch);
202
+ if (ffFailure !== null) {
203
+ warnEventBestEffort(root, tip.role, `merge blocked: ${ffFailure}`);
201
204
  return "merge_blocked";
205
+ }
202
206
  // Best-effort like tryMerge's merged events: the stack is already fast-forwarded, so an
203
- // unwritable feed must not flip a landed batch to a failure and skip syncRootInstall below.
204
- for (const entry of landed) {
207
+ // unwritable feed must not flip a merged batch to a failure and skip syncRootInstall below.
208
+ for (const entry of mergeDone) {
205
209
  logEventBestEffort(root, {
206
- loop: entry.role,
210
+ source: entry.role,
207
211
  type: "merged",
208
212
  commit: entry.sha,
209
213
  summary: entry.summary,
210
214
  });
211
215
  }
212
- logNewQuestions(root, before, landed[0].role);
216
+ logNewQuestions(root, before, mergeDone[0].role);
213
217
  await syncRootInstall(root, tip.role); // As tryMerge: re-sync the root install under the lock.
214
218
  return "changed";
215
219
  });