tumwater 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (464) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +55 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +6 -4
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +404 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +281 -55
  41. package/dist/src/backlog/backlog-cli-view.js +255 -0
  42. package/dist/src/backlog/backlog-cli.js +91 -335
  43. package/dist/src/backlog/backlog-commit.js +20 -13
  44. package/dist/src/backlog/backlog-eligibility.js +193 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +264 -0
  48. package/dist/src/backlog/backlog-release.js +134 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-guardrails.js +136 -0
  56. package/dist/src/baseline/main-red.js +153 -36
  57. package/dist/src/baseline/main-watch.js +232 -0
  58. package/dist/src/brief/brief-edit.js +12 -12
  59. package/dist/src/brief/brief.js +31 -51
  60. package/dist/src/budget/budget-fallback-view.js +288 -0
  61. package/dist/src/budget/budget-wiring.js +158 -0
  62. package/dist/src/budget/budget.js +31 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +65 -3
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +181 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +183 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +291 -598
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-review-commands.js +113 -0
  84. package/dist/src/cli/cli-role-command.js +525 -0
  85. package/dist/src/cli/cli-start-summary.js +167 -0
  86. package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
  87. package/dist/src/cli/completion.js +169 -22
  88. package/dist/src/cli/config-commands.js +58 -26
  89. package/dist/src/cli/follow-view.js +75 -0
  90. package/dist/src/cli/help.js +258 -82
  91. package/dist/src/cli/log-commands.js +164 -85
  92. package/dist/src/cli/question-commands.js +211 -10
  93. package/dist/src/cli.js +173 -45
  94. package/dist/src/collections.js +56 -13
  95. package/dist/src/concurrency/check-permit.js +20 -3
  96. package/dist/src/concurrency/lock.js +17 -13
  97. package/dist/src/concurrency/provider-lease.js +44 -8
  98. package/dist/src/concurrency/semaphore.js +102 -26
  99. package/dist/src/config/config-aliases.js +0 -1
  100. package/dist/src/config/config-editable-keys.js +16 -1
  101. package/dist/src/config/config-fallbacks.js +94 -56
  102. package/dist/src/config/config-field-checks.js +40 -10
  103. package/dist/src/config/config-keys.js +30 -8
  104. package/dist/src/config/config-live.js +63 -24
  105. package/dist/src/config/config-request.js +137 -0
  106. package/dist/src/config/config-schema.js +31 -24
  107. package/dist/src/config/config-selector-checks.js +123 -0
  108. package/dist/src/config/config-validation-agents.js +80 -0
  109. package/dist/src/config/config-validation-model.js +116 -0
  110. package/dist/src/config/config-validation.js +169 -222
  111. package/dist/src/config/config-views.js +136 -54
  112. package/dist/src/config/config-write-core.js +135 -0
  113. package/dist/src/config/config-write.js +164 -265
  114. package/dist/src/config/config.js +53 -38
  115. package/dist/src/config/model-selector.js +34 -34
  116. package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
  117. package/dist/src/doctor/doctor-backlog-plans.js +398 -0
  118. package/dist/src/doctor/doctor-backlog.js +249 -512
  119. package/dist/src/doctor/doctor-check-names.js +85 -0
  120. package/dist/src/doctor/doctor-checks.js +61 -593
  121. package/dist/src/doctor/doctor-claude-checks.js +142 -0
  122. package/dist/src/doctor/doctor-codex-checks.js +276 -0
  123. package/dist/src/doctor/doctor-contention-checks.js +506 -0
  124. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  125. package/dist/src/doctor/doctor-holds.js +80 -66
  126. package/dist/src/doctor/doctor-install.js +2 -1
  127. package/dist/src/doctor/doctor-model-checks.js +216 -537
  128. package/dist/src/doctor/doctor-notify.js +9 -5
  129. package/dist/src/doctor/doctor-opencode-checks.js +164 -0
  130. package/dist/src/doctor/doctor-orphans.js +182 -63
  131. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  132. package/dist/src/doctor/doctor-project-checks.js +397 -0
  133. package/dist/src/doctor/doctor.js +126 -77
  134. package/dist/src/doctor/image-probe.js +21 -8
  135. package/dist/src/doctor/outcome.js +3 -2
  136. package/dist/src/doctor/probe.js +21 -12
  137. package/dist/src/events/event-format.js +185 -89
  138. package/dist/src/events/event-labels.js +11 -5
  139. package/dist/src/events/event-legacy.js +39 -15
  140. package/dist/src/events/event-read.js +52 -39
  141. package/dist/src/events/event-tally.js +16 -0
  142. package/dist/src/events/event-window.js +80 -26
  143. package/dist/src/events/events.js +30 -13
  144. package/dist/src/events/notify.js +7 -7
  145. package/dist/src/failure/failure-cluster.js +12 -12
  146. package/dist/src/failure/failure-data.js +65 -60
  147. package/dist/src/failure/failure-render.js +29 -21
  148. package/dist/src/failure/failure-state-change.js +17 -12
  149. package/dist/src/failure/time-spend.js +35 -35
  150. package/dist/src/files/file-queue.js +17 -0
  151. package/dist/src/files/files.js +111 -35
  152. package/dist/src/files/json-files.js +3 -3
  153. package/dist/src/files/json-object.js +28 -7
  154. package/dist/src/files/stat-cache.js +2 -2
  155. package/dist/src/files/tail.js +1 -1
  156. package/dist/src/gates/bootstrap-gates.js +4 -5
  157. package/dist/src/gates/budget-gates.js +88 -225
  158. package/dist/src/gates/disk-gate.js +1 -1
  159. package/dist/src/gates/gate-polls.js +101 -143
  160. package/dist/src/gates/maintenance-quota.js +37 -30
  161. package/dist/src/gates/pause-gates.js +28 -10
  162. package/dist/src/gates/readiness.js +7 -3
  163. package/dist/src/gates/role-cap-gates.js +8 -8
  164. package/dist/src/gates/startup-gate.js +94 -32
  165. package/dist/src/gates/streak-gate.js +54 -30
  166. package/dist/src/git/commit-message.js +89 -46
  167. package/dist/src/git/git-diff.js +16 -10
  168. package/dist/src/git/git-run.js +79 -38
  169. package/dist/src/git/git.js +54 -34
  170. package/dist/src/git/slot-salvage.js +253 -0
  171. package/dist/src/git/slots-state.js +6 -6
  172. package/dist/src/git/worktree-pool.js +132 -38
  173. package/dist/src/git/worktree-use.js +4 -11
  174. package/dist/src/git/worktree.js +94 -37
  175. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  176. package/dist/src/gui/gui-args.js +79 -8
  177. package/dist/src/gui/gui-backlog-commands.js +43 -19
  178. package/dist/src/gui/gui-backlog-endpoints.js +156 -0
  179. package/dist/src/gui/gui-command.js +26 -13
  180. package/dist/src/gui/gui-config-commands.js +168 -80
  181. package/dist/src/gui/gui-endpoint-commands.js +5 -4
  182. package/dist/src/gui/gui-endpoint-common.js +4 -4
  183. package/dist/src/gui/gui-endpoints.js +11 -380
  184. package/dist/src/gui/gui-history-endpoints.js +301 -0
  185. package/dist/src/gui/gui-operator-commands.js +235 -13
  186. package/dist/src/gui/gui-prompt-commands.js +26 -20
  187. package/dist/src/gui/gui-report-endpoints.js +97 -0
  188. package/dist/src/gui/gui-server.js +26 -5
  189. package/dist/src/gui/gui-team-commands.js +7 -7
  190. package/dist/src/gui/http-body.js +1 -1
  191. package/dist/src/history/history-data.js +122 -234
  192. package/dist/src/history/history-queued.js +155 -0
  193. package/dist/src/history/history-render.js +42 -22
  194. package/dist/src/history/history.js +190 -49
  195. package/dist/src/inbox/inbox-attachments.js +29 -30
  196. package/dist/src/inbox/inbox-cancel.js +10 -11
  197. package/dist/src/inbox/inbox-edit.js +9 -9
  198. package/dist/src/inbox/inbox-submit.js +18 -18
  199. package/dist/src/inbox/inbox-top.js +28 -17
  200. package/dist/src/inbox/inbox.js +42 -43
  201. package/dist/src/inbox/pending-prompt.js +26 -26
  202. package/dist/src/inbox/prompt-commands.js +62 -49
  203. package/dist/src/inbox/prompt-not-before.js +2 -2
  204. package/dist/src/init/init-templates.js +1 -2
  205. package/dist/src/init/init.js +28 -23
  206. package/dist/src/merge/backlog-conflicts.js +215 -35
  207. package/dist/src/merge/merge-batch.js +14 -8
  208. package/dist/src/merge/merge-cancel.js +53 -0
  209. package/dist/src/merge/merge-check-failures.js +44 -22
  210. package/dist/src/merge/merge-core.js +58 -32
  211. package/dist/src/merge/merge-diff.js +5 -4
  212. package/dist/src/merge/merge-drain.js +45 -19
  213. package/dist/src/merge/merge-git.js +177 -38
  214. package/dist/src/merge/merge-pipeline.js +15 -3
  215. package/dist/src/merge/merge-post-review.js +214 -0
  216. package/dist/src/merge/merge-queue-render.js +48 -11
  217. package/dist/src/merge/merge-queue.js +52 -15
  218. package/dist/src/merge/merge-requeue.js +103 -0
  219. package/dist/src/merge/merge-slot.js +61 -16
  220. package/dist/src/merge/merge-stack.js +27 -11
  221. package/dist/src/merge/merge-to-main.js +77 -26
  222. package/dist/src/merge/merge-top.js +83 -0
  223. package/dist/src/merge/merge-verify.js +75 -18
  224. package/dist/src/notes-cli.js +35 -15
  225. package/dist/src/operator/operator-commands.js +192 -81
  226. package/dist/src/operator/operator-intent.js +69 -42
  227. package/dist/src/operator/operator-requests.js +35 -40
  228. package/dist/src/operator/retire.js +5 -6
  229. package/dist/src/operator/revert.js +142 -34
  230. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  231. package/dist/src/orchestrator/orchestrator-poll.js +352 -0
  232. package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
  233. package/dist/src/orchestrator/orchestrator.js +129 -369
  234. package/dist/src/orchestrator/retention.js +42 -8
  235. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  236. package/dist/src/orchestrator/scheduling-scope.js +245 -0
  237. package/dist/src/orchestrator/scheduling-skip.js +195 -0
  238. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  239. package/dist/src/orchestrator/startup-migrations.js +11 -7
  240. package/dist/src/paths.js +126 -49
  241. package/dist/src/pi/command-shape.js +12 -1
  242. package/dist/src/pi/pi-args.js +6 -4
  243. package/dist/src/pi/pi-backend.js +6 -6
  244. package/dist/src/pi/pi-bin.js +54 -63
  245. package/dist/src/pi/pi-listed-models.js +53 -0
  246. package/dist/src/pi/pi-models.js +63 -30
  247. package/dist/src/pi/pi-run-result.js +1 -1
  248. package/dist/src/pi/pi-settings.js +1 -1
  249. package/dist/src/pi/pi-stream.js +6 -6
  250. package/dist/src/pi/pi-watchdogs.js +18 -19
  251. package/dist/src/pi-extension/bounded-output.js +5 -3
  252. package/dist/src/pi-extension/context-budget.js +2 -2
  253. package/dist/src/pi-extension/context-shake.js +1 -1
  254. package/dist/src/pi-extension/context-window.js +0 -1
  255. package/dist/src/pi-extension/full-output.js +3 -10
  256. package/dist/src/pi-extension/strip-thinking.js +0 -1
  257. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  258. package/dist/src/pi-extension/write-guard.js +37 -0
  259. package/dist/src/process/launch-services.js +1 -2
  260. package/dist/src/process/node-bin.js +16 -0
  261. package/dist/src/process/process-group.js +8 -18
  262. package/dist/src/process/process.js +41 -2
  263. package/dist/src/process/run-marker.js +26 -6
  264. package/dist/src/process/supervisor.js +22 -21
  265. package/dist/src/prompt/dialect.js +17 -2
  266. package/dist/src/prompt/gate-notes.js +203 -0
  267. package/dist/src/prompt/gate-prompts.js +150 -217
  268. package/dist/src/prompt/notes-backlog.js +184 -0
  269. package/dist/src/prompt/principles.js +1 -2
  270. package/dist/src/prompt/prompt-followup.js +19 -14
  271. package/dist/src/prompt/prompt.js +131 -204
  272. package/dist/src/redeploy/redeploy-policy.js +24 -19
  273. package/dist/src/redeploy/redeploy-probes.js +1 -1
  274. package/dist/src/redeploy/redeploy.js +2 -3
  275. package/dist/src/redeploy/redeployer.js +20 -20
  276. package/dist/src/redeploy/self-reload.js +2 -1
  277. package/dist/src/report/report-data.js +188 -124
  278. package/dist/src/report/report-fold.js +130 -54
  279. package/dist/src/report/report-main-red.js +55 -0
  280. package/dist/src/report/report-render.js +136 -65
  281. package/dist/src/report/report-types.js +1 -0
  282. package/dist/src/report/report.js +23 -9
  283. package/dist/src/request-timeouts.js +2 -2
  284. package/dist/src/review/diff-rules.js +53 -0
  285. package/dist/src/review/known-flakes.js +112 -1
  286. package/dist/src/review/post-merge-review-accept.js +51 -0
  287. package/dist/src/review/post-merge-review-render.js +112 -0
  288. package/dist/src/review/post-merge-review.js +239 -0
  289. package/dist/src/review/protected-edits.js +24 -10
  290. package/dist/src/review/quarantine-render.js +65 -0
  291. package/dist/src/review/review-followup.js +8 -8
  292. package/dist/src/review/review-precheck.js +66 -23
  293. package/dist/src/review/review-run.js +395 -0
  294. package/dist/src/review/review-verdict.js +2 -2
  295. package/dist/src/review/review.js +180 -401
  296. package/dist/src/review/secret-leaks.js +36 -16
  297. package/dist/src/review/suite-rerun.js +0 -1
  298. package/dist/src/roles/legacy-role-migration.js +80 -7
  299. package/dist/src/roles/role-catalog.js +127 -12
  300. package/dist/src/roles/role-guidance.js +65 -20
  301. package/dist/src/roles/role-labels.js +7 -5
  302. package/dist/src/roles/role-render.js +108 -56
  303. package/dist/src/roles/role-view.js +95 -56
  304. package/dist/src/roles/roles.js +33 -33
  305. package/dist/src/roles/runner-ids.js +97 -0
  306. package/dist/src/run/acceptance-greps.js +119 -0
  307. package/dist/src/run/ci-parity.js +121 -0
  308. package/dist/src/run/feedback-filings.js +51 -0
  309. package/dist/src/run/qa-coverage.js +125 -0
  310. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  311. package/dist/src/run/run-detail-data.js +175 -0
  312. package/dist/src/run/run-detail-render.js +91 -0
  313. package/dist/src/run/run-detail.js +168 -0
  314. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
  315. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  316. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
  317. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  318. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
  319. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  320. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  321. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
  322. package/dist/src/run/selfcheck-digest.js +40 -0
  323. package/dist/src/run/stage-backlog-checks.js +249 -0
  324. package/dist/src/run/stage-check.js +178 -0
  325. package/dist/src/run/stage-checks-shared.js +65 -0
  326. package/dist/src/run/stage-name-checks.js +204 -0
  327. package/dist/src/run/stage-path-checks.js +231 -0
  328. package/dist/src/run/stage-prose-checks.js +261 -0
  329. package/dist/src/runner/leftover.js +216 -0
  330. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  331. package/dist/src/runner/revision.js +191 -0
  332. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  333. package/dist/src/runner/runner-outcome.js +109 -0
  334. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  335. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
  336. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
  337. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  338. package/dist/src/runner/runner-state.js +103 -0
  339. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  340. package/dist/src/scheduling/backoff.js +38 -38
  341. package/dist/src/scheduling/claim-entries.js +49 -0
  342. package/dist/src/scheduling/claims.js +35 -22
  343. package/dist/src/scheduling/once-round.js +22 -22
  344. package/dist/src/scheduling/quiet-hours.js +5 -6
  345. package/dist/src/scheduling/scheduling.js +74 -64
  346. package/dist/src/scheduling/work-merged-cache.js +2 -2
  347. package/dist/src/status/status-budget.js +55 -47
  348. package/dist/src/status/status-data.js +49 -17
  349. package/dist/src/status/status-inbox.js +2 -2
  350. package/dist/src/status/status-install.js +0 -1
  351. package/dist/src/status/status-polls.js +43 -28
  352. package/dist/src/status/status-roles.js +50 -13
  353. package/dist/src/team/error-storm.js +8 -8
  354. package/dist/src/team/failure-spread.js +2 -2
  355. package/dist/src/team/orchestrator-info.js +15 -9
  356. package/dist/src/team/reclaim.js +7 -8
  357. package/dist/src/team/team-hold.js +7 -7
  358. package/dist/src/team/team-polls.js +91 -50
  359. package/dist/src/team/team-state.js +8 -2
  360. package/dist/src/text/datetime.js +65 -18
  361. package/dist/src/text/format.js +48 -11
  362. package/dist/src/text/markdown.js +8 -5
  363. package/dist/src/text/phrases.js +60 -23
  364. package/dist/src/text/suggest.js +1 -1
  365. package/dist/src/text/text.js +78 -13
  366. package/dist/src/ui/badges.js +99 -38
  367. package/dist/src/ui/gui/gui-client-backlog.js +314 -0
  368. package/dist/src/ui/gui/gui-client-boot.js +89 -61
  369. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  370. package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
  371. package/dist/src/ui/gui/gui-client-drawer.js +155 -289
  372. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  373. package/dist/src/ui/gui/gui-client-history.js +437 -68
  374. package/dist/src/ui/gui/gui-client-labels.js +6 -3
  375. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  376. package/dist/src/ui/gui/gui-client-model.js +27 -36
  377. package/dist/src/ui/gui/gui-client-operator.js +60 -52
  378. package/dist/src/ui/gui/gui-client-pending.js +405 -24
  379. package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
  380. package/dist/src/ui/gui/gui-client-report.js +105 -430
  381. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  382. package/dist/src/ui/gui/gui-client-settings.js +163 -5
  383. package/dist/src/ui/gui/gui-client-team.js +36 -465
  384. package/dist/src/ui/gui/gui-client.js +38 -26
  385. package/dist/src/ui/gui/gui-icons.js +0 -2
  386. package/dist/src/ui/gui/gui-page.js +10 -7
  387. package/dist/src/ui/gui/gui-styles.js +38 -34
  388. package/dist/src/ui/progress-data.js +9 -11
  389. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
  390. package/dist/src/ui/status-model.js +133 -125
  391. package/dist/src/ui/status-payload.js +364 -49
  392. package/dist/src/ui/status-render.js +109 -82
  393. package/dist/src/ui/team-alerts.js +728 -62
  394. package/dist/src/ui/tone.js +12 -6
  395. package/dist/src/ui/transcript-run.js +58 -0
  396. package/dist/src/ui/transcript-tail.js +28 -20
  397. package/dist/src/ui/transcript.js +63 -23
  398. package/dist/src/ui/tui/tui-app.js +0 -1
  399. package/dist/src/ui/tui/tui-backlog.js +53 -14
  400. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  401. package/dist/src/ui/tui/tui-editor-attach.js +4 -12
  402. package/dist/src/ui/tui/tui-editor-backends.js +127 -0
  403. package/dist/src/ui/tui/tui-editor-brief.js +14 -36
  404. package/dist/src/ui/tui/tui-editor-budget.js +36 -39
  405. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  406. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  407. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  408. package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
  409. package/dist/src/ui/tui/tui-editor-config.js +99 -0
  410. package/dist/src/ui/tui/tui-editor-entry.js +39 -64
  411. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  412. package/dist/src/ui/tui/tui-editor-instances.js +46 -0
  413. package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
  414. package/dist/src/ui/tui/tui-editor-interval.js +29 -16
  415. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  416. package/dist/src/ui/tui/tui-editor-notes.js +9 -29
  417. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  418. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  419. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  420. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  421. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  422. package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
  423. package/dist/src/ui/tui/tui-editor-retire.js +5 -13
  424. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  425. package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
  426. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  427. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  428. package/dist/src/ui/tui/tui-editor-session.js +101 -34
  429. package/dist/src/ui/tui/tui-editor-slots.js +24 -0
  430. package/dist/src/ui/tui/tui-editors.js +44 -2
  431. package/dist/src/ui/tui/tui-frame.js +68 -11
  432. package/dist/src/ui/tui/tui-history.js +8 -9
  433. package/dist/src/ui/tui/tui-input.js +13 -11
  434. package/dist/src/ui/tui/tui-keymap.js +0 -1
  435. package/dist/src/ui/tui/tui-keys.js +33 -9
  436. package/dist/src/ui/tui/tui-pane-activity.js +35 -19
  437. package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
  438. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  439. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  440. package/dist/src/ui/tui/tui-pane-health.js +168 -8
  441. package/dist/src/ui/tui/tui-pane-history.js +446 -82
  442. package/dist/src/ui/tui/tui-pane-pending.js +539 -15
  443. package/dist/src/ui/tui/tui-pane-queued.js +78 -57
  444. package/dist/src/ui/tui/tui-pane-report.js +54 -16
  445. package/dist/src/ui/tui/tui-pane-session.js +34 -6
  446. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  447. package/dist/src/ui/tui/tui-pane.js +91 -28
  448. package/dist/src/ui/tui/tui-panes.js +153 -34
  449. package/dist/src/ui/tui/tui.js +18 -15
  450. package/dist/src/verdict/fix-claim.js +129 -33
  451. package/dist/src/verdict/no-change.js +4 -4
  452. package/dist/src/verdict/refusal.js +40 -16
  453. package/dist/src/verdict/reply-contract.js +77 -31
  454. package/dist/src/version.js +0 -1
  455. package/package.json +2 -1
  456. package/dist/src/loop/leftover.js +0 -130
  457. package/dist/src/loop/revision.js +0 -70
  458. package/dist/src/roles/loop-ids.js +0 -77
  459. package/dist/src/tick/qa-coverage.js +0 -108
  460. package/dist/src/tick/stage-check.js +0 -419
  461. package/dist/src/tick/telemetry-digest.js +0 -25
  462. package/dist/src/tick/tick-detail-data.js +0 -108
  463. package/dist/src/tick/tick-detail.js +0 -163
  464. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,41 +1,34 @@
1
- import { publishInfoFields, writeOrchestratorInfo } from "../team/orchestrator-info.js";
2
- import { loopEnabled, loopIds } from "../roles/loop-ids.js";
1
+ import { readOrchestratorInfo, writeOrchestratorInfo } from "../team/orchestrator-info.js";
2
+ import { runnerIds } from "../roles/runner-ids.js";
3
3
  import { newLiveConfigReload } from "../config/config-live.js";
4
- import { FALLBACK_BREAKER_POLICY, fallbackProbeDuePair, } from "../budget/fallback-breaker.js";
5
- import { DIRECTOR_ROLE } from "../roles/roles.js";
6
- import { modelPairName } from "../budget/budget.js";
7
- import { roleSeamTier, seamConcurrencyKey } from "../config/config-views.js";
8
- import { launchDueTicks } from "./orchestrator-launch.js";
9
- import { pollRunnerReasons, reviewHoldKey } from "./orchestrator-scheduling.js";
10
- import { openBugs } from "../backlog/backlog.js";
11
- import { eligibleEntries } from "../backlog/backlog-eligibility.js";
12
- import { LoopRunner } from "../loop/loop.js";
13
- import { branchHead, currentBranch } from "../git/git.js";
14
- import { removeLegacyLandWorktrees } from "../git/worktree.js";
15
- import { retireLegacyRoleWorktrees } from "../git/worktree-pool.js";
16
- import { queuedMergeFiles, queuedMergeRoles } from "../merge/merge-queue.js";
17
- import { drainMergeQueue, settleAbortedApproved } from "../merge/merge-drain.js";
18
- import { abortableMerges, mergeTasks, newMergePipeline } from "../merge/merge-pipeline.js";
4
+ import { FALLBACK_BREAKER_POLICY, } from "../budget/fallback-breaker.js";
5
+ import { newPollState, pollGatesAndHolds, pollMergeDrain, pollRedeploy, pollRequestsAndWatch, pollReserveAndExit, pollSchedule, } from "./orchestrator-poll.js";
6
+ import { RoleRunner } from "../runner/runner.js";
7
+ import { dropDanglingTumwaterPins, removeLegacyLandWorktrees } from "../git/worktree.js";
8
+ import { pinCrashedRuns, retireLegacyRoleWorktrees } from "../git/worktree-pool.js";
9
+ import { mergeTasks, newMergePipeline } from "../merge/merge-pipeline.js";
19
10
  import { bestEffortEventLogger, logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
20
11
  import { removeQuiet } from "../files/files.js";
21
12
  import { OnceRound } from "../scheduling/once-round.js";
22
13
  import { newNotifier } from "../events/notify.js";
23
- import { consumeAbortRequests, consumeReclaimRequest, consumeRestartRequest, consumeResetRequest, consumeWakeRequest, } from "../operator/operator-requests.js";
24
14
  import { piModelsPath } from "../pi/pi-models.js";
25
15
  import { piSettingsPath, readPiDefaultModel } from "../pi/pi-settings.js";
26
16
  import { Semaphore } from "../concurrency/semaphore.js";
27
17
  import { orchestratorStatePath } from "../paths.js";
18
+ import { createMainWatch } from "../baseline/main-watch.js";
19
+ import { queueCulpritHeal } from "../baseline/main-red.js";
28
20
  import { RetentionPruner } from "./retention.js";
21
+ import { newOpencodeCatalogRefresh } from "../gates/startup-gate.js";
29
22
  import { WorkMergedCache } from "../scheduling/work-merged-cache.js";
30
- import { drainInFlightWork, HANDOFF_MERGE_WINDOW_MS, p75TickDurationMs, sleepInterruptible, } from "../tick/tick-timing.js";
31
- import { newTeamGateStates, pollTeamGates } from "../gates/gate-polls.js";
23
+ import { drainInFlightWork, HANDOFF_MERGE_WINDOW_MS, sleepInterruptible, } from "../run/run-timing.js";
24
+ import { newTeamGateStates } from "../gates/gate-polls.js";
25
+ import { loggedWindowKeys } from "../team/team-polls.js";
32
26
  import { ReclaimController } from "../team/reclaim.js";
33
- import { heldProviders, windowHeldProviders } from "../team/team-hold.js";
34
- import { planBacklogFull, workReserveCount } from "../scheduling/claims.js";
27
+ import { reapPreviousTeamOrphans } from "../doctor/doctor-orphans.js";
35
28
  import { runStartupMigrations } from "./startup-migration-steps.js";
36
29
  import { archiveAtTeamStart } from "../backlog/backlog-archive.js";
37
30
  const POLL_MS = 2000;
38
- /** Run all enabled loops until the signal aborts — or until a pending self-redeploy has drained
31
+ /** Run all enabled roles until the signal aborts — or until a pending self-redeploy has drained
39
32
  * the team and swapped the new build into dist/ (then `restart` is true). */
40
33
  export async function runOrchestrator(opts) {
41
34
  const { root, config, mainBranch, signal: externalSignal } = opts;
@@ -46,27 +39,27 @@ export async function runOrchestrator(opts) {
46
39
  const piDefault = readPiDefaultModel(piSettingsPath(modelsPath), root);
47
40
  const breakerPolicy = opts.fallbackBreakerPolicy ?? FALLBACK_BREAKER_POLICY;
48
41
  const breakerNow = opts.breakerNow ?? Date.now;
49
- // Parallel work instances (part 5b/7): the runner list is every loop id the config enables
42
+ // Parallel work instances (part 5b/7): the runner list is every runner id the config enables
50
43
  // (`feature`, `feature-2`, …), so `instances` creates real runners. A `--once --role` filter
51
- // targets exactly that loop id.
52
- const enabled = opts.roleFilter !== undefined ? [opts.roleFilter] : loopIds(config);
44
+ // targets exactly that runner id.
45
+ const enabled = opts.roleFilter !== undefined ? [...opts.roleFilter] : runnerIds(config);
53
46
  // Name the fix, not just the failure: an operator who disabled the last role (or hand-edited
54
- // a roles map to all-false) gets the exact edit that unblocks `tumwater run`, and the
55
- // defaults they can fall back to. With a role filter this is unreachable — cmdRun validated
47
+ // a roles map to all-false) gets the exact edit that unblocks `tumwater start`, and the
48
+ // defaults they can fall back to. With a role filter this is unreachable — cmdStart validated
56
49
  // the id against the enabled set before booting — but the guard stays for in-process callers.
57
50
  if (enabled.length === 0)
58
51
  throw new Error('no roles enabled in tumwater.json — enable at least one role in its "roles" section (e.g. `"feature": { "enabled": true }`), or remove that section to restore every role\'s default');
59
52
  // Two internal stop controllers ride beside the caller's (Ctrl+C/SIGTERM): internalStop, the
60
53
  // graceful shutdown the once-mode exit and the hand-off's deadline abort fire — it cuts role
61
- // ticks and merges alike — and internalRoleStop, which ONLY the restart drain fires when it
62
- // gives up on its permit-holding ROLE ticks (they then end as `aborted`, resumable on the new
54
+ // runs and merges alike — and internalRoleStop, which ONLY the restart drain fires when it
55
+ // gives up on its permit-holding ROLE runs (they then end as `aborted`, resumable on the new
63
56
  // build, exactly like a shutdown). The split is the point: the drain's abort must not cut off
64
57
  // an in-flight merge, which then never reaches the hand-off window the wait announces
65
58
  // (BUGS.md 2026-09-30: the merge died 13 ms after the warning). So the runners and the timed
66
- // tick wrapper watch roleSignal — the full signal PLUS the role-only stop — while the merge
67
- // tasks keep wiring the full signal alone. A director tick is never aborted by either
59
+ // run wrapper watch roleSignal — the full signal PLUS the role-only stop — while the merge
60
+ // tasks keep wiring the full signal alone. A director run is never aborted by either
68
61
  // controller: poll holds for it without a cap, so by the time `restart` takes effect only
69
- // role ticks and the merge can remain.
62
+ // role runs and the merge can remain.
70
63
  const internalStop = new AbortController();
71
64
  const internalRoleStop = new AbortController();
72
65
  const signal = AbortSignal.any([externalSignal, internalStop.signal]);
@@ -78,15 +71,14 @@ export async function runOrchestrator(opts) {
78
71
  const launchServicesWatch = opts.once ? null : (opts.launchServicesWatch ?? null);
79
72
  // Pressure reclaim (plans/disk-floor.md, part 2/4): one background pass at a time, driven by
80
73
  // the disk gate's poll below. In-process state (the use registry) is enough, because this
81
- // process is the one that runs ticks and merges.
74
+ // process is the one that starts runs and merges.
82
75
  const reclaim = new ReclaimController(root);
83
- let restart = false;
84
- // The shared concurrency cap: role ticks and the merges (each verify, and the merge's conflict
76
+ // The shared concurrency cap: role runs and the merges (each verify, and the merge's conflict
85
77
  // resolver — merge-drain.ts) all hold a permit; see its MergePipelineContext for why a
86
78
  // merge is not exempt.
87
79
  const semaphore = new Semaphore(Math.max(1, config.maxConcurrent));
88
80
  // Seed the pool's per-provider caps from the startup config (plans/provider-concurrency.md
89
- // §7): a role tick's lease is bounded by its provider key from the first launch onward, not
81
+ // §7): a role run's lease is bounded by its provider key from the first launch onward, not
90
82
  // just after a live edit. A config without the key passes an empty map, the no-change case.
91
83
  semaphore.setKeyCaps(config.maxConcurrentPerProvider ?? {});
92
84
  const infoFile = orchestratorStatePath(root);
@@ -95,6 +87,7 @@ export async function runOrchestrator(opts) {
95
87
  info.build = redeploy.status();
96
88
  if (opts.stopsAt !== undefined)
97
89
  info.stopsAt = opts.stopsAt;
90
+ const previous = readOrchestratorInfo(root);
98
91
  writeOrchestratorInfo(root, info);
99
92
  // Best-effort, like every other event write in this function (see `emit` below): this runs
100
93
  // BEFORE the try/finally and its cleanup, so a raw logEvent throw here would not just stop
@@ -110,57 +103,65 @@ export async function runOrchestrator(opts) {
110
103
  roles: enabled,
111
104
  ...(redeploy ? { build: redeploy.build.sha } : {}),
112
105
  });
113
- // The first per-runner state read is each LoopRunner constructor's loadLoopState, so the
106
+ // The first per-runner state read is each RoleRunner constructor's loadRunnerState, so the
114
107
  // persisted-name migrations run before it. (After the info write and start event above, which
115
- // an in-process test or a second `tumwater run` probe reads synchronously.)
108
+ // an in-process test or a second `tumwater start` probe reads synchronously.)
116
109
  await runStartupMigrations(root);
110
+ // The mergeRefs migration moved legacy landing pins but keeps a dangling one (setRef refuses a
111
+ // missing object); this drops what is left before any run.
112
+ await dropDanglingTumwaterPins(root);
113
+ // A clean stop removes the info file, so only a crashed predecessor leaves a pid to reap for.
114
+ if (previous !== null && previous.pid !== process.pid)
115
+ void reapPreviousTeamOrphans(root, previous.pid);
117
116
  // Finished backlog entries still in the live files move to the archives now
118
117
  // (plans/backlog-archive.md), while no merge is in progress and no run is in flight: a
119
118
  // repository migrates on its first start with this build, and an entry a hand commit moved is
120
119
  // swept. One commit on main, or a warning; the team starts either way.
121
120
  await archiveAtTeamStart(root);
122
- const runners = enabled.map((role) => new LoopRunner(root, role, config, mainBranch, roleSignal));
121
+ const runners = enabled.map((role) => new RoleRunner(root, role, config, mainBranch, roleSignal));
123
122
  // Legacy merger checkouts (`_land-<role>`) from a pre-pool build hold nothing now that verifies
124
- // and merges lease pooled slots: remove them once, before any tick, so the retired layout
123
+ // and merges lease pooled slots: remove them once, before any run, so the retired layout
125
124
  // never lingers on disk. (After the info write and start event above, so startup's published
126
- // state is on disk synchronously for an in-process test or a second `tumwater run` probe.)
125
+ // state is on disk synchronously for an in-process test or a second `tumwater start` probe.)
127
126
  await removeLegacyLandWorktrees(root);
128
127
  // Legacy role checkouts (`<role>` from a pre-pool build) are retired in the same startup
129
- // window, right after the LoopRunners above: a role with a pending resume keeps its dir as a
130
- // pinned slot, every other legacy dir goes. Pass every configured loop id, not `enabled` (a
128
+ // window, right after the RoleRunners above: a role with a pending resume keeps its dir as a
129
+ // pinned slot, every other legacy dir goes. Pass every configured runner id, not `enabled` (a
131
130
  // `--once --role` filter): a scoped run must not retire or unpin the other roles, whose
132
131
  // pending resumes the next full-team start still owns. See retireLegacyRoleWorktrees.
133
- await retireLegacyRoleWorktrees(root, loopIds(config));
132
+ await retireLegacyRoleWorktrees(root, runnerIds(config));
133
+ // A run the previous process died in resumes in its own slot: pin it before any run leases.
134
+ pinCrashedRuns(root, runnerIds(config));
134
135
  // Session retention (src/orchestrator/retention.ts owns the whole concern): construction runs the
135
136
  // startup prune, seeding the once-per-day gate so an unchanged team prunes at most once
136
137
  // per day — and 0 disables pruning, the same convention as quietTimeoutSeconds.
137
138
  const retentionPruner = new RetentionPruner(root, config.sessionRetentionDays);
138
- // In-flight tasks, split by who requested them: the redeploy drain caps role ticks at its
139
- // window but waits for a director tick without one — an explicit human prompt outranks the
139
+ // In-flight tasks, split by who requested them: the redeploy drain caps role runs at its
140
+ // window but waits for a director run without one — an explicit human prompt outranks the
140
141
  // self-redeploy (BUGS.md 2026-09-08). The merge tasks are deliberately OUTSIDE this split:
141
142
  // in-process tasks (the verifies and the one merge, since 2026-09-18 under the same maxConcurrent
142
- // permits as role ticks) that shutdown still awaits — to the end on an operator stop, and for a
143
+ // permits as role runs) that shutdown still awaits — to the end on an operator stop, and for a
143
144
  // bounded hand-off on a restart (drainInFlightWork) — an aborted merge keeps its ref and
144
- // drops its entry, recovering on next start. roleInFlight holds every RESERVED role tick
145
+ // drops its entry, recovering on next start. roleInFlight holds every RESERVED role run
145
146
  // (permit holders and waiters parked in the semaphore queue alike), since shutdown awaits
146
- // both; what the restart drain waits on, aborts, and reports as abortedTicks is only
147
- // rolePermitHolders, the role ticks actually holding a permit. A parked waiter has nothing to
148
- // drain — the start gate (tickStartHeld below) keeps it from starting while a restart is
149
- // pending — and counting it held drains open for ticks that had not begun and inflated
150
- // abortedTicks (12 reported against 3 aborted run_ends on 2026-09-21; BUGS.md 2026-09-23).
147
+ // both; what the restart drain waits on, aborts, and reports as abortedRuns is only
148
+ // rolePermitHolders, the role runs actually holding a permit. A parked waiter has nothing to
149
+ // drain — the start gate (runStartHeld below) keeps it from starting while a restart is
150
+ // pending — and counting it held drains open for runs that had not begun and inflated
151
+ // abortedRuns (12 reported against 3 aborted run_ends on 2026-09-21; BUGS.md 2026-09-23).
151
152
  const roleInFlight = new Set();
152
153
  const rolePermitHolders = new Set();
153
154
  const directorInFlight = new Set();
154
- // The restart drain's window tracks the team's real tick duration (BUGS.md 2026-09-18): the
155
- // durations of recent COMPLETED role ticks feed a p75 that poll uses in place of the
156
- // cold-start constant. Bounded (orchestrator-launch.ts's ROLE_TICK_DURATION_SAMPLES) so a
157
- // long-running team's memory stays flat; aborted ticks are
155
+ // The restart drain's window tracks the team's real run duration (BUGS.md 2026-09-18): the
156
+ // durations of recent COMPLETED role runs feed a p75 that poll uses in place of the
157
+ // cold-start constant. Bounded (orchestrator-launch.ts's ROLE_RUN_DURATION_SAMPLES) so a
158
+ // long-running team's memory stays flat; aborted runs are
158
159
  // excluded — their short cut-off durations would drag the window down and cause more aborts.
159
- const roleTickDurationsMs = [];
160
+ const roleRunDurationsMs = [];
160
161
  // Every merge task (merge-drain.ts's MergePipeline): the verification stage's tasks, the
161
162
  // changes they approved, and the merge slot.
162
163
  const merges = newMergePipeline();
163
- // Once-mode bookkeeping (`tumwater run --once`) — the tick-count snapshot, the per-role
164
+ // Once-mode bookkeeping (`tumwater start --once`) — the run-count snapshot, the per-role
164
165
  // settle reasons, and the quiet-poll exit rule — lives in src/scheduling/once-round.ts.
165
166
  const once = new OnceRound(runners, opts.once === true);
166
167
  // The team-wide gates' and alarms' cross-poll memory (src/gates/gate-polls.ts owns the family's
@@ -168,43 +169,31 @@ export async function runOrchestrator(opts) {
168
169
  // previous values for the edge-triggered events, the fallback breaker, and the hold's own
169
170
  // memory (deadline, kind, relapse count). In memory only — a restart re-trusts the
170
171
  // fallback, starts with the hold open, and can re-log at most one event per gate. The
171
- // budget gate's breaker is also the fallback ticks' evidence sink: the start pass below
172
- // records every tick's outcome into it and runs its probe (src/budget/fallback-breaker.ts), and
172
+ // budget gate's breaker is also the fallback runs' evidence sink: the start pass below
173
+ // records every run's outcome into it and runs its probe (src/budget/fallback-breaker.ts), and
173
174
  // the start gate reads gateStates.teamHold at permit time, so its closures always see
174
175
  // the latest poll's hold verdict rather than the poll that created them.
175
176
  const gateStates = newTeamGateStates(config);
177
+ // A restart must not re-announce a window that is still open.
178
+ gateStates.claudeWindowWarned = loggedWindowKeys(root, "subscription_window_warning", Date.now());
179
+ gateStates.claudeWindowReserved = loggedWindowKeys(root, "subscription_window_reserved", Date.now());
176
180
  // The live config the last successful reload produced (last-known-good while the file is
177
181
  // broken or missing). The reload bookkeeping itself lives in src/config/config-live.ts.
178
182
  const liveReload = newLiveConfigReload({ root, config, mainBranch, runnerSignal: roleSignal, runners, semaphore, roleFilter: opts.roleFilter });
179
- // The pause gates (src/gates/pause-gates.ts owns the concern): the operator pause's and the
180
- // per-role pause's cross-poll bookkeeping, so each pause/resume crossing logs exactly one
181
- // event instead of once per ~2s poll.
182
- // The primary checkout's branch, for the edge-triggered divergence warning: the team
183
- // resolved its target branch at startup, and a human checking out something else mid-run
184
- // must not silently change what the team merges into — every role worktree is based on
185
- // the resolved branch. One warning per episode, re-armed when the checkout returns.
186
- let warnedBranchDivergence = false;
187
183
  // Need-based deferral: whether qualifying work has merged
188
184
  // since a role's last-seen main head, cached per head (see scheduling/work-merged-cache.ts).
189
185
  const workMergedSince = new WorkMergedCache(root, mainBranch);
186
+ const mainWatch = createMainWatch(root, mainBranch, (culprit) => {
187
+ // A red main heals forward (Forward, never back, part 4/6): the watch's named culprit goes
188
+ // straight to ci's heal prompt with the existing wording; nothing is reverted and no revision
189
+ // is handed back.
190
+ queueCulpritHeal(root, culprit.tip, { culprit: culprit.commit, failingTests: culprit.failingTests });
191
+ });
190
192
  // Per-role deferred-due state for one-shot tick_deferred events: the previous poll's
191
193
  // deferral per role (like prevBudgetPaused/prevUserPaused, but per role), so each episode
192
194
  // logs exactly once — on the transition in, never while merely not-due and never on exit.
193
195
  // In-memory only: a restart mid-episode can re-log at most one event.
194
196
  const deferredDue = new Map();
195
- // Whether the last poll found a self-redeploy pending (`hold`). Hoisted out of the poll loop
196
- // because it is read at two points: at scheduling (no new tick is reserved) and — through
197
- // tickStartHeld — whenever a parked waiter is granted its permit, which happens between polls.
198
- let holdForRestart = false;
199
- // Whether the last poll's disk gate held new work (plans/disk-floor.md, part 1/4). Hoisted
200
- // beside holdForRestart because tickStartHeld reads it at permit time, between polls — the
201
- // disk hold closes the same gate point every team-wide hold uses.
202
- let diskHeld = false;
203
- // The start gate every reserved tick passes the moment its permit is granted
204
- // (runTimedRoleTick's `held`): closed while a restart is pending, and after one is decided so
205
- // no waiter the shutdown hands a permit to starts on the build being replaced. One predicate,
206
- // so another team-wide hold on new ticks can close the same gate point.
207
- const tickStartHeld = () => holdForRestart || restart || diskHeld;
208
197
  // The operator notify hook (src/events/notify.ts owns the whole concern): one configured shell
209
198
  // command run on notable events (budget_paused, role_streak_paused, merge_failed,
210
199
  // restart_blocked). Subscribed here so it sees every event this process logs from start;
@@ -216,308 +205,79 @@ export async function runOrchestrator(opts) {
216
205
  // notifier/quiet-file cleanup or mask an in-flight error. The feed is the failure; the poll
217
206
  // and the shutdown still run.
218
207
  const { emit, warn } = bestEffortEventLogger(root);
219
- // The maintenance permits withheld for work loops that have work to take (part 7/7), carried
220
- // across polls so the event logs only the crossings into and out of a reserved state.
221
- let lastReserve = 0;
208
+ const opencodeCatalogRefresh = newOpencodeCatalogRefresh(root, {}, warn);
209
+ const ctx = {
210
+ root,
211
+ mainBranch,
212
+ onceMode: opts.once === true,
213
+ signal,
214
+ roleSignal,
215
+ internalStop,
216
+ internalRoleStop,
217
+ runners,
218
+ semaphore,
219
+ merges,
220
+ gateStates,
221
+ once,
222
+ info,
223
+ modelsPath,
224
+ piDefault,
225
+ redeploy,
226
+ launchServicesWatch,
227
+ reclaim,
228
+ liveReload,
229
+ notifier,
230
+ retentionPruner,
231
+ opencodeCatalogRefresh,
232
+ mainWatch,
233
+ workMergedSince,
234
+ deferredDue,
235
+ breakerPolicy,
236
+ breakerNow,
237
+ roleInFlight,
238
+ rolePermitHolders,
239
+ directorInFlight,
240
+ roleRunDurationsMs,
241
+ emit,
242
+ warn,
243
+ };
244
+ // The flags that outlive one poll (orchestrator-poll.ts's PollState).
245
+ const state = newPollState();
246
+ // Why the poll loop ended, for the orchestrator_stop event: a throw leaves "error".
247
+ let stopCause = "error";
222
248
  try {
223
249
  while (!signal.aborted) {
224
- // Live-reload tumwater.json — the single reload point shared by all loops.
225
- // src/config/config-live.ts owns the last-known-good retention and the edge-triggered
226
- // warnings/events around it.
227
- const liveConfig = liveReload.poll();
228
- // The notify command rides the same last-known-good reload (src/events/notify.ts): a live
229
- // `config set notify` edit takes effect on the next poll, no restart.
230
- notifier.update(liveConfig);
231
- // Live session retention (the last restart-only setting): the edge-triggered
232
- // retention_changed event, the once-per-day gate, and the prune itself live in
233
- // src/orchestrator/retention.ts. The live config (last-known-good while the file is broken or
234
- // missing) drives the check, like the budget gate.
235
- retentionPruner.poll(root, liveConfig.sessionRetentionDays);
236
- // launchservicesd's Mach-port budget (src/process/launch-services.ts owns it):
237
- // a background sample at most every 15 minutes, and a warning days before the kernel
238
- // kills the daemon and wedges the Mac's GUI session. Never awaited — a poll does not
239
- // wait on `top`.
240
- void launchServicesWatch?.poll();
241
- // Consume CLI request markers: a reset-counters request, a wake request, a forced-restart
242
- // request, and per-role abort requests.
243
- consumeResetRequest(root, runners);
244
- consumeWakeRequest(root, runners);
245
- consumeRestartRequest(root, redeploy);
246
- consumeReclaimRequest(root, reclaim);
247
- consumeAbortRequests(root, runners, abortableMerges(merges));
248
- await settleAbortedApproved(root, merges);
249
- // branchHead reads the ref files first (microsecond-scale; this runs every poll) and
250
- // spawns `git rev-parse` only when they cannot resolve it. "" means main does not exist
251
- // yet — isEligible treats an empty head as "no wake".
252
- const mainHead = (await branchHead(root, mainBranch)) ?? "";
253
- // Branch-divergence watch, once per poll (see warnedBranchDivergence above): the
254
- // team keeps fast-forwarding the branch it resolved at startup — the safe behavior,
255
- // since role worktrees are based on it — and one warning names the divergence
256
- // instead of leaving it mysterious. Back on the target branch re-arms the check.
257
- const liveBranch = await currentBranch(root);
258
- if (liveBranch !== null && liveBranch !== mainBranch) {
259
- if (!warnedBranchDivergence) {
260
- warnedBranchDivergence = true;
261
- warn(`your checkout moved to ${liveBranch} — the team keeps merging into ${mainBranch}`);
262
- }
263
- }
264
- else {
265
- warnedBranchDivergence = false;
266
- }
267
- const now = Date.now();
268
- // The team's gates and alarms, one poll of the family (src/gates/gate-polls.ts): the daily
269
- // budget gate — with its derived-state publish and the fallback handback to the
270
- // primary — the operator and per-role pause gates, quiet hours, the team-wide failure
271
- // hold, and the two observational storm alarms. The family's cross-poll memory lives
272
- // in gateStates, advanced in place; this returns this poll's verdicts for the
273
- // scheduling and start passes. The failure hold is deliberately not among them — the
274
- // start gate and the merges' startHeld read the LATEST poll's verdict at permit
275
- // time from gateStates.teamHold, so a waiter granted its permit after later polls
276
- // ran meets the hold as it stands then, not as this poll left it.
277
- const { roleConfig, directorConfig, userPaused, pausedRoles: pausedRolesSet, capPaused, maintenanceQuota, bootstrapHeld, bootstrapActive, budgetPausedRoles, servingResolved, priceResolved, budgetActive, roleQuietHeld, quietNow, diskHeld: polledDiskHeld, } = pollTeamGates(gateStates, {
278
- root,
279
- runners,
280
- liveConfig,
281
- modelsPath,
282
- now,
283
- info,
284
- reclaim,
285
- });
286
- // The latest poll's disk verdict, hoisted for tickStartHeld's permit-time reads
287
- // (plans/disk-floor.md, part 1/4).
288
- diskHeld = polledDiskHeld;
289
- // Publish the permit pool's live state (plans/provider-concurrency.md §8): observers
290
- // (status, TUI, GUI) read one file instead of coupling to this Semaphore, and the team
291
- // line shows per-provider counts only while a cap map is configured. Written only when
292
- // it changes, like budget and disk; a map-less team publishes `byKey` empty, which
293
- // renders exactly as before.
294
- publishInfoFields(root, info, [["permits", semaphore.snapshot()]]);
295
- // The failure hold, keyed per provider (PLANS.md 2026-10-05): a role is held when ITS
296
- // tick model's provider stands under a hold, and every role is held when the
297
- // reviewer's provider is (with the review gate on, nothing could merge — and the merge
298
- // queue below reads the same reviewHeld verdict). Key presence in the map is relapse
299
- // memory, not a hold: heldProviders() reads only standing holds (`until` non-null),
300
- // so a lifted hold never keeps blocking. Read fresh from gateStates.teamHold — the
301
- // permit-time closures below see the latest poll's verdicts, not this poll's snapshot.
302
- const teamHeldProviders = heldProviders(gateStates.teamHold);
303
- // Fresh reads for the permit-time closures: they run after later polls have advanced
304
- // gateStates.teamHold, so they re-derive from the LATEST poll's map, not this snapshot.
305
- const teamHeldNow = () => heldProviders(gateStates.teamHold);
306
- // The reviewer's seam is read from the config merges actually review under — the poll's
307
- // roleConfig, which a used-up claude window or the cap has already moved to a fallback —
308
- // so a reviewer that left claude no longer holds the whole team on the claude hold.
309
- const reviewSeamKey = reviewHoldKey(roleConfig);
310
- const reviewHeldNow = () => liveConfig.review.enabled && teamHeldNow().has(reviewSeamKey);
311
- const reviewHeld = liveConfig.review.enabled && teamHeldProviders.has(reviewSeamKey);
312
- const roleProviders = new Map(runners.map((r) => [r.role, r.runProvider(now)]));
313
- // Self-redeploy (src/redeploy/redeployer.ts): with main's head in hand, let the state
314
- // machine observe it. `hold` starts no new ticks at all — director included; a restart
315
- // takes effect within the drain's window plus a bounded merge hand-off, and its prompt waits
316
- // in the inbox for the new build — while the green check/compile/drain run in the
317
- // background. That covers ticks
318
- // reserved before the hold too: a waiter parked in the semaphore queue that is granted a
319
- // permit mid-drain meets the closed start gate (tickStartHeld), releases the permit, and
320
- // hands its reservation back so it re-schedules once the hold lifts or on the new build.
321
- // `restart` means dist/ already holds the new
322
- // build: stop scheduling, abort whatever role ticks the drain gave up waiting for — the
323
- // permit holders; they resume on the new build — and return. A director tick can never be
324
- // in flight here — poll only returns `restart` once it has finished. (holdForRestart keeps
325
- // the previous poll's verdict until this one's is in: resetting it before the await
326
- // would open the gate for a waiter granted a permit while poll runs.)
327
- if (redeploy) {
328
- const action = await redeploy.poll(mainHead, {
329
- roleInFlight: rolePermitHolders.size,
330
- directorInFlight: directorInFlight.size,
331
- roleTickP75Ms: p75TickDurationMs(roleTickDurationsMs),
332
- }, liveConfig.autoRestart, now);
333
- const build = redeploy.status(now);
334
- publishInfoFields(root, info, [["build", build]]);
335
- if (action === "restart") {
336
- restart = true;
337
- // Only permit holders have a pi run to cut off (the director is guaranteed finished by
338
- // then): a parked waiter meets the closed start gate whenever the shutdown hands it a
339
- // permit, so it needs no abort — and aborting for it alone would also cut off an
340
- // in-flight merge that no permit-holding tick put at stake. The role-only controller
341
- // keeps that promise: the merge, wired to the full signal, runs on into the hand-off
342
- // window the wait announces (BUGS.md 2026-09-30).
343
- if (rolePermitHolders.size > 0)
344
- internalRoleStop.abort();
345
- break;
346
- }
347
- holdForRestart = action === "hold";
348
- }
349
- // Merge queue 3/5 — drain the durable merge queue while neither a restart nor a failure
350
- // hold is pending (the scheduler's WHEN; merge-drain.ts owns the HOW — the verification
351
- // stage, its merge slot, and the dedupe against main). A held poll starts no verify and no
352
- // merge, exactly as it starts no tick; what is already in flight runs on, and a verify
353
- // parked for its permit meets the same start gate as a parked tick when the permit comes.
354
- if (!holdForRestart && !reviewHeld && !diskHeld) {
355
- await drainMergeQueue({
356
- root,
357
- mainBranch,
358
- signal,
359
- semaphore,
360
- runners,
361
- liveConfig,
362
- roleConfig,
363
- directorConfig,
364
- piDefault,
365
- startHeld: () => tickStartHeld() || reviewHeldNow(),
366
- }, merges);
367
- }
368
- // Backlog-aware deferral: while PLANS.md `## Planned` or BUGS.md `## Open` on main
369
- // holds an entry feature or bugfix may take (eligibleEntries), idle maintenance ticks
370
- // stay deferred — queued feature/bugfix work outranks
371
- // them regardless of what merged. Stat-cached reads (backlog.ts): one stat per file per
372
- // poll while the files are unchanged. bugfix is not a maintenance role, but while BUGS.md
373
- // `## Open` is empty it defers like one (deferTick) — openBugsNow is that emptiness, read
374
- // once here and reused for both predicates.
375
- const openBugsNow = openBugs(root).length > 0;
376
- const workBacklogOpen = eligibleEntries(root, "feature").length > 0 || eligibleEntries(root, "bugfix").length > 0;
377
- const plansFull = planBacklogFull(root, liveConfig);
378
- // A demoted fallback's half-open window (part 5c/8): the pair whose probe is due, and
379
- // the roles whose model tier WOULD run on that pair absent the demotion (the price-based
380
- // resolution — the serving resolution has already dropped the pair, which is exactly
381
- // why it is being probed). Only those roles pierce the budget hold, and the launch pass
382
- // admits exactly one of them as the probe — a probe blind to the pair's tier would lift
383
- // the pause for every role (objection to an earlier draft), and a probe with no
384
- // eligible runner must not hijack the poll's other launches (the launch pass continues
385
- // only the eligible runners it cannot admit, never the rest of the team).
386
- const probePair = fallbackProbeDuePair(gateStates.budget.breakers, breakerNow());
387
- const probeRoles = new Set();
388
- if (probePair !== null) {
389
- for (const r of runners) {
390
- if (r.role === DIRECTOR_ROLE)
391
- continue;
392
- const p = priceResolved[roleSeamTier(liveConfig, r.role)].pair;
393
- if (p !== null && modelPairName(p) === probePair)
394
- probeRoles.add(r.role);
395
- }
396
- }
397
- // The review-on strong pause (budgetGate): with the review gate on, an unresolvable
398
- // strong tier means nothing could merge, so EVERY role is held — the per-tier pause set
399
- // alone would let small/default roles author changes no reviewer could judge. The probe
400
- // of the strong pair pierces this hold for exactly the probed tier's roles too.
401
- const reviewStrongPaused = budgetActive && liveConfig.review.enabled && servingResolved.strong.pair === null;
402
- const reasons = await pollRunnerReasons({
403
- root,
404
- runners,
405
- once,
406
- now,
407
- mainHead,
408
- userPaused,
409
- quietNow,
410
- pausedRoles: pausedRolesSet,
411
- capPaused,
412
- maintenanceQuota,
413
- bootstrapHeld,
414
- bootstrapActive,
415
- budgetPausedRoles,
416
- probeRoles,
417
- reviewStrongPaused,
418
- roleQuietHeld,
419
- holdForRestart,
420
- diskHeld,
421
- heldProviders: teamHeldProviders,
422
- windowHeldProviders: windowHeldProviders(gateStates.teamHold),
423
- reviewHeld,
424
- roleProviders,
425
- openBugsNow,
426
- workBacklogOpen,
427
- planBacklogFull: plansFull,
428
- deferredDue,
429
- workMergedSince,
430
- });
431
- launchDueTicks({
432
- root,
433
- reasons,
434
- // roleSignal, not signal: the timed tick wrapper watches the full signal PLUS the
435
- // role-only stop, exactly as the pre-extraction inline pass did — the restart drain's
436
- // give-up abort must also turn away a waiter granted its permit at that moment.
437
- signal: roleSignal,
438
- gateStates,
439
- breakerPolicy,
440
- breakerNow,
441
- probePair,
442
- probeRoles,
443
- budgetActive,
444
- semaphore,
445
- piDefault,
446
- rolePermitHolders,
447
- roleInFlight,
448
- directorInFlight,
449
- roleTickDurationsMs,
450
- startHeld: (role) => tickStartHeld() ||
451
- (role !== DIRECTOR_ROLE
452
- ? reviewHeldNow() || teamHeldNow().has(roleProviders.get(role))
453
- : windowHeldProviders(gateStates.teamHold).has(roleProviders.get(role))),
454
- });
455
- // Priority headroom (plans/parallel-work-instances.md part 7/7): withhold a few permits
456
- // from maintenance while a work loop has work to take, so a due work tick never waits out
457
- // a whole maintenance tick. Sized after the launch pass, so a runner launched this poll
458
- // already reads as running and reserves nothing for itself.
459
- const poolSnapshot = semaphore.snapshot();
460
- // The provider-headroom predicate receives the structural ClaimRunner; the config that
461
- // names the provider lives on the real LoopRunners, so look the runner up by role.
462
- const runnerByRole = new Map(runners.map((r) => [r.role, r]));
463
- const reserve = workReserveCount(root, runners, {
464
- maxConcurrent: liveConfig.maxConcurrent,
465
- enabled: new Set(runners.filter((r) => loopEnabled(r.config, r.role)).map((r) => r.role)),
466
- queuedMergeRoles: queuedMergeRoles(root),
467
- // Per-provider headroom (plans/provider-concurrency.md §6): an idle work loop reserves a
468
- // permit only when its own provider could use it — capped and full, it would only idle
469
- // the slot another provider's maintenance tick could take. Read from the pool's live
470
- // snapshot; an uncapped key (or the team-total-only case) always has headroom.
471
- providerHasHeadroom: (r) => {
472
- const runner = runnerByRole.get(r.role);
473
- const cfg = runner && runner.role !== DIRECTOR_ROLE ? runner.runConfig(now) : null;
474
- const key = cfg ? seamConcurrencyKey(cfg, piDefault) : undefined;
475
- if (key === undefined)
476
- return true;
477
- const k = poolSnapshot.byKey[key];
478
- if (k?.limit === undefined)
479
- return true;
480
- return k.inUse < k.limit;
481
- },
482
- });
483
- semaphore.setReserve(reserve);
484
- if ((lastReserve === 0) !== (reserve === 0)) {
485
- emit({ source: TUMWATER_SOURCE, type: "permit_reserve", reserve });
250
+ const inputs = await pollRequestsAndWatch(ctx, state);
251
+ const gates = pollGatesAndHolds(ctx, state, inputs);
252
+ if (await pollRedeploy(ctx, state, inputs)) {
253
+ stopCause = "restart";
254
+ break;
486
255
  }
487
- lastReserve = reserve;
488
- // Once mode's exit: every enabled role settled, no tick in flight, and the merge queue
489
- // empty with no merge in flight for one full poll cycle — then fire the internal
490
- // stop, the same graceful shutdown the restart drain uses (the `finally` awaits
491
- // in-flight merge tasks to the end). The one-poll-cycle requirement is the guard
492
- // against dropping a queued-but-not-yet-started merge: a stop that arrives during the
493
- // shutdown drain drops the queue entry, so the slot gets its poll to pick the entry up
494
- // before the stop is trusted.
495
- if (once.exitReady({
496
- roleTicks: roleInFlight.size,
497
- directorTicks: directorInFlight.size,
498
- merges: mergeTasks(merges).length,
499
- queuedMerges: queuedMergeFiles(root).length,
500
- })) {
501
- internalStop.abort();
256
+ await pollMergeDrain(ctx, state, inputs, gates);
257
+ await pollSchedule(ctx, state, inputs, gates);
258
+ if (pollReserveAndExit(ctx, state, inputs)) {
259
+ stopCause = "once";
502
260
  break;
503
261
  }
504
262
  await sleepInterruptible(pollMs, signal);
505
263
  }
264
+ if (stopCause === "error")
265
+ stopCause = "signal";
506
266
  }
507
267
  finally {
508
- await drainInFlightWork(root, roleInFlight, directorInFlight, mergeTasks(merges), restart, opts.handoffMergeWindowMs ?? HANDOFF_MERGE_WINDOW_MS, () => internalStop.abort());
509
- emit({ source: TUMWATER_SOURCE, type: "orchestrator_stop" });
268
+ await drainInFlightWork(root, roleInFlight, directorInFlight, mergeTasks(merges), state.restart, opts.handoffMergeWindowMs ?? HANDOFF_MERGE_WINDOW_MS, () => internalStop.abort());
269
+ emit({ source: TUMWATER_SOURCE, type: "orchestrator_stop", cause: stopCause });
510
270
  notifier.dispose();
511
271
  removeQuiet(infoFile);
512
272
  }
513
- // Only once mode carries the settle reasons and the per-role ticks-run: the daemon return
273
+ // Only once mode carries the settle reasons and the per-role run counts: the daemon return
514
274
  // keeps its exact shape (an e2e test deep-equals it), and a daemon caller has no summary to
515
275
  // feed.
516
276
  return once.active
517
277
  ? {
518
- restart,
278
+ restart: state.restart,
519
279
  settled: once.reasons,
520
- ticksRun: new Map(runners.map((r) => [r.role, once.ticksRun(r)])),
280
+ runsDone: new Map(runners.map((r) => [r.role, once.runsDone(r)])),
521
281
  }
522
- : { restart };
282
+ : { restart: state.restart };
523
283
  }