tumwater 0.2.0 → 0.3.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 (446) 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 +42 -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 +4 -3
  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 +402 -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 +246 -55
  41. package/dist/src/backlog/backlog-cli-view.js +280 -0
  42. package/dist/src/backlog/backlog-cli.js +85 -274
  43. package/dist/src/backlog/backlog-commit.js +4 -5
  44. package/dist/src/backlog/backlog-eligibility.js +175 -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 +254 -0
  48. package/dist/src/backlog/backlog-release.js +117 -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-culprit.js +94 -0
  56. package/dist/src/baseline/main-guardrails.js +209 -0
  57. package/dist/src/baseline/main-red.js +139 -32
  58. package/dist/src/baseline/main-revert.js +133 -0
  59. package/dist/src/baseline/main-watch.js +184 -0
  60. package/dist/src/brief/brief-edit.js +9 -11
  61. package/dist/src/brief/brief.js +31 -51
  62. package/dist/src/budget/budget.js +30 -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 +1 -2
  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 +118 -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 +150 -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 +308 -590
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-role-command.js +420 -0
  84. package/dist/src/cli/cli-start-summary.js +160 -0
  85. package/dist/src/cli/{cli-run.js → cli-start.js} +165 -237
  86. package/dist/src/cli/completion.js +149 -22
  87. package/dist/src/cli/config-commands.js +41 -18
  88. package/dist/src/cli/follow-view.js +75 -0
  89. package/dist/src/cli/help.js +189 -73
  90. package/dist/src/cli/log-commands.js +148 -82
  91. package/dist/src/cli/question-commands.js +205 -10
  92. package/dist/src/cli.js +106 -43
  93. package/dist/src/collections.js +26 -10
  94. package/dist/src/concurrency/check-permit.js +20 -3
  95. package/dist/src/concurrency/lock.js +17 -13
  96. package/dist/src/concurrency/provider-lease.js +44 -8
  97. package/dist/src/concurrency/semaphore.js +102 -26
  98. package/dist/src/config/config-aliases.js +0 -1
  99. package/dist/src/config/config-editable-keys.js +13 -1
  100. package/dist/src/config/config-fallbacks.js +93 -56
  101. package/dist/src/config/config-field-checks.js +40 -10
  102. package/dist/src/config/config-keys.js +30 -8
  103. package/dist/src/config/config-live.js +63 -24
  104. package/dist/src/config/config-request.js +137 -0
  105. package/dist/src/config/config-schema.js +31 -24
  106. package/dist/src/config/config-selector-checks.js +123 -0
  107. package/dist/src/config/config-validation-agents.js +80 -0
  108. package/dist/src/config/config-validation-model.js +116 -0
  109. package/dist/src/config/config-validation.js +141 -199
  110. package/dist/src/config/config-views.js +127 -53
  111. package/dist/src/config/config-write-core.js +135 -0
  112. package/dist/src/config/config-write.js +131 -264
  113. package/dist/src/config/config.js +48 -38
  114. package/dist/src/config/model-selector.js +34 -34
  115. package/dist/src/doctor/doctor-backlog-bugs.js +287 -0
  116. package/dist/src/doctor/doctor-backlog-plans.js +263 -0
  117. package/dist/src/doctor/doctor-backlog.js +100 -496
  118. package/dist/src/doctor/doctor-check-names.js +81 -0
  119. package/dist/src/doctor/doctor-checks.js +52 -579
  120. package/dist/src/doctor/doctor-claude-checks.js +145 -0
  121. package/dist/src/doctor/doctor-codex-checks.js +279 -0
  122. package/dist/src/doctor/doctor-contention-checks.js +380 -0
  123. package/dist/src/doctor/doctor-holds.js +82 -66
  124. package/dist/src/doctor/doctor-install.js +2 -1
  125. package/dist/src/doctor/doctor-model-checks.js +236 -402
  126. package/dist/src/doctor/doctor-notify.js +3 -4
  127. package/dist/src/doctor/doctor-opencode-checks.js +162 -0
  128. package/dist/src/doctor/doctor-orphans.js +182 -63
  129. package/dist/src/doctor/doctor-project-checks.js +303 -0
  130. package/dist/src/doctor/doctor.js +121 -77
  131. package/dist/src/doctor/image-probe.js +21 -8
  132. package/dist/src/doctor/outcome.js +3 -2
  133. package/dist/src/doctor/probe.js +7 -11
  134. package/dist/src/events/event-format.js +181 -86
  135. package/dist/src/events/event-labels.js +12 -5
  136. package/dist/src/events/event-legacy.js +39 -15
  137. package/dist/src/events/event-read.js +39 -39
  138. package/dist/src/events/event-tally.js +16 -0
  139. package/dist/src/events/event-window.js +60 -24
  140. package/dist/src/events/events.js +30 -13
  141. package/dist/src/events/notify.js +7 -7
  142. package/dist/src/failure/failure-cluster.js +12 -12
  143. package/dist/src/failure/failure-data.js +56 -56
  144. package/dist/src/failure/failure-render.js +31 -23
  145. package/dist/src/failure/failure-state-change.js +17 -12
  146. package/dist/src/failure/time-spend.js +99 -35
  147. package/dist/src/files/files.js +111 -35
  148. package/dist/src/files/json-files.js +3 -3
  149. package/dist/src/files/json-object.js +28 -7
  150. package/dist/src/files/stat-cache.js +2 -2
  151. package/dist/src/files/tail.js +1 -1
  152. package/dist/src/gates/bootstrap-gates.js +4 -5
  153. package/dist/src/gates/budget-gates.js +177 -84
  154. package/dist/src/gates/disk-gate.js +1 -1
  155. package/dist/src/gates/gate-polls.js +108 -58
  156. package/dist/src/gates/maintenance-quota.js +10 -13
  157. package/dist/src/gates/pause-gates.js +28 -10
  158. package/dist/src/gates/readiness.js +7 -3
  159. package/dist/src/gates/role-cap-gates.js +8 -8
  160. package/dist/src/gates/startup-gate.js +94 -32
  161. package/dist/src/gates/streak-gate.js +54 -30
  162. package/dist/src/git/commit-message.js +77 -46
  163. package/dist/src/git/git-diff.js +16 -10
  164. package/dist/src/git/git-run.js +79 -38
  165. package/dist/src/git/git.js +36 -34
  166. package/dist/src/git/slot-salvage.js +253 -0
  167. package/dist/src/git/slots-state.js +6 -6
  168. package/dist/src/git/worktree-pool.js +132 -38
  169. package/dist/src/git/worktree-use.js +4 -11
  170. package/dist/src/git/worktree.js +94 -37
  171. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  172. package/dist/src/gui/gui-args.js +79 -8
  173. package/dist/src/gui/gui-backlog-commands.js +42 -18
  174. package/dist/src/gui/gui-backlog-endpoints.js +155 -0
  175. package/dist/src/gui/gui-command.js +8 -13
  176. package/dist/src/gui/gui-config-commands.js +94 -72
  177. package/dist/src/gui/gui-endpoint-commands.js +4 -3
  178. package/dist/src/gui/gui-endpoint-common.js +4 -4
  179. package/dist/src/gui/gui-endpoints.js +11 -380
  180. package/dist/src/gui/gui-history-endpoints.js +301 -0
  181. package/dist/src/gui/gui-operator-commands.js +108 -13
  182. package/dist/src/gui/gui-prompt-commands.js +11 -14
  183. package/dist/src/gui/gui-report-endpoints.js +90 -0
  184. package/dist/src/gui/gui-server.js +11 -4
  185. package/dist/src/gui/gui-team-commands.js +7 -7
  186. package/dist/src/gui/http-body.js +1 -1
  187. package/dist/src/history/history-data.js +122 -234
  188. package/dist/src/history/history-queued.js +147 -0
  189. package/dist/src/history/history-render.js +42 -22
  190. package/dist/src/history/history.js +193 -49
  191. package/dist/src/inbox/inbox-attachments.js +29 -30
  192. package/dist/src/inbox/inbox-cancel.js +10 -11
  193. package/dist/src/inbox/inbox-edit.js +9 -9
  194. package/dist/src/inbox/inbox-submit.js +18 -18
  195. package/dist/src/inbox/inbox-top.js +25 -9
  196. package/dist/src/inbox/inbox.js +42 -43
  197. package/dist/src/inbox/pending-prompt.js +26 -26
  198. package/dist/src/inbox/prompt-commands.js +62 -49
  199. package/dist/src/inbox/prompt-not-before.js +2 -2
  200. package/dist/src/init/init-templates.js +1 -2
  201. package/dist/src/init/init.js +28 -23
  202. package/dist/src/merge/backlog-conflicts.js +215 -35
  203. package/dist/src/merge/merge-batch.js +14 -8
  204. package/dist/src/merge/merge-cancel.js +53 -0
  205. package/dist/src/merge/merge-check-failures.js +44 -22
  206. package/dist/src/merge/merge-core.js +81 -32
  207. package/dist/src/merge/merge-diff.js +5 -4
  208. package/dist/src/merge/merge-drain.js +150 -11
  209. package/dist/src/merge/merge-git.js +177 -38
  210. package/dist/src/merge/merge-pipeline.js +15 -3
  211. package/dist/src/merge/merge-post-review.js +90 -0
  212. package/dist/src/merge/merge-queue-render.js +49 -11
  213. package/dist/src/merge/merge-queue.js +31 -15
  214. package/dist/src/merge/merge-requeue.js +102 -0
  215. package/dist/src/merge/merge-slot.js +61 -16
  216. package/dist/src/merge/merge-stack.js +33 -11
  217. package/dist/src/merge/merge-to-main.js +86 -27
  218. package/dist/src/merge/merge-top.js +69 -0
  219. package/dist/src/merge/merge-verify.js +12 -10
  220. package/dist/src/notes-cli.js +30 -15
  221. package/dist/src/operator/operator-commands.js +192 -81
  222. package/dist/src/operator/operator-intent.js +69 -42
  223. package/dist/src/operator/operator-requests.js +35 -40
  224. package/dist/src/operator/retire.js +5 -6
  225. package/dist/src/operator/revert.js +142 -34
  226. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  227. package/dist/src/orchestrator/orchestrator-poll.js +320 -0
  228. package/dist/src/orchestrator/orchestrator-scheduling.js +68 -369
  229. package/dist/src/orchestrator/orchestrator.js +128 -369
  230. package/dist/src/orchestrator/retention.js +42 -8
  231. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  232. package/dist/src/orchestrator/scheduling-scope.js +201 -0
  233. package/dist/src/orchestrator/scheduling-skip.js +185 -0
  234. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  235. package/dist/src/orchestrator/startup-migrations.js +11 -7
  236. package/dist/src/paths.js +116 -49
  237. package/dist/src/pi/command-shape.js +12 -1
  238. package/dist/src/pi/pi-args.js +6 -4
  239. package/dist/src/pi/pi-backend.js +6 -6
  240. package/dist/src/pi/pi-bin.js +54 -63
  241. package/dist/src/pi/pi-listed-models.js +53 -0
  242. package/dist/src/pi/pi-models.js +63 -30
  243. package/dist/src/pi/pi-run-result.js +1 -1
  244. package/dist/src/pi/pi-settings.js +1 -1
  245. package/dist/src/pi/pi-stream.js +6 -6
  246. package/dist/src/pi/pi-watchdogs.js +18 -19
  247. package/dist/src/pi-extension/bounded-output.js +5 -3
  248. package/dist/src/pi-extension/context-budget.js +2 -2
  249. package/dist/src/pi-extension/context-shake.js +1 -1
  250. package/dist/src/pi-extension/context-window.js +0 -1
  251. package/dist/src/pi-extension/full-output.js +3 -10
  252. package/dist/src/pi-extension/strip-thinking.js +0 -1
  253. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  254. package/dist/src/pi-extension/write-guard.js +37 -0
  255. package/dist/src/process/launch-services.js +1 -2
  256. package/dist/src/process/node-bin.js +16 -0
  257. package/dist/src/process/process-group.js +8 -18
  258. package/dist/src/process/process.js +41 -2
  259. package/dist/src/process/run-marker.js +18 -6
  260. package/dist/src/process/supervisor.js +22 -21
  261. package/dist/src/prompt/dialect.js +17 -2
  262. package/dist/src/prompt/gate-notes.js +203 -0
  263. package/dist/src/prompt/gate-prompts.js +121 -211
  264. package/dist/src/prompt/notes-backlog.js +181 -0
  265. package/dist/src/prompt/principles.js +1 -2
  266. package/dist/src/prompt/prompt-followup.js +19 -14
  267. package/dist/src/prompt/prompt.js +129 -204
  268. package/dist/src/redeploy/redeploy-policy.js +24 -19
  269. package/dist/src/redeploy/redeploy-probes.js +1 -1
  270. package/dist/src/redeploy/redeploy.js +2 -3
  271. package/dist/src/redeploy/redeployer.js +20 -20
  272. package/dist/src/redeploy/self-reload.js +2 -1
  273. package/dist/src/report/report-data.js +161 -77
  274. package/dist/src/report/report-fold.js +129 -52
  275. package/dist/src/report/report-main-red.js +55 -0
  276. package/dist/src/report/report-render.js +133 -64
  277. package/dist/src/report/report-types.js +1 -0
  278. package/dist/src/report/report.js +26 -9
  279. package/dist/src/request-timeouts.js +2 -2
  280. package/dist/src/review/known-flakes.js +100 -1
  281. package/dist/src/review/post-merge-review.js +106 -0
  282. package/dist/src/review/protected-edits.js +4 -5
  283. package/dist/src/review/review-followup.js +8 -8
  284. package/dist/src/review/review-precheck.js +66 -23
  285. package/dist/src/review/review-run.js +395 -0
  286. package/dist/src/review/review-verdict.js +2 -2
  287. package/dist/src/review/review.js +155 -360
  288. package/dist/src/review/secret-leaks.js +36 -16
  289. package/dist/src/review/suite-rerun.js +0 -1
  290. package/dist/src/roles/legacy-role-migration.js +80 -7
  291. package/dist/src/roles/role-catalog.js +69 -12
  292. package/dist/src/roles/role-guidance.js +55 -20
  293. package/dist/src/roles/role-labels.js +1 -2
  294. package/dist/src/roles/role-render.js +67 -50
  295. package/dist/src/roles/role-view.js +59 -47
  296. package/dist/src/roles/roles.js +31 -31
  297. package/dist/src/roles/runner-ids.js +97 -0
  298. package/dist/src/run/acceptance-greps.js +119 -0
  299. package/dist/src/run/feedback-filings.js +51 -0
  300. package/dist/src/{tick → run}/qa-coverage.js +9 -27
  301. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  302. package/dist/src/run/run-detail-data.js +179 -0
  303. package/dist/src/run/run-detail-render.js +98 -0
  304. package/dist/src/run/run-detail.js +168 -0
  305. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +72 -60
  306. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  307. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +108 -73
  308. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  309. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +108 -50
  310. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  311. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  312. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +91 -56
  313. package/dist/src/run/selfcheck-digest.js +40 -0
  314. package/dist/src/run/stage-backlog-checks.js +249 -0
  315. package/dist/src/run/stage-check.js +172 -0
  316. package/dist/src/run/stage-path-checks.js +730 -0
  317. package/dist/src/{loop → runner}/leftover.js +80 -25
  318. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  319. package/dist/src/runner/revision.js +202 -0
  320. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  321. package/dist/src/runner/runner-outcome.js +107 -0
  322. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  323. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +21 -15
  324. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +218 -240
  325. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  326. package/dist/src/runner/runner-state.js +102 -0
  327. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  328. package/dist/src/scheduling/backoff.js +38 -38
  329. package/dist/src/scheduling/claims.js +35 -22
  330. package/dist/src/scheduling/once-round.js +22 -22
  331. package/dist/src/scheduling/quiet-hours.js +5 -6
  332. package/dist/src/scheduling/scheduling.js +74 -64
  333. package/dist/src/scheduling/work-merged-cache.js +2 -2
  334. package/dist/src/status/status-budget.js +55 -47
  335. package/dist/src/status/status-data.js +43 -16
  336. package/dist/src/status/status-inbox.js +2 -2
  337. package/dist/src/status/status-install.js +0 -1
  338. package/dist/src/status/status-polls.js +28 -28
  339. package/dist/src/status/status-roles.js +39 -13
  340. package/dist/src/team/error-storm.js +8 -8
  341. package/dist/src/team/failure-spread.js +2 -2
  342. package/dist/src/team/orchestrator-info.js +15 -9
  343. package/dist/src/team/reclaim.js +5 -6
  344. package/dist/src/team/team-hold.js +7 -7
  345. package/dist/src/team/team-polls.js +91 -50
  346. package/dist/src/team/team-state.js +8 -2
  347. package/dist/src/text/datetime.js +63 -17
  348. package/dist/src/text/format.js +48 -11
  349. package/dist/src/text/markdown.js +8 -5
  350. package/dist/src/text/phrases.js +57 -23
  351. package/dist/src/text/suggest.js +1 -1
  352. package/dist/src/text/text.js +67 -13
  353. package/dist/src/ui/badges.js +65 -37
  354. package/dist/src/ui/gui/gui-client-backlog.js +292 -0
  355. package/dist/src/ui/gui/gui-client-boot.js +32 -26
  356. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  357. package/dist/src/ui/gui/gui-client-drawer-config.js +212 -0
  358. package/dist/src/ui/gui/gui-client-drawer.js +126 -285
  359. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  360. package/dist/src/ui/gui/gui-client-history.js +434 -67
  361. package/dist/src/ui/gui/gui-client-labels.js +1 -2
  362. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  363. package/dist/src/ui/gui/gui-client-model.js +27 -36
  364. package/dist/src/ui/gui/gui-client-operator.js +55 -51
  365. package/dist/src/ui/gui/gui-client-pending.js +227 -23
  366. package/dist/src/ui/gui/gui-client-report-chart.js +464 -0
  367. package/dist/src/ui/gui/gui-client-report.js +87 -427
  368. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  369. package/dist/src/ui/gui/gui-client-settings.js +56 -4
  370. package/dist/src/ui/gui/gui-client-team.js +25 -465
  371. package/dist/src/ui/gui/gui-client.js +27 -25
  372. package/dist/src/ui/gui/gui-icons.js +0 -2
  373. package/dist/src/ui/gui/gui-page.js +9 -7
  374. package/dist/src/ui/gui/gui-styles.js +34 -34
  375. package/dist/src/ui/progress-data.js +9 -11
  376. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +31 -31
  377. package/dist/src/ui/status-model.js +133 -125
  378. package/dist/src/ui/status-payload.js +136 -49
  379. package/dist/src/ui/status-render.js +101 -82
  380. package/dist/src/ui/team-alerts.js +248 -57
  381. package/dist/src/ui/tone.js +5 -6
  382. package/dist/src/ui/transcript-run.js +58 -0
  383. package/dist/src/ui/transcript-tail.js +28 -20
  384. package/dist/src/ui/transcript.js +63 -23
  385. package/dist/src/ui/tui/tui-app.js +0 -1
  386. package/dist/src/ui/tui/tui-backlog.js +51 -14
  387. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  388. package/dist/src/ui/tui/tui-editor-attach.js +3 -5
  389. package/dist/src/ui/tui/tui-editor-backends.js +132 -0
  390. package/dist/src/ui/tui/tui-editor-brief.js +10 -12
  391. package/dist/src/ui/tui/tui-editor-budget.js +35 -31
  392. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  393. package/dist/src/ui/tui/tui-editor-concurrency.js +24 -0
  394. package/dist/src/ui/tui/tui-editor-config.js +86 -0
  395. package/dist/src/ui/tui/tui-editor-entry.js +36 -47
  396. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  397. package/dist/src/ui/tui/tui-editor-instances.js +37 -0
  398. package/dist/src/ui/tui/tui-editor-instructions.js +136 -0
  399. package/dist/src/ui/tui/tui-editor-interval.js +11 -9
  400. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  401. package/dist/src/ui/tui/tui-editor-notes.js +3 -5
  402. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  403. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  404. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  405. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  406. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  407. package/dist/src/ui/tui/tui-editor-quiet.js +99 -35
  408. package/dist/src/ui/tui/tui-editor-retire.js +4 -6
  409. package/dist/src/ui/tui/tui-editor-role-key.js +76 -0
  410. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  411. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  412. package/dist/src/ui/tui/tui-editor-session.js +60 -34
  413. package/dist/src/ui/tui/tui-editor-slots.js +23 -0
  414. package/dist/src/ui/tui/tui-editors.js +35 -2
  415. package/dist/src/ui/tui/tui-frame.js +56 -11
  416. package/dist/src/ui/tui/tui-history.js +8 -9
  417. package/dist/src/ui/tui/tui-input.js +13 -11
  418. package/dist/src/ui/tui/tui-keymap.js +0 -1
  419. package/dist/src/ui/tui/tui-keys.js +24 -9
  420. package/dist/src/ui/tui/tui-pane-activity.js +14 -11
  421. package/dist/src/ui/tui/tui-pane-backlog.js +171 -126
  422. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  423. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  424. package/dist/src/ui/tui/tui-pane-health.js +58 -8
  425. package/dist/src/ui/tui/tui-pane-history.js +402 -82
  426. package/dist/src/ui/tui/tui-pane-pending.js +308 -13
  427. package/dist/src/ui/tui/tui-pane-queued.js +71 -55
  428. package/dist/src/ui/tui/tui-pane-report.js +46 -14
  429. package/dist/src/ui/tui/tui-pane-session.js +25 -6
  430. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  431. package/dist/src/ui/tui/tui-pane.js +80 -23
  432. package/dist/src/ui/tui/tui-panes.js +100 -30
  433. package/dist/src/ui/tui/tui.js +14 -14
  434. package/dist/src/verdict/fix-claim.js +82 -32
  435. package/dist/src/verdict/no-change.js +4 -4
  436. package/dist/src/verdict/refusal.js +40 -16
  437. package/dist/src/verdict/reply-contract.js +42 -31
  438. package/dist/src/version.js +0 -1
  439. package/package.json +2 -1
  440. package/dist/src/loop/revision.js +0 -70
  441. package/dist/src/roles/loop-ids.js +0 -77
  442. package/dist/src/tick/stage-check.js +0 -419
  443. package/dist/src/tick/telemetry-digest.js +0 -25
  444. package/dist/src/tick/tick-detail-data.js +0 -108
  445. package/dist/src/tick/tick-detail.js +0 -163
  446. 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, revert: 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,90 @@
1
+ import { revertSkipReason } from "../baseline/main-revert.js";
2
+ import { reviewRunConfig, seamConcurrencyKey } from "../config/config-views.js";
3
+ import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
4
+ import { POST_REVIEW_FAILURE_LIMIT, readPostReviews, reviewMergedCommit, settlePostReview, } from "../review/post-merge-review.js";
5
+ import { shortSha } from "../text/format.js";
6
+ import { errorMessage } from "../text/text.js";
7
+ import { abortOnShutdown, acquireUnlessAborted, guardMergeTask } from "./merge-pipeline.js";
8
+ import { resolveAuthor, VERIFY_TIER } from "./merge-verify.js";
9
+ /** Pipelines with a revert skip check in flight, so a poll does not start a second one. */
10
+ const skipChecks = new WeakSet();
11
+ /** One poll of the post-merge reviews (review.afterMerge): a reviewed-and-rejected commit goes to
12
+ * the revert slot when it and the merge slot are free, and the next unreviewed commit starts its
13
+ * review when a permit is free right now. One review at a time; starts a task and returns. */
14
+ export function drainPostReviews(ctx, p) {
15
+ const pending = readPostReviews(ctx.root);
16
+ if (pending.length === 0)
17
+ return;
18
+ const rejected = pending.find((e) => e.rejected !== undefined);
19
+ // The entry stays in the file until its revert has run (startRevert settles it), so a shutdown,
20
+ // or the main-red watch replacing the slot, only delays the revert to a later poll.
21
+ if (rejected && p.revert === null && p.merge === null && !skipChecks.has(p)) {
22
+ // Check the skip reason before filling the slot: drainMerge starts the revert in the same poll.
23
+ skipChecks.add(p);
24
+ const culprit = { commit: rejected.commit, runner: rejected.role, failingTests: [], tip: rejected.commit, postReview: { reasons: rejected.rejected } };
25
+ void revertSkipReason(ctx.root, ctx.mainBranch, culprit)
26
+ .then((skip) => {
27
+ if (skip !== null) {
28
+ settlePostReview(ctx.root, rejected.commit, null);
29
+ warnEventBestEffort(ctx.root, rejected.role, `not reverting ${shortSha(rejected.commit)} after its post-merge rejection: ${skip}`);
30
+ }
31
+ else if (p.revert === null && p.merge === null)
32
+ p.revert = culprit;
33
+ })
34
+ .finally(() => skipChecks.delete(p));
35
+ }
36
+ if (p.postReview !== null || ctx.startHeld())
37
+ return;
38
+ const next = pending.find((e) => e.rejected === undefined);
39
+ if (next)
40
+ p.postReview = startPostReview(ctx, p, next);
41
+ }
42
+ function startPostReview(ctx, p, entry) {
43
+ const { root, semaphore } = ctx;
44
+ const task = { promise: Promise.resolve(), controller: new AbortController(), roles: [], userAborted: false, parked: true };
45
+ const detach = abortOnShutdown(ctx.signal, task.controller);
46
+ const author = resolveAuthor(ctx, entry.role);
47
+ const key = seamConcurrencyKey(reviewRunConfig(ctx.liveConfig), ctx.piDefault);
48
+ const run = (async () => {
49
+ try {
50
+ if (!semaphore.canLeaseAtOnce(VERIFY_TIER, key))
51
+ return; // permits are busy; try again next poll
52
+ const lease = await acquireUnlessAborted(semaphore, VERIFY_TIER, task.controller.signal, key);
53
+ task.parked = false;
54
+ if (lease === null)
55
+ return;
56
+ let outcome;
57
+ try {
58
+ outcome = await reviewMergedCommit(root, ctx.liveConfig, entry, (opts) => author.runGatePi(opts), task.controller.signal);
59
+ }
60
+ finally {
61
+ lease.release();
62
+ }
63
+ if (task.controller.signal.aborted)
64
+ return; // a shutdown: the entry stays for the next generation
65
+ if (outcome.kind === "approve")
66
+ settlePostReview(root, entry.commit, null);
67
+ else if (outcome.kind === "reject")
68
+ settlePostReview(root, entry.commit, { ...entry, rejected: outcome.reasons });
69
+ else {
70
+ const failures = (entry.failures ?? 0) + 1;
71
+ warnEventBestEffort(root, entry.role, `the post-merge review of ${shortSha(entry.commit)} failed (${outcome.detail})`);
72
+ if (failures >= POST_REVIEW_FAILURE_LIMIT) {
73
+ warnEventBestEffort(root, entry.role, `dropping the post-merge review of ${shortSha(entry.commit)} after ${failures} failed runs; it stays on main unreviewed`);
74
+ settlePostReview(root, entry.commit, null);
75
+ }
76
+ else
77
+ settlePostReview(root, entry.commit, { ...entry, failures });
78
+ }
79
+ }
80
+ catch (err) {
81
+ warnEventBestEffort(root, TUMWATER_SOURCE, `the post-merge review of ${shortSha(entry.commit)} could not run: ${errorMessage(err)}`);
82
+ }
83
+ finally {
84
+ detach();
85
+ p.postReview = null;
86
+ }
87
+ })();
88
+ task.promise = guardMergeTask(root, `post-merge review of ${shortSha(entry.commit)}`, run);
89
+ return task;
90
+ }
@@ -1,16 +1,15 @@
1
- // vocabulary: plain
2
1
  /** The renderer behind `tumwater queue` — the operator-facing text for the merge queue view the
3
2
  * reader gathers (merge-queue.ts's readMergeQueue, beside it): one line per queued merge
4
3
  * in execution order, plus the count of queue files the reader skipped. Pure function of the
5
4
  * collected view and a `now` instant — no I/O, no clock reads — so the age it prints can be
6
5
  * pinned by a test. */
7
- import { humanSeconds, secondsSince } from "../text/datetime.js";
6
+ import { humanAgo, humanSeconds, secondsSince, secondsUntil } from "../text/datetime.js";
8
7
  import { roleLabel } from "../roles/role-labels.js";
9
8
  import { shortSha } from "../text/format.js";
10
9
  import { plural } from "../text/phrases.js";
11
10
  import { mergeChanges, MERGE_STAGE_LABELS } from "./merge-slot.js";
12
- /** The bracketed-label text for one queue entry, in the wording the loop rows use (status-model's
13
- * loopPhase): `merge <elapsed> · <stage>` while a verify or the merge holds the change,
11
+ /** The bracketed-label text for one queue entry, in the wording the role rows use (status-model's
12
+ * rolePhase): `merge <elapsed> · <stage>` while a verify or the merge holds the change,
14
13
  * `approved, awaiting merge` once approved, and "" when the marker is absent, names no record for
15
14
  * `sha`, or the record is `done`. Reads the marker through mergeChanges, so an older-generation
16
15
  * single-change marker counts as one `merge` record. */
@@ -44,28 +43,67 @@ export function revisionNote(entry) {
44
43
  note += " · " + plural(reasons.length, "prior objection");
45
44
  return note;
46
45
  }
46
+ /** One re-queued entry's what-brought-it-back note:
47
+ * `re-queued after <result>`, plus ` · eligible in <duration>` while a `review_error` 60 s
48
+ * deferral still holds (its `notBefore` in the future). "" for a fresh entry with no `retry`
49
+ * field, so a first submission's queue line is byte-identical. */
50
+ export function retryNote(entry, now) {
51
+ if (entry.retry === undefined)
52
+ return "";
53
+ let note = `re-queued after ${entry.retry}`;
54
+ if (entry.notBefore !== undefined && entry.notBefore > now) {
55
+ note += ` · eligible in ${humanSeconds(secondsUntil(entry.notBefore, now))}`;
56
+ }
57
+ return note;
58
+ }
59
+ /** Narrows a whole-queue view to the entries whose role `keep` accepts, and returns the
60
+ * `scope` renderMergeQueue takes: the kept roles, each kept entry's 1-based place in the whole
61
+ * queue, and the whole queue's entry count. `unreadable` stays whole because an unreadable file
62
+ * has no role to attribute it to. */
63
+ export function scopeMergeQueue(whole, roles, keep) {
64
+ const positions = [];
65
+ const entries = whole.entries.filter((e, i) => {
66
+ const kept = keep(e.role);
67
+ if (kept)
68
+ positions.push(i + 1);
69
+ return kept;
70
+ });
71
+ return { view: { ...whole, entries }, scope: { roles, positions, total: whole.entries.length } };
72
+ }
47
73
  /** Render the queue as the text `tumwater queue` prints: a `merge queue (<depth>):` header then
48
- * one 1-based line per readable entry, oldest first, carrying the loop, tick number, short
74
+ * one 1-based line per readable entry, oldest first, carrying the role, run number, short
49
75
  * pinned sha, age, and summary. An empty queue (no entries and nothing skipped) prints
50
76
  * `merge queue empty`; a non-zero skip count appends one `(+N unreadable queue file(s) skipped)`
51
77
  * line so a torn or foreign file the reader dropped is visible rather than silent. An entry the
52
78
  * optional in-flight marker has a live record for carries ` · [<label>]` after its age
53
- * (queueEntryMergeLabel). */
54
- export function renderMergeQueue(view, now, inFlight) {
79
+ * (queueEntryMergeLabel). With the optional `scope` (`queue --role`: `view` holds only the
80
+ * listed roles' entries, `positions` their 1-based places in the whole queue, `total` the whole
81
+ * queue's entry count) each line keeps its whole-queue number, the header reads
82
+ * `merge queue (<kept> of <whole depth>, --role <ids>):`, and a scope that keeps nothing of a
83
+ * non-empty queue prints `no queued merges for <ids> (merge queue holds <depth>)`. Without
84
+ * `scope` the output is the whole queue's. */
85
+ export function renderMergeQueue(view, now, inFlight, scope) {
55
86
  const depth = view.entries.length + view.unreadable;
87
+ const wholeDepth = scope ? scope.total + view.unreadable : depth;
88
+ const ids = scope ? scope.roles.join(",") : "";
56
89
  const lines = [];
57
- if (depth === 0) {
90
+ if (wholeDepth === 0) {
58
91
  lines.push("merge queue empty");
59
92
  }
93
+ else if (scope && view.entries.length === 0) {
94
+ lines.push(`no queued merges for ${ids} (merge queue holds ${wholeDepth})`);
95
+ }
60
96
  else {
61
- lines.push(`merge queue (${depth}):`);
97
+ lines.push(scope ? `merge queue (${view.entries.length} of ${wholeDepth}, --role ${ids}):` : `merge queue (${depth}):`);
62
98
  view.entries.forEach((e, i) => {
63
- const age = humanSeconds(secondsSince(e.enqueuedAt, now));
99
+ const age = humanAgo(e.enqueuedAt, now);
64
100
  const label = queueEntryMergeLabel(e.sha, inFlight, now);
65
101
  const tag = label ? ` · [${label}]` : "";
66
102
  const note = revisionNote(e);
67
103
  const noteTag = note ? ` · ${note}` : "";
68
- lines.push(` ${i + 1}. ${roleLabel(e.role)} run #${e.run} · ${shortSha(e.sha)} · ${age} ago${tag}${noteTag} · ${e.summary}`);
104
+ const retry = retryNote(e, now);
105
+ const retryTag = retry ? ` · ${retry}` : "";
106
+ lines.push(` ${scope ? scope.positions[i] : i + 1}. ${roleLabel(e.role)} run #${e.run} · ${shortSha(e.sha)} · ${age}${tag}${retryTag}${noteTag} · ${e.summary}`);
69
107
  });
70
108
  }
71
109
  if (view.unreadable > 0) {