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,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,245 @@
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 { readMergeMarker, mergeChanges } from "../merge/merge-slot.js";
13
+ import { isAfterMergeRole } from "../review/post-merge-review.js";
14
+ import { aheadOfMainBranch, branchExists } from "../git/git.js";
15
+ import { branchName } from "../paths.js";
16
+ import { hasUnmergedPin } from "../runner/leftover.js";
17
+ import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
18
+ import { eligibleEntries } from "../backlog/backlog-eligibility.js";
19
+ import { CLAIM_IDLE_MAX_MS, claimReleaseReason, heldKeys, listedKeys, } from "../scheduling/claims.js";
20
+ /** Record why the poll skipped a due role on its state (RunnerState.skipReason), saving only when
21
+ * the value changes: a skipped role gets no run to save its own state, and a role that stays
22
+ * skipped must not rewrite its file every poll. */
23
+ export function noteSkip(root, runner, reason) {
24
+ if (runner.state.skipReason === reason)
25
+ return;
26
+ runner.state.skipReason = reason;
27
+ saveRunnerStateBestEffort(root, runner.state);
28
+ }
29
+ /** Compute the once-per-poll precompute the scheduling pass's per-runner loop reads. Has the
30
+ * same side effects the poll body's own precompute had: the merge-queue snapshot, the
31
+ * hasUnmergedPin director pin, the per-base claim-group build (with its claim-release loop,
32
+ * which mutates runner state, persists and logs), and the writing count. */
33
+ export async function buildSchedulingScope(ctx) {
34
+ const { root, runners, mainBranch, merges, once, now, mainHead, userPaused, quietNow, pausedRoles, capPaused, maintenanceQuota, bootstrapHeld, budgetPausedRoles, probeRoles, reviewStrongPaused, afterMergeRoles, roleQuietHeld, heldProviders, windowHeldProviders, reviewHeld, roleProviders, openBugsNow, workBacklogOpen, planBacklogFull, holdForRestart, diskHeld, deferredDue, workMergedSince, } = ctx;
35
+ // Merge-queue interlock data, listed ONCE per poll (was once per runner per poll — each
36
+ // queuedMergeFiles() re-lists the merge-queue directory and re-stats every queued entry): the
37
+ // check below only asks whether the runner's role has a queued entry. The queue changes
38
+ // only when a merge completes or a run enqueues — both asynchronous events this pass
39
+ // observes on its next poll — so one snapshot is as fresh as per-runner reads, and a
40
+ // merge that completes mid-pass just keeps its role blocked one extra poll (conservative).
41
+ const queuedRoles = queuedMergeRoles(root);
42
+ // The director's kept merge pin (a non-terminal merge failure kept it): due on its own min-run
43
+ // clock, since its only other run is an operator prompt. A queued or in-flight pin is the
44
+ // merge-queue interlock's to hold, so the leftover reason is not offered then.
45
+ const pinnedDirector = !queuedRoles.has(DIRECTOR_ROLE) && (await hasUnmergedPin(root, DIRECTOR_ROLE));
46
+ // Merge-queue interlock relief (parts 1-3/4): for a post-merge-review role — one whose own
47
+ // config `review.afterMerge` names it — the queued/in-flight interlock is scoped to the stages
48
+ // that actually race the author's branch. `interlockedHold` holds exactly the QUEUED post-merge
49
+ // roles in a non-admissible stage: pre-approval (`verifying`, or queued but not yet picked up —
50
+ // absent from the pipeline's approved/merge sets), conflict resolution (`resolve-wait`/
51
+ // `resolving`, read from the live marker), or a branch already ahead of main (a prior relief
52
+ // run's held commit). An approved-and-waiting or in-flight-merge queued change no longer parks
53
+ // the author. Pre-merge-review roles keep the full interlock (their rejection must reach the
54
+ // runner before it authors again), so they are never added here; scheduling-skip.ts skips a
55
+ // queued role unless it is BOTH post-merge AND absent from interlockedHold.
56
+ const interlockedHold = new Set();
57
+ if (queuedRoles.size > 0) {
58
+ const marker = readMergeMarker(root);
59
+ const markerChanges = marker ? mergeChanges(marker) : [];
60
+ for (const runner of runners) {
61
+ const role = runner.role;
62
+ if (!queuedRoles.has(role) || !isAfterMergeRole(runner.config, role))
63
+ continue;
64
+ const inApproved = merges.approved.has(role);
65
+ const inMerge = (merges.merge?.roles ?? []).includes(role);
66
+ const stage = markerChanges.find((c) => c.role === role)?.stage;
67
+ const resolving = stage === "resolve-wait" || stage === "resolving";
68
+ const heldFromStage = !(inApproved || inMerge) || resolving;
69
+ // The branch-ahead git query is only paid when the stage is already admissible — a role
70
+ // held from its stage needs no extra spawn. The role's branch may live in a leased pool
71
+ // slot, so it is read from the shared root, not a worktree path. A missing branch (role
72
+ // retire --force deletes it while the queue entry lingers, or a manual `git branch -D`) is
73
+ // not "ahead", so it must not throw the poll out of the whole orchestrator run — check it
74
+ // exists before paying the sharp rev-list query.
75
+ const ahead = heldFromStage || !(await branchExists(root, branchName(role)))
76
+ ? 0
77
+ : await aheadOfMainBranch(root, branchName(role), mainBranch);
78
+ if (heldFromStage || ahead > 0)
79
+ interlockedHold.add(role);
80
+ }
81
+ }
82
+ // Parallel work instances (plans/parallel-work-instances.md "Claims", part 4/7): a base role
83
+ // with more than one runner assigns each due instance a distinct eligible entry, and releases
84
+ // the claims whose entry left, turned ineligible, was disabled or went stale. Computed once
85
+ // per poll and only for a multi-runner base, so a single-runner team is unchanged. The
86
+ // release runs before assignment so a freed key counts as free below.
87
+ const claimGroups = new Map();
88
+ const baseCounts = new Map();
89
+ for (const runner of runners) {
90
+ const base = baseRoleOf(runner.role);
91
+ increment(baseCounts, base);
92
+ }
93
+ for (const [base, count] of baseCounts) {
94
+ if (count <= 1)
95
+ continue;
96
+ const groupRunners = runners.filter((r) => baseRoleOf(r.role) === base);
97
+ const eligible = eligibleEntries(root, base);
98
+ const eligibleKeys = new Set(eligible.map((e) => e.key));
99
+ const listed = listedKeys(root, base);
100
+ // A group whose runner is mid-entry with no recorded claim (it took the entry while it was
101
+ // the only runner, before a live reload raised `instances`) cannot be told WHICH entry that
102
+ // is from state alone. Assigning a free entry to a sibling could then hand it the same
103
+ // work: hold the whole group's assignment until that runner settles (merges, drops or goes
104
+ // idle), at which point its own staging may have recorded a claim. A busy runner that DOES
105
+ // hold a claim is fine — `held` excludes its key and siblings take other entries.
106
+ let hold = false;
107
+ for (const r of groupRunners) {
108
+ const busy = r.state.running ||
109
+ queuedRoles.has(r.role) ||
110
+ hasOutstandingWork(r.state);
111
+ if (r.state.claim === undefined && busy)
112
+ hold = true;
113
+ const released = claimReleaseReason(r, {
114
+ listedKeys: listed,
115
+ eligibleKeys,
116
+ now,
117
+ hasQueuedMerge: queuedRoles.has(r.role),
118
+ // Per instance, not per base role: a lowered `instances` leaves the surplus runner in
119
+ // place with `runnerEnabled` false, and its idle claim must be released so a sibling can
120
+ // take the entry instead of the key staying held until the 24h stale sweep.
121
+ enabled: runnerEnabled(r.config, r.role),
122
+ // Only this instance paused (not its whole role; instance 1 under `<role>-1`): a sibling
123
+ // can take the entry. With the base role paused no sibling runs, so the claim stays.
124
+ paused: runnerPaused(pausedRoles, r.role) && !pausedRoles.has(base),
125
+ });
126
+ if (released === null)
127
+ continue;
128
+ const title = r.state.claim?.title ?? "";
129
+ r.state.claim = undefined;
130
+ // Persist the release: an idle extra instance gets no run to save its state, so the dead
131
+ // claim would stay on disk for status/doctor to show and for a restart to release again.
132
+ saveRunnerStateBestEffort(root, r.state);
133
+ const reason = released;
134
+ logEventBestEffort(root, { source: r.role, type: "claim", action: "released", title, reason });
135
+ if (reason === "stale") {
136
+ warnEventBestEffort(root, r.role, `released "${title}", which a stopped instance was working on (idle ${Math.round(CLAIM_IDLE_MAX_MS / 3_600_000)}h)`);
137
+ }
138
+ }
139
+ claimGroups.set(base, {
140
+ eligible,
141
+ eligibleKeys,
142
+ held: heldKeys(groupRunners, eligibleKeys, listed),
143
+ hold,
144
+ });
145
+ }
146
+ // The team gates' block predicate (the director exempt — an explicit human prompt
147
+ // outranks any autonomous gate, quiet hours included): the once-mode settle below and
148
+ // the start gate must agree on exactly when a team gate holds, so the compound lives
149
+ // here once. Quiet hours fold in beside the operator pause and the budget gate — the
150
+ // schedule is not probe-worthy the way a paused budget is, so no probeDue exception.
151
+ // The per-role windows (quietHoursPerRole) fold in beside the team one as a fourth
152
+ // disjunct — a role held by either window (or both, held once) starts no new runs;
153
+ // the director stays exempt by the same role check that covers the other disjuncts.
154
+ // The budget hold, per role (part 5c/8): a tier that resolved to pause holds its own roles,
155
+ // and — with review on — an unresolvable strong tier holds every role a pre-merge review
156
+ // gates (nothing could merge) — except a role on `review.afterMerge`, whose review runs
157
+ // after merging, so it keeps authoring. The probe of a demoted pair pierces the hold for
158
+ // EXACTLY the probed pair's tier's roles: the earlier draft's pair-blind pierce lifted the
159
+ // pause team-wide on any due probe.
160
+ const budgetHolds = (role) => (budgetPausedRoles.has(role) ||
161
+ (reviewStrongPaused && !afterMergeRoles.has(baseRoleOf(role)))) &&
162
+ !probeRoles.has(role);
163
+ const operatorPauseBlocks = (role) => (userPaused || quietNow || roleQuietHeld.has(role) || budgetHolds(role)) &&
164
+ role !== DIRECTOR_ROLE;
165
+ // Fresh operator demand: a queued prompt or a wake newer than the role's last run end. The
166
+ // same override the need-based deferral honors (an explicit "try again now" is not idle
167
+ // maintenance); shared by the bootstrap and maintenance-allowance holds so the two cannot
168
+ // drift on what counts as demand. Computed ONCE per runner per poll (the scheduling loop's
169
+ // per-runner `demand`), so a role's queue is listed a single time a poll instead of once per
170
+ // hold check; every consumer takes the boolean rather than re-calling inboxSize.
171
+ // The new-project bootstrap hold (part 2/2): held unless the role has a fresh operator wake
172
+ // or a queued prompt.
173
+ const bootstrapHeldNow = (runner, demand) => bootstrapHeld.has(runner.role) && !demand;
174
+ // The maintenance allowance's team-level hold (part 1b/4): `used` has reached `allowance`,
175
+ // so no maintenance role starts unless it carries demand (`tumwater wake <maintenance-role>`
176
+ // gets exactly one run). The per-poll admission headroom is spent separately, after each
177
+ // role's due check, where the pass knows what actually runs; the verdict alone cannot.
178
+ const maintenanceQuotaOverLine = (runner, demand) => commitTier(baseRoleOf(runner.role)) === "maintenance" &&
179
+ maintenanceQuota.used >= maintenanceQuota.allowance &&
180
+ !demand;
181
+ // Runs writing code per multi-runner base role (plans/fast-merge.md "Lever 1"): a runner in
182
+ // flight, not one whose change waits in the merge queue. `instances` limits this count, so the
183
+ // role's extra runner ids start work only while fewer than `instances` are running; admissions
184
+ // this poll count at once, so one pass cannot overshoot.
185
+ const writing = new Map();
186
+ for (const r of runners) {
187
+ if (claimGroups.has(baseRoleOf(r.role)) && r.state.running && !queuedRoles.has(r.role)) {
188
+ increment(writing, baseRoleOf(r.role));
189
+ }
190
+ }
191
+ // Admission order: a runner of a multi-runner base with a pending revision or conflict
192
+ // hand-back comes first (its change came back; it holds no new claim), then the rest in runner
193
+ // order, so the lowest-index free runner takes new work. Other roles keep their place.
194
+ const ownsOutstanding = (r) => claimGroups.has(baseRoleOf(r.role)) && hasOutstandingWork(r.state);
195
+ const ordered = [
196
+ ...runners.filter((r) => ownsOutstanding(r)),
197
+ ...runners.filter((r) => !ownsOutstanding(r)),
198
+ ];
199
+ // The stand-down's eligible lists per base role, read at most once per poll (see the check
200
+ // below).
201
+ const eligibleMemo = new Map();
202
+ return {
203
+ root,
204
+ runners,
205
+ once,
206
+ now,
207
+ mainHead,
208
+ userPaused,
209
+ quietNow,
210
+ pausedRoles,
211
+ capPaused,
212
+ maintenanceQuota,
213
+ bootstrapHeld,
214
+ budgetPausedRoles,
215
+ probeRoles,
216
+ reviewStrongPaused,
217
+ afterMergeRoles,
218
+ roleQuietHeld,
219
+ heldProviders,
220
+ windowHeldProviders,
221
+ reviewHeld,
222
+ roleProviders,
223
+ openBugsNow,
224
+ workBacklogOpen,
225
+ planBacklogFull,
226
+ holdForRestart,
227
+ diskHeld,
228
+ deferredDue,
229
+ workMergedSince,
230
+ queuedRoles,
231
+ interlockedHold,
232
+ pinnedDirector,
233
+ claimGroups,
234
+ writing,
235
+ ownsOutstanding,
236
+ ordered,
237
+ eligibleMemo,
238
+ reasons: new Map(),
239
+ quotaAdmitted: 0,
240
+ budgetHolds,
241
+ operatorPauseBlocks,
242
+ bootstrapHeldNow,
243
+ maintenanceQuotaOverLine,
244
+ };
245
+ }
@@ -0,0 +1,195 @@
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 { isAfterMergeRole } from "../review/post-merge-review.js";
5
+ import { deferRunReason, isEligible } from "../scheduling/scheduling.js";
6
+ import { inboxSize } from "../inbox/inbox.js";
7
+ import { freshlyWoken } from "../runner/runner-state.js";
8
+ import { MERGE_CONFLICT_LIMIT } from "../runner/leftover.js";
9
+ import { noteSkip } from "./scheduling-scope.js";
10
+ /** Decide whether this runner is due this poll, running every skip gate and the once/deferral
11
+ * bookkeeping against scheduling-scope.ts's scope. Returns skip:true when a gate declined the
12
+ * run, and skip:false with the due reason otherwise (the admit tail in scheduling-admit.ts
13
+ * consumes the reason). Mutates the shared scope —
14
+ * deferredDue, quotaAdmitted, once-mode settlement — and writes skip reasons as its only
15
+ * side effects. */
16
+ export async function skipRunner(scope, runner) {
17
+ const { root, once, now, mainHead, pausedRoles, capPaused, holdForRestart, diskHeld, windowHeldProviders, roleProviders, reviewHeld, afterMergeRoles, heldProviders, openBugsNow, workBacklogOpen, planBacklogFull, deferredDue, workMergedSince, queuedRoles, interlockedHold, pinnedDirector, claimGroups, writing, maintenanceQuota, maintenanceQuotaOverLine, bootstrapHeldNow, operatorPauseBlocks, } = scope;
18
+ // The role's inbox size and operator demand, computed once per runner per poll: every
19
+ // consumer below (the holds, isEligible's inbox count, the maintenance admission) shares
20
+ // this one directory listing + per-file stat read instead of each re-scanning the queue.
21
+ // The queue is append-only while this pass runs (nothing dequeues until launch, after the
22
+ // pass), so one read per runner is stable, and `now` here matches the poll's own clock.
23
+ const inboxCount = inboxSize(root, runner.role);
24
+ const demand = inboxCount > 0 || freshlyWoken(runner.state);
25
+ // Once mode: a paused role gets no run this round and must be reported as skipped,
26
+ // so it settles here — before the gates that would otherwise skip it silently (a
27
+ // once round has to end even when a pause marker is left over; a later resume within
28
+ // the same round cannot un-settle it, which is the at-most-one-run contract).
29
+ if (once.active &&
30
+ !once.isSettled(runner) &&
31
+ !runner.state.running &&
32
+ (runnerPaused(pausedRoles, runner.role) ||
33
+ capPaused.has(runner.role) ||
34
+ maintenanceQuotaOverLine(runner, demand) ||
35
+ bootstrapHeldNow(runner, demand) ||
36
+ operatorPauseBlocks(runner.role))) {
37
+ once.settle(runner.role, "paused");
38
+ }
39
+ // Once mode's at-most-one-run contract (src/scheduling/once-round.ts): a role that already had
40
+ // its run — or was settled with a skip reason — runs nothing further this round, even
41
+ // when its backoff has expired or the clock override would admit it again. Without this
42
+ // gate the one-run guarantee held only for deferrable built-ins (their deferral
43
+ // settles them); a custom role or work role with a short backoff re-qualified every
44
+ // poll and the round never ended.
45
+ if (once.active && !runner.state.running && once.isSettled(runner))
46
+ return { skip: true };
47
+ if (holdForRestart || diskHeld)
48
+ return { skip: true }; // a restart or a low disk is pending: nothing new starts, on any role
49
+ // The per-role pause gates BEFORE the team check and exempts nothing — the director
50
+ // included (the operator named that one role deliberately).
51
+ if (runnerPaused(pausedRoles, runner.role))
52
+ return { skip: true };
53
+ // The role's own daily cost cap (src/gates/role-cap-gates.ts): no start-gate change — a
54
+ // parked waiter finishes (in-flight runs finish, NEW runs are gated at scheduling,
55
+ // exactly like every per-role pause), and there is no fallback-probe exception: the
56
+ // stop is about spend, and probing it adds noise, not signal. The lift is a live
57
+ // config edit or local midnight — no marker exists for `resume --role` to touch.
58
+ if (capPaused.has(runner.role))
59
+ return { skip: true };
60
+ // The maintenance allowance (plans/work-ratio.md, part 1b/4): no new maintenance run while
61
+ // the rolling 24 h window is over its line. A fresh operator wake or a queued prompt admits
62
+ // one anyway (maintenanceQuotaOverLine), the same demand override the bootstrap hold honors.
63
+ if (maintenanceQuotaOverLine(runner, demand))
64
+ return { skip: true };
65
+ // The new-project bootstrap (part 2/2): no new maintenance runs until the plan target is
66
+ // met; a fresh wake or queued prompt admits one anyway (bootstrapHeldNow).
67
+ if (bootstrapHeldNow(runner, demand))
68
+ return { skip: true };
69
+ if (operatorPauseBlocks(runner.role))
70
+ return { skip: true }; // no new role runs while either gate holds
71
+ // The director honors only the subscription-window hold: the per-run 429 retry that backs
72
+ // its other exemptions does not answer a window rejection, so its prompt would fail and
73
+ // re-queue every poll until the repeated-failure pause stops it.
74
+ if (runner.role === DIRECTOR_ROLE && windowHeldProviders.has(roleProviders.get(runner.role)))
75
+ return { skip: true };
76
+ if (runner.role !== DIRECTOR_ROLE &&
77
+ (heldProviders.has(roleProviders.get(runner.role)) ||
78
+ (reviewHeld && !afterMergeRoles.has(baseRoleOf(runner.role)))))
79
+ return { skip: true }; // nor while ITS provider's failure storm holds — or the reviewer's, which a pre-merge review gates (a review.afterMerge role is exempt)
80
+ // The role's OWN queue decides inbox due-ness: the director counts its historical
81
+ // inbox, every other role its per-role queue (tumwater prompt --role <id>) — so a
82
+ // queued prompt makes its role due by itself, no wake marker needed (isEligible).
83
+ const { run, reason, skip } = isEligible(runner, now, mainHead, inboxCount, {
84
+ once: once.active,
85
+ planBacklogOpen: !planBacklogFull,
86
+ // A pin at the conflict cap whose lineage is not yet handed back is owed an authoring run
87
+ // (the promptless path would skip it unchanged every poll), so only a prompt makes it due.
88
+ leftoverPending: pinnedDirector &&
89
+ !((runner.state.mergeConflicts?.count ?? 0) >= MERGE_CONFLICT_LIMIT &&
90
+ runner.state.conflictHandback === undefined),
91
+ });
92
+ if (!run) {
93
+ // Not due this poll: any deferral episode has ended (or never started). No event —
94
+ // the run's own events cover it.
95
+ if (deferredDue.get(runner.role))
96
+ deferredDue.set(runner.role, false);
97
+ // Once mode: idle and not due — settleSkipped records the skip reason (the caller's
98
+ // summary reports it).
99
+ if (once.active && !runner.state.running)
100
+ once.settleSkipped(runner);
101
+ // Thread a gate-specific skip through (e.g. the yield-stretched gap) so a runner the
102
+ // poll declined and will keep declining does not read as "due — starts on the next
103
+ // poll"; stays undefined for every ordinary not-due path.
104
+ noteSkip(root, runner, skip);
105
+ return { skip: true };
106
+ }
107
+ // Merge queue 3/5 interlock (invariant 3): a role with a QUEUED or IN-FLIGHT merge never
108
+ // starts a run — the entry stays in the queue until its merge completes, so one check
109
+ // covers both. Uniform over every role, director included: its prompt is not finished until it
110
+ // merges. Placed before the need-based deferral block on purpose — no episode bookkeeping is
111
+ // needed (the run that queued the merge finished, which closed any deferral episode; its
112
+ // `queued` can leave a prior no_change in lastResult — runner-state.ts records only completed
113
+ // results — but the merge's outcome replaces it before the interlock lets the role back into
114
+ // this pass), and skipping here saves that branch's workMergedSince git-range query for the
115
+ // skipped role.
116
+ // Interlock relief (parts 1-3/4): a post-merge-review role (its own config `review.afterMerge`
117
+ // names it) with a queued change is held only while it is in a non-admissible stage — the
118
+ // pre-approval/conflict/branch-ahead holds buildSchedulingScope put in `interlockedHold` — and
119
+ // otherwise falls through to author (the merge no longer parks it). A pre-merge-review role,
120
+ // and a post-merge role still in a held stage, keep the full interlock: their rejection must
121
+ // reach the runner before it authors again.
122
+ if (queuedRoles.has(runner.role)) {
123
+ if (!isAfterMergeRole(runner.config, runner.role) || interlockedHold.has(runner.role)) {
124
+ noteSkip(root, runner, undefined);
125
+ return { skip: true };
126
+ }
127
+ }
128
+ // The role's `instances` limit on concurrent runs: a multi-runner base admits a runner only
129
+ // while fewer than `instances` of its runners are running. A parked runner (queued change)
130
+ // was skipped by the interlock above and holds no place.
131
+ const writingBase = baseRoleOf(runner.role);
132
+ if (claimGroups.has(writingBase) && !runner.state.running) {
133
+ if ((writing.get(writingBase) ?? 0) >= configuredInstances(runner.config, runner.role)) {
134
+ noteSkip(root, runner, "instances-full");
135
+ return { skip: true };
136
+ }
137
+ }
138
+ // Need-based deferral: a due maintenance run (scheduled or main-moved wake) whose last
139
+ // run did nothing stays deferred while the feature/bugfix backlog is open or no new
140
+ // work has merged to react to — nextRunAt is left untouched, so it re-checks every poll
141
+ // until the backlog drains and qualifying work merges. Resume wakes precede this check in
142
+ // isEligible, the director's "inbox" reason skips it, work roles are not in
143
+ // DEFERRABLE_ROLES (bugfix is the exception while its backlog is empty — deferRun), and
144
+ // a fresh operator wake overrides it (deferRun's woken check — an explicit "try again
145
+ // now" is a demand, not idle maintenance). Sitting before reasons.set also keeps a
146
+ // deferred role out of the wake-event pass below.
147
+ if (reason === "scheduled" || reason === "main moved") {
148
+ const s = runner.state;
149
+ // The git range is only consulted when the other conditions already hold — a
150
+ // never-run role, or one with pending business, runs without paying for it, and an
151
+ // open backlog defers regardless of what merged. bugfix in search mode is the
152
+ // exception: its deferral keys on the work-merged verdict alone, so the backlog-open
153
+ // shortcut must not stand in for the query.
154
+ const searchBugfix = baseRoleOf(runner.role) === BUGFIX_ROLE && !openBugsNow;
155
+ const merged = (!workBacklogOpen || searchBugfix) && s.lastMainHead !== ""
156
+ ? await workMergedSince.since(s.lastMainHead, mainHead)
157
+ : true;
158
+ const deferral = deferRunReason(s, runner.role, merged, workBacklogOpen, openBugsNow, now, planBacklogFull);
159
+ const deferredNow = deferral !== null;
160
+ if (deferredNow !== (deferredDue.get(runner.role) ?? false)) {
161
+ if (deferral)
162
+ logEventBestEffort(root, { source: runner.role, type: "run_deferred", reason: deferral });
163
+ deferredDue.set(runner.role, deferredNow);
164
+ }
165
+ if (deferredNow) {
166
+ // Once mode: a maintenance run that deferred itself — its once-round
167
+ // answer, so it settles instead of holding the round open for DEFER_MAX_MS.
168
+ if (once.active)
169
+ once.settle(runner.role, "deferred");
170
+ noteSkip(root, runner, deferral === "backlog"
171
+ ? "deferred-backlog"
172
+ : deferral === "plans-full"
173
+ ? "deferred-plans-full"
174
+ : "deferred-no-work");
175
+ return { skip: true };
176
+ }
177
+ }
178
+ else if (deferredDue.get(runner.role)) {
179
+ deferredDue.set(runner.role, false); // an inbox/resume run ends the episode
180
+ }
181
+ // The allowance's in-flight admission (plans/work-ratio.md, part 1b/4): `used` counts the
182
+ // maintenance roles already in flight, so a due maintenance run admitted here spends one
183
+ // of the remaining `allowance - used` slots. This must sit after the due check — the pure
184
+ // verdict cannot know which roles will be eligible this poll, so the reservation lives in
185
+ // the pass that does; without it two due maintenance roles start in the same poll. Demand
186
+ // spends no slot: it is an explicit operator request, not idle maintenance.
187
+ if (commitTier(baseRoleOf(runner.role)) === "maintenance" && !demand) {
188
+ if (scope.quotaAdmitted >= maintenanceQuota.allowance - maintenanceQuota.used) {
189
+ noteSkip(root, runner, "maintenance-due-cap");
190
+ return { skip: true };
191
+ }
192
+ scope.quotaAdmitted++;
193
+ }
194
+ return { skip: false, reason: reason };
195
+ }