tumwater 0.2.0 → 0.4.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 (464) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +55 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +6 -4
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +404 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +281 -55
  41. package/dist/src/backlog/backlog-cli-view.js +255 -0
  42. package/dist/src/backlog/backlog-cli.js +91 -335
  43. package/dist/src/backlog/backlog-commit.js +20 -13
  44. package/dist/src/backlog/backlog-eligibility.js +193 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +264 -0
  48. package/dist/src/backlog/backlog-release.js +134 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-guardrails.js +136 -0
  56. package/dist/src/baseline/main-red.js +153 -36
  57. package/dist/src/baseline/main-watch.js +232 -0
  58. package/dist/src/brief/brief-edit.js +12 -12
  59. package/dist/src/brief/brief.js +31 -51
  60. package/dist/src/budget/budget-fallback-view.js +288 -0
  61. package/dist/src/budget/budget-wiring.js +158 -0
  62. package/dist/src/budget/budget.js +31 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +65 -3
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +181 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +183 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +291 -598
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-review-commands.js +113 -0
  84. package/dist/src/cli/cli-role-command.js +525 -0
  85. package/dist/src/cli/cli-start-summary.js +167 -0
  86. package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
  87. package/dist/src/cli/completion.js +169 -22
  88. package/dist/src/cli/config-commands.js +58 -26
  89. package/dist/src/cli/follow-view.js +75 -0
  90. package/dist/src/cli/help.js +258 -82
  91. package/dist/src/cli/log-commands.js +164 -85
  92. package/dist/src/cli/question-commands.js +211 -10
  93. package/dist/src/cli.js +173 -45
  94. package/dist/src/collections.js +56 -13
  95. package/dist/src/concurrency/check-permit.js +20 -3
  96. package/dist/src/concurrency/lock.js +17 -13
  97. package/dist/src/concurrency/provider-lease.js +44 -8
  98. package/dist/src/concurrency/semaphore.js +102 -26
  99. package/dist/src/config/config-aliases.js +0 -1
  100. package/dist/src/config/config-editable-keys.js +16 -1
  101. package/dist/src/config/config-fallbacks.js +94 -56
  102. package/dist/src/config/config-field-checks.js +40 -10
  103. package/dist/src/config/config-keys.js +30 -8
  104. package/dist/src/config/config-live.js +63 -24
  105. package/dist/src/config/config-request.js +137 -0
  106. package/dist/src/config/config-schema.js +31 -24
  107. package/dist/src/config/config-selector-checks.js +123 -0
  108. package/dist/src/config/config-validation-agents.js +80 -0
  109. package/dist/src/config/config-validation-model.js +116 -0
  110. package/dist/src/config/config-validation.js +169 -222
  111. package/dist/src/config/config-views.js +136 -54
  112. package/dist/src/config/config-write-core.js +135 -0
  113. package/dist/src/config/config-write.js +164 -265
  114. package/dist/src/config/config.js +53 -38
  115. package/dist/src/config/model-selector.js +34 -34
  116. package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
  117. package/dist/src/doctor/doctor-backlog-plans.js +398 -0
  118. package/dist/src/doctor/doctor-backlog.js +249 -512
  119. package/dist/src/doctor/doctor-check-names.js +85 -0
  120. package/dist/src/doctor/doctor-checks.js +61 -593
  121. package/dist/src/doctor/doctor-claude-checks.js +142 -0
  122. package/dist/src/doctor/doctor-codex-checks.js +276 -0
  123. package/dist/src/doctor/doctor-contention-checks.js +506 -0
  124. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  125. package/dist/src/doctor/doctor-holds.js +80 -66
  126. package/dist/src/doctor/doctor-install.js +2 -1
  127. package/dist/src/doctor/doctor-model-checks.js +216 -537
  128. package/dist/src/doctor/doctor-notify.js +9 -5
  129. package/dist/src/doctor/doctor-opencode-checks.js +164 -0
  130. package/dist/src/doctor/doctor-orphans.js +182 -63
  131. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  132. package/dist/src/doctor/doctor-project-checks.js +397 -0
  133. package/dist/src/doctor/doctor.js +126 -77
  134. package/dist/src/doctor/image-probe.js +21 -8
  135. package/dist/src/doctor/outcome.js +3 -2
  136. package/dist/src/doctor/probe.js +21 -12
  137. package/dist/src/events/event-format.js +185 -89
  138. package/dist/src/events/event-labels.js +11 -5
  139. package/dist/src/events/event-legacy.js +39 -15
  140. package/dist/src/events/event-read.js +52 -39
  141. package/dist/src/events/event-tally.js +16 -0
  142. package/dist/src/events/event-window.js +80 -26
  143. package/dist/src/events/events.js +30 -13
  144. package/dist/src/events/notify.js +7 -7
  145. package/dist/src/failure/failure-cluster.js +12 -12
  146. package/dist/src/failure/failure-data.js +65 -60
  147. package/dist/src/failure/failure-render.js +29 -21
  148. package/dist/src/failure/failure-state-change.js +17 -12
  149. package/dist/src/failure/time-spend.js +35 -35
  150. package/dist/src/files/file-queue.js +17 -0
  151. package/dist/src/files/files.js +111 -35
  152. package/dist/src/files/json-files.js +3 -3
  153. package/dist/src/files/json-object.js +28 -7
  154. package/dist/src/files/stat-cache.js +2 -2
  155. package/dist/src/files/tail.js +1 -1
  156. package/dist/src/gates/bootstrap-gates.js +4 -5
  157. package/dist/src/gates/budget-gates.js +88 -225
  158. package/dist/src/gates/disk-gate.js +1 -1
  159. package/dist/src/gates/gate-polls.js +101 -143
  160. package/dist/src/gates/maintenance-quota.js +37 -30
  161. package/dist/src/gates/pause-gates.js +28 -10
  162. package/dist/src/gates/readiness.js +7 -3
  163. package/dist/src/gates/role-cap-gates.js +8 -8
  164. package/dist/src/gates/startup-gate.js +94 -32
  165. package/dist/src/gates/streak-gate.js +54 -30
  166. package/dist/src/git/commit-message.js +89 -46
  167. package/dist/src/git/git-diff.js +16 -10
  168. package/dist/src/git/git-run.js +79 -38
  169. package/dist/src/git/git.js +54 -34
  170. package/dist/src/git/slot-salvage.js +253 -0
  171. package/dist/src/git/slots-state.js +6 -6
  172. package/dist/src/git/worktree-pool.js +132 -38
  173. package/dist/src/git/worktree-use.js +4 -11
  174. package/dist/src/git/worktree.js +94 -37
  175. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  176. package/dist/src/gui/gui-args.js +79 -8
  177. package/dist/src/gui/gui-backlog-commands.js +43 -19
  178. package/dist/src/gui/gui-backlog-endpoints.js +156 -0
  179. package/dist/src/gui/gui-command.js +26 -13
  180. package/dist/src/gui/gui-config-commands.js +168 -80
  181. package/dist/src/gui/gui-endpoint-commands.js +5 -4
  182. package/dist/src/gui/gui-endpoint-common.js +4 -4
  183. package/dist/src/gui/gui-endpoints.js +11 -380
  184. package/dist/src/gui/gui-history-endpoints.js +301 -0
  185. package/dist/src/gui/gui-operator-commands.js +235 -13
  186. package/dist/src/gui/gui-prompt-commands.js +26 -20
  187. package/dist/src/gui/gui-report-endpoints.js +97 -0
  188. package/dist/src/gui/gui-server.js +26 -5
  189. package/dist/src/gui/gui-team-commands.js +7 -7
  190. package/dist/src/gui/http-body.js +1 -1
  191. package/dist/src/history/history-data.js +122 -234
  192. package/dist/src/history/history-queued.js +155 -0
  193. package/dist/src/history/history-render.js +42 -22
  194. package/dist/src/history/history.js +190 -49
  195. package/dist/src/inbox/inbox-attachments.js +29 -30
  196. package/dist/src/inbox/inbox-cancel.js +10 -11
  197. package/dist/src/inbox/inbox-edit.js +9 -9
  198. package/dist/src/inbox/inbox-submit.js +18 -18
  199. package/dist/src/inbox/inbox-top.js +28 -17
  200. package/dist/src/inbox/inbox.js +42 -43
  201. package/dist/src/inbox/pending-prompt.js +26 -26
  202. package/dist/src/inbox/prompt-commands.js +62 -49
  203. package/dist/src/inbox/prompt-not-before.js +2 -2
  204. package/dist/src/init/init-templates.js +1 -2
  205. package/dist/src/init/init.js +28 -23
  206. package/dist/src/merge/backlog-conflicts.js +215 -35
  207. package/dist/src/merge/merge-batch.js +14 -8
  208. package/dist/src/merge/merge-cancel.js +53 -0
  209. package/dist/src/merge/merge-check-failures.js +44 -22
  210. package/dist/src/merge/merge-core.js +58 -32
  211. package/dist/src/merge/merge-diff.js +5 -4
  212. package/dist/src/merge/merge-drain.js +45 -19
  213. package/dist/src/merge/merge-git.js +177 -38
  214. package/dist/src/merge/merge-pipeline.js +15 -3
  215. package/dist/src/merge/merge-post-review.js +214 -0
  216. package/dist/src/merge/merge-queue-render.js +48 -11
  217. package/dist/src/merge/merge-queue.js +52 -15
  218. package/dist/src/merge/merge-requeue.js +103 -0
  219. package/dist/src/merge/merge-slot.js +61 -16
  220. package/dist/src/merge/merge-stack.js +27 -11
  221. package/dist/src/merge/merge-to-main.js +77 -26
  222. package/dist/src/merge/merge-top.js +83 -0
  223. package/dist/src/merge/merge-verify.js +75 -18
  224. package/dist/src/notes-cli.js +35 -15
  225. package/dist/src/operator/operator-commands.js +192 -81
  226. package/dist/src/operator/operator-intent.js +69 -42
  227. package/dist/src/operator/operator-requests.js +35 -40
  228. package/dist/src/operator/retire.js +5 -6
  229. package/dist/src/operator/revert.js +142 -34
  230. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  231. package/dist/src/orchestrator/orchestrator-poll.js +352 -0
  232. package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
  233. package/dist/src/orchestrator/orchestrator.js +129 -369
  234. package/dist/src/orchestrator/retention.js +42 -8
  235. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  236. package/dist/src/orchestrator/scheduling-scope.js +245 -0
  237. package/dist/src/orchestrator/scheduling-skip.js +195 -0
  238. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  239. package/dist/src/orchestrator/startup-migrations.js +11 -7
  240. package/dist/src/paths.js +126 -49
  241. package/dist/src/pi/command-shape.js +12 -1
  242. package/dist/src/pi/pi-args.js +6 -4
  243. package/dist/src/pi/pi-backend.js +6 -6
  244. package/dist/src/pi/pi-bin.js +54 -63
  245. package/dist/src/pi/pi-listed-models.js +53 -0
  246. package/dist/src/pi/pi-models.js +63 -30
  247. package/dist/src/pi/pi-run-result.js +1 -1
  248. package/dist/src/pi/pi-settings.js +1 -1
  249. package/dist/src/pi/pi-stream.js +6 -6
  250. package/dist/src/pi/pi-watchdogs.js +18 -19
  251. package/dist/src/pi-extension/bounded-output.js +5 -3
  252. package/dist/src/pi-extension/context-budget.js +2 -2
  253. package/dist/src/pi-extension/context-shake.js +1 -1
  254. package/dist/src/pi-extension/context-window.js +0 -1
  255. package/dist/src/pi-extension/full-output.js +3 -10
  256. package/dist/src/pi-extension/strip-thinking.js +0 -1
  257. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  258. package/dist/src/pi-extension/write-guard.js +37 -0
  259. package/dist/src/process/launch-services.js +1 -2
  260. package/dist/src/process/node-bin.js +16 -0
  261. package/dist/src/process/process-group.js +8 -18
  262. package/dist/src/process/process.js +41 -2
  263. package/dist/src/process/run-marker.js +26 -6
  264. package/dist/src/process/supervisor.js +22 -21
  265. package/dist/src/prompt/dialect.js +17 -2
  266. package/dist/src/prompt/gate-notes.js +203 -0
  267. package/dist/src/prompt/gate-prompts.js +150 -217
  268. package/dist/src/prompt/notes-backlog.js +184 -0
  269. package/dist/src/prompt/principles.js +1 -2
  270. package/dist/src/prompt/prompt-followup.js +19 -14
  271. package/dist/src/prompt/prompt.js +131 -204
  272. package/dist/src/redeploy/redeploy-policy.js +24 -19
  273. package/dist/src/redeploy/redeploy-probes.js +1 -1
  274. package/dist/src/redeploy/redeploy.js +2 -3
  275. package/dist/src/redeploy/redeployer.js +20 -20
  276. package/dist/src/redeploy/self-reload.js +2 -1
  277. package/dist/src/report/report-data.js +188 -124
  278. package/dist/src/report/report-fold.js +130 -54
  279. package/dist/src/report/report-main-red.js +55 -0
  280. package/dist/src/report/report-render.js +136 -65
  281. package/dist/src/report/report-types.js +1 -0
  282. package/dist/src/report/report.js +23 -9
  283. package/dist/src/request-timeouts.js +2 -2
  284. package/dist/src/review/diff-rules.js +53 -0
  285. package/dist/src/review/known-flakes.js +112 -1
  286. package/dist/src/review/post-merge-review-accept.js +51 -0
  287. package/dist/src/review/post-merge-review-render.js +112 -0
  288. package/dist/src/review/post-merge-review.js +239 -0
  289. package/dist/src/review/protected-edits.js +24 -10
  290. package/dist/src/review/quarantine-render.js +65 -0
  291. package/dist/src/review/review-followup.js +8 -8
  292. package/dist/src/review/review-precheck.js +66 -23
  293. package/dist/src/review/review-run.js +395 -0
  294. package/dist/src/review/review-verdict.js +2 -2
  295. package/dist/src/review/review.js +180 -401
  296. package/dist/src/review/secret-leaks.js +36 -16
  297. package/dist/src/review/suite-rerun.js +0 -1
  298. package/dist/src/roles/legacy-role-migration.js +80 -7
  299. package/dist/src/roles/role-catalog.js +127 -12
  300. package/dist/src/roles/role-guidance.js +65 -20
  301. package/dist/src/roles/role-labels.js +7 -5
  302. package/dist/src/roles/role-render.js +108 -56
  303. package/dist/src/roles/role-view.js +95 -56
  304. package/dist/src/roles/roles.js +33 -33
  305. package/dist/src/roles/runner-ids.js +97 -0
  306. package/dist/src/run/acceptance-greps.js +119 -0
  307. package/dist/src/run/ci-parity.js +121 -0
  308. package/dist/src/run/feedback-filings.js +51 -0
  309. package/dist/src/run/qa-coverage.js +125 -0
  310. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  311. package/dist/src/run/run-detail-data.js +175 -0
  312. package/dist/src/run/run-detail-render.js +91 -0
  313. package/dist/src/run/run-detail.js +168 -0
  314. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
  315. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  316. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
  317. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  318. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
  319. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  320. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  321. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
  322. package/dist/src/run/selfcheck-digest.js +40 -0
  323. package/dist/src/run/stage-backlog-checks.js +249 -0
  324. package/dist/src/run/stage-check.js +178 -0
  325. package/dist/src/run/stage-checks-shared.js +65 -0
  326. package/dist/src/run/stage-name-checks.js +204 -0
  327. package/dist/src/run/stage-path-checks.js +231 -0
  328. package/dist/src/run/stage-prose-checks.js +261 -0
  329. package/dist/src/runner/leftover.js +216 -0
  330. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  331. package/dist/src/runner/revision.js +191 -0
  332. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  333. package/dist/src/runner/runner-outcome.js +109 -0
  334. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  335. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
  336. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
  337. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  338. package/dist/src/runner/runner-state.js +103 -0
  339. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  340. package/dist/src/scheduling/backoff.js +38 -38
  341. package/dist/src/scheduling/claim-entries.js +49 -0
  342. package/dist/src/scheduling/claims.js +35 -22
  343. package/dist/src/scheduling/once-round.js +22 -22
  344. package/dist/src/scheduling/quiet-hours.js +5 -6
  345. package/dist/src/scheduling/scheduling.js +74 -64
  346. package/dist/src/scheduling/work-merged-cache.js +2 -2
  347. package/dist/src/status/status-budget.js +55 -47
  348. package/dist/src/status/status-data.js +49 -17
  349. package/dist/src/status/status-inbox.js +2 -2
  350. package/dist/src/status/status-install.js +0 -1
  351. package/dist/src/status/status-polls.js +43 -28
  352. package/dist/src/status/status-roles.js +50 -13
  353. package/dist/src/team/error-storm.js +8 -8
  354. package/dist/src/team/failure-spread.js +2 -2
  355. package/dist/src/team/orchestrator-info.js +15 -9
  356. package/dist/src/team/reclaim.js +7 -8
  357. package/dist/src/team/team-hold.js +7 -7
  358. package/dist/src/team/team-polls.js +91 -50
  359. package/dist/src/team/team-state.js +8 -2
  360. package/dist/src/text/datetime.js +65 -18
  361. package/dist/src/text/format.js +48 -11
  362. package/dist/src/text/markdown.js +8 -5
  363. package/dist/src/text/phrases.js +60 -23
  364. package/dist/src/text/suggest.js +1 -1
  365. package/dist/src/text/text.js +78 -13
  366. package/dist/src/ui/badges.js +99 -38
  367. package/dist/src/ui/gui/gui-client-backlog.js +314 -0
  368. package/dist/src/ui/gui/gui-client-boot.js +89 -61
  369. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  370. package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
  371. package/dist/src/ui/gui/gui-client-drawer.js +155 -289
  372. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  373. package/dist/src/ui/gui/gui-client-history.js +437 -68
  374. package/dist/src/ui/gui/gui-client-labels.js +6 -3
  375. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  376. package/dist/src/ui/gui/gui-client-model.js +27 -36
  377. package/dist/src/ui/gui/gui-client-operator.js +60 -52
  378. package/dist/src/ui/gui/gui-client-pending.js +405 -24
  379. package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
  380. package/dist/src/ui/gui/gui-client-report.js +105 -430
  381. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  382. package/dist/src/ui/gui/gui-client-settings.js +163 -5
  383. package/dist/src/ui/gui/gui-client-team.js +36 -465
  384. package/dist/src/ui/gui/gui-client.js +38 -26
  385. package/dist/src/ui/gui/gui-icons.js +0 -2
  386. package/dist/src/ui/gui/gui-page.js +10 -7
  387. package/dist/src/ui/gui/gui-styles.js +38 -34
  388. package/dist/src/ui/progress-data.js +9 -11
  389. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
  390. package/dist/src/ui/status-model.js +133 -125
  391. package/dist/src/ui/status-payload.js +364 -49
  392. package/dist/src/ui/status-render.js +109 -82
  393. package/dist/src/ui/team-alerts.js +728 -62
  394. package/dist/src/ui/tone.js +12 -6
  395. package/dist/src/ui/transcript-run.js +58 -0
  396. package/dist/src/ui/transcript-tail.js +28 -20
  397. package/dist/src/ui/transcript.js +63 -23
  398. package/dist/src/ui/tui/tui-app.js +0 -1
  399. package/dist/src/ui/tui/tui-backlog.js +53 -14
  400. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  401. package/dist/src/ui/tui/tui-editor-attach.js +4 -12
  402. package/dist/src/ui/tui/tui-editor-backends.js +127 -0
  403. package/dist/src/ui/tui/tui-editor-brief.js +14 -36
  404. package/dist/src/ui/tui/tui-editor-budget.js +36 -39
  405. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  406. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  407. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  408. package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
  409. package/dist/src/ui/tui/tui-editor-config.js +99 -0
  410. package/dist/src/ui/tui/tui-editor-entry.js +39 -64
  411. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  412. package/dist/src/ui/tui/tui-editor-instances.js +46 -0
  413. package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
  414. package/dist/src/ui/tui/tui-editor-interval.js +29 -16
  415. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  416. package/dist/src/ui/tui/tui-editor-notes.js +9 -29
  417. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  418. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  419. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  420. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  421. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  422. package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
  423. package/dist/src/ui/tui/tui-editor-retire.js +5 -13
  424. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  425. package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
  426. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  427. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  428. package/dist/src/ui/tui/tui-editor-session.js +101 -34
  429. package/dist/src/ui/tui/tui-editor-slots.js +24 -0
  430. package/dist/src/ui/tui/tui-editors.js +44 -2
  431. package/dist/src/ui/tui/tui-frame.js +68 -11
  432. package/dist/src/ui/tui/tui-history.js +8 -9
  433. package/dist/src/ui/tui/tui-input.js +13 -11
  434. package/dist/src/ui/tui/tui-keymap.js +0 -1
  435. package/dist/src/ui/tui/tui-keys.js +33 -9
  436. package/dist/src/ui/tui/tui-pane-activity.js +35 -19
  437. package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
  438. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  439. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  440. package/dist/src/ui/tui/tui-pane-health.js +168 -8
  441. package/dist/src/ui/tui/tui-pane-history.js +446 -82
  442. package/dist/src/ui/tui/tui-pane-pending.js +539 -15
  443. package/dist/src/ui/tui/tui-pane-queued.js +78 -57
  444. package/dist/src/ui/tui/tui-pane-report.js +54 -16
  445. package/dist/src/ui/tui/tui-pane-session.js +34 -6
  446. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  447. package/dist/src/ui/tui/tui-pane.js +91 -28
  448. package/dist/src/ui/tui/tui-panes.js +153 -34
  449. package/dist/src/ui/tui/tui.js +18 -15
  450. package/dist/src/verdict/fix-claim.js +129 -33
  451. package/dist/src/verdict/no-change.js +4 -4
  452. package/dist/src/verdict/refusal.js +40 -16
  453. package/dist/src/verdict/reply-contract.js +77 -31
  454. package/dist/src/version.js +0 -1
  455. package/package.json +2 -1
  456. package/dist/src/loop/leftover.js +0 -130
  457. package/dist/src/loop/revision.js +0 -70
  458. package/dist/src/roles/loop-ids.js +0 -77
  459. package/dist/src/tick/qa-coverage.js +0 -108
  460. package/dist/src/tick/stage-check.js +0 -419
  461. package/dist/src/tick/telemetry-digest.js +0 -25
  462. package/dist/src/tick/tick-detail-data.js +0 -108
  463. package/dist/src/tick/tick-detail.js +0 -163
  464. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,26 +1,30 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { COMMIT_IDENT, git, gitTry, runGit } from "../git/git-run.js";
4
- import { writeBytesAtomic } from "../files/files.js";
3
+ import { GitError, committerIdentArgs, git, gitCouldNotStart, gitTry, runGit } from "../git/git-run.js";
4
+ import { readTextOrNull, writeBytesAtomic } from "../files/files.js";
5
5
  import { currentBranch, gitLines, headOf } from "../git/git.js";
6
- import { unquotePorcelainPath } from "../git/git-diff.js";
7
- import { CONFIG_BASENAME, configPath, primaryWorktreeLockPath } from "../paths.js";
8
- import { withStateLockAsync } from "../concurrency/lock.js";
9
- import { errorMessage } from "../text/text.js";
6
+ import { treeDiffFiles, unquotePorcelainPath } from "../git/git-diff.js";
7
+ import { errCode } from "../errno.js";
8
+ import { CONFIG_BASENAME, configPath } from "../paths.js";
9
+ import { withPrimaryWorktreeLockAsync } from "../concurrency/lock.js";
10
+ import { TUMWATER_SOURCE, warnEventBestEffort } from "../events/events.js";
11
+ import { errorFirstLine, errorMessage, joinWithMore } from "../text/text.js";
10
12
  /** Git plumbing for the merge flow: rebasing a worktree branch onto main, inspecting and
11
13
  * finishing a conflicted rebase, and fast-forwarding main — the mechanics, with no merge policy
12
14
  * of their own. Merge policy (lock → rebase → verify → ff, conflict resolution, batch ff)
13
15
  * lives in merge-to-main.ts on top of these. Moved here from merge-to-main.ts: the cluster started
14
- * as merge-to-main.ts-only helpers (organize tick 78, moving them out of git/git.ts), but the
16
+ * as merge-to-main.ts-only helpers (organize run 78, moving them out of git/git.ts), but the
15
17
  * merger and the batch merger grew to call them directly, leaving merge-to-main.ts exporting
16
18
  * generic rebase/ff plumbing that had nothing to do with merging. */
17
19
  /** Paths currently in conflict (unmerged) in the worktree. C-quoted names are decoded to
18
20
  * real paths — a non-ASCII conflicted file arrives as `"h\303\251llo.ts"` (core.quotePath is on
19
21
  * by default), and undecoded it does not exist on disk: hasConflictMarkers could never read
20
22
  * it, so an unresolved conflict in such a file passed the marker check and continueRebase
21
- * committed its markers to main. */
23
+ * committed its markers to main. A failed `git diff` throws: read as "no conflicts", it sent
24
+ * the resolver's caller straight to `continueRebase`, which stages every marker, and a
25
+ * stopped cherry-pick down the path that deletes a hand-back's ref. */
22
26
  export async function conflictedFiles(wt) {
23
- const out = await gitTry(wt, "diff", "--name-only", "--diff-filter=U");
27
+ const out = await git(wt, "diff", "--name-only", "--diff-filter=U");
24
28
  return gitLines(out).map(unquotePorcelainPath);
25
29
  }
26
30
  /** How many main commits the resolver prompt lists, and how many body lines it keeps per
@@ -61,22 +65,26 @@ export async function mainCommitsTouching(wt, since, mainBranch, files) {
61
65
  * cleaning up: "rebased" (including already up to date), "conflict" (the rebase stopped on
62
66
  * unmerged paths, which remain in the worktree for a resolver), or "other" (any other
63
67
  * failure). Shared by the two rebase wrappers below, which differ only in cleanup policy.
64
- * Rebase — not merge — keeps main's history linear: each tick merges as its own commit on top
65
- * of whatever main holds. */
68
+ * Rebase — not merge — keeps main's history linear: each run merges as its own commit on top
69
+ * of whatever main holds. A rebase git could not start throws (gitCouldNotStart), as does a
70
+ * failed conflict listing: neither says anything about the change, so neither may count as a
71
+ * merge conflict. */
66
72
  async function attemptRebase(wt, mainBranch) {
67
73
  try {
68
74
  // The -c ident is needed for the rewritten committer identity. diff3 carries the base
69
75
  // section of every conflict, which the deterministic backlog resolver reads
70
76
  // (plans/parallel-work-instances.md, part 3/7); rebaseOntoMain aborts on conflict, so the
71
77
  // marker style only matters to its leave-conflicts sibling.
72
- await runGit(wt, [...COMMIT_IDENT, "-c", "merge.conflictStyle=diff3", "rebase", mainBranch]);
78
+ await runGit(wt, [...(await committerIdentArgs(wt)), "-c", "merge.conflictStyle=diff3", "rebase", mainBranch]);
73
79
  return "rebased";
74
80
  }
75
- catch {
81
+ catch (err) {
82
+ if (gitCouldNotStart(err))
83
+ throw err;
76
84
  return (await conflictedFiles(wt)).length > 0 ? "conflict" : "other";
77
85
  }
78
86
  }
79
- /** Rebase the worktree branch onto main (main may have advanced during the tick).
87
+ /** Rebase the worktree branch onto main (main may have advanced during the run).
80
88
  * Returns false and aborts the rebase on conflict. */
81
89
  export async function rebaseOntoMain(wt, mainBranch) {
82
90
  const state = await attemptRebase(wt, mainBranch);
@@ -98,23 +106,44 @@ export async function rebaseOntoMainLeaveConflicts(wt, mainBranch) {
98
106
  * The one marker definition hasConflictMarkers and conflictHunkRanges share, so the ranges a
99
107
  * conflict prompt names are exactly the blocks the marker check later rejects. */
100
108
  const MARKER_EDGE = /^(<{7}|>{7})( |$)/m;
109
+ /** One file's marker check: "clean", "marked" (holds a start/end marker) or "unreadable". Only
110
+ * a path with no file (deleted, a parent replaced, or a directory/gitlink) is clean without
111
+ * being read; any other read error cannot be vouched for. */
112
+ function markerState(wt, f) {
113
+ // lstat, not stat: a symlink is never followed out of the worktree. A symlink (or a
114
+ // directory/gitlink) holds no text of its own to check, so it counts as clean — the same
115
+ // policy secret-leaks (INV-G4) already pins: a change that adds a symlink writes no
116
+ // content, and its target is not what the worktree put there.
117
+ let st;
118
+ try {
119
+ st = fs.lstatSync(path.join(wt, f));
120
+ }
121
+ catch (err) {
122
+ const code = errCode(err);
123
+ return code === "ENOENT" || code === "ENOTDIR" ? "clean" : "unreadable";
124
+ }
125
+ if (!st.isFile())
126
+ return "clean";
127
+ let text;
128
+ try {
129
+ text = fs.readFileSync(path.join(wt, f), "utf8");
130
+ }
131
+ catch (err) {
132
+ const code = errCode(err);
133
+ return code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR" ? "clean" : "unreadable";
134
+ }
135
+ return MARKER_EDGE.test(text) ? "marked" : "clean";
136
+ }
101
137
  /** True if any of the given files still contains a git conflict marker.
102
- * A deleted file counts as resolved (the resolver chose the deletion). */
138
+ * A missing path counts as resolved (the resolver chose the deletion), but an unreadable one
139
+ * counts as conflicted: a check that cannot read a file cannot vouch for it. */
103
140
  export function hasConflictMarkers(wt, files) {
104
141
  // Only start/end markers are checked: every real conflict block carries them, while a bare
105
142
  // `=======` line is legitimate content (a markdown setext or RST underline of exactly seven
106
143
  // characters), and flagging it would reject clean resolutions forever. A resolver that
107
144
  // leaves only a separator line behind is treated as resolved; its stray line is content the
108
145
  // project's own tests can catch.
109
- return files.some((f) => {
110
- const p = path.join(wt, f);
111
- try {
112
- return MARKER_EDGE.test(fs.readFileSync(p, "utf8"));
113
- }
114
- catch {
115
- return false;
116
- }
117
- });
146
+ return files.some((f) => markerState(wt, f) !== "clean");
118
147
  }
119
148
  /** How many conflict hunks a conflict prompt lists per file: every hunk of a typical backlog or
120
149
  * code conflict, and a bound on a file pasted over wholesale. */
@@ -150,13 +179,17 @@ export function conflictHunkRanges(text) {
150
179
  * file counts, backlog or code; a deleted, unreadable or binary (NUL-bearing) file lists none. */
151
180
  export function conflictedFileHunks(wt, files) {
152
181
  return files.map((f) => {
153
- let text = "";
182
+ // Deleted or unreadable reads as empty: no ranges to name. A symlink is never followed
183
+ // out of the worktree (it holds no text of its own), so it too reads as empty.
184
+ let full = path.join(wt, f);
154
185
  try {
155
- text = fs.readFileSync(path.join(wt, f), "utf8");
186
+ if (!fs.lstatSync(full).isFile())
187
+ return { path: f, hunks: [], more: 0 };
156
188
  }
157
189
  catch {
158
- // Deleted or unreadable: no ranges to name.
190
+ return { path: f, hunks: [], more: 0 };
159
191
  }
192
+ const text = readTextOrNull(full) ?? "";
160
193
  const all = text.includes("\0") ? [] : conflictHunkRanges(text);
161
194
  return { path: f, hunks: all.slice(0, MAX_LISTED_HUNKS), more: Math.max(0, all.length - MAX_LISTED_HUNKS) };
162
195
  });
@@ -168,8 +201,17 @@ export function conflictedFileHunks(wt, files) {
168
201
  * lines on main (PLANS.md "Robust conflict landing, part 2/2"). Builds on hasConflictMarkers
169
202
  * above, the shared definition of what a marker is. */
170
203
  export function conflictMarkerReason(wt, files) {
171
- const marked = files.filter((f) => hasConflictMarkers(wt, [f]));
172
- return marked.length > 0 ? `conflict markers remain in: ${marked.join(", ")}` : undefined;
204
+ const states = files.map((f) => [f, markerState(wt, f)]);
205
+ const named = (state) => states.filter(([, s]) => s === state).map(([f]) => f);
206
+ const marked = named("marked");
207
+ const unreadable = named("unreadable");
208
+ const clauses = [
209
+ ...(marked.length > 0 ? [`conflict markers remain in: ${marked.join(", ")}`] : []),
210
+ ...(unreadable.length > 0
211
+ ? [`could not be read to check for conflict markers: ${unreadable.join(", ")}`]
212
+ : []),
213
+ ];
214
+ return clauses.length > 0 ? clauses.join("; ") : undefined;
173
215
  }
174
216
  /** Conclude an in-progress rebase with everything in the worktree as the resolution.
175
217
  * GIT_EDITOR=true so `rebase --continue` can never block on a commit-message prompt. A
@@ -179,24 +221,40 @@ export function conflictMarkerReason(wt, files) {
179
221
  * added; the caller aborts and reports merge_conflict. */
180
222
  export async function continueRebase(wt) {
181
223
  await git(wt, "add", "-A");
182
- await runGit(wt, [...COMMIT_IDENT, "rebase", "--continue"], { GIT_EDITOR: "true" });
224
+ await runGit(wt, [...(await committerIdentArgs(wt)), "rebase", "--continue"], { GIT_EDITOR: "true" });
183
225
  return headOf(wt, "HEAD");
184
226
  }
185
227
  /** The live config's bytes, when the merge about to fast-forward `ref` would delete it
186
228
  * (plans/portability.md §4b/7): the config file exists, is tracked in the current index (and
187
229
  * HEAD — a staged-or-dirty config makes `git merge --ff-only` refuse below, so the two agree
188
230
  * on every merge that can succeed), and is absent from the incoming tree. Anything else —
189
- * already untracked, already absent, present in `ref` — needs no preserve step. */
231
+ * already untracked, already absent, present in `ref` — needs no preserve step. A failed
232
+ * `cat-file` reads as absent, which only preserves bytes the write-back then leaves alone. */
190
233
  export async function configBytesToPreserve(root, ref) {
191
234
  const cfg = configPath(root);
192
235
  if (!fs.existsSync(cfg))
193
236
  return null;
194
- if ((await gitTry(root, "ls-files", "--error-unmatch", CONFIG_BASENAME)) === null)
237
+ if (!(await configTracked(root)))
195
238
  return null;
196
239
  if ((await gitTry(root, "cat-file", "-e", `${ref}:${CONFIG_BASENAME}`)) !== null)
197
240
  return null;
198
241
  return fs.readFileSync(cfg);
199
242
  }
243
+ /** Whether the live config is tracked in `root`'s index: `ls-files --error-unmatch` exits 1 for
244
+ * an untracked path, and any other failure throws. Read as "untracked", an unreadable index or
245
+ * a git that could not start skipped the preserve step, so a merge that untracks the config
246
+ * deleted the live one. */
247
+ async function configTracked(root) {
248
+ try {
249
+ await git(root, "ls-files", "--error-unmatch", CONFIG_BASENAME);
250
+ return true;
251
+ }
252
+ catch (err) {
253
+ if (err instanceof GitError && err.code === 1)
254
+ return false;
255
+ throw err;
256
+ }
257
+ }
200
258
  /** The preserve step's write-back, restore-only-when-absent: write the saved bytes only when
201
259
  * the live config is absent at write-back time. The merge deletes the file mid-window, so a
202
260
  * config request applied in that window (3/7's applyConfigRequest runs outside the merge lock)
@@ -219,7 +277,9 @@ export function restoreConfigBytes(root, saved) {
219
277
  * files update), otherwise a local ref push. Returns true on success. The working-tree arm
220
278
  * preserves the live config across a merge that untracks it (plans/portability.md §4b/7):
221
279
  * without the write-back, the commit that removes the tracked config would delete the team's
222
- * running config out from under it and the next config poll would fall back to defaults. */
280
+ * running config out from under it and the next config poll would fall back to defaults. The
281
+ * merge has already happened by the time of the write-back, so a failed write-back is a warning
282
+ * event and the function still returns null. */
223
283
  export async function ffMainToOrWhy(root, ref, mainBranch) {
224
284
  const primaryBranch = await currentBranch(root);
225
285
  if (primaryBranch === mainBranch) {
@@ -228,7 +288,7 @@ export async function ffMainToOrWhy(root, ref, mainBranch) {
228
288
  // that read before this merge can rename its stale whole-file write over the merged change.
229
289
  // The ref-push arm below touches no working tree and needs no lock; the save/merge/restore
230
290
  // stays one short section, never held across a build re-check.
231
- return withStateLockAsync(primaryWorktreeLockPath(root), async () => {
291
+ return withPrimaryWorktreeLockAsync(root, async () => {
232
292
  const saved = await configBytesToPreserve(root, ref);
233
293
  try {
234
294
  await git(root, "merge", "--ff-only", ref);
@@ -236,7 +296,13 @@ export async function ffMainToOrWhy(root, ref, mainBranch) {
236
296
  catch (err) {
237
297
  return ffFailureReason(root, ref, mainBranch, err);
238
298
  }
239
- restoreConfigBytes(root, saved);
299
+ try {
300
+ restoreConfigBytes(root, saved);
301
+ }
302
+ catch (err) {
303
+ warnEventBestEffort(root, TUMWATER_SOURCE, `main fast-forwarded, but writing the live ${CONFIG_BASENAME} back failed: ` +
304
+ `${errorMessage(err)}; restore it by hand (it is no longer tracked on main)`);
305
+ }
240
306
  return null;
241
307
  });
242
308
  }
@@ -249,14 +315,87 @@ export async function ffMainToOrWhy(root, ref, mainBranch) {
249
315
  }
250
316
  }
251
317
  /** Why a fast-forward to `ref` failed: main having moved off `ref`'s base (a commit that did not
252
- * take the merge lock) is named with its new head and subject; any other cause reports git's own
253
- * error, e.g. a dirty primary checkout. */
318
+ * take the merge lock) is named with its new head and subject; a dirty primary checkout is
319
+ * reported with the files it would overwrite; any other cause reports git's first error line. */
254
320
  async function ffFailureReason(root, ref, mainBranch, err) {
255
321
  if ((await gitTry(root, "merge-base", "--is-ancestor", mainBranch, ref)) === null) {
256
322
  const now = await gitTry(root, "log", "-1", "--format=%h %s", mainBranch);
257
323
  return `main moved outside the merge lock (now ${now?.trim() ?? mainBranch})`;
258
324
  }
259
- return `fast-forward failed: ${errorMessage(err).trim().split("\n")[0]}`;
325
+ const blockers = overwriteBlockers(errorMessage(err));
326
+ if (blockers.uncommitted.length > 0 || blockers.untracked.length > 0) {
327
+ return `fast-forward failed: ${overwriteBlockedReason(blockers)}`;
328
+ }
329
+ return `fast-forward failed: ${errorFirstLine(err)}`;
330
+ }
331
+ /** The files git lists under its two "would be overwritten by merge:" headers. */
332
+ export function overwriteBlockers(gitError) {
333
+ const out = { uncommitted: [], untracked: [] };
334
+ let group = null;
335
+ for (const line of gitError.split("\n")) {
336
+ if (line.includes("Your local changes to the following files would be overwritten by merge:")) {
337
+ group = out.uncommitted;
338
+ }
339
+ else if (line.includes("untracked working tree files would be overwritten by merge:")) {
340
+ group = out.untracked;
341
+ }
342
+ else if (group && line.startsWith("\t")) {
343
+ group.push(line.trim());
344
+ }
345
+ else {
346
+ group = null;
347
+ }
348
+ }
349
+ return out;
350
+ }
351
+ const MAX_BLOCKERS_LISTED = 5;
352
+ /** Why a dirty primary checkout blocks a merge: up to 5 files per group. Callers prefix it with
353
+ * their own stage (`fast-forward failed: `, or none from the pre-flight). */
354
+ export function overwriteBlockedReason(blockers) {
355
+ const list = (names) => joinWithMore(names, MAX_BLOCKERS_LISTED);
356
+ const groups = [];
357
+ if (blockers.uncommitted.length > 0)
358
+ groups.push(`uncommitted: ${list(blockers.uncommitted)}`);
359
+ if (blockers.untracked.length > 0)
360
+ groups.push(`untracked: ${list(blockers.untracked)}`);
361
+ const where = `the primary checkout (${groups.join("; ")})`;
362
+ return `the merge would overwrite files in ${where} — commit, stash or remove them there`;
363
+ }
364
+ /** The primary checkout's dirty paths a merge to `tip` would overwrite, read before any check
365
+ * runs: null when the checkout is not on main (the ref push touches no working tree), when
366
+ * nothing the merge changes is dirty, or when a git read fails — the fast-forward still decides. */
367
+ export async function primaryCheckoutBlockers(root, mainBranch, tip) {
368
+ if ((await currentBranch(root)) !== mainBranch)
369
+ return null;
370
+ const changed = await treeDiffFiles(root, mainBranch, tip);
371
+ const status = await gitTry(root, "--no-optional-locks", "status", "--porcelain=v1", "-z", "--untracked-files=all");
372
+ if (changed === null || status === null)
373
+ return null;
374
+ const untrackedSet = new Set();
375
+ const dirtySet = new Set();
376
+ const records = status.split("\0");
377
+ for (let i = 0; i < records.length; i++) {
378
+ const rec = records[i];
379
+ if (rec.length < 4)
380
+ continue;
381
+ const xy = rec.slice(0, 2);
382
+ const file = rec.slice(3);
383
+ if (xy === "??") {
384
+ untrackedSet.add(file);
385
+ continue;
386
+ }
387
+ dirtySet.add(file);
388
+ if (xy.startsWith("R") || xy.startsWith("C")) {
389
+ const source = records[++i];
390
+ if (source)
391
+ dirtySet.add(source);
392
+ }
393
+ }
394
+ const out = {
395
+ uncommitted: changed.filter((f) => dirtySet.has(f)),
396
+ untracked: changed.filter((f) => untrackedSet.has(f)),
397
+ };
398
+ return out.uncommitted.length > 0 || out.untracked.length > 0 ? out : null;
260
399
  }
261
400
  /** Boolean form of ffMainToOrWhy for callers that need no reason. */
262
401
  export async function ffMainTo(root, ref, mainBranch) {
@@ -3,17 +3,18 @@ import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
3
3
  import { errorMessage } from "../text/text.js";
4
4
  import { deleteRef } from "../git/git.js";
5
5
  import { removeMergeChange, writeMergeOutcome } from "./merge-slot.js";
6
+ import { requeueKeptChange } from "./merge-requeue.js";
6
7
  import { mergeRefName, rejectedRefName } from "../paths.js";
7
8
  /** A fresh pipeline: nothing in flight anywhere — no verification running, no approved change
8
9
  * waiting, no merge holding main. The orchestrator builds one per team start (orchestrator.ts). */
9
10
  export function newMergePipeline() {
10
- return { verifying: new Map(), approved: new Map(), merge: null };
11
+ return { verifying: new Map(), approved: new Map(), merge: null, postReview: null };
11
12
  }
12
13
  /** Every merge task in flight — each verification holding its permit, and the merge — for the
13
14
  * shutdown and restart waits. A parked verification is not one of them: it has started nothing, and
14
15
  * a shutdown (its controller) or a closed start gate (at its grant) settles it without a run. */
15
16
  export function mergeTasks(p) {
16
- return [...[...p.verifying.values()].filter((v) => !v.parked), ...(p.merge ? [p.merge] : [])];
17
+ return [...[...p.verifying.values()].filter((v) => !v.parked), ...(p.merge ? [p.merge] : []), ...(p.postReview && !p.postReview.parked ? [p.postReview] : [])];
17
18
  }
18
19
  /** Everything `tumwater abort --role` can reach: every task in flight, plus each approved change
19
20
  * waiting with no task of its own. A parked verification is out of reach, as a plainly queued entry
@@ -85,7 +86,7 @@ export async function discardPinnedRefs(root, roles) {
85
86
  * write-back, so no settled entry can keep a stale marker or lose its outcome write. The pinned-ref
86
87
  * discard stays with the callers: only a user abort throws work away, and the merge discards its
87
88
  * whole stack at once rather than change by change. */
88
- export async function settleMergeOutcome(root, entry, state, result, durationMs, usage, file) {
89
+ export async function settleMergeOutcome(root, entry, state, result, durationMs, usage, file, mainBranch) {
89
90
  writeMergeOutcome(root, entry, state, result, durationMs, usage, file);
90
91
  // A revision that merges ends the rejected change's life: drop the rejected ref (plans/revise-
91
92
  // rejected.md). A rejection repoints or deletes it in merge-core.ts, so this is the only
@@ -96,4 +97,15 @@ export async function settleMergeOutcome(root, entry, state, result, durationMs,
96
97
  await deleteRef(root, rejectedRefName(entry.role));
97
98
  }
98
99
  removeMergeChange(root, entry.role);
100
+ // A kept change goes straight back on the queue (the callers pass the main branch; a
101
+ // direct caller without one leaves the retry to the author's leftover recovery). The write-back
102
+ // above already happened, so a failure here must not lose the settled outcome.
103
+ if (mainBranch !== undefined) {
104
+ try {
105
+ await requeueKeptChange(root, mainBranch, entry, state, result);
106
+ }
107
+ catch (err) {
108
+ warnEventBestEffort(root, entry.role, `could not re-queue the kept change: ${errorMessage(err)}`);
109
+ }
110
+ }
99
111
  }
@@ -0,0 +1,214 @@
1
+ import { withProviderLease } from "../concurrency/provider-lease.js";
2
+ import { reviewRunConfig, seamConcurrencyKey } from "../config/config-views.js";
3
+ import { subscribeEvents, warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
4
+ import { deleteRef, headOf, subjectOfCommit } from "../git/git.js";
5
+ import { ensureDetachedWorktree } from "../git/worktree.js";
6
+ import { useWorktree } from "../git/worktree-use.js";
7
+ import { postReviewWorktreePath, reviewFixRef } from "../paths.js";
8
+ import { POST_REVIEW_FAILURE_LIMIT, addPostReview, readPostReviews, reviewMergedCommit, settlePostReview, } from "../review/post-merge-review.js";
9
+ import { shortSha } from "../text/format.js";
10
+ import { formatDate } from "../text/datetime.js";
11
+ import { collapseWhitespace, errorMessage } from "../text/text.js";
12
+ import { fileBugsOnMain } from "../backlog/backlog-archive.js";
13
+ import { abortOnShutdown, acquireUnlessAborted, guardMergeTask } from "./merge-pipeline.js";
14
+ import { resolveWaitStage } from "./merge-slot.js";
15
+ import { mergeToMain } from "./merge-to-main.js";
16
+ import { MERGE_TIER, resolveAuthor, VERIFY_TIER } from "./merge-verify.js";
17
+ /** One poll of the post-merge reviews (review.afterMerge): a rejected-and-not-fixed commit is
18
+ * answered by the reviewer's own landed fix (Forward, never back 1/6) — never by a revert — and
19
+ * the next unreviewed commit starts its review when a permit is free right now. One review at a
20
+ * time; starts a task and returns. */
21
+ export function drainPostReviews(ctx, p) {
22
+ const pending = readPostReviews(ctx.root);
23
+ if (pending.length === 0)
24
+ return;
25
+ if (p.postReview !== null || ctx.startHeld())
26
+ return;
27
+ // A `fixed` entry is a settled record naming both shas (the reviewed commit and its landed fix)
28
+ // — it is not a pending unreviewed commit, so it is never re-reviewed (Forward never back 1a/6).
29
+ const next = pending.find((e) => e.rejected === undefined && e.fixed === undefined);
30
+ if (next)
31
+ p.postReview = startPostReview(ctx, p, next);
32
+ }
33
+ function startPostReview(ctx, p, entry) {
34
+ const { root, semaphore } = ctx;
35
+ const task = { promise: Promise.resolve(), controller: new AbortController(), roles: [], userAborted: false, parked: true };
36
+ const detach = abortOnShutdown(ctx.signal, task.controller);
37
+ const author = resolveAuthor(ctx, entry.role);
38
+ const key = seamConcurrencyKey(reviewRunConfig(ctx.liveConfig), ctx.piDefault);
39
+ const run = (async () => {
40
+ try {
41
+ if (!semaphore.canLeaseAtOnce(VERIFY_TIER, key))
42
+ return; // permits are busy; try again next poll
43
+ const lease = await acquireUnlessAborted(semaphore, VERIFY_TIER, task.controller.signal, key);
44
+ task.parked = false;
45
+ if (lease === null)
46
+ return;
47
+ let outcome;
48
+ try {
49
+ outcome = await reviewMergedCommit(root, ctx.liveConfig, entry, (opts) => author.runGatePi(opts), task.controller.signal);
50
+ }
51
+ finally {
52
+ lease.release();
53
+ }
54
+ if (task.controller.signal.aborted)
55
+ return; // a shutdown: the entry stays for the next generation
56
+ if (outcome.kind === "approve")
57
+ settlePostReview(root, entry.commit, null);
58
+ else if (outcome.kind === "reject")
59
+ settlePostReview(root, entry.commit, { ...entry, rejected: outcome.reasons });
60
+ else if (outcome.kind === "fixed")
61
+ await mergeReviewerFix(ctx, entry, outcome.fix);
62
+ else if (outcome.kind === "exhausted") {
63
+ // A self-fix chain at the backstop: reviewMergedCommit has already recorded the surviving
64
+ // findings; the entry is cleared so nothing re-reviews it (Forward never back 2/6).
65
+ settlePostReview(root, entry.commit, null);
66
+ }
67
+ else {
68
+ const failures = (entry.failures ?? 0) + 1;
69
+ warnEventBestEffort(root, entry.role, `the post-merge review of ${shortSha(entry.commit)} failed (${outcome.detail})`);
70
+ if (failures >= POST_REVIEW_FAILURE_LIMIT) {
71
+ warnEventBestEffort(root, entry.role, `dropping the post-merge review of ${shortSha(entry.commit)} after ${failures} failed runs; it stays on main unreviewed`);
72
+ await fileDroppedReview(ctx, entry, outcome.detail);
73
+ settlePostReview(root, entry.commit, null);
74
+ }
75
+ else
76
+ settlePostReview(root, entry.commit, { ...entry, failures, lastFailure: outcome.detail });
77
+ }
78
+ }
79
+ catch (err) {
80
+ warnEventBestEffort(root, TUMWATER_SOURCE, `the post-merge review of ${shortSha(entry.commit)} could not run: ${errorMessage(err)}`);
81
+ }
82
+ finally {
83
+ detach();
84
+ p.postReview = null;
85
+ }
86
+ })();
87
+ task.promise = guardMergeTask(root, `post-merge review of ${shortSha(entry.commit)}`, run);
88
+ return task;
89
+ }
90
+ /** File a post-merge review that is being dropped at POST_REVIEW_FAILURE_LIMIT (its commit stays
91
+ * on main, so it becomes ordinary backlog work a role can pick up) as one BUGS.md `## Open`
92
+ * entry in a best-effort `tumwater(operator):` commit. A skipped filing (off-base checkout or a
93
+ * BUGS.md with uncommitted changes) or a throw logs one warning and returns, so a filing problem
94
+ * never prevents the entry from being dropped and never throws out of the drain. */
95
+ async function fileDroppedReview(ctx, entry, failure) {
96
+ const root = ctx.root;
97
+ try {
98
+ const outcome = await fileBugsOnMain(root, [
99
+ {
100
+ title: `post-merge review of ${shortSha(entry.commit)} never reached a verdict (found by the review role ${formatDate(new Date())})`,
101
+ // The failure text is untrusted and can carry newlines (a backend/stderr trace) or a
102
+ // `## `/code-fence line that would make appendBugEntryLocked call fail() and exit the
103
+ // whole drain; folding it to one line keeps the body structurally sound, so a hostile
104
+ // failure skips the filing via the ordinary throw path instead of killing the process.
105
+ body: collapseWhitespace(`The post-merge review of commit \`${entry.commit}\` (${entry.summary}) never reached a verdict: ${failure}. It stays on main as ordinary unreviewed backlog work.`),
106
+ },
107
+ ], () => "File an unreviewed post-merge commit in BUGS.md");
108
+ if (outcome.status === "skipped") {
109
+ warnEventBestEffort(root, entry.role, `could not file the dropped post-merge review of ${shortSha(entry.commit)} in BUGS.md (${outcome.reason})`);
110
+ }
111
+ }
112
+ catch (err) {
113
+ warnEventBestEffort(root, entry.role, `could not file the dropped post-merge review of ${shortSha(entry.commit)} in BUGS.md: ${errorMessage(err)}`);
114
+ }
115
+ }
116
+ /** Land a reviewer's fix commit on main through the ordinary change path — mergeToMain, whose
117
+ * gate runs over the fix tree and whose mergeLockDir lock serializes it with every other write
118
+ * to main — then settle the entry `fixed` naming both shas. A landing that fails (a conflict, a
119
+ * red gate, a refused fast-forward) leaves main at the reviewed commit and keeps the entry with
120
+ * its failure recorded for the next poll's retry, exactly as a failed run is kept today — up to
121
+ * the same POST_REVIEW_FAILURE_LIMIT the verdict-less-review path obeys, so the at-risk doctor
122
+ * row/alert's "one more failed run drops it" stays true and the reviewer is not re-run forever;
123
+ * the review-fix ref survives so leftover recovery can still reach the commit.
124
+ * Throws when git could not start; a failed merge itself returns a non-terminal RunResult and
125
+ * is never a throw. */
126
+ async function mergeReviewerFix(ctx, entry, fix) {
127
+ const author = resolveAuthor(ctx, entry.role);
128
+ // The fix merge's conflict resolver rides the same provider lease the merge slot's resolver
129
+ // does (merge-drain.ts startMerge): the MERGE_TIER lease is keyed on the resolver config's
130
+ // provider and rides in the provider-lease store, so a conflict resolution counts against the
131
+ // strong tier's cap like any other strong-tier run instead of acquiring a provider unchecked.
132
+ const runPi = async (wt, prompt, sessionName, config) => {
133
+ const key = config ? seamConcurrencyKey(config, ctx.piDefault) : undefined;
134
+ const hooks = resolveWaitStage(ctx.root, entry.role);
135
+ if (!ctx.semaphore.canLeaseAtOnce(MERGE_TIER, key))
136
+ hooks.waiting();
137
+ const lease = await ctx.semaphore.acquireLease(MERGE_TIER, key);
138
+ hooks.granted();
139
+ try {
140
+ return await withProviderLease(lease, ctx.semaphore, () => author.runMergePi(wt, prompt, sessionName, config), { piDefault: ctx.piDefault });
141
+ }
142
+ finally {
143
+ lease.release();
144
+ }
145
+ };
146
+ // Land through the merge's own write path — mergeToMain fast-forwards main under the shared
147
+ // mergeLockDir mutex, so a fix landing and the ordinary merge slot serialize on main's one
148
+ // writer either way — but from the DEDICATED post-review worktree (which already holds the fix
149
+ // commit), NOT the shared `_merge` checkout that belongs to the merge slot. That removes the
150
+ // race the merge slot's mergeApprovedChangeIn has: each resets and merges its own worktree, so
151
+ // a fix landing can never reset or merge the shared `_merge` worktree concurrently with the
152
+ // merge slot (both could start in one poll), while main's writes stay serialized by the real
153
+ // mergeLockDir mutex. Hold the worktree from before ensureDetachedWorktree through the merge
154
+ // (plans/disk-floor.md part 2/4); this task is the only user of the post-review worktree while
155
+ // p.postReview is held, so the reset-and-merge cannot collide with a fresh review.
156
+ let result;
157
+ // The commit that actually merges, from our own `merged` event (not the pre-rebase `fix`):
158
+ // mergeToMain rebases `fix` onto main's tip, so when main moved under the review the landed
159
+ // commit is a different sha — and `fixed` must name a commit that is provably on main.
160
+ // Subscribing mirrors the merge slot's startMerge, so a concurrent merge of another role
161
+ // landing after ours cannot overwrite the sha we record.
162
+ const mergedMine = new Map();
163
+ const unsubscribe = subscribeEvents((e) => {
164
+ if (e.type === "merged" && typeof e.commit === "string")
165
+ mergedMine.set(e.source, e.commit);
166
+ });
167
+ try {
168
+ result = await useWorktree(ctx.root, postReviewWorktreePath(ctx.root), async () => {
169
+ const wt = await ensureDetachedWorktree(ctx.root, postReviewWorktreePath(ctx.root), fix);
170
+ return mergeToMain({
171
+ root: ctx.root,
172
+ role: entry.role,
173
+ mainBranch: ctx.mainBranch,
174
+ config: ctx.liveConfig,
175
+ exemptPaths: ctx.liveConfig.review.exemptPaths,
176
+ runNumber: author.state.runs,
177
+ runPi,
178
+ // A conflict resolution of the reviewer's fix is resolver-authored bytes the reviewer
179
+ // never judged; unlike the merge slot's mergeApprovedChangeIn there is no review gate
180
+ // here to re-run, so a diverging resolution must not merge on the in-lock build check
181
+ // alone (BUGS.md 2026-10-01). Withhold it: the landing reports the conflict, the fix
182
+ // ref and the entry stay for the next poll's retry, and main never carries resolver
183
+ // bytes it was never judged against.
184
+ recheckResolved: async () => ({ verdict: "retry" }),
185
+ }, wt, (await subjectOfCommit(wt, fix)) ?? `Fix ${shortSha(entry.commit)}`);
186
+ });
187
+ }
188
+ finally {
189
+ unsubscribe();
190
+ }
191
+ if (result === "changed") {
192
+ const merged = mergedMine.get(entry.role) ?? (await headOf(ctx.root, ctx.mainBranch));
193
+ await deleteRef(ctx.root, reviewFixRef(entry.commit)); // the fix is on main; the ref has done its job
194
+ settlePostReview(ctx.root, entry.commit, { ...entry, fixed: merged }); // the entry names the landed fix
195
+ // A landed fix is a new post-review entry (Forward never back 2/6): re-queue it so the next
196
+ // iteration reviews it like any unreviewed commit, counting its round toward the same
197
+ // REVISION_BACKSTOP — the round lives on the fix commit's `Revision:` trailer.
198
+ addPostReview(ctx.root, { commit: merged, role: entry.role, summary: entry.summary });
199
+ return;
200
+ }
201
+ // A failed merge: main is unmoved, the fix ref survives. Count it against the drop limit -
202
+ // keeping the entry (with the failure recorded) so the next poll retries the review, but
203
+ // dropping it once the limit is reached, so the reviewer is not re-run unboundedly.
204
+ const failures = (entry.failures ?? 0) + 1;
205
+ if (failures >= POST_REVIEW_FAILURE_LIMIT) {
206
+ warnEventBestEffort(ctx.root, entry.role, `dropping the post-merge review of ${shortSha(entry.commit)} after ${failures} failed fix merges; the fix stays unreviewed`);
207
+ await deleteRef(ctx.root, reviewFixRef(entry.commit)); // the merge gave up on the fix; the ref must not pin it forever
208
+ await fileDroppedReview(ctx, entry, `fix did not merge: ${result}`);
209
+ settlePostReview(ctx.root, entry.commit, null);
210
+ return;
211
+ }
212
+ warnEventBestEffort(ctx.root, entry.role, `the post-merge fix of ${shortSha(entry.commit)} could not merge (${result}); it stays queued for the next poll`);
213
+ settlePostReview(ctx.root, entry.commit, { ...entry, failures, lastFailure: `fix did not merge: ${result}` });
214
+ }