tumwater 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (446) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +42 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +4 -3
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +402 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +246 -55
  41. package/dist/src/backlog/backlog-cli-view.js +280 -0
  42. package/dist/src/backlog/backlog-cli.js +85 -274
  43. package/dist/src/backlog/backlog-commit.js +4 -5
  44. package/dist/src/backlog/backlog-eligibility.js +175 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +254 -0
  48. package/dist/src/backlog/backlog-release.js +117 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-culprit.js +94 -0
  56. package/dist/src/baseline/main-guardrails.js +209 -0
  57. package/dist/src/baseline/main-red.js +139 -32
  58. package/dist/src/baseline/main-revert.js +133 -0
  59. package/dist/src/baseline/main-watch.js +184 -0
  60. package/dist/src/brief/brief-edit.js +9 -11
  61. package/dist/src/brief/brief.js +31 -51
  62. package/dist/src/budget/budget.js +30 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +1 -2
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +118 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +150 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +308 -590
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-role-command.js +420 -0
  84. package/dist/src/cli/cli-start-summary.js +160 -0
  85. package/dist/src/cli/{cli-run.js → cli-start.js} +165 -237
  86. package/dist/src/cli/completion.js +149 -22
  87. package/dist/src/cli/config-commands.js +41 -18
  88. package/dist/src/cli/follow-view.js +75 -0
  89. package/dist/src/cli/help.js +189 -73
  90. package/dist/src/cli/log-commands.js +148 -82
  91. package/dist/src/cli/question-commands.js +205 -10
  92. package/dist/src/cli.js +106 -43
  93. package/dist/src/collections.js +26 -10
  94. package/dist/src/concurrency/check-permit.js +20 -3
  95. package/dist/src/concurrency/lock.js +17 -13
  96. package/dist/src/concurrency/provider-lease.js +44 -8
  97. package/dist/src/concurrency/semaphore.js +102 -26
  98. package/dist/src/config/config-aliases.js +0 -1
  99. package/dist/src/config/config-editable-keys.js +13 -1
  100. package/dist/src/config/config-fallbacks.js +93 -56
  101. package/dist/src/config/config-field-checks.js +40 -10
  102. package/dist/src/config/config-keys.js +30 -8
  103. package/dist/src/config/config-live.js +63 -24
  104. package/dist/src/config/config-request.js +137 -0
  105. package/dist/src/config/config-schema.js +31 -24
  106. package/dist/src/config/config-selector-checks.js +123 -0
  107. package/dist/src/config/config-validation-agents.js +80 -0
  108. package/dist/src/config/config-validation-model.js +116 -0
  109. package/dist/src/config/config-validation.js +141 -199
  110. package/dist/src/config/config-views.js +127 -53
  111. package/dist/src/config/config-write-core.js +135 -0
  112. package/dist/src/config/config-write.js +131 -264
  113. package/dist/src/config/config.js +48 -38
  114. package/dist/src/config/model-selector.js +34 -34
  115. package/dist/src/doctor/doctor-backlog-bugs.js +287 -0
  116. package/dist/src/doctor/doctor-backlog-plans.js +263 -0
  117. package/dist/src/doctor/doctor-backlog.js +100 -496
  118. package/dist/src/doctor/doctor-check-names.js +81 -0
  119. package/dist/src/doctor/doctor-checks.js +52 -579
  120. package/dist/src/doctor/doctor-claude-checks.js +145 -0
  121. package/dist/src/doctor/doctor-codex-checks.js +279 -0
  122. package/dist/src/doctor/doctor-contention-checks.js +380 -0
  123. package/dist/src/doctor/doctor-holds.js +82 -66
  124. package/dist/src/doctor/doctor-install.js +2 -1
  125. package/dist/src/doctor/doctor-model-checks.js +236 -402
  126. package/dist/src/doctor/doctor-notify.js +3 -4
  127. package/dist/src/doctor/doctor-opencode-checks.js +162 -0
  128. package/dist/src/doctor/doctor-orphans.js +182 -63
  129. package/dist/src/doctor/doctor-project-checks.js +303 -0
  130. package/dist/src/doctor/doctor.js +121 -77
  131. package/dist/src/doctor/image-probe.js +21 -8
  132. package/dist/src/doctor/outcome.js +3 -2
  133. package/dist/src/doctor/probe.js +7 -11
  134. package/dist/src/events/event-format.js +181 -86
  135. package/dist/src/events/event-labels.js +12 -5
  136. package/dist/src/events/event-legacy.js +39 -15
  137. package/dist/src/events/event-read.js +39 -39
  138. package/dist/src/events/event-tally.js +16 -0
  139. package/dist/src/events/event-window.js +60 -24
  140. package/dist/src/events/events.js +30 -13
  141. package/dist/src/events/notify.js +7 -7
  142. package/dist/src/failure/failure-cluster.js +12 -12
  143. package/dist/src/failure/failure-data.js +56 -56
  144. package/dist/src/failure/failure-render.js +31 -23
  145. package/dist/src/failure/failure-state-change.js +17 -12
  146. package/dist/src/failure/time-spend.js +99 -35
  147. package/dist/src/files/files.js +111 -35
  148. package/dist/src/files/json-files.js +3 -3
  149. package/dist/src/files/json-object.js +28 -7
  150. package/dist/src/files/stat-cache.js +2 -2
  151. package/dist/src/files/tail.js +1 -1
  152. package/dist/src/gates/bootstrap-gates.js +4 -5
  153. package/dist/src/gates/budget-gates.js +177 -84
  154. package/dist/src/gates/disk-gate.js +1 -1
  155. package/dist/src/gates/gate-polls.js +108 -58
  156. package/dist/src/gates/maintenance-quota.js +10 -13
  157. package/dist/src/gates/pause-gates.js +28 -10
  158. package/dist/src/gates/readiness.js +7 -3
  159. package/dist/src/gates/role-cap-gates.js +8 -8
  160. package/dist/src/gates/startup-gate.js +94 -32
  161. package/dist/src/gates/streak-gate.js +54 -30
  162. package/dist/src/git/commit-message.js +77 -46
  163. package/dist/src/git/git-diff.js +16 -10
  164. package/dist/src/git/git-run.js +79 -38
  165. package/dist/src/git/git.js +36 -34
  166. package/dist/src/git/slot-salvage.js +253 -0
  167. package/dist/src/git/slots-state.js +6 -6
  168. package/dist/src/git/worktree-pool.js +132 -38
  169. package/dist/src/git/worktree-use.js +4 -11
  170. package/dist/src/git/worktree.js +94 -37
  171. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  172. package/dist/src/gui/gui-args.js +79 -8
  173. package/dist/src/gui/gui-backlog-commands.js +42 -18
  174. package/dist/src/gui/gui-backlog-endpoints.js +155 -0
  175. package/dist/src/gui/gui-command.js +8 -13
  176. package/dist/src/gui/gui-config-commands.js +94 -72
  177. package/dist/src/gui/gui-endpoint-commands.js +4 -3
  178. package/dist/src/gui/gui-endpoint-common.js +4 -4
  179. package/dist/src/gui/gui-endpoints.js +11 -380
  180. package/dist/src/gui/gui-history-endpoints.js +301 -0
  181. package/dist/src/gui/gui-operator-commands.js +108 -13
  182. package/dist/src/gui/gui-prompt-commands.js +11 -14
  183. package/dist/src/gui/gui-report-endpoints.js +90 -0
  184. package/dist/src/gui/gui-server.js +11 -4
  185. package/dist/src/gui/gui-team-commands.js +7 -7
  186. package/dist/src/gui/http-body.js +1 -1
  187. package/dist/src/history/history-data.js +122 -234
  188. package/dist/src/history/history-queued.js +147 -0
  189. package/dist/src/history/history-render.js +42 -22
  190. package/dist/src/history/history.js +193 -49
  191. package/dist/src/inbox/inbox-attachments.js +29 -30
  192. package/dist/src/inbox/inbox-cancel.js +10 -11
  193. package/dist/src/inbox/inbox-edit.js +9 -9
  194. package/dist/src/inbox/inbox-submit.js +18 -18
  195. package/dist/src/inbox/inbox-top.js +25 -9
  196. package/dist/src/inbox/inbox.js +42 -43
  197. package/dist/src/inbox/pending-prompt.js +26 -26
  198. package/dist/src/inbox/prompt-commands.js +62 -49
  199. package/dist/src/inbox/prompt-not-before.js +2 -2
  200. package/dist/src/init/init-templates.js +1 -2
  201. package/dist/src/init/init.js +28 -23
  202. package/dist/src/merge/backlog-conflicts.js +215 -35
  203. package/dist/src/merge/merge-batch.js +14 -8
  204. package/dist/src/merge/merge-cancel.js +53 -0
  205. package/dist/src/merge/merge-check-failures.js +44 -22
  206. package/dist/src/merge/merge-core.js +81 -32
  207. package/dist/src/merge/merge-diff.js +5 -4
  208. package/dist/src/merge/merge-drain.js +150 -11
  209. package/dist/src/merge/merge-git.js +177 -38
  210. package/dist/src/merge/merge-pipeline.js +15 -3
  211. package/dist/src/merge/merge-post-review.js +90 -0
  212. package/dist/src/merge/merge-queue-render.js +49 -11
  213. package/dist/src/merge/merge-queue.js +31 -15
  214. package/dist/src/merge/merge-requeue.js +102 -0
  215. package/dist/src/merge/merge-slot.js +61 -16
  216. package/dist/src/merge/merge-stack.js +33 -11
  217. package/dist/src/merge/merge-to-main.js +86 -27
  218. package/dist/src/merge/merge-top.js +69 -0
  219. package/dist/src/merge/merge-verify.js +12 -10
  220. package/dist/src/notes-cli.js +30 -15
  221. package/dist/src/operator/operator-commands.js +192 -81
  222. package/dist/src/operator/operator-intent.js +69 -42
  223. package/dist/src/operator/operator-requests.js +35 -40
  224. package/dist/src/operator/retire.js +5 -6
  225. package/dist/src/operator/revert.js +142 -34
  226. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  227. package/dist/src/orchestrator/orchestrator-poll.js +320 -0
  228. package/dist/src/orchestrator/orchestrator-scheduling.js +68 -369
  229. package/dist/src/orchestrator/orchestrator.js +128 -369
  230. package/dist/src/orchestrator/retention.js +42 -8
  231. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  232. package/dist/src/orchestrator/scheduling-scope.js +201 -0
  233. package/dist/src/orchestrator/scheduling-skip.js +185 -0
  234. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  235. package/dist/src/orchestrator/startup-migrations.js +11 -7
  236. package/dist/src/paths.js +116 -49
  237. package/dist/src/pi/command-shape.js +12 -1
  238. package/dist/src/pi/pi-args.js +6 -4
  239. package/dist/src/pi/pi-backend.js +6 -6
  240. package/dist/src/pi/pi-bin.js +54 -63
  241. package/dist/src/pi/pi-listed-models.js +53 -0
  242. package/dist/src/pi/pi-models.js +63 -30
  243. package/dist/src/pi/pi-run-result.js +1 -1
  244. package/dist/src/pi/pi-settings.js +1 -1
  245. package/dist/src/pi/pi-stream.js +6 -6
  246. package/dist/src/pi/pi-watchdogs.js +18 -19
  247. package/dist/src/pi-extension/bounded-output.js +5 -3
  248. package/dist/src/pi-extension/context-budget.js +2 -2
  249. package/dist/src/pi-extension/context-shake.js +1 -1
  250. package/dist/src/pi-extension/context-window.js +0 -1
  251. package/dist/src/pi-extension/full-output.js +3 -10
  252. package/dist/src/pi-extension/strip-thinking.js +0 -1
  253. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  254. package/dist/src/pi-extension/write-guard.js +37 -0
  255. package/dist/src/process/launch-services.js +1 -2
  256. package/dist/src/process/node-bin.js +16 -0
  257. package/dist/src/process/process-group.js +8 -18
  258. package/dist/src/process/process.js +41 -2
  259. package/dist/src/process/run-marker.js +18 -6
  260. package/dist/src/process/supervisor.js +22 -21
  261. package/dist/src/prompt/dialect.js +17 -2
  262. package/dist/src/prompt/gate-notes.js +203 -0
  263. package/dist/src/prompt/gate-prompts.js +121 -211
  264. package/dist/src/prompt/notes-backlog.js +181 -0
  265. package/dist/src/prompt/principles.js +1 -2
  266. package/dist/src/prompt/prompt-followup.js +19 -14
  267. package/dist/src/prompt/prompt.js +129 -204
  268. package/dist/src/redeploy/redeploy-policy.js +24 -19
  269. package/dist/src/redeploy/redeploy-probes.js +1 -1
  270. package/dist/src/redeploy/redeploy.js +2 -3
  271. package/dist/src/redeploy/redeployer.js +20 -20
  272. package/dist/src/redeploy/self-reload.js +2 -1
  273. package/dist/src/report/report-data.js +161 -77
  274. package/dist/src/report/report-fold.js +129 -52
  275. package/dist/src/report/report-main-red.js +55 -0
  276. package/dist/src/report/report-render.js +133 -64
  277. package/dist/src/report/report-types.js +1 -0
  278. package/dist/src/report/report.js +26 -9
  279. package/dist/src/request-timeouts.js +2 -2
  280. package/dist/src/review/known-flakes.js +100 -1
  281. package/dist/src/review/post-merge-review.js +106 -0
  282. package/dist/src/review/protected-edits.js +4 -5
  283. package/dist/src/review/review-followup.js +8 -8
  284. package/dist/src/review/review-precheck.js +66 -23
  285. package/dist/src/review/review-run.js +395 -0
  286. package/dist/src/review/review-verdict.js +2 -2
  287. package/dist/src/review/review.js +155 -360
  288. package/dist/src/review/secret-leaks.js +36 -16
  289. package/dist/src/review/suite-rerun.js +0 -1
  290. package/dist/src/roles/legacy-role-migration.js +80 -7
  291. package/dist/src/roles/role-catalog.js +69 -12
  292. package/dist/src/roles/role-guidance.js +55 -20
  293. package/dist/src/roles/role-labels.js +1 -2
  294. package/dist/src/roles/role-render.js +67 -50
  295. package/dist/src/roles/role-view.js +59 -47
  296. package/dist/src/roles/roles.js +31 -31
  297. package/dist/src/roles/runner-ids.js +97 -0
  298. package/dist/src/run/acceptance-greps.js +119 -0
  299. package/dist/src/run/feedback-filings.js +51 -0
  300. package/dist/src/{tick → run}/qa-coverage.js +9 -27
  301. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  302. package/dist/src/run/run-detail-data.js +179 -0
  303. package/dist/src/run/run-detail-render.js +98 -0
  304. package/dist/src/run/run-detail.js +168 -0
  305. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +72 -60
  306. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  307. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +108 -73
  308. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  309. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +108 -50
  310. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  311. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  312. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +91 -56
  313. package/dist/src/run/selfcheck-digest.js +40 -0
  314. package/dist/src/run/stage-backlog-checks.js +249 -0
  315. package/dist/src/run/stage-check.js +172 -0
  316. package/dist/src/run/stage-path-checks.js +730 -0
  317. package/dist/src/{loop → runner}/leftover.js +80 -25
  318. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  319. package/dist/src/runner/revision.js +202 -0
  320. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  321. package/dist/src/runner/runner-outcome.js +107 -0
  322. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  323. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +21 -15
  324. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +218 -240
  325. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  326. package/dist/src/runner/runner-state.js +102 -0
  327. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  328. package/dist/src/scheduling/backoff.js +38 -38
  329. package/dist/src/scheduling/claims.js +35 -22
  330. package/dist/src/scheduling/once-round.js +22 -22
  331. package/dist/src/scheduling/quiet-hours.js +5 -6
  332. package/dist/src/scheduling/scheduling.js +74 -64
  333. package/dist/src/scheduling/work-merged-cache.js +2 -2
  334. package/dist/src/status/status-budget.js +55 -47
  335. package/dist/src/status/status-data.js +43 -16
  336. package/dist/src/status/status-inbox.js +2 -2
  337. package/dist/src/status/status-install.js +0 -1
  338. package/dist/src/status/status-polls.js +28 -28
  339. package/dist/src/status/status-roles.js +39 -13
  340. package/dist/src/team/error-storm.js +8 -8
  341. package/dist/src/team/failure-spread.js +2 -2
  342. package/dist/src/team/orchestrator-info.js +15 -9
  343. package/dist/src/team/reclaim.js +5 -6
  344. package/dist/src/team/team-hold.js +7 -7
  345. package/dist/src/team/team-polls.js +91 -50
  346. package/dist/src/team/team-state.js +8 -2
  347. package/dist/src/text/datetime.js +63 -17
  348. package/dist/src/text/format.js +48 -11
  349. package/dist/src/text/markdown.js +8 -5
  350. package/dist/src/text/phrases.js +57 -23
  351. package/dist/src/text/suggest.js +1 -1
  352. package/dist/src/text/text.js +67 -13
  353. package/dist/src/ui/badges.js +65 -37
  354. package/dist/src/ui/gui/gui-client-backlog.js +292 -0
  355. package/dist/src/ui/gui/gui-client-boot.js +32 -26
  356. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  357. package/dist/src/ui/gui/gui-client-drawer-config.js +212 -0
  358. package/dist/src/ui/gui/gui-client-drawer.js +126 -285
  359. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  360. package/dist/src/ui/gui/gui-client-history.js +434 -67
  361. package/dist/src/ui/gui/gui-client-labels.js +1 -2
  362. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  363. package/dist/src/ui/gui/gui-client-model.js +27 -36
  364. package/dist/src/ui/gui/gui-client-operator.js +55 -51
  365. package/dist/src/ui/gui/gui-client-pending.js +227 -23
  366. package/dist/src/ui/gui/gui-client-report-chart.js +464 -0
  367. package/dist/src/ui/gui/gui-client-report.js +87 -427
  368. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  369. package/dist/src/ui/gui/gui-client-settings.js +56 -4
  370. package/dist/src/ui/gui/gui-client-team.js +25 -465
  371. package/dist/src/ui/gui/gui-client.js +27 -25
  372. package/dist/src/ui/gui/gui-icons.js +0 -2
  373. package/dist/src/ui/gui/gui-page.js +9 -7
  374. package/dist/src/ui/gui/gui-styles.js +34 -34
  375. package/dist/src/ui/progress-data.js +9 -11
  376. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +31 -31
  377. package/dist/src/ui/status-model.js +133 -125
  378. package/dist/src/ui/status-payload.js +136 -49
  379. package/dist/src/ui/status-render.js +101 -82
  380. package/dist/src/ui/team-alerts.js +248 -57
  381. package/dist/src/ui/tone.js +5 -6
  382. package/dist/src/ui/transcript-run.js +58 -0
  383. package/dist/src/ui/transcript-tail.js +28 -20
  384. package/dist/src/ui/transcript.js +63 -23
  385. package/dist/src/ui/tui/tui-app.js +0 -1
  386. package/dist/src/ui/tui/tui-backlog.js +51 -14
  387. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  388. package/dist/src/ui/tui/tui-editor-attach.js +3 -5
  389. package/dist/src/ui/tui/tui-editor-backends.js +132 -0
  390. package/dist/src/ui/tui/tui-editor-brief.js +10 -12
  391. package/dist/src/ui/tui/tui-editor-budget.js +35 -31
  392. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  393. package/dist/src/ui/tui/tui-editor-concurrency.js +24 -0
  394. package/dist/src/ui/tui/tui-editor-config.js +86 -0
  395. package/dist/src/ui/tui/tui-editor-entry.js +36 -47
  396. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  397. package/dist/src/ui/tui/tui-editor-instances.js +37 -0
  398. package/dist/src/ui/tui/tui-editor-instructions.js +136 -0
  399. package/dist/src/ui/tui/tui-editor-interval.js +11 -9
  400. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  401. package/dist/src/ui/tui/tui-editor-notes.js +3 -5
  402. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  403. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  404. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  405. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  406. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  407. package/dist/src/ui/tui/tui-editor-quiet.js +99 -35
  408. package/dist/src/ui/tui/tui-editor-retire.js +4 -6
  409. package/dist/src/ui/tui/tui-editor-role-key.js +76 -0
  410. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  411. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  412. package/dist/src/ui/tui/tui-editor-session.js +60 -34
  413. package/dist/src/ui/tui/tui-editor-slots.js +23 -0
  414. package/dist/src/ui/tui/tui-editors.js +35 -2
  415. package/dist/src/ui/tui/tui-frame.js +56 -11
  416. package/dist/src/ui/tui/tui-history.js +8 -9
  417. package/dist/src/ui/tui/tui-input.js +13 -11
  418. package/dist/src/ui/tui/tui-keymap.js +0 -1
  419. package/dist/src/ui/tui/tui-keys.js +24 -9
  420. package/dist/src/ui/tui/tui-pane-activity.js +14 -11
  421. package/dist/src/ui/tui/tui-pane-backlog.js +171 -126
  422. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  423. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  424. package/dist/src/ui/tui/tui-pane-health.js +58 -8
  425. package/dist/src/ui/tui/tui-pane-history.js +402 -82
  426. package/dist/src/ui/tui/tui-pane-pending.js +308 -13
  427. package/dist/src/ui/tui/tui-pane-queued.js +71 -55
  428. package/dist/src/ui/tui/tui-pane-report.js +46 -14
  429. package/dist/src/ui/tui/tui-pane-session.js +25 -6
  430. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  431. package/dist/src/ui/tui/tui-pane.js +80 -23
  432. package/dist/src/ui/tui/tui-panes.js +100 -30
  433. package/dist/src/ui/tui/tui.js +14 -14
  434. package/dist/src/verdict/fix-claim.js +82 -32
  435. package/dist/src/verdict/no-change.js +4 -4
  436. package/dist/src/verdict/refusal.js +40 -16
  437. package/dist/src/verdict/reply-contract.js +42 -31
  438. package/dist/src/version.js +0 -1
  439. package/package.json +2 -1
  440. package/dist/src/loop/revision.js +0 -70
  441. package/dist/src/roles/loop-ids.js +0 -77
  442. package/dist/src/tick/stage-check.js +0 -419
  443. package/dist/src/tick/telemetry-digest.js +0 -25
  444. package/dist/src/tick/tick-detail-data.js +0 -108
  445. package/dist/src/tick/tick-detail.js +0 -163
  446. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,16 +1,19 @@
1
- // vocabulary: plain
2
1
  /** Session retention (the `sessionRetentionDays` setting): the once-per-day gate and the prune
3
2
  * itself, split out of orchestrator.ts — the poll-loop runtime carried both a startup prune and
4
3
  * a near-identical in-poll prune plus the last-applied-window bookkeeping, and scheduling.ts
5
- * held the due-gate despite being about tick policy, not file retention. Dependency direction:
4
+ * held the due-gate despite being about run policy, not file retention. Dependency direction:
6
5
  * orchestrator → retention; this module owns the whole "which files age out, and when" concern
7
6
  * and the orchestrator only hands it the (possibly live-reloaded) window. */
8
7
  import { logEventBestEffort, warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
9
- import { pruneOldFiles } from "../files/files.js";
10
- import { pruneSessionLedgers, pruneOpencodeSessions } from "../agent/session-ledger.js";
8
+ import { DAY_MS } from "../text/datetime.js";
9
+ import { ensureParentDir, pruneOldFiles } from "../files/files.js";
10
+ import { pruneSessionLedgers, pruneOpencodeSessions, pruneCodexSessions } from "../agent/session-ledger.js";
11
11
  import { loadConfigSafe } from "../config/config.js";
12
+ import { resolvedBackendConfig } from "../config/config-views.js";
12
13
  import { opencodeBackend, opencodeSpillDir } from "../agent/opencode/opencode-backend.js";
13
- import { opencodeDbPath, sessionsRootDir, toolOutputDir } from "../paths.js";
14
+ import { codexBackend } from "../agent/codex/codex-backend.js";
15
+ import { codexHome } from "../agent/codex/codex-catalog.js";
16
+ import { opencodeDbPath, salvageDir, sessionsRootDir, toolOutputDir } from "../paths.js";
14
17
  /** Is a once-per-day session prune due? Due when retention is enabled (> 0) and a full day
15
18
  * has passed since the last prune (or no prune has run yet). */
16
19
  export function dueForPrune(lastPruneAt, now, retentionDays) {
@@ -18,7 +21,7 @@ export function dueForPrune(lastPruneAt, now, retentionDays) {
18
21
  return false; // 0 disables pruning — never due.
19
22
  if (lastPruneAt === null)
20
23
  return true; // Never pruned yet — due immediately.
21
- return now - lastPruneAt >= 24 * 3600 * 1000;
24
+ return now - lastPruneAt >= DAY_MS;
22
25
  }
23
26
  /** The retention state machine across one orchestrator's lifetime: which window was last
24
27
  * applied and when we last pruned. Constructed once at startup — construction runs the startup
@@ -71,14 +74,39 @@ export class RetentionPruner {
71
74
  // hands every expired OpenCode session id to `opencode session delete`. Each half warns its
72
75
  // own unit.
73
76
  const { config } = loadConfigSafe(root);
74
- const bin = config !== undefined ? opencodeBackend.resolveBin(config).bin : undefined;
77
+ let bin = config !== undefined ? opencodeBackend.resolveBin(config).bin : undefined;
78
+ const dbPath = opencodeDbPath(root);
79
+ try {
80
+ ensureParentDir(dbPath);
81
+ }
82
+ catch {
83
+ // The directory cannot be created, so no OpenCode DB is there to delete from; this pass
84
+ // runs in the poll loop, where a throw ends the team. Skip only the session deletes.
85
+ bin = undefined;
86
+ }
75
87
  const deleted = pruneOpencodeSessions(sessions, days, {
76
88
  bin,
77
- env: { ...process.env, OPENCODE_DB: opencodeDbPath(root) },
89
+ env: { ...process.env, OPENCODE_DB: dbPath },
78
90
  spillDir: opencodeSpillDir(root),
79
91
  });
80
92
  if (deleted > 0)
81
93
  warnEventBestEffort(root, TUMWATER_SOURCE, `deleted ${deleted} expired opencode session(s)`);
94
+ // Codex's sessions also live outside .tumwater — under the team's CODEX_HOME (part 8/9).
95
+ // Resolve its binary and home exactly as a run's invocation would and purge its expired
96
+ // sessions from the ledger BEFORE the file pass below ages it out. Like the OpenCode twin,
97
+ // an unavailable config skips the deletes rather than guessing at a binary and home: a
98
+ // bare `codex` under the operator's inherited CODEX_HOME could act on the wrong home
99
+ // (review objection 2). CODEX_HOME is always set explicitly — to the resolved team home,
100
+ // or to undefined when the config is absent — so the inherited CODEX_HOME never reaches
101
+ // the purge (its rollout fallback would otherwise unlink the operator's own home).
102
+ const codexBin = config !== undefined ? codexBackend.resolveBin(config).bin : undefined;
103
+ const codexHomeDir = config !== undefined ? codexHome(resolvedBackendConfig(config, "codex")) : undefined;
104
+ const deletedCodex = pruneCodexSessions(sessions, days, {
105
+ bin: codexBin,
106
+ env: { ...process.env, CODEX_HOME: codexHomeDir },
107
+ });
108
+ if (deletedCodex > 0)
109
+ warnEventBestEffort(root, TUMWATER_SOURCE, `deleted ${deletedCodex} expired codex session(s)`);
82
110
  // Age out session/tool-output files now: an entirely stale dir is gone before the line
83
111
  // pass reads it. A running role's ledger mtime stays young, so pruneOldFiles never ages
84
112
  // that file out; each warning must name the unit it pruned.
@@ -88,5 +116,11 @@ export class RetentionPruner {
88
116
  const prunedLines = pruneSessionLedgers(sessions, days);
89
117
  if (prunedLines > 0)
90
118
  warnEventBestEffort(root, TUMWATER_SOURCE, `pruned ${prunedLines} stale session ledger line(s)`);
119
+ // Salvaged slot leftovers (src/git/slot-salvage.ts) age out on the same window as the
120
+ // sessions that made them: a patch still waiting by then belongs to a role that has not run
121
+ // since, and the operator had the window's length to take a kept one.
122
+ const prunedPatches = pruneOldFiles(salvageDir(root), days);
123
+ if (prunedPatches > 0)
124
+ warnEventBestEffort(root, TUMWATER_SOURCE, `pruned ${prunedPatches} old salvaged workspace patch(es)`);
91
125
  }
92
126
  }
@@ -0,0 +1,116 @@
1
+ /** The poll loop's per-runner admission tail (part 3/3, split out of pollRunnerReasons): the
2
+ * stand-down turn-down and the parallel-work-instance claim assignment, run after every skip
3
+ * gate has admitted a runner. Returns "admitted" when the runner may start a run, or "skipped"
4
+ * (having recorded its skip reason and settled its once-round answer) when it stands down or
5
+ * yields its place. Split out of orchestrator-scheduling.ts so the post-gate admission policy
6
+ * reads on its own screen; orchestrator-scheduling.ts's loop owns the binder: the final
7
+ * `noteSkip(root, runner, undefined)`, the `reasons` entry and the `writing` increment when an
8
+ * admitted runner actually starts. */
9
+ import { assignNext } from "../scheduling/claims.js";
10
+ import { eligibleEntries } from "../backlog/backlog-eligibility.js";
11
+ import { logEventBestEffort } from "../events/events.js";
12
+ import { freshlyWoken, hasOutstandingWork } from "../runner/runner-state.js";
13
+ import { BUGFIX_ROLE } from "../roles/roles.js";
14
+ import { baseRoleOf, instanceIndex } from "../roles/runner-ids.js";
15
+ import { noteSkip } from "./scheduling-scope.js";
16
+ /** Decide a runner's post-gate admission. `reason` is the runner's due reason — the
17
+ * `skipRunner` verdict's reason, itself `isEligible`'s — which the stand-down block keyed on
18
+ * scheduled/main-moved and the claim-group block keyed on inbox/resume read. Mutates the
19
+ * runner's claim/heldElsewhere state, the claim group's held set, and once-mode settlement on
20
+ * the skip paths. */
21
+ export function admitRunner(scope, runner, reason) {
22
+ // Parallel work instances (part 4/7): a multi-runner base role's claim assignment, after
23
+ // every gate has admitted this runner. A runner that already holds a claim proceeds — its
24
+ // revision, resume or retry is the same entry. An unclaimed runner with outstanding work
25
+ // (a revision) is the mid-entry runner the group hold waits on: it proceeds unassigned —
26
+ // holding it too would deadlock the whole group, since only its run can settle the hold.
27
+ // Otherwise, when the run is not an inbox/resume run, it takes the first free entry; an
28
+ // extra instance with none free is skipped without touching its backoff, and the primary
29
+ // stands down too while siblings hold every eligible entry, unless an operator wake is fresh
30
+ // (then it runs unassigned).
31
+ // Stand-down: the feature charter says "If PLANS.md is empty or everything is done, there is
32
+ // nothing to do" and the bugfix charter says "when every open entry carries [a Refused note],
33
+ // there is nothing to do", so a feature runner (primary included) with no eligible plan at all,
34
+ // or a bugfix runner whose open bugs are all held (no open bugs at all is search mode, which
35
+ // still runs), skips its scheduled and main-moved runs instead of paying a model run that
36
+ // can only end in
37
+ // nothing-to-do. Startup, inbox and resume runs, a fresh operator wake, and a runner with a
38
+ // claim or outstanding work still run. nextRunAt and backoff stay put, so the first poll
39
+ // after a plan turns eligible starts a run for it.
40
+ const standDownBase = baseRoleOf(runner.role);
41
+ if ((standDownBase === "feature" || (standDownBase === BUGFIX_ROLE && scope.openBugsNow)) &&
42
+ (reason === "scheduled" || reason === "main moved") &&
43
+ !freshlyWoken(runner.state) &&
44
+ runner.state.claim === undefined &&
45
+ !hasOutstandingWork(runner.state)) {
46
+ let eligible = scope.eligibleMemo.get(standDownBase);
47
+ if (eligible === undefined) {
48
+ eligible =
49
+ scope.claimGroups.get(standDownBase)?.eligible ??
50
+ eligibleEntries(scope.root, standDownBase);
51
+ scope.eligibleMemo.set(standDownBase, eligible);
52
+ }
53
+ if (eligible.length === 0) {
54
+ noteSkip(scope.root, runner, "no-free-entry");
55
+ runner.state.heldElsewhere = undefined;
56
+ if (scope.once.active)
57
+ scope.once.settle(runner.role, "no free entry");
58
+ return "skipped";
59
+ }
60
+ }
61
+ const claimGroup = scope.claimGroups.get(baseRoleOf(runner.role));
62
+ if (claimGroup) {
63
+ if (runner.state.claim) {
64
+ // keeps its claim
65
+ }
66
+ else if (hasOutstandingWork(runner.state)) {
67
+ // finishes its own unrecorded entry
68
+ }
69
+ else if (reason !== "inbox" && reason !== "resume") {
70
+ // The group holds an unclaimed mid-entry sibling: assign nothing this poll, or a
71
+ // sibling could be handed that same entry (the reported `feature-2`/`bugfix-2`
72
+ // duplication). It resolves the moment the sibling settles.
73
+ if (claimGroup.hold) {
74
+ noteSkip(scope.root, runner, "claim-hold");
75
+ return "skipped";
76
+ }
77
+ const free = claimGroup.eligible.filter((e) => !claimGroup.held.has(e.key));
78
+ const pick = assignNext(free);
79
+ // An unassigned runner is told which entries its siblings hold (cleared when none).
80
+ const heldTitles = claimGroup.eligible
81
+ .filter((e) => claimGroup.held.has(e.key))
82
+ .map((e) => e.title);
83
+ runner.state.heldElsewhere = !pick && heldTitles.length > 0 ? heldTitles : undefined;
84
+ if (pick) {
85
+ runner.state.claim = {
86
+ file: baseRoleOf(runner.role) === BUGFIX_ROLE ? "BUGS.md" : "PLANS.md",
87
+ key: pick.key,
88
+ title: pick.title,
89
+ at: scope.now,
90
+ source: "assigned",
91
+ };
92
+ claimGroup.held.add(pick.key);
93
+ logEventBestEffort(scope.root, {
94
+ source: runner.role,
95
+ type: "claim",
96
+ action: "assigned",
97
+ key: pick.key,
98
+ title: pick.title,
99
+ });
100
+ }
101
+ else if (instanceIndex(runner.role) >= 2 ||
102
+ (heldTitles.length > 0 && !freshlyWoken(runner.state))) {
103
+ // An idle extra instance, or a primary whose eligible entries siblings all hold
104
+ // (unless an operator wake is fresh): no run, no backoff climb.
105
+ noteSkip(scope.root, runner, "no-free-entry");
106
+ if (scope.once.active)
107
+ scope.once.settle(runner.role, "no free entry");
108
+ return "skipped";
109
+ }
110
+ }
111
+ }
112
+ else {
113
+ runner.state.heldElsewhere = undefined; // no claim group (e.g. instances lowered to 1)
114
+ }
115
+ return "admitted";
116
+ }
@@ -0,0 +1,201 @@
1
+ /** One poll's scheduling-scope precompute, built by orchestrator-scheduling.ts's
2
+ * pollRunnerReasons: the shared pass context and the values computed once per poll before the
3
+ * per-runner loop — the merge-queue snapshot, the director pin, per-base claim groups, the
4
+ * gate predicates, and the mutable admission state (quota counter, ordered runners, eligible
5
+ * memo, reasons map). Parts 2/3 and 3/3 lift the loop's skip and admit halves against this
6
+ * scope. */
7
+ import { increment } from "../collections.js";
8
+ import { DIRECTOR_ROLE, commitTier } from "../roles/roles.js";
9
+ import { baseRoleOf, runnerEnabled, runnerPaused } from "../roles/runner-ids.js";
10
+ import { hasOutstandingWork, saveRunnerStateBestEffort } from "../runner/runner-state.js";
11
+ import { queuedMergeRoles } from "../merge/merge-queue.js";
12
+ import { hasUnmergedPin } from "../runner/leftover.js";
13
+ import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
14
+ import { eligibleEntries } from "../backlog/backlog-eligibility.js";
15
+ import { CLAIM_IDLE_MAX_MS, claimReleaseReason, heldKeys, listedKeys, } from "../scheduling/claims.js";
16
+ /** Record why the poll skipped a due role on its state (RunnerState.skipReason), saving only when
17
+ * the value changes: a skipped role gets no run to save its own state, and a role that stays
18
+ * skipped must not rewrite its file every poll. */
19
+ export function noteSkip(root, runner, reason) {
20
+ if (runner.state.skipReason === reason)
21
+ return;
22
+ runner.state.skipReason = reason;
23
+ saveRunnerStateBestEffort(root, runner.state);
24
+ }
25
+ /** Compute the once-per-poll precompute the scheduling pass's per-runner loop reads. Has the
26
+ * same side effects the poll body's own precompute had: the merge-queue snapshot, the
27
+ * hasUnmergedPin director pin, the per-base claim-group build (with its claim-release loop,
28
+ * which mutates runner state, persists and logs), and the writing count. */
29
+ export async function buildSchedulingScope(ctx) {
30
+ const { root, runners, once, now, mainHead, userPaused, quietNow, pausedRoles, capPaused, maintenanceQuota, bootstrapHeld, budgetPausedRoles, probeRoles, reviewStrongPaused, roleQuietHeld, heldProviders, windowHeldProviders, reviewHeld, roleProviders, openBugsNow, workBacklogOpen, planBacklogFull, holdForRestart, diskHeld, deferredDue, workMergedSince, } = ctx;
31
+ // Merge-queue interlock data, listed ONCE per poll (was once per runner per poll — each
32
+ // queuedMergeFiles() re-lists the merge-queue directory and re-stats every queued entry): the
33
+ // check below only asks whether the runner's role has a queued entry. The queue changes
34
+ // only when a merge completes or a run enqueues — both asynchronous events this pass
35
+ // observes on its next poll — so one snapshot is as fresh as per-runner reads, and a
36
+ // merge that completes mid-pass just keeps its role blocked one extra poll (conservative).
37
+ const queuedRoles = queuedMergeRoles(root);
38
+ // The director's kept merge pin (a non-terminal merge failure kept it): due on its own min-run
39
+ // clock, since its only other run is an operator prompt. A queued or in-flight pin is the
40
+ // merge-queue interlock's to hold, so the leftover reason is not offered then.
41
+ const pinnedDirector = !queuedRoles.has(DIRECTOR_ROLE) && (await hasUnmergedPin(root, DIRECTOR_ROLE));
42
+ // Parallel work instances (plans/parallel-work-instances.md "Claims", part 4/7): a base role
43
+ // with more than one runner assigns each due instance a distinct eligible entry, and releases
44
+ // the claims whose entry left, turned ineligible, was disabled or went stale. Computed once
45
+ // per poll and only for a multi-runner base, so a single-runner team is unchanged. The
46
+ // release runs before assignment so a freed key counts as free below.
47
+ const claimGroups = new Map();
48
+ const baseCounts = new Map();
49
+ for (const runner of runners) {
50
+ const base = baseRoleOf(runner.role);
51
+ increment(baseCounts, base);
52
+ }
53
+ for (const [base, count] of baseCounts) {
54
+ if (count <= 1)
55
+ continue;
56
+ const groupRunners = runners.filter((r) => baseRoleOf(r.role) === base);
57
+ const eligible = eligibleEntries(root, base);
58
+ const eligibleKeys = new Set(eligible.map((e) => e.key));
59
+ const listed = listedKeys(root, base);
60
+ // A group whose runner is mid-entry with no recorded claim (it took the entry while it was
61
+ // the only runner, before a live reload raised `instances`) cannot be told WHICH entry that
62
+ // is from state alone. Assigning a free entry to a sibling could then hand it the same
63
+ // work: hold the whole group's assignment until that runner settles (merges, drops or goes
64
+ // idle), at which point its own staging may have recorded a claim. A busy runner that DOES
65
+ // hold a claim is fine — `held` excludes its key and siblings take other entries.
66
+ let hold = false;
67
+ for (const r of groupRunners) {
68
+ const busy = r.state.running ||
69
+ queuedRoles.has(r.role) ||
70
+ hasOutstandingWork(r.state);
71
+ if (r.state.claim === undefined && busy)
72
+ hold = true;
73
+ const released = claimReleaseReason(r, {
74
+ listedKeys: listed,
75
+ eligibleKeys,
76
+ now,
77
+ hasQueuedMerge: queuedRoles.has(r.role),
78
+ // Per instance, not per base role: a lowered `instances` leaves the surplus runner in
79
+ // place with `runnerEnabled` false, and its idle claim must be released so a sibling can
80
+ // take the entry instead of the key staying held until the 24h stale sweep.
81
+ enabled: runnerEnabled(r.config, r.role),
82
+ // Only this instance paused (not its whole role; instance 1 under `<role>-1`): a sibling
83
+ // can take the entry. With the base role paused no sibling runs, so the claim stays.
84
+ paused: runnerPaused(pausedRoles, r.role) && !pausedRoles.has(base),
85
+ });
86
+ if (released === null)
87
+ continue;
88
+ const title = r.state.claim?.title ?? "";
89
+ r.state.claim = undefined;
90
+ // Persist the release: an idle extra instance gets no run to save its state, so the dead
91
+ // claim would stay on disk for status/doctor to show and for a restart to release again.
92
+ saveRunnerStateBestEffort(root, r.state);
93
+ const reason = released;
94
+ logEventBestEffort(root, { source: r.role, type: "claim", action: "released", title, reason });
95
+ if (reason === "stale") {
96
+ warnEventBestEffort(root, r.role, `released "${title}", which a stopped instance was working on (idle ${Math.round(CLAIM_IDLE_MAX_MS / 3_600_000)}h)`);
97
+ }
98
+ }
99
+ claimGroups.set(base, {
100
+ eligible,
101
+ eligibleKeys,
102
+ held: heldKeys(groupRunners, eligibleKeys, listed),
103
+ hold,
104
+ });
105
+ }
106
+ // The team gates' block predicate (the director exempt — an explicit human prompt
107
+ // outranks any autonomous gate, quiet hours included): the once-mode settle below and
108
+ // the start gate must agree on exactly when a team gate holds, so the compound lives
109
+ // here once. Quiet hours fold in beside the operator pause and the budget gate — the
110
+ // schedule is not probe-worthy the way a paused budget is, so no probeDue exception.
111
+ // The per-role windows (quietHoursPerRole) fold in beside the team one as a fourth
112
+ // disjunct — a role held by either window (or both, held once) starts no new runs;
113
+ // the director stays exempt by the same role check that covers the other disjuncts.
114
+ // The budget hold, per role (part 5c/8): a tier that resolved to pause holds its own roles,
115
+ // and — with review on — an unresolvable strong tier holds every role (nothing could merge).
116
+ // The probe of a demoted pair pierces the hold for EXACTLY the probed pair's tier's roles:
117
+ // the earlier draft's pair-blind pierce lifted the pause team-wide on any due probe.
118
+ // Quiet hours fold in beside the operator pause — the schedule is not probe-worthy the way a
119
+ // paused budget is.
120
+ const budgetHolds = (role) => (reviewStrongPaused || budgetPausedRoles.has(role)) && !probeRoles.has(role);
121
+ const operatorPauseBlocks = (role) => (userPaused || quietNow || roleQuietHeld.has(role) || budgetHolds(role)) &&
122
+ role !== DIRECTOR_ROLE;
123
+ // Fresh operator demand: a queued prompt or a wake newer than the role's last run end. The
124
+ // same override the need-based deferral honors (an explicit "try again now" is not idle
125
+ // maintenance); shared by the bootstrap and maintenance-allowance holds so the two cannot
126
+ // drift on what counts as demand. Computed ONCE per runner per poll (the scheduling loop's
127
+ // per-runner `demand`), so a role's queue is listed a single time a poll instead of once per
128
+ // hold check; every consumer takes the boolean rather than re-calling inboxSize.
129
+ // The new-project bootstrap hold (part 2/2): held unless the role has a fresh operator wake
130
+ // or a queued prompt.
131
+ const bootstrapHeldNow = (runner, demand) => bootstrapHeld.has(runner.role) && !demand;
132
+ // The maintenance allowance's team-level hold (part 1b/4): `used` has reached `allowance`,
133
+ // so no maintenance role starts unless it carries demand (`tumwater wake <maintenance-role>`
134
+ // gets exactly one run). The per-poll admission headroom is spent separately, after each
135
+ // role's due check, where the pass knows what actually runs; the verdict alone cannot.
136
+ const maintenanceQuotaOverLine = (runner, demand) => commitTier(baseRoleOf(runner.role)) === "maintenance" &&
137
+ maintenanceQuota.used >= maintenanceQuota.allowance &&
138
+ !demand;
139
+ // Runs writing code per multi-runner base role (plans/fast-merge.md "Lever 1"): a runner in
140
+ // flight, not one whose change waits in the merge queue. `instances` limits this count, so the
141
+ // role's extra runner ids start work only while fewer than `instances` are running; admissions
142
+ // this poll count at once, so one pass cannot overshoot.
143
+ const writing = new Map();
144
+ for (const r of runners) {
145
+ if (claimGroups.has(baseRoleOf(r.role)) && r.state.running && !queuedRoles.has(r.role)) {
146
+ increment(writing, baseRoleOf(r.role));
147
+ }
148
+ }
149
+ // Admission order: a runner of a multi-runner base with a pending revision or conflict
150
+ // hand-back comes first (its change came back; it holds no new claim), then the rest in runner
151
+ // order, so the lowest-index free runner takes new work. Other roles keep their place.
152
+ const ownsOutstanding = (r) => claimGroups.has(baseRoleOf(r.role)) && hasOutstandingWork(r.state);
153
+ const ordered = [
154
+ ...runners.filter((r) => ownsOutstanding(r)),
155
+ ...runners.filter((r) => !ownsOutstanding(r)),
156
+ ];
157
+ // The stand-down's eligible lists per base role, read at most once per poll (see the check
158
+ // below).
159
+ const eligibleMemo = new Map();
160
+ return {
161
+ root,
162
+ runners,
163
+ once,
164
+ now,
165
+ mainHead,
166
+ userPaused,
167
+ quietNow,
168
+ pausedRoles,
169
+ capPaused,
170
+ maintenanceQuota,
171
+ bootstrapHeld,
172
+ budgetPausedRoles,
173
+ probeRoles,
174
+ reviewStrongPaused,
175
+ roleQuietHeld,
176
+ heldProviders,
177
+ windowHeldProviders,
178
+ reviewHeld,
179
+ roleProviders,
180
+ openBugsNow,
181
+ workBacklogOpen,
182
+ planBacklogFull,
183
+ holdForRestart,
184
+ diskHeld,
185
+ deferredDue,
186
+ workMergedSince,
187
+ queuedRoles,
188
+ pinnedDirector,
189
+ claimGroups,
190
+ writing,
191
+ ownsOutstanding,
192
+ ordered,
193
+ eligibleMemo,
194
+ reasons: new Map(),
195
+ quotaAdmitted: 0,
196
+ budgetHolds,
197
+ operatorPauseBlocks,
198
+ bootstrapHeldNow,
199
+ maintenanceQuotaOverLine,
200
+ };
201
+ }
@@ -0,0 +1,185 @@
1
+ import { logEventBestEffort } from "../events/events.js";
2
+ import { baseRoleOf, configuredInstances, runnerPaused } from "../roles/runner-ids.js";
3
+ import { BUGFIX_ROLE, DIRECTOR_ROLE, commitTier } from "../roles/roles.js";
4
+ import { deferRunReason, isEligible } from "../scheduling/scheduling.js";
5
+ import { inboxSize } from "../inbox/inbox.js";
6
+ import { freshlyWoken } from "../runner/runner-state.js";
7
+ import { MERGE_CONFLICT_LIMIT } from "../runner/leftover.js";
8
+ import { noteSkip } from "./scheduling-scope.js";
9
+ /** Decide whether this runner is due this poll, running every skip gate and the once/deferral
10
+ * bookkeeping against scheduling-scope.ts's scope. Returns skip:true when a gate declined the
11
+ * run, and skip:false with the due reason otherwise (the admit tail in scheduling-admit.ts
12
+ * consumes the reason). Mutates the shared scope —
13
+ * deferredDue, quotaAdmitted, once-mode settlement — and writes skip reasons as its only
14
+ * side effects. */
15
+ export async function skipRunner(scope, runner) {
16
+ const { root, once, now, mainHead, pausedRoles, capPaused, holdForRestart, diskHeld, windowHeldProviders, roleProviders, reviewHeld, heldProviders, openBugsNow, workBacklogOpen, planBacklogFull, deferredDue, workMergedSince, queuedRoles, pinnedDirector, claimGroups, writing, maintenanceQuota, maintenanceQuotaOverLine, bootstrapHeldNow, operatorPauseBlocks, } = scope;
17
+ // The role's inbox size and operator demand, computed once per runner per poll: every
18
+ // consumer below (the holds, isEligible's inbox count, the maintenance admission) shares
19
+ // this one directory listing + per-file stat read instead of each re-scanning the queue.
20
+ // The queue is append-only while this pass runs (nothing dequeues until launch, after the
21
+ // pass), so one read per runner is stable, and `now` here matches the poll's own clock.
22
+ const inboxCount = inboxSize(root, runner.role);
23
+ const demand = inboxCount > 0 || freshlyWoken(runner.state);
24
+ // Once mode: a paused role gets no run this round and must be reported as skipped,
25
+ // so it settles here — before the gates that would otherwise skip it silently (a
26
+ // once round has to end even when a pause marker is left over; a later resume within
27
+ // the same round cannot un-settle it, which is the at-most-one-run contract).
28
+ if (once.active &&
29
+ !once.isSettled(runner) &&
30
+ !runner.state.running &&
31
+ (runnerPaused(pausedRoles, runner.role) ||
32
+ capPaused.has(runner.role) ||
33
+ maintenanceQuotaOverLine(runner, demand) ||
34
+ bootstrapHeldNow(runner, demand) ||
35
+ operatorPauseBlocks(runner.role))) {
36
+ once.settle(runner.role, "paused");
37
+ }
38
+ // Once mode's at-most-one-run contract (src/scheduling/once-round.ts): a role that already had
39
+ // its run — or was settled with a skip reason — runs nothing further this round, even
40
+ // when its backoff has expired or the clock override would admit it again. Without this
41
+ // gate the one-run guarantee held only for deferrable built-ins (their deferral
42
+ // settles them); a custom role or work role with a short backoff re-qualified every
43
+ // poll and the round never ended.
44
+ if (once.active && !runner.state.running && once.isSettled(runner))
45
+ return { skip: true };
46
+ if (holdForRestart || diskHeld)
47
+ return { skip: true }; // a restart or a low disk is pending: nothing new starts, on any role
48
+ // The per-role pause gates BEFORE the team check and exempts nothing — the director
49
+ // included (the operator named that one role deliberately).
50
+ if (runnerPaused(pausedRoles, runner.role))
51
+ return { skip: true };
52
+ // The role's own daily cost cap (src/gates/role-cap-gates.ts): no start-gate change — a
53
+ // parked waiter finishes (in-flight runs finish, NEW runs are gated at scheduling,
54
+ // exactly like every per-role pause), and there is no fallback-probe exception: the
55
+ // stop is about spend, and probing it adds noise, not signal. The lift is a live
56
+ // config edit or local midnight — no marker exists for `resume --role` to touch.
57
+ if (capPaused.has(runner.role))
58
+ return { skip: true };
59
+ // The maintenance allowance (plans/work-ratio.md, part 1b/4): no new maintenance run while
60
+ // the rolling 24 h window is over its line. A fresh operator wake or a queued prompt admits
61
+ // one anyway (maintenanceQuotaOverLine), the same demand override the bootstrap hold honors.
62
+ if (maintenanceQuotaOverLine(runner, demand))
63
+ return { skip: true };
64
+ // The new-project bootstrap (part 2/2): no new maintenance runs until the plan target is
65
+ // met; a fresh wake or queued prompt admits one anyway (bootstrapHeldNow).
66
+ if (bootstrapHeldNow(runner, demand))
67
+ return { skip: true };
68
+ if (operatorPauseBlocks(runner.role))
69
+ return { skip: true }; // no new role runs while either gate holds
70
+ // The director honors only the subscription-window hold: the per-run 429 retry that backs
71
+ // its other exemptions does not answer a window rejection, so its prompt would fail and
72
+ // re-queue every poll until the repeated-failure pause stops it.
73
+ if (runner.role === DIRECTOR_ROLE && windowHeldProviders.has(roleProviders.get(runner.role)))
74
+ return { skip: true };
75
+ if (runner.role !== DIRECTOR_ROLE &&
76
+ (reviewHeld || heldProviders.has(roleProviders.get(runner.role))))
77
+ return { skip: true }; // nor while ITS provider's failure storm holds — or the reviewer's (nothing could merge)
78
+ // The role's OWN queue decides inbox due-ness: the director counts its historical
79
+ // inbox, every other role its per-role queue (tumwater prompt --role <id>) — so a
80
+ // queued prompt makes its role due by itself, no wake marker needed (isEligible).
81
+ const { run, reason, skip } = isEligible(runner, now, mainHead, inboxCount, {
82
+ once: once.active,
83
+ planBacklogOpen: !planBacklogFull,
84
+ // A pin at the conflict cap whose lineage is not yet handed back is owed an authoring run
85
+ // (the promptless path would skip it unchanged every poll), so only a prompt makes it due.
86
+ leftoverPending: pinnedDirector &&
87
+ !((runner.state.mergeConflicts?.count ?? 0) >= MERGE_CONFLICT_LIMIT &&
88
+ runner.state.conflictHandback === undefined),
89
+ });
90
+ if (!run) {
91
+ // Not due this poll: any deferral episode has ended (or never started). No event —
92
+ // the run's own events cover it.
93
+ if (deferredDue.get(runner.role))
94
+ deferredDue.set(runner.role, false);
95
+ // Once mode: idle and not due — settleSkipped records the skip reason (the caller's
96
+ // summary reports it).
97
+ if (once.active && !runner.state.running)
98
+ once.settleSkipped(runner);
99
+ // Thread a gate-specific skip through (e.g. the yield-stretched gap) so a runner the
100
+ // poll declined and will keep declining does not read as "due — starts on the next
101
+ // poll"; stays undefined for every ordinary not-due path.
102
+ noteSkip(root, runner, skip);
103
+ return { skip: true };
104
+ }
105
+ // Merge queue 3/5 interlock (invariant 3): a role with a QUEUED or IN-FLIGHT merge never
106
+ // starts a run — the entry stays in the queue until its merge completes, so one check
107
+ // covers both. Uniform over every role, director included: its prompt is not finished until it
108
+ // merges. Placed before the need-based deferral block on purpose — no episode bookkeeping is
109
+ // needed (the run that queued the merge finished, which closed any deferral episode; its
110
+ // `queued` can leave a prior no_change in lastResult — runner-state.ts records only completed
111
+ // results — but the merge's outcome replaces it before the interlock lets the role back into
112
+ // this pass), and skipping here saves that branch's workMergedSince git-range query for the
113
+ // skipped role.
114
+ if (queuedRoles.has(runner.role)) {
115
+ noteSkip(root, runner, undefined);
116
+ return { skip: true };
117
+ }
118
+ // The role's `instances` limit on concurrent runs: a multi-runner base admits a runner only
119
+ // while fewer than `instances` of its runners are running. A parked runner (queued change)
120
+ // was skipped by the interlock above and holds no place.
121
+ const writingBase = baseRoleOf(runner.role);
122
+ if (claimGroups.has(writingBase) && !runner.state.running) {
123
+ if ((writing.get(writingBase) ?? 0) >= configuredInstances(runner.config, runner.role)) {
124
+ noteSkip(root, runner, "instances-full");
125
+ return { skip: true };
126
+ }
127
+ }
128
+ // Need-based deferral: a due maintenance run (scheduled or main-moved wake) whose last
129
+ // run did nothing stays deferred while the feature/bugfix backlog is open or no new
130
+ // work has merged to react to — nextRunAt is left untouched, so it re-checks every poll
131
+ // until the backlog drains and qualifying work merges. Resume wakes precede this check in
132
+ // isEligible, the director's "inbox" reason skips it, work roles are not in
133
+ // DEFERRABLE_ROLES (bugfix is the exception while its backlog is empty — deferRun), and
134
+ // a fresh operator wake overrides it (deferRun's woken check — an explicit "try again
135
+ // now" is a demand, not idle maintenance). Sitting before reasons.set also keeps a
136
+ // deferred role out of the wake-event pass below.
137
+ if (reason === "scheduled" || reason === "main moved") {
138
+ const s = runner.state;
139
+ // The git range is only consulted when the other conditions already hold — a
140
+ // never-run role, or one with pending business, runs without paying for it, and an
141
+ // open backlog defers regardless of what merged. bugfix in search mode is the
142
+ // exception: its deferral keys on the work-merged verdict alone, so the backlog-open
143
+ // shortcut must not stand in for the query.
144
+ const searchBugfix = baseRoleOf(runner.role) === BUGFIX_ROLE && !openBugsNow;
145
+ const merged = (!workBacklogOpen || searchBugfix) && s.lastMainHead !== ""
146
+ ? await workMergedSince.since(s.lastMainHead, mainHead)
147
+ : true;
148
+ const deferral = deferRunReason(s, runner.role, merged, workBacklogOpen, openBugsNow, now, planBacklogFull);
149
+ const deferredNow = deferral !== null;
150
+ if (deferredNow !== (deferredDue.get(runner.role) ?? false)) {
151
+ if (deferral)
152
+ logEventBestEffort(root, { source: runner.role, type: "run_deferred", reason: deferral });
153
+ deferredDue.set(runner.role, deferredNow);
154
+ }
155
+ if (deferredNow) {
156
+ // Once mode: a maintenance run that deferred itself — its once-round
157
+ // answer, so it settles instead of holding the round open for DEFER_MAX_MS.
158
+ if (once.active)
159
+ once.settle(runner.role, "deferred");
160
+ noteSkip(root, runner, deferral === "backlog"
161
+ ? "deferred-backlog"
162
+ : deferral === "plans-full"
163
+ ? "deferred-plans-full"
164
+ : "deferred-no-work");
165
+ return { skip: true };
166
+ }
167
+ }
168
+ else if (deferredDue.get(runner.role)) {
169
+ deferredDue.set(runner.role, false); // an inbox/resume run ends the episode
170
+ }
171
+ // The allowance's in-flight admission (plans/work-ratio.md, part 1b/4): `used` counts the
172
+ // maintenance roles already in flight, so a due maintenance run admitted here spends one
173
+ // of the remaining `allowance - used` slots. This must sit after the due check — the pure
174
+ // verdict cannot know which roles will be eligible this poll, so the reservation lives in
175
+ // the pass that does; without it two due maintenance roles start in the same poll. Demand
176
+ // spends no slot: it is an explicit operator request, not idle maintenance.
177
+ if (commitTier(baseRoleOf(runner.role)) === "maintenance" && !demand) {
178
+ if (scope.quotaAdmitted >= maintenanceQuota.allowance - maintenanceQuota.used) {
179
+ noteSkip(root, runner, "maintenance-due-cap");
180
+ return { skip: true };
181
+ }
182
+ scope.quotaAdmitted++;
183
+ }
184
+ return { skip: false, reason: reason };
185
+ }
@@ -1,3 +1,4 @@
1
+ import { errCode } from "../errno.js";
1
2
  import { legacyMigrateLandQueueDir } from "../merge/merge-queue.js";
2
3
  import fs from "node:fs";
3
4
  import { deleteRefIfAt, refSha, refsUnder, setRef } from "../git/git.js";
@@ -13,11 +14,11 @@ registerStartupMigration({
13
14
  run: async (root) => {
14
15
  try {
15
16
  fs.unlinkSync(legacyLandingStatePath(root));
16
- return ["removed the old landing.json marker"]; // vocabulary-ok: names the legacy file
17
+ return { moved: ["removed the old landing.json marker"], kept: [], failed: [] }; // vocabulary-ok: names the legacy file
17
18
  }
18
19
  catch (err) {
19
- if (err.code === "ENOENT")
20
- return [];
20
+ if (errCode(err) === "ENOENT")
21
+ return { moved: [], kept: [], failed: [] };
21
22
  throw err;
22
23
  }
23
24
  },
@@ -26,7 +27,7 @@ registerStartupMigration({
26
27
  name: "mergeRefs",
27
28
  run: async (root) => {
28
29
  const legacyPrefix = "refs/tumwater/landing/"; // vocabulary-ok: legacy name read from old refs or commits
29
- const notes = [];
30
+ const notes = { moved: [], kept: [], failed: [] };
30
31
  for (const { ref, sha } of await refsUnder(root, legacyPrefix)) {
31
32
  const role = ref.slice(legacyPrefix.length);
32
33
  const target = mergeRefName(role);
@@ -35,14 +36,14 @@ registerStartupMigration({
35
36
  if (!(await setRef(root, target, sha)))
36
37
  continue; // keep the legacy pin; the next start retries
37
38
  await deleteRefIfAt(root, ref, sha);
38
- notes.push(`moved ${ref} to ${target}`);
39
+ notes.moved.push(`moved ${ref} to ${target}`);
39
40
  }
40
41
  else if (existing === sha) {
41
42
  await deleteRefIfAt(root, ref, sha);
42
- notes.push(`removed ${ref}: ${target} already names the same commit`);
43
+ notes.moved.push(`removed ${ref}: ${target} already names the same commit`);
43
44
  }
44
45
  else
45
- notes.push(`kept ${ref} and ${target}: they name different commits`);
46
+ notes.kept.push(`kept ${ref} and ${target}: they name different commits`);
46
47
  }
47
48
  return notes;
48
49
  },