tumwater 0.4.2 → 0.4.4

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 (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -7,7 +7,7 @@
7
7
  * and nothing can get stuck"): the verdict is recomputed from the runner state every poll, no
8
8
  * marker is written, and local midnight lifts it by itself.
9
9
  *
10
- * Deliberate differences from the streak gate's shape (src/gates/streak-gate.ts), stated in
10
+ * Deliberate differences from the streak gate's shape (src/gates/streak-gates.ts), stated in
11
11
  * the plan so no implementer re-litigates them:
12
12
  * - This gate writes NOTHING to the shared per-role pause marker (src/team/team-state.ts's
13
13
  * pauseRole). The marker is anonymous — after a restart across midnight the harness could
@@ -24,7 +24,7 @@
24
24
  * module owns the only event emission — the orchestrator's poll owns only the wiring. The
25
25
  * in-memory `prev` and `warned` sets are the whole cross-poll memory: a restart with a
26
26
  * still-over-cap role re-logs one `role_cap_paused` on the first poll, and a role already past
27
- * 80% re-logs one `role_cap_warning` (the durable-cause honest report the streak-gate doc
27
+ * 80% re-logs one `role_cap_warning` (the durable-cause honest report the streak-gates doc
28
28
  * accepts). */
29
29
  import { groupBy } from "../collections.js";
30
30
  import { budgetWarning, dailyCost, todayStamp } from "../budget/budget.js";
@@ -50,7 +50,7 @@ export function roleCapPaused(state, cap, now = Date.now()) {
50
50
  * ids so a caller can log the base (the gate) or flatten the ids (the observer). */
51
51
  export function roleCapVerdicts(runners, caps, now) {
52
52
  // Group by base role via the shared Map-of-arrays step (collections.groupBy, which
53
- // time-spend, history-data, and team-polls also fold), then drop the exempt director's
53
+ // failure-time-spend, history-data, and team-polls also fold), then drop the exempt director's
54
54
  // group so it never holds a verdict; first-appearance order is preserved.
55
55
  const groups = groupBy(runners, (r) => baseRoleOf(r.role));
56
56
  groups.delete(DIRECTOR_ROLE);
@@ -71,6 +71,45 @@ export function roleCapVerdicts(runners, caps, now) {
71
71
  export function newRoleCapGateState() {
72
72
  return { prev: new Set(), warned: new Set() };
73
73
  }
74
+ /** One base role's pause/resume edge, logged by the poll's loop: reads whether the role was
75
+ * already judged over its cap at the last poll (`state.prev`) and on ENTER logs one
76
+ * `role_cap_paused` (role, spentUsd, capUsd); on EXIT logs one `role_cap_resumed` (role). */
77
+ function logCapCrossing(root, base, verdict, cap, state) {
78
+ const was = state.prev.has(base);
79
+ if (verdict.over && !was) {
80
+ logEventBestEffort(root, {
81
+ source: TUMWATER_SOURCE,
82
+ type: "role_cap_paused",
83
+ role: base,
84
+ spentUsd: verdict.spentUsd,
85
+ capUsd: cap,
86
+ });
87
+ }
88
+ else if (!verdict.over && was) {
89
+ logEventBestEffort(root, { source: TUMWATER_SOURCE, type: "role_cap_resumed", role: base });
90
+ }
91
+ }
92
+ /** The 80% early warning (PLANS.md 2026-10-09), the per-role twin of the team's
93
+ * budget_warning and edge-triggered with the same reset-on-below: crossing the threshold
94
+ * while still under the cap logs one `role_cap_warning`, a dip back under re-arms it. Warn
95
+ * only while the cap is open — a poll that jumps straight to the cap already logs its own
96
+ * `role_cap_paused`, so the warning never doubles the page on the poll the cap is reached. */
97
+ function logCapWarning(root, base, verdict, cap, state) {
98
+ const warn = !verdict.over && cap !== undefined && budgetWarning({ spentUsd: verdict.spentUsd, capUsd: cap });
99
+ if (warn && !state.warned.has(base)) {
100
+ logEventBestEffort(root, {
101
+ source: TUMWATER_SOURCE,
102
+ type: "role_cap_warning",
103
+ role: base,
104
+ spentUsd: verdict.spentUsd,
105
+ capUsd: cap,
106
+ });
107
+ state.warned.add(base);
108
+ }
109
+ else if (!warn) {
110
+ state.warned.delete(base);
111
+ }
112
+ }
74
113
  /** Step the gate by one orchestrator poll. Per runner (the director exempt, like every
75
114
  * autonomous gate): judge `roleCapPaused` against its configured cap; on ENTER log one
76
115
  * `role_cap_paused` carrying the role, its spend, and its cap; on EXIT log one
@@ -86,39 +125,9 @@ export function pollRoleCapGate(root, state, runners, caps, now) {
86
125
  const paused = new Set();
87
126
  const overBases = new Set();
88
127
  for (const [base, verdict] of verdicts) {
89
- const was = state.prev.has(base);
90
- if (verdict.over && !was) {
91
- logEventBestEffort(root, {
92
- source: TUMWATER_SOURCE,
93
- type: "role_cap_paused",
94
- role: base,
95
- spentUsd: verdict.spentUsd,
96
- capUsd: caps?.[base],
97
- });
98
- }
99
- else if (!verdict.over && was) {
100
- logEventBestEffort(root, { source: TUMWATER_SOURCE, type: "role_cap_resumed", role: base });
101
- }
102
- // The 80% early warning (PLANS.md 2026-10-09), the per-role twin of the team's
103
- // budget_warning and edge-triggered with the same reset-on-below: crossing the threshold
104
- // while still under the cap logs one role_cap_warning, a dip back under re-arms it. Warn
105
- // only while the cap is open — a poll that jumps straight to the cap already logs its own
106
- // role_cap_paused, so the warning never doubles the page on the poll the cap is reached.
107
128
  const cap = caps?.[base];
108
- const warn = !verdict.over && cap !== undefined && budgetWarning({ spentUsd: verdict.spentUsd, capUsd: cap });
109
- if (warn && !state.warned.has(base)) {
110
- logEventBestEffort(root, {
111
- source: TUMWATER_SOURCE,
112
- type: "role_cap_warning",
113
- role: base,
114
- spentUsd: verdict.spentUsd,
115
- capUsd: cap,
116
- });
117
- state.warned.add(base);
118
- }
119
- else if (!warn) {
120
- state.warned.delete(base);
121
- }
129
+ logCapCrossing(root, base, verdict, cap, state);
130
+ logCapWarning(root, base, verdict, cap, state);
122
131
  if (verdict.over) {
123
132
  overBases.add(base);
124
133
  for (const id of verdict.roleIds)
@@ -9,15 +9,16 @@ import { DETACHED_HEAD_MESSAGE, NOT_A_REPO_MESSAGE, NOT_INITIALIZED_MESSAGE, NO_
9
9
  import { owningTeamRoot } from "../paths.js";
10
10
  import { backendMissingMessage, findBackendBinary, resolveBackendBin } from "../pi/pi-bin.js";
11
11
  import { seedClaudeAccount } from "../agent/claude/claude-account.js";
12
- import { seedCodexAccount } from "../agent/codex/codex-account.js";
12
+ import { codexApiKeyRefusalMessage, seedCodexAccount } from "../agent/codex/codex-account.js";
13
+ import { distinctOrdered } from "../collections.js";
13
14
  import { opencodeCatalogStale, readOpencodeCache, refreshOpencodeCatalog, } from "../agent/opencode/opencode-catalog.js";
14
15
  import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
15
16
  import { HOUR_MS } from "../text/datetime.js";
16
17
  import { errorMessage } from "../text/text.js";
17
18
  import { backendById } from "../agent/agent-backend.js";
18
19
  // Register the built-in pi and claude adapters into the registry this module reads (see
19
- // unregisteredBackendInUse) — importing run-agent.ts is the one place they register.
20
- import "../agent/run-agent.js";
20
+ // unregisteredBackendInUse) — importing agent-run.ts is the one place they register.
21
+ import "../agent/agent-run.js";
21
22
  /** The repo-level preconditions every repo-bound command shares (requireReadyRepo in
22
23
  * cli/cli-repo-gate.ts): git
23
24
  * on PATH, a repository, tumwater.json present, at least one commit. The first unmet one's
@@ -58,7 +59,7 @@ opencodeSeed = {}) {
58
59
  // A backend an enabled seam routes to that this build has no registered adapter for cannot
59
60
  // run its jobs: refuse to start rather than letting the run fall through to pi with a model
60
61
  // id pi cannot serve (plans/opencode-backend.md part 2/7). Read the registry through
61
- // run-agent.ts, whose module scope registers the built-in pi and claude adapters.
62
+ // agent-run.ts, whose module scope registers the built-in pi and claude adapters.
62
63
  const unregistered = unregisteredBackendInUse(config);
63
64
  if (unregistered !== undefined) {
64
65
  return { problem: `backend ${unregistered} is configured but this build has no adapter for it` };
@@ -88,7 +89,7 @@ opencodeSeed = {}) {
88
89
  const codexAccount = seedOnce(root, config, "codex", codexSeedAttempted, () => seedCodexAccount(root, config));
89
90
  if (codexAccount?.billing === "api") {
90
91
  return {
91
- problem: "Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run `CODEX_HOME=<home> codex login --device-auth`",
92
+ problem: codexApiKeyRefusalMessage("<home>"),
92
93
  };
93
94
  }
94
95
  // Seed the opencode models catalog (part 6b/7) before the first run: the poll surfaces price
@@ -189,16 +190,16 @@ export function opencodeProviders(config) {
189
190
  return [...providers];
190
191
  }
191
192
  /** Every agent backend an enabled seam resolves to — the distinct agents of the one
192
- * seamsInUse list (startup-gate's consumer of it), so a claude or pi clause that exists only
193
+ * seamsInUse list (startup-gates's consumer of it), so a claude or pi clause that exists only
193
194
  * as a fallback, the resolver, or the director is checked exactly like a primary seam.
194
195
  * One list the startup gate and doctor's binary check share, so a route the harness would
195
196
  * spawn is checked on both surfaces. */
196
197
  export function backendsInUse(config) {
197
- return [...new Set(seamsInUse(config).map((s) => s.agent ?? "pi"))];
198
+ return distinctOrdered(seamsInUse(config).map((s) => s.agent ?? "pi"));
198
199
  }
199
200
  /** The first backend an enabled seam routes to that this build has no registered adapter for,
200
201
  * or undefined when every seam's backend is registered. The built-in pi and claude adapters
201
- * are registered by importing src/agent/run-agent.ts (see the import above); a seam backend
202
+ * are registered by importing src/agent/agent-run.ts (see the import above); a seam backend
202
203
  * that is not in the registry is one this build cannot run. Doctor's binary check asks the
203
204
  * same question, so the startup gate and doctor cannot disagree about which backend a build
204
205
  * can serve. opencode's and codex's adapters now register like pi's and claude's, so only a
@@ -1,9 +1,10 @@
1
1
  /** The error-streak circuit breaker (PLANS.md 2026-09-30): a role whose runs keep failing
2
2
  * consecutively is warned at ERROR_STREAK_WARN (src/run/run-apply.ts) and named by the team-wide
3
- * storm alarms (src/team/error-storm.ts, src/team/failure-spread.ts), but both only talk — the
4
- * role keeps running on the error ladder's 600 s max backoff forever, burning a model slot and
5
- * spend on a role that cannot succeed. Past ERROR_STREAK_BREAKER consecutive failed runs this gate
6
- * pauses the role through the same per-role pause marker the operator's `tumwater pause --role
3
+ * storm alarms (src/team/team-error-storm.ts, src/team/team-failure-spread.ts), but both
4
+ * only talk — the role keeps running on the error ladder's 600 s max backoff forever,
5
+ * burning a model slot and spend on a role that cannot succeed. Past ERROR_STREAK_BREAKER
6
+ * consecutive failed runs this gate pauses the role through the same per-role pause marker
7
+ * the operator's `tumwater pause --role
7
8
  * <id>` writes (src/team/team-state.ts's pauseRole — the marker, lock, and idempotence come
8
9
  * free), so the scheduler's existing pausedRolesSet skip blocks new runs with zero scheduler
9
10
  * changes. The director is NOT exempt: a failing director cannot process prompts anyway, and one
@@ -44,6 +45,73 @@ import { logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
44
45
  export function newStreakGateState() {
45
46
  return { acked: new Map() };
46
47
  }
48
+ /** Seed a fresh in-run ack cache from the persisted excused streak, so a restart keeps the
49
+ * grace the breaker (and a later operator resume) relies on instead of re-pausing a role
50
+ * whose frozen streak never ran down (BUGS.md 2026-10-23). */
51
+ function seedStreakAck(state, role, s) {
52
+ if (!state.acked.has(role) && s.streakAcked !== undefined) {
53
+ state.acked.set(role, s.streakAcked);
54
+ }
55
+ }
56
+ /** Ack while paused: the marker is the ack carrier. The streak is frozen while a role is
57
+ * paused (no new runs start), so recording it here is exactly the level the resume excuses. */
58
+ function ackPausedStreak(root, role, streak, s, state) {
59
+ if ((state.acked.get(role) ?? 0) !== streak) {
60
+ state.acked.set(role, streak);
61
+ s.streakAcked = streak;
62
+ saveRunnerStateBestEffort(root, s);
63
+ }
64
+ }
65
+ /** A streak that reset through the role's own success ends the excused episode: the next one
66
+ * must climb a fresh ERROR_STREAK_BREAKER of its own, so drop the durable ack too. */
67
+ function clearResetStreak(root, role, s, state) {
68
+ if (state.acked.get(role) !== undefined) {
69
+ state.acked.delete(role);
70
+ delete s.streakAcked;
71
+ saveRunnerStateBestEffort(root, s);
72
+ }
73
+ }
74
+ /** Ack at trip time, before the pause is observed: the bar for the next trip is
75
+ * ERROR_STREAK_BREAKER more failures from this level, whether or not any poll sees the role
76
+ * paused in between (an operator resume racing the next poll must not re-trip). The durable
77
+ * copy is written only once the pause is confirmed below — a failed or crashed pauseRole must
78
+ * not leave a permanent excused ack with no marker behind it. Returns the pause key when the
79
+ * pause was written, undefined when pauseRole failed. */
80
+ function tripStreakPause(root, role, streak, s, state, pauseKey) {
81
+ state.acked.set(role, streak);
82
+ if (!pauseRole(root, pauseKey(role)))
83
+ return undefined;
84
+ s.streakAcked = streak;
85
+ saveRunnerStateBestEffort(root, s);
86
+ logEventBestEffort(root, {
87
+ source: TUMWATER_SOURCE,
88
+ type: "role_streak_paused",
89
+ role: pauseKey(role),
90
+ streak,
91
+ ...(s.lastError ? { lastError: s.lastError } : {}),
92
+ });
93
+ return pauseKey(role);
94
+ }
95
+ /** One runner's turn in the poll: seed the in-run ack, ack while paused, clear on a reset,
96
+ * and trip past the bar. Returns the pause key when this poll tripped the role, undefined
97
+ * otherwise. The order is forced and unchanged: seed, then paused, then reset, then the bar,
98
+ * then trip. */
99
+ function pollStreakRunner(root, role, s, state, pausedRoles, pauseKey) {
100
+ const streak = s.consecutiveErrors ?? 0;
101
+ seedStreakAck(state, role, s);
102
+ if (runnerPaused(pausedRoles, role)) {
103
+ ackPausedStreak(root, role, streak, s, state);
104
+ return undefined;
105
+ }
106
+ if (streak === 0) {
107
+ clearResetStreak(root, role, s, state);
108
+ return undefined;
109
+ }
110
+ const ack = state.acked.get(role) ?? 0;
111
+ if (streak < ERROR_STREAK_BREAKER + ack)
112
+ return undefined;
113
+ return tripStreakPause(root, role, streak, s, state, pauseKey);
114
+ }
47
115
  /** Step the gate by one orchestrator poll. For each runner whose consecutive error streak
48
116
  * has reached ERROR_STREAK_BREAKER plus what its ack already excused, and whose role is not
49
117
  * in `pausedRoles`: write the per-role pause marker and log one `role_streak_paused` event
@@ -59,54 +127,9 @@ export function newStreakGateState() {
59
127
  export function pollStreakGate(root, state, runners, pausedRoles, pauseKey = (r) => r) {
60
128
  const tripped = [];
61
129
  for (const { role, state: s } of runners) {
62
- const streak = s.consecutiveErrors ?? 0;
63
- // Seed a fresh in-run ack cache from the persisted excused streak, so a restart keeps the
64
- // grace the breaker (and a later operator resume) relies on instead of re-pausing a role
65
- // whose frozen streak never ran down (BUGS.md 2026-10-23).
66
- if (!state.acked.has(role) && s.streakAcked !== undefined) {
67
- state.acked.set(role, s.streakAcked);
68
- }
69
- // Ack while paused: the marker is the ack carrier. The streak is frozen while a role is
70
- // paused (no new runs start), so recording it here is exactly the level the resume excuses.
71
- if (runnerPaused(pausedRoles, role)) {
72
- if ((state.acked.get(role) ?? 0) !== streak) {
73
- state.acked.set(role, streak);
74
- s.streakAcked = streak;
75
- saveRunnerStateBestEffort(root, s);
76
- }
77
- continue;
78
- }
79
- if (streak === 0) {
80
- // A streak that reset through the role's own success ends the excused episode: the
81
- // next one must climb a fresh ERROR_STREAK_BREAKER of its own, so drop the durable ack too.
82
- if (state.acked.get(role) !== undefined) {
83
- state.acked.delete(role);
84
- delete s.streakAcked;
85
- saveRunnerStateBestEffort(root, s);
86
- }
87
- continue;
88
- }
89
- const ack = state.acked.get(role) ?? 0;
90
- if (streak < ERROR_STREAK_BREAKER + ack)
91
- continue;
92
- // Ack at trip time, before the pause is observed: the bar for the next trip is
93
- // ERROR_STREAK_BREAKER more failures from this level, whether or not any poll sees the
94
- // role paused in between (an operator resume racing the next poll must not re-trip). The
95
- // durable copy is written only once the pause is confirmed below — a failed or crashed
96
- // pauseRole must not leave a permanent excused ack with no marker behind it.
97
- state.acked.set(role, streak);
98
- if (pauseRole(root, pauseKey(role))) {
99
- s.streakAcked = streak;
100
- saveRunnerStateBestEffort(root, s);
101
- logEventBestEffort(root, {
102
- source: TUMWATER_SOURCE,
103
- type: "role_streak_paused",
104
- role: pauseKey(role),
105
- streak,
106
- ...(s.lastError ? { lastError: s.lastError } : {}),
107
- });
108
- tripped.push(pauseKey(role));
109
- }
130
+ const key = pollStreakRunner(root, role, s, state, pausedRoles, pauseKey);
131
+ if (key !== undefined)
132
+ tripped.push(key);
110
133
  }
111
134
  return tripped;
112
135
  }
@@ -5,6 +5,10 @@
5
5
  import { changeBaseRev, gitLines } from "./git.js";
6
6
  import { git, gitTry } from "./git-run.js";
7
7
  import { cutSplitsSurrogatePair } from "../text/text.js";
8
+ /** The one cap on a fetched change diff, shared by the repo-relative patch (aheadOfMainDiff,
9
+ * revisionInterdiff) and the view half's uncommitted patch (change-data.ts) so neither side
10
+ * of a reviewed change can print unbounded text and one bound is all they agree on. */
11
+ export const DIFF_MAX_BYTES = 200_000;
8
12
  /** Git's C-quoting short escapes (its `sq_lookup` table) and the byte each decodes to. The
9
13
  * mapping was a nine-case switch that repeated `bytes += …; break;` per entry; keeping it as
10
14
  * data lets the decoder below handle the octal and literal fallbacks in one place. */
@@ -153,7 +157,7 @@ function splitDiffByFile(diff) {
153
157
  * see, and a binary file's short "Binary files differ" stub naturally ranks below any real text
154
158
  * change. A failed `git diff` throws: the reviewer handed an empty diff judged no change at
155
159
  * all. A view catches it itself. */
156
- export async function aheadOfMainDiff(wt, mainBranch, maxBytes = 200_000, pathspec = []) {
160
+ export async function aheadOfMainDiff(wt, mainBranch, maxBytes = DIFF_MAX_BYTES, pathspec = []) {
157
161
  const range = `${mainBranch}...HEAD`;
158
162
  const full = await git(wt, "diff", range, ...pathspec);
159
163
  if (full.length <= maxBytes)
@@ -181,7 +185,7 @@ export async function aheadOfMainDiff(wt, mainBranch, maxBytes = 200_000, pathsp
181
185
  * the two versions even when the change is tiny: at the default 60% a two-line addition can
182
186
  * fail to pair and render as two unrelated commits (`<`/`>`), which is exactly the case a
183
187
  * small revision is. */
184
- export async function revisionInterdiff(wt, mainBranch, priorSha, head, maxBytes = 200_000) {
188
+ export async function revisionInterdiff(wt, mainBranch, priorSha, head, maxBytes = DIFF_MAX_BYTES) {
185
189
  try {
186
190
  const priorBase = await changeBaseRev(wt, mainBranch, priorSha);
187
191
  const headBase = await changeBaseRev(wt, mainBranch, head);
@@ -64,40 +64,57 @@ const GIT_REDIRECT_ENV = ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_NAM
64
64
  /** Refs git keeps per worktree (in the worktree's own git dir, not the common one). They are
65
65
  * left to git. */
66
66
  const PER_WORKTREE_REF = /^refs\/(?:bisect|worktree|rewritten)\//;
67
- /** `dir`'s git dirs when its `.git` resolves from files to a files-backend repository this
68
- * process's user owns, or undefined when git should decide. */
69
- function repoFiles(dir) {
70
- if (GIT_REDIRECT_ENV.some((name) => process.env[name]))
71
- return undefined;
72
- const uid = process.getuid?.();
73
- if (uid === undefined)
74
- return undefined;
75
- const gitdir = resolveGitDir(dir);
76
- if (gitdir === undefined)
77
- return undefined;
78
- let commondir = gitdir;
67
+ /** Whether an environment variable makes git look somewhere other than the files `dir` holds. */
68
+ function gitRedirectsSet() {
69
+ return GIT_REDIRECT_ENV.some((name) => process.env[name]);
70
+ }
71
+ /** The shared refs/objects dir a gitdir points at via its `commondir` file: the gitdir itself
72
+ * when there is no file (a primary checkout), the resolved pointer when there is, and
73
+ * undefined when the file is blank or unreadable for any reason but absence. */
74
+ function resolveCommondir(gitdir) {
79
75
  try {
80
76
  const pointer = fs.readFileSync(path.join(gitdir, "commondir"), "utf8").trim();
81
77
  if (pointer === "")
82
78
  return undefined;
83
- commondir = path.resolve(gitdir, pointer);
79
+ return path.resolve(gitdir, pointer);
84
80
  }
85
81
  catch (err) {
86
82
  if (errCode(err) !== "ENOENT")
87
83
  return undefined; // A primary checkout has none.
84
+ return gitdir;
88
85
  }
86
+ }
87
+ /** Whether the files-backend trust checks hold: this process's user owns both dirs, the
88
+ * objects dir and HEAD are present, and no reftable store shadows them. */
89
+ function filesBackendTrusted(dir, gitdir, commondir, uid) {
89
90
  try {
90
91
  if (fs.statSync(dir).uid !== uid || fs.statSync(gitdir).uid !== uid)
91
- return undefined;
92
+ return false;
92
93
  if (!fs.statSync(path.join(commondir, "objects")).isDirectory())
93
- return undefined;
94
+ return false;
94
95
  if (!fs.statSync(path.join(gitdir, "HEAD")).isFile())
95
- return undefined;
96
+ return false;
96
97
  }
97
98
  catch {
98
- return undefined;
99
+ return false;
99
100
  }
100
- if (fs.existsSync(path.join(commondir, "reftable")))
101
+ return !fs.existsSync(path.join(commondir, "reftable"));
102
+ }
103
+ /** `dir`'s git dirs when its `.git` resolves from files to a files-backend repository this
104
+ * process's user owns, or undefined when git should decide. */
105
+ function repoFiles(dir) {
106
+ if (gitRedirectsSet())
107
+ return undefined;
108
+ const uid = process.getuid?.();
109
+ if (uid === undefined)
110
+ return undefined;
111
+ const gitdir = resolveGitDir(dir);
112
+ if (gitdir === undefined)
113
+ return undefined;
114
+ const commondir = resolveCommondir(gitdir);
115
+ if (commondir === undefined)
116
+ return undefined;
117
+ if (!filesBackendTrusted(dir, gitdir, commondir, uid))
101
118
  return undefined;
102
119
  return { gitdir, commondir };
103
120
  }
@@ -147,35 +164,33 @@ function storedRef(commondir, ref) {
147
164
  }
148
165
  return shaFromPackedRefs(packed, ` ${ref}`);
149
166
  }
150
- /** The SHA `git rev-parse --verify <ref>` would print in `dir`, read from files, or undefined
151
- * when the files do not settle it (see the section comment): the caller then spawns git. `ref`
152
- * is HEAD (followed when it is a branch symref or a detached SHA), a full `refs/…` name, or a
153
- * short branch name, taken only when git's DWIM order could not resolve it to anything else
154
- * first (`<gitdir>/<name>`, `refs/<name>`, `refs/tags/<name>` all absent). */
155
- export function refShaFromFiles(dir, ref) {
156
- const repo = repoFiles(dir);
157
- if (repo === undefined)
158
- return undefined;
159
- if (ref === "HEAD") {
160
- let head;
161
- try {
162
- head = fs.readFileSync(path.join(repo.gitdir, "HEAD"), "utf8").trim();
163
- }
164
- catch {
165
- return undefined;
166
- }
167
- if (isSha(head))
168
- return head;
169
- const target = head.startsWith("ref: ") ? head.slice("ref: ".length).trim() : "";
170
- if (!target.startsWith("refs/heads/") || !isPlainRefName(target))
171
- return undefined;
172
- return storedRef(repo.commondir, target) ?? undefined;
167
+ /** The HEAD read: the detached SHA held in `<repo.gitdir>/HEAD`, else the SHA its `ref: `
168
+ * branch symref (a plain `refs/heads/…` name) resolves to, or undefined when the files do not
169
+ * settle it. */
170
+ function refShaFromHead(repo) {
171
+ let head;
172
+ try {
173
+ head = fs.readFileSync(path.join(repo.gitdir, "HEAD"), "utf8").trim();
173
174
  }
174
- if (ref.startsWith("refs/")) {
175
- if (!isPlainRefName(ref) || PER_WORKTREE_REF.test(ref))
176
- return undefined;
177
- return storedRef(repo.commondir, ref) ?? undefined;
175
+ catch {
176
+ return undefined;
178
177
  }
178
+ if (isSha(head))
179
+ return head;
180
+ const target = head.startsWith("ref: ") ? head.slice("ref: ".length).trim() : "";
181
+ if (!target.startsWith("refs/heads/") || !isPlainRefName(target))
182
+ return undefined;
183
+ return storedRef(repo.commondir, target) ?? undefined;
184
+ }
185
+ /** A full `refs/…` name: its plain-name and per-worktree guards, then the stored read. */
186
+ function refShaFromFullRefName(repo, ref) {
187
+ if (!isPlainRefName(ref) || PER_WORKTREE_REF.test(ref))
188
+ return undefined;
189
+ return storedRef(repo.commondir, ref) ?? undefined;
190
+ }
191
+ /** A short branch name: the DWIM-order guards (`<gitdir>/<name>`, `refs/<name>` and
192
+ * `refs/tags/<name>` all outrank the branch), then `refs/heads/<name>`. */
193
+ function refShaFromShortName(repo, ref) {
179
194
  // A full object name means the object to git, ahead of any same-named ref.
180
195
  if (isSha(ref) || !isPlainRefName(`refs/heads/${ref}`))
181
196
  return undefined;
@@ -189,6 +204,21 @@ export function refShaFromFiles(dir, ref) {
189
204
  }
190
205
  return storedRef(repo.commondir, `refs/heads/${ref}`) ?? undefined;
191
206
  }
207
+ /** The SHA `git rev-parse --verify <ref>` would print in `dir`, read from files, or undefined
208
+ * when the files do not settle it (see the section comment): the caller then spawns git. `ref`
209
+ * is HEAD (followed when it is a branch symref or a detached SHA), a full `refs/…` name, or a
210
+ * short branch name, taken only when git's DWIM order could not resolve it to anything else
211
+ * first (`<gitdir>/<name>`, `refs/<name>`, `refs/tags/<name>` all absent). */
212
+ export function refShaFromFiles(dir, ref) {
213
+ const repo = repoFiles(dir);
214
+ if (repo === undefined)
215
+ return undefined;
216
+ if (ref === "HEAD")
217
+ return refShaFromHead(repo);
218
+ if (ref.startsWith("refs/"))
219
+ return refShaFromFullRefName(repo, ref);
220
+ return refShaFromShortName(repo, ref);
221
+ }
192
222
  /** False when the files settle that `root`'s repository has no linked-worktree registrations
193
223
  * (its `<commondir>/worktrees` admin dir is absent or empty), so `git worktree prune` has
194
224
  * nothing to do. True when there are some, and undefined when git should decide. */
@@ -19,7 +19,6 @@ import { beginWorktreeUse } from "./worktree-use.js";
19
19
  import { ensureDetachedWorktree, removeWorktreeDir } from "./worktree.js";
20
20
  import { logEventBestEffort, warnEventBestEffort, tryOrWarn, TUMWATER_SOURCE } from "../events/events.js";
21
21
  import { errorMessage } from "../text/text.js";
22
- import { dropProviderLeaseKey } from "../concurrency/provider-lease.js";
23
22
  /** In-process waiters per root, woken when any slot is released. Keyed by resolved root so two
24
23
  * roots never wake each other. */
25
24
  const waiters = new Map();
@@ -192,11 +191,9 @@ async function acquire(root, role, purpose, signal, slotBudget) {
192
191
  logSlotWait(root, role, purpose, Date.now() - startedAt);
193
192
  return chosen;
194
193
  }
195
- // The pool-slot wait rule (plans/provider-concurrency.md §5 "No deadlock"): a lease never holds
196
- // a provider key while it waits for a pool slot. Drop the calling lease's key (which never
197
- // waits) before parking here, so this run or verify holds no provider slot whose in-use a
198
- // re-key waiter is waiting to drain; the next run re-takes the key as an ordinary re-key.
199
- await dropProviderLeaseKey();
194
+ // The pool-slot wait needs no permit dropped: a run or verify holds only the one team
195
+ // permit (no provider key any more), so it parks on the busy pool like any other slot
196
+ // waiter.
200
197
  const waiter = registerWaiter(root);
201
198
  try {
202
199
  await raceAbort(waiter.promise, signal);
@@ -2,13 +2,13 @@
2
2
  * The dashboard's GET settings/admin handlers (src/gui/gui-server.ts routes to them):
3
3
  * prompt-text, doctor, config, retire-list, and brief. Each answers its request and touches no
4
4
  * socket beyond its own `res`; the domain work lives one layer down (doctor/doctor.js,
5
- * config/config.js, operator/retire.js), so this module only adapts HTTP onto it. Barrelled
6
- * into gui/gui-endpoints.ts beside the other per-family GET endpoint modules.
5
+ * config/config.js, operator/operator-retire.js), so this module only adapts HTTP onto it.
6
+ * it is barrelled into gui/gui-endpoints.ts beside the other per-family GET endpoint modules.
7
7
  */
8
8
  import { runDoctor } from "../doctor/doctor.js";
9
9
  import { knownRoleIds, loadConfigSafe } from "../config/config.js";
10
10
  import { runnerEnabled } from "../roles/runner-ids.js";
11
- import { artifactPhrase, previewRetire } from "../operator/retire.js";
11
+ import { artifactPhrase, previewRetire } from "../operator/operator-retire.js";
12
12
  import { EDITABLE_CONFIG_KEYS } from "../config/config-editable-keys.js";
13
13
  import { readBrief } from "../brief/brief.js";
14
14
  import { readQueuedPromptBody } from "../inbox/inbox-edit.js";
@@ -80,6 +80,18 @@ export function rejectBadQueueFileName(res, name) {
80
80
  badRequest(res, problem);
81
81
  return true;
82
82
  }
83
+ /** Pull a required `sha` commit id out of a POST body by the rule the History revert, the
84
+ * merge-queue cancel/top, and the post-review accept endpoints share: a value that is not a
85
+ * 4-64 hex-digit commit id (so never a flag or a ref name) is refused as a 400 naming the
86
+ * value. Returns the validated commit id, or null once the 400 is sent — the body-sha twin of
87
+ * requireStringField / requirePausedFlag, so the four operators' validation and 400 wording
88
+ * cannot drift. */
89
+ export function requireSha(res, sha) {
90
+ if (typeof sha === "string" && /^[0-9a-f]{4,64}$/i.test(sha))
91
+ return sha;
92
+ badRequest(res, `"sha" must be a hex commit id of 4-64 digits${gotSuffix(sha)}`);
93
+ return null;
94
+ }
83
95
  /** Read an integer query parameter with the GUI's established discipline for an explicit
84
96
  * count: absent → `fallback` (or, when no fallback is given, the parameter is required and
85
97
  * its absence is its own 400 — "<name> required"); present but not a plain decimal integer
@@ -4,7 +4,7 @@
4
4
  * question-answer, backlog-file, backlog-cancel, backlog-release, backlog-top, and
5
5
  * backlog-edit — the `checkedBacklogKind` / `checkedEntryPositionAndTitle` validators they share
6
6
  * ride with them. Each handler adapts
7
- * HTTP onto the domain work one layer down (backlog-write, question-answer, backlog-commit)
7
+ * HTTP onto the domain work one layer down (backlog-write, backlog-question-answer, backlog-commit)
8
8
  * and touches no socket beyond its own `res`.
9
9
  */
10
10
  import { backlogHolds, cancelledClaimNote, stillHeldNote } from "../backlog/backlog-eligibility.js";
@@ -15,7 +15,7 @@ import { commitOperatorBacklogEdit } from "../backlog/backlog-commit.js";
15
15
  import { stampedSubject } from "../git/commit-message.js";
16
16
  import { fileEntryWithBody, normalizeEntryBody, OPERATOR_FILE_KINDS, tryCancelEntry, tryEditEntry, bodyStructureProblem } from "../backlog/backlog-write.js";
17
17
  import { tryMoveEntryToTop, tryReleaseEntry } from "../backlog/backlog-release.js";
18
- import { tryAnswerQuestion, UNCLOSED_FENCE_MESSAGE } from "../backlog/question-answer.js";
18
+ import { tryAnswerQuestion, UNCLOSED_FENCE_MESSAGE } from "../backlog/backlog-question-answer.js";
19
19
  import { promptLengthProblem } from "../inbox/inbox-submit.js";
20
20
  import { requestWake, submitRolePromptAndWake, wakeQuestionAsker } from "../operator/operator-intent.js";
21
21
  import { badRequest, okGone, sendJson } from "./gui-http-body.js";
@@ -202,8 +202,8 @@ async function respondBacklogWrite(res, root, kind, position, outcome, verb, sta
202
202
  * `tumwater plan|bug --release <n>` through the non-exiting tryReleaseEntry. Body as
203
203
  * backlog-cancel's. A released entry that is not still held is newly eligible, so the kind's
204
204
  * role is woken (a held one wakes no role and carries `heldNote`); `gone` and `moved` answer
205
- * okGone, and an entry with none of the Refused or
206
- * held-for-operator notes answers status "not_refused" with no write and no wake. */
205
+ * okGone, and an entry with no Refused or Needs review note answers status "not_refused" with no
206
+ * write and no wake. */
207
207
  export async function handleBacklogRelease(req, res, root) {
208
208
  const body = await readPostBody(req, res, '{"kind": "bug", "position": 1, "title": "<heading>"}');
209
209
  if (!body)