tumwater 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -6,15 +6,12 @@ import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
6
6
  import { promptPreview } from "../inbox/inbox.js";
7
7
  import { assembleRunPrompt } from "../run/run-prompt.js";
8
8
  import { RunnerPi } from "./runner-pi.js";
9
- import { configForRole, seamHoldKey, } from "../config/config-views.js";
10
- import { fallbackRoleConfig } from "../config/config-fallbacks.js";
9
+ import { configForRole, seamHoldKey, withModelOverrides, } from "../config/config-views.js";
11
10
  import { PendingPrompt } from "../inbox/pending-prompt.js";
12
- import { modelFallbackActive } from "./model-fallback.js";
13
11
  import { clearBackoff } from "../scheduling/backoff.js";
14
12
  import { RunUsage } from "../run/run-usage.js";
15
13
  import { runOncePhase, runPhase, } from "./runner-run.js";
16
14
  import { finishAbortedRunPhase, finishRecoveryRunPhase, pinAndResetPhase, } from "./runner-recovery.js";
17
- import { foldModelFallbackPhase } from "./runner-fallback.js";
18
15
  /** One role runner: owns a persistent worktree + branch and runs one run at a time. */
19
16
  export class RoleRunner {
20
17
  root;
@@ -73,6 +70,13 @@ export class RoleRunner {
73
70
  * resolve to, since different roles may run on different pairs.
74
71
  * Transient — never persisted — and captured fresh at every run start. */
75
72
  runPair;
73
+ /** Whether the budget gate currently has the team on its free fallback pairs (src/gates/
74
+ * budget-gates.ts onFallback, set alongside `config` by the orchestrator's poll): an
75
+ * image reroute may then look only among the free fallback pairs, never a priced primary
76
+ * (plans/image-input.md "Reroute target" "Regime"). Pure scheduler input, never whether
77
+ * the runner itself is mid-fallback; defaults false, so standalone-runner tests stay on the
78
+ * primary regime unless they set it. */
79
+ budgetFallbackActive = false;
76
80
  /** The merge failure this run's leftover recovery is retrying, if any (a retriable merger
77
81
  * outcome that kept the pin, which recovery re-queued): set in finishRecoveryRun and
78
82
  * attached to the returned RunOutcome so applyRunOutcome can feed it into the error streak
@@ -117,7 +121,6 @@ export class RoleRunner {
117
121
  this.state.resumePending = true;
118
122
  this.state.running = false;
119
123
  this.state.parkedSince = undefined;
120
- this.state.parkedOn = undefined;
121
124
  }
122
125
  save() {
123
126
  saveRunnerState(this.root, this.state);
@@ -174,22 +177,39 @@ export class RoleRunner {
174
177
  runModelPair() {
175
178
  return this.state.running ? (this.runPair ?? null) : null;
176
179
  }
177
- /** The config this role's NEXT run will run on: the tier fallback pair while the episode is
178
- * active (a due probe runs the primary), else the role's own resolved config. The hold and
179
- * start passes read this so a role mid-episode is keyed by the provider it will actually use.
180
- * Pure read of config + state + the caller's clock. */
181
- runConfig(now) {
182
- const fallback = fallbackRoleConfig(this.config, this.role);
183
- if (fallback !== null && modelFallbackActive(this.state.modelFallback, now))
184
- return fallback;
180
+ /** Which pair set an image reroute may look among (plans/image-input.md "Reroute target"
181
+ * "Regime"): the free fallback pairs while the budget gate is on fallback; otherwise the
182
+ * primary tiers. One home for the rule, shared by the target-resolution sites (the verdict's
183
+ * reroute arm, the attachment preflight). */
184
+ rerouteRegime() {
185
+ return this.budgetFallbackActive ? "fallback" : "primary";
186
+ }
187
+ /** The config this role's NEXT run will run on: a pending image reroute's target, else the
188
+ * role's own resolved config (configForRole). The hold and start passes read this so a role
189
+ * is keyed by the provider it will use. The budget gate's fallback pair, when a cap episode
190
+ * is active, is installed into the live config itself, so this read already names the pair
191
+ * the run uses. */
192
+ runConfig() {
193
+ // A pending image-input reroute (plans/image-input.md part 4/5) outranks the role's own
194
+ // tier: the next run starts on the image-capable target the blind-read run recorded,
195
+ // rebuilt from the stored seam over the live config.
196
+ if (this.state.imageReroute) {
197
+ const r = this.state.imageReroute;
198
+ return withModelOverrides(this.config, r.tier, {
199
+ backend: r.backend,
200
+ provider: r.provider,
201
+ model: r.model,
202
+ thinking: r.thinking,
203
+ });
204
+ }
185
205
  return configForRole(this.config, this.role);
186
206
  }
187
- /** The seam key `runConfig(now)` resolves to (config-views.ts seamHoldKey): the agent id
207
+ /** The seam key `runConfig()` resolves to (config-views.ts seamHoldKey): the agent id
188
208
  * for a non-pi backend (claude seams carry no pi provider), else the pi provider. The hold
189
209
  * and start passes read only this, so a claude seam is gated by a `claude` hold and pi's
190
210
  * default seams (undefined) are not. */
191
- runProvider(now) {
192
- return seamHoldKey(this.runConfig(now));
211
+ runProvider() {
212
+ return seamHoldKey(this.runConfig());
193
213
  }
194
214
  /** The signal every pi run of the current run watches: harness shutdown OR a per-run user
195
215
  * abort (Node ≥ 20's AbortSignal.any). */
@@ -258,19 +278,15 @@ export class RoleRunner {
258
278
  this.usage.fold(this.state, run, false, provider, pair);
259
279
  }
260
280
  /** Run one full run of this role runner (the pipeline lives in src/runner/runner-run.ts):
261
- * `runPhase` resolves the config and fallback episode, logs run_start, runs the pipeline,
281
+ * `runPhase` resolves the role's own config, logs run_start, runs the pipeline,
262
282
  * and finalizes. Never throws: a failed run is an "error" result, saved and logged. */
263
283
  async run() {
264
284
  return runPhase(this.runContext());
265
285
  }
266
- /** Thin delegate to foldModelFallbackPhase (src/runner/runner-fallback.ts). */
267
- foldModelFallback(fallbackCtx, pi) {
268
- foldModelFallbackPhase(this.runContext(), fallbackCtx, pi);
269
- }
270
286
  /** The run pipeline's entry (src/runner/runner-run.ts): a thin delegate so `run` assembles
271
287
  * phase context once and tests can still override `runOnce` to bypass the pipeline. */
272
- async runOnce(cfg, fallbackCtx) {
273
- return runOncePhase(this.runContext(), cfg, fallbackCtx);
288
+ async runOnce(cfg) {
289
+ return runOncePhase(this.runContext(), cfg);
274
290
  }
275
291
  /** Assemble the run pipeline's host context (src/runner/runner-run.ts) from this runner's
276
292
  * fields. `config` and `recoveryFailure` are live getters, so a mid-run change is seen as
@@ -293,13 +309,14 @@ export class RoleRunner {
293
309
  warn: (message) => this.warn(message),
294
310
  runSignal: () => this.runSignal(),
295
311
  runPrompt: (backend) => this.runPrompt(backend),
312
+ rerouteRegime: () => this.rerouteRegime(),
296
313
  get recoveryFailure() {
297
314
  return self.recoveryFailure;
298
315
  },
299
316
  setRecoveryFailure: (message) => {
300
317
  self.recoveryFailure = message;
301
318
  },
302
- runOnce: (cfg, fallbackCtx) => this.runOnce(cfg, fallbackCtx),
319
+ runOnce: (cfg) => this.runOnce(cfg),
303
320
  setRunPair: (pair) => {
304
321
  self.runPair = pair;
305
322
  },
@@ -312,7 +329,6 @@ export class RoleRunner {
312
329
  },
313
330
  finishAbortedRun: (userPrompt, wt) => this.finishAbortedRun(userPrompt, wt),
314
331
  pinAndReset: (wt, sha) => this.pinAndReset(wt, sha),
315
- foldModelFallback: (fallbackCtx, pi) => this.foldModelFallback(fallbackCtx, pi),
316
332
  finishRecoveryRun: (recovered, userPrompt, wt, priorMergeFailure) => this.finishRecoveryRun(recovered, userPrompt, wt, priorMergeFailure),
317
333
  };
318
334
  }
@@ -68,6 +68,11 @@ export function pushYieldOutcome(s, result) {
68
68
  s.recentOutcomes = ((s.recentOutcomes ?? "") + ch).slice(-YIELD_RING);
69
69
  }
70
70
  const YIELD_MAX = 8;
71
+ /** How many recent counted runs the reset window spans — a merge anywhere in the last 10
72
+ * keeps the multiplier at 1, and the same 10 empties are what start the doubling. */
73
+ const YIELD_RESET_RECENT = 10;
74
+ /** How many further empty runs double the multiplier again (10→×2, 15→×4, 20→×8). */
75
+ const YIELD_DOUBLE_STEP = 5;
71
76
  /** The multiplier on a scalable role's minRunIntervalSeconds gap its recent yield earns:
72
77
  * 1 while any of the last 10 counted runs merged, otherwise doubling per 5 further empty
73
78
  * runs — 10 empties ×2, 15 ×4, 20 ×8 — capped at 8. `recent` is the ring's chars, oldest
@@ -76,12 +81,12 @@ const YIELD_MAX = 8;
76
81
  * the ring: one merge is the evidence the role's clock should trust, and the ring's older
77
82
  * half only matters once the merge has aged out of the recent window. */
78
83
  export function yieldMultiplier(recent) {
79
- if (recent.slice(-10).includes(YIELD_MERGE))
84
+ if (recent.slice(-YIELD_RESET_RECENT).includes(YIELD_MERGE))
80
85
  return 1;
81
86
  const empty = recent.filter((c) => c !== YIELD_MERGE).length;
82
- if (empty < 10)
87
+ if (empty < YIELD_RESET_RECENT)
83
88
  return 1;
84
- return Math.min(YIELD_MAX, 2 ** (Math.floor((empty - 10) / 5) + 1));
89
+ return Math.min(YIELD_MAX, 2 ** (Math.floor((empty - YIELD_RESET_RECENT) / YIELD_DOUBLE_STEP) + 1));
85
90
  }
86
91
  /** The yield multiplier a runner's state earns: the role's recent-yield multiplier when
87
92
  * yieldScaledRole(role) holds, else 1 (a role outside the scalable set keeps its plain gap
@@ -8,6 +8,7 @@
8
8
  * backlog through `loadRunnerState` / `listedKeys`, never mutates. */
9
9
  import { loadRunnerState } from "../runner/runner-state-load.js";
10
10
  import { CLAIM_IDLE_MAX_MS, listedKeys } from "./claims.js";
11
+ import { HOUR_MS } from "../text/datetime.js";
11
12
  /** The claim states among `ids` that an operator should act on, in the given order: a claim
12
13
  * whose key is absent from its role's backlog section is `gone` (0 hours, and gone wins over
13
14
  * idle for the same claim); otherwise a claim older than `CLAIM_IDLE_MAX_MS` is `idle` with
@@ -29,7 +30,7 @@ export function staleClaimEntries(root, ids, now = Date.now()) {
29
30
  file: claim.file,
30
31
  title: claim.title,
31
32
  kind: "idle",
32
- hours: Math.round((now - claim.at) / 3_600_000),
33
+ hours: Math.round((now - claim.at) / HOUR_MS),
33
34
  });
34
35
  }
35
36
  }
@@ -152,10 +152,6 @@ export function workReserveCount(root, runners, opts) {
152
152
  continue;
153
153
  if (r.state.running || opts.queuedMergeRoles.has(r.role))
154
154
  continue;
155
- // A work role whose provider is full (capped and at its cap) cannot use a reserved permit
156
- // anyway, so it must not withhold one from another provider's maintenance run (§6).
157
- if (opts.providerHasHeadroom !== undefined && !opts.providerHasHeadroom(r))
158
- continue;
159
155
  const eligible = eligibleFor(base);
160
156
  const holdsClaim = r.state.claim !== undefined && eligible.has(r.state.claim.key);
161
157
  const hasFreeEntry = [...eligible].some((k) => !(heldByBase.get(base)?.has(k) ?? false));
@@ -13,91 +13,78 @@ import { BUGFIX_ROLE, DEFERRABLE_ROLES, DIRECTOR_ROLE, roleTier, } from "../role
13
13
  import { baseRoleOf, runnerEnabled } from "../roles/runner-ids.js";
14
14
  import { HOUR_MS } from "../text/datetime.js";
15
15
  import { yieldMultiplierFor } from "./backoff.js";
16
- /** Should this role run now? `inboxCount` is the calling role's own queued-prompt count
17
- * (the director's queue for the director, the per-role queue for everyone else) — see the
18
- * inbox check below. Exported for tests. */
19
- export function isEligible(runner, now, mainHead, inboxCount, opts = {}) {
20
- const s = runner.state;
21
- // A role disabled in tumwater.json stops running immediately (live-reload); re-enabling
22
- // resumes within one poll cycle because the runner and its persisted state survive.
23
- if (!runnerEnabled(runner.config, runner.role))
24
- return { run: false };
25
- if (s.running)
26
- return { run: false };
27
- // The director carries the user's own requests: no min-gap, no backoff — a queued
28
- // prompt runs as soon as the previous one finishes.
29
- if (runner.role === DIRECTOR_ROLE) {
30
- if (inboxCount > 0)
31
- return { run: true, reason: "inbox" };
32
- if (opts.leftoverPending)
33
- return now >= s.nextRunAt ? { run: true, reason: "leftover" } : { run: false };
34
- return { run: false };
35
- }
36
- // An interrupted run (graceful abort or crash) leaves half-finished work in its pi
37
- // session and worktree: resume it promptly on restart instead of holding it for a full
38
- // interval — the min gap below throttles scheduled runs, not recovery. nextRunAt still
39
- // gates cut-off resumes, which deliberately wait one interval from their compacted context.
40
- // A queued prompt never preempts this gate: a resuming run assembles buildResumePrompt
41
- // and does not dequeue the per-role queue, so an early run would only resume sooner than
42
- // its deliberate wait — the prompt is consumed by the fresh run the inbox check below
43
- // makes due the moment the resume finishes.
16
+ /** Can the runner start at all right now? A role disabled in tumwater.json stops running
17
+ * immediately (live-reload; re-enabling resumes within one poll cycle because the runner and
18
+ * its persisted state survive), and an already-running role is never eligible. Either way the
19
+ * runner holds this poll — the single gate behind isEligible's two early returns. */
20
+ function blockedFromRunning(runner, s) {
21
+ return !runnerEnabled(runner.config, runner.role) || s.running === true;
22
+ }
23
+ /** The director carries the user's own requests: no min-gap, no backoff — a queued prompt
24
+ * runs as soon as the previous one finishes. Reaches either arm and always resolves. */
25
+ function directorGate(s, now, inboxCount, opts) {
26
+ if (inboxCount > 0)
27
+ return { run: true, reason: "inbox" };
28
+ if (opts.leftoverPending)
29
+ return now >= s.nextRunAt ? { run: true, reason: "leftover" } : { run: false };
30
+ return { run: false };
31
+ }
32
+ /** An interrupted run (graceful abort or crash) resumes promptly on restart; nextRunAt still
33
+ * gates cut-off resumes, which deliberately wait one interval. A queued prompt never preempts
34
+ * this gate. Resolves only when a resume is pending, else undefined. */
35
+ function resumeGate(s, now) {
44
36
  if (s.resumePending) {
45
37
  return now >= s.nextRunAt ? { run: true, reason: "resume" } : { run: false };
46
38
  }
47
- // A queued per-role prompt is due on its own existence, exactly like the director's inbox:
48
- // no min-gap, no backoff, no scheduled clock. The queue is the durable record of the
49
- // demand, so it survives every race a wake marker cannot — a wake consumed while a run
50
- // is in flight is overwritten by that run's end-save (runner.ts's wake() contract), but the
51
- // prompt file it enqueued is not, and this check picks the demand up on the first poll
52
- // after the run ends.
39
+ return undefined;
40
+ }
41
+ /** A queued per-role prompt is due on its own existence, exactly like the director's inbox: no
42
+ * min-gap, no backoff, no scheduled clock. The queue is the durable record of the demand. */
43
+ function inboxGate(inboxCount) {
53
44
  if (inboxCount > 0)
54
45
  return { run: true, reason: "inbox" };
55
- // The per-role interval (a slow clock, e.g. the steward's ~6 h) gates scheduled runs and
56
- // "main moved" early wakes — resolved here so a live-reloaded config applies. An operator
57
- // wake overrides it (the woken check below): it is an explicit demand, the same class of
58
- // request the director's inbox runs without any gap.
59
- //
60
- // Yield scaling: for a scalable role the gap is
61
- // multiplied by the multiplier its recent results earned — a search role that keeps
62
- // finding nothing slows down, and the multiplication gates the "main moved" wake below
63
- // too, which is where the cost actually comes from (the gap check above runs first, so a
64
- // scaled role inside its stretched gap neither runs on schedule nor on a main move).
65
- // One merge in the last ten counted runs restores the plain gap; error-class results
66
- // never enter the ring (backoff.ts), so a failing backend does not stretch anything;
67
- // `--once`, an operator wake, and a queued prompt all bypass the gap check entirely.
46
+ return undefined;
47
+ }
48
+ /** The per-role interval (a slow clock) gates scheduled runs and "main moved" early wakes.
49
+ * Yield scaling multiplies the gap; an operator wake overrides it. Resolves with the hold when
50
+ * the runner is inside the (possibly stretched) gap, else undefined. */
51
+ function gapGate(runner, s, now, opts) {
68
52
  const mult = yieldMultiplierFor(runner.state);
69
- // The same planBacklogOpen rule (scheduleConfigForRole) runner-run.ts schedules nextRunAt
70
- // from: while PLANS.md has planning room, plan runs on a zero gap; once the target is met it
71
- // drops to its 3600 s default (the plans-full deferral then holds the run). Yield scaling
72
- // still applies to whatever gap is chosen.
73
53
  const minGapSeconds = scheduleConfigForRole(runner.config, runner.role, opts.planBacklogOpen === true).minRunIntervalSeconds;
74
54
  const minGap = opts.once ? 0 : minGapSeconds * 1000 * mult;
75
55
  const sinceLast = now - (s.lastRunEndedAt ?? 0);
76
- // An operator wake newer than the gap window's opening run overrides the interval
77
- // (RunnerState.wokenAt): an explicit "try again now" with an empty queue must not silently
78
- // wait out the remaining slow clock it ran inside — a queued prompt carries its own
79
- // due-ness in the inbox check above, so it never depends on this stamp surviving.
80
56
  const woken = freshlyWoken(s);
81
57
  if (sinceLast < minGap && !woken) {
82
- // Held inside the stretched gap: the runner is not due this poll (a schedule clock with
83
- // the plain gap, or a main move under the multiplier, would otherwise admit it). Report
84
- // the yield-scaled hold so the phase cell reads "waiting out its gap" instead of the
85
- // bare "due — starts on the next poll" of the no-reason fallback (RunnerState.skipReason).
86
58
  return mult > 1 ? { run: false, skip: "yield-gap" } : { run: false };
87
59
  }
88
- // Once mode's clock override, second half: a raised backoffSeconds means nextRunAt is a
89
- // backoff deadline, not the scheduled clock, and is honored; at zero it is the clock a
90
- // productive run scheduled, and the explicit round demand overrides it.
60
+ return undefined;
61
+ }
62
+ /** The scheduled clock, then the "main moved" wake. Once mode overrides the clock when
63
+ * backoffSeconds is 0 (a productive run scheduled it); a raised backoff honors the deadline.
64
+ * Resolves only once a scheduled reason applies, else undefined. */
65
+ function scheduledGate(s, now, mainHead, opts) {
91
66
  if (now >= s.nextRunAt || (opts.once && s.backoffSeconds === 0)) {
92
67
  return { run: true, reason: s.runs === 0 ? "startup" : "scheduled" };
93
68
  }
94
- // The world changed under a sleeping role: main moved since its last run. The stretched
95
- // gap above gates this wake too (the gap check runs first), so a role whose recent runs
96
- // merged nothing does not re-check on every merge — the multiplier's whole point.
97
69
  if (s.lastMainHead && mainHead && mainHead !== s.lastMainHead) {
98
70
  return { run: true, reason: "main moved" };
99
71
  }
100
- return { run: false };
72
+ return undefined;
73
+ }
74
+ /** Should this role run now? `inboxCount` is the calling role's own queued-prompt count
75
+ * (the director's queue for the director, the per-role queue for everyone else) — see the
76
+ * inbox check below. Exported for tests. */
77
+ export function isEligible(runner, now, mainHead, inboxCount, opts = {}) {
78
+ const s = runner.state;
79
+ if (blockedFromRunning(runner, s))
80
+ return { run: false };
81
+ if (runner.role === DIRECTOR_ROLE)
82
+ return directorGate(s, now, inboxCount, opts);
83
+ return (resumeGate(s, now) ??
84
+ inboxGate(inboxCount) ??
85
+ gapGate(runner, s, now, opts) ??
86
+ scheduledGate(s, now, mainHead, opts) ??
87
+ { run: false });
101
88
  }
102
89
  /** Fair scheduling order for one poll's eligible roles: the director always leads (it runs
103
90
  * the user's prompts), then the work tier (feature/bugfix/plan) before maintenance — a long-idle
@@ -1,46 +1,11 @@
1
1
  import { budgetReached, teamDailyCost, modelPairName, projectCapHit } from "../budget/budget.js";
2
2
  import { roleCapVerdicts } from "../gates/role-cap-gates.js";
3
- import { configForRole, resolverConfig, reviewConfig, roleSeamTier, } from "../config/config-views.js";
3
+ import { roleSeamTier } from "../config/config-views.js";
4
4
  import { resolveTierFallbacks, tierFallbackLabels, tiersResolveDistinctPairs } from "../config/config-fallbacks.js";
5
- import { enabledRoleIds } from "../config/config.js";
6
- import { fallbackModelFree, piPairFree, readPiProviders, seamFree, teamModelsFree } from "../pi/pi-models.js";
5
+ import { fallbackModelFree, piPairFree, readPiProviders, teamModelsFree } from "../pi/pi-models.js";
7
6
  import { readOpencodeCache } from "../agent/opencode/opencode-catalog.js";
8
- import { backendDisplayName } from "../agent/backend-traits.js";
9
- import { compareWindowEntries, readAccount, windowEntries, } from "../agent/subscription-account.js";
7
+ import { readAccount } from "../agent/subscription-account.js";
10
8
  import { DIRECTOR_ROLE } from "../roles/roles.js";
11
- /** The subscription-window blocks and the team-global free-ness answer for a poll (see
12
- * StatusSnapshot.subscriptionWindows / subscriptionOnly): one `SubscriptionWindowBlock` per
13
- * distinct non-`pi` subscription backend the enabled seams (roles, review, resolver) route to
14
- * whose `accounts(backend)` reads subscription billing with at least one window. `only` is true
15
- * exactly when every paying seam is free (judged by its own backend's account or pi pair
16
- * price), so the display knows whether the windows replace or merely join the daily spend. The
17
- * blocks sort most-constrained-first by the same comparator windowEntries uses for one
18
- * account's windows (then backend), so a team's tightest window always leads. For a claude-only
19
- * team this is one claude block whose fields match the old claudeWindow block's, aside from the
20
- * added backend/label. The reviewer and the strong-tier resolver are included, like
21
- * teamModelsFree, since they can spend. */
22
- function windowBlocks(cfg, providers, accounts, catalog) {
23
- const seams = enabledRoleIds(cfg).map((role) => configForRole(cfg, role));
24
- if (cfg.review.enabled)
25
- seams.push(reviewConfig(cfg));
26
- if (seams.length > 0)
27
- seams.push(resolverConfig(cfg));
28
- const blocks = [];
29
- for (const backend of new Set(seams.map((s) => s.agent).filter((b) => b !== undefined && b !== "pi"))) {
30
- const account = accounts(backend);
31
- if (account?.billing !== "subscription")
32
- continue;
33
- const windows = windowEntries(account);
34
- if (windows.length === 0)
35
- continue;
36
- const heldUntil = windows
37
- .filter((w) => w.status === "rejected" && w.isUsingOverage !== true && typeof w.resetsAt === "number")
38
- .reduce((max, w) => Math.max(max, w.resetsAt), 0) || null;
39
- blocks.push({ backend, label: backendDisplayName(backend), windows, primary: windows[0], heldUntil });
40
- }
41
- blocks.sort((a, b) => compareWindowEntries(a.primary, b.primary) || a.backend.localeCompare(b.backend));
42
- return { blocks, only: !seams.some((s) => !seamFree(providers, s, accounts, catalog)) };
43
- }
44
9
  /** The spend/billing block for a poll: the one materialized `spentUsd` (the scheduler's own
45
10
  * published figure while running — BUGS.md 2026-09-30 — the persisted states' sum otherwise)
46
11
  * that the budget object, the burn-rate projection and `free`/`fallback`/`tiers` all read;
@@ -49,7 +14,7 @@ function windowBlocks(cfg, providers, accounts, catalog) {
49
14
  * (piPairFree, fallbackModelFree, teamModelsFree, resolveTierFallbacks) so it has one home:
50
15
  * models.json is read once (readPiProviders) and each subscription backend's account once
51
16
  * (readAccount, on the snapshot's own pinned `now`, via the per-poll memo), and every free-ness
52
- * verdict and the window blocks judge those same readings. The director is exempt from both
17
+ * verdict judges those same readings. The director is exempt from both
53
18
  * pause sets, exactly as the gates exempt it. */
54
19
  export function collectBudget(root, cfg, rows, roles, running, info, modelsPath, now) {
55
20
  // The running orchestrator's published gate figures (read once above, with the pid and the
@@ -80,8 +45,6 @@ export function collectBudget(root, cfg, rows, roles, running, info, modelsPath,
80
45
  const publishedDemotions = info?.fallbackDemotions ?? {};
81
46
  const usable = (p) => piPairFree(providers, p, accounts, catalog) && !(modelPairName(p) in publishedDemotions);
82
47
  const servingResolved = resolveTierFallbacks(cfg, usable);
83
- // The subscription usage windows (part 3a/9): one block per subscription backend in use.
84
- const { blocks: subscriptionWindows, only: subscriptionOnly } = windowBlocks(cfg, providers, accounts, catalog);
85
48
  // The per-tier budget pause set (part 5c/8): the same resolution the scheduler's gate folds,
86
49
  // over the SAME usable predicate an observer can re-derive — the price check, and the pair
87
50
  // not among the running orchestrator's published per-pair demotions. Computed only while the
@@ -115,8 +78,6 @@ export function collectBudget(root, cfg, rows, roles, running, info, modelsPath,
115
78
  ? tierFallbackLabels(servingResolved)
116
79
  : null,
117
80
  },
118
- subscriptionWindows,
119
- subscriptionOnly,
120
81
  budgetPausedRoles,
121
82
  capPaused,
122
83
  };
@@ -5,8 +5,8 @@ import { piModelsPath } from "../pi/pi-models.js";
5
5
  import { configForStatus, queueInFlight, mainCheckForPoll, maintenanceQuotaForPoll, ciParityForPoll } from "./status-polls.js";
6
6
  import { quietHoursStatus, roleQuietHold } from "../scheduling/quiet-hours.js";
7
7
  import { DIRECTOR_ROLE } from "../roles/roles.js";
8
- import { pausedRoles, pausedRolesUntil, scheduledStopAt, scheduledWakes, standingTeamPause } from "../team/team-state.js";
9
- import { orchestratorAlive, readOrchestratorInfo, } from "../team/orchestrator-info.js";
8
+ import { scheduledStopAt, scheduledWakes, standingRolePause, standingTeamPause } from "../team/team-state.js";
9
+ import { orchestratorAlive, readOrchestratorInfo, } from "../team/team-orchestrator-info.js";
10
10
  import { bootstrapHoldsRole, bootstrapStatus } from "../gates/bootstrap-gates.js";
11
11
  import { readMergeQueue } from "../merge/merge-queue.js";
12
12
  import { collectRoleRows } from "./status-roles.js";
@@ -15,7 +15,7 @@ import { collectRoleNotesOverCap } from "./status-role-notes.js";
15
15
  import { collectBudget } from "./status-budget.js";
16
16
  import { collectRootInstallDrift } from "./status-install.js";
17
17
  import { detectBuildCheck } from "../build/build-check-detect.js";
18
- import { briefBlockMissing, briefBlockOverCap } from "../brief/brief.js";
18
+ import { briefStatus } from "../brief/brief.js";
19
19
  import { principlesBlockOverCap } from "../prompt/principles.js";
20
20
  /** The earlier of the two optional stop deadlines (`run --for`'s and a scheduled stop's). */
21
21
  function earliestStop(a, b) {
@@ -49,28 +49,14 @@ function snapshotInboxFields(root, inboxes) {
49
49
  questions: openQuestions(root).length,
50
50
  };
51
51
  }
52
- /** The spend/billing fields of the snapshot: the budget, the per-backend subscription windows
53
- * (and their claude-window spelling) and the running-only chain/provider holds. */
54
- function snapshotBudgetFields(budget, subscriptionWindows, subscriptionOnly, claudeBlock, running, info) {
52
+ /** The spend/billing fields of the snapshot: the budget and the running-only chain/provider
53
+ * holds. */
54
+ function snapshotBudgetFields(budget, running, info) {
55
55
  return {
56
56
  // Unconditional (never null): a disabled team still shows its spend and the badge is
57
57
  // the affordance for SETTING a cap. models.json itself is stat-cached inside pi-models.ts,
58
58
  // so an unchanged catalog costs one stat per poll, not a re-read plus parse.
59
59
  budget,
60
- // One window block per subscription backend in use, raw for `status --json` and the
61
- // parts 3b/9–3e/9 displays; omitted when no subscription backend reported a window.
62
- ...(subscriptionWindows.length > 0 ? { subscriptionWindows } : {}),
63
- subscriptionOnly,
64
- ...(claudeBlock
65
- ? {
66
- claudeWindow: {
67
- windows: claudeBlock.windows,
68
- primary: claudeBlock.primary,
69
- only: subscriptionOnly,
70
- heldUntil: claudeBlock.heldUntil,
71
- },
72
- }
73
- : {}),
74
60
  ...(running && info?.chain && Object.keys(info.chain).length > 0 ? { chain: info.chain } : {}),
75
61
  // Only while running: a stale info file beside a dead pid must not speak for a stopped
76
62
  // team, and only while a hold stands (an empty list is the same as none).
@@ -79,7 +65,7 @@ function snapshotBudgetFields(budget, subscriptionWindows, subscriptionOnly, cla
79
65
  }
80
66
  /** The pause/quiet-hours fields of the snapshot: the paused flags and their deadlines, the
81
67
  * maintenance quota and bootstrap holds, and the per-role quiet windows. */
82
- function snapshotPauseFields(teamPause, markerRoles, roles, markerSet, capPaused, maintenanceQuota, bootstrapNow, roleRows, budgetPausedRoles, cfg, root, wakes, quiet) {
68
+ function snapshotPauseFields(teamPause, markerRoles, markerRolesUntil, roles, markerSet, capPaused, maintenanceQuota, bootstrapNow, roleRows, budgetPausedRoles, cfg, root, wakes, quiet) {
83
69
  return {
84
70
  paused: teamPause !== null,
85
71
  pausedRoles: markerRoles,
@@ -105,7 +91,7 @@ function snapshotPauseFields(teamPause, markerRoles, roles, markerSet, capPaused
105
91
  .filter((l) => l.role !== DIRECTOR_ROLE && roleQuietHold(cfg.quietHoursPerRole, baseRoleOf(l.role), new Date()))
106
92
  .map((l) => [l.role, cfg.quietHoursPerRole?.[baseRoleOf(l.role)] ?? ""])),
107
93
  pausedUntil: teamPause?.until,
108
- pausedRolesUntil: pausedRolesUntil(root),
94
+ pausedRolesUntil: markerRolesUntil,
109
95
  scheduledWake: wakes[0],
110
96
  scheduledWakes: wakes.length > 0 ? wakes : undefined,
111
97
  pauseReason: teamPause?.reason,
@@ -116,13 +102,17 @@ function snapshotPauseFields(teamPause, markerRoles, roles, markerSet, capPaused
116
102
  /** The merge-queue and doctor health fields of the snapshot (main-check, ci-parity and the
117
103
  * build-check/brief/principles flags, plus the role notes over cap). */
118
104
  function snapshotHealthFields(root, cfg, mergeQueue, roleNotesOverCap) {
105
+ // The brief's two degraded flags from ONE scan/read (brief.briefStatus): the always-on
106
+ // `no-brief` and `brief-truncated` amber alerts, read once per poll rather than twice
107
+ // re-reading the whole brief file.
108
+ const brief = briefStatus(root);
119
109
  return {
120
110
  mergeQueue,
121
111
  mainCheck: mainCheckForPoll(root, cfg),
122
112
  ciParity: ciParityForPoll(root, cfg),
123
113
  noDeclaredCheck: detectBuildCheck(root, cfg) === null ? true : undefined,
124
- noBrief: briefBlockMissing(root) ? true : undefined,
125
- briefTruncated: briefBlockOverCap(root) ? true : undefined,
114
+ noBrief: brief.missing ? true : undefined,
115
+ briefTruncated: brief.overCap ? true : undefined,
126
116
  ...(roleNotesOverCap.length > 0 ? { roleNotesOverCap } : {}),
127
117
  principlesTruncated: principlesBlockOverCap(root) ? true : undefined,
128
118
  };
@@ -166,7 +156,10 @@ export function snapshot(root, modelsPath = piModelsPath(), now = Date.now()) {
166
156
  const bootstrapNow = bootstrapStatus(root, cfg);
167
157
  // One row per role id (part 5b/7): an instance role shows `feature` and `feature-2`.
168
158
  const roles = runnerIds(cfg);
169
- const markerRoles = pausedRoles(root);
159
+ // One read of the paused-roles marker per poll serves both the set and its shared deadline
160
+ // (`pause --role --for` wrote it): standingRolePause returns both from the same parse, so a
161
+ // snapshot never reads the same marker file twice (pausedRoles + pausedRolesUntil each did).
162
+ const { roles: markerRoles, until: markerRolesUntil } = standingRolePause(root);
170
163
  const markerSet = new Set(markerRoles);
171
164
  // One read of the orchestrator info file per poll: it serves both the displayed pid and the
172
165
  // liveness check (passing it to orchestratorAlive skips its own re-read).
@@ -175,7 +168,15 @@ export function snapshot(root, modelsPath = piModelsPath(), now = Date.now()) {
175
168
  // run or verification) or is pinned to — threaded into the roles collector so `running`,
176
169
  // `disk` and the rows agree.
177
170
  const slots = readSlotsState(root).slots;
178
- const roleRows = collectRoleRows(root, cfg, now, slots, modelsPath, markerSet);
171
+ // One merge-queue pass per poll (merge-queue.ts's stat cache keeps an unchanged queue at one
172
+ // stat per file) serves three consumers — the `mergeQueue` field below, the busy runner rows'
173
+ // surplus-eligibility check (collectRoleRows' `queued`), and the in-maintenance in-flight
174
+ // count (maintenanceQuotaForPoll) — so a snapshot never lists or re-reads the same queue
175
+ // twice, and the rows, the depth and the quota cannot straddle a concurrent enqueue/drop.
176
+ const queue = readMergeQueue(root);
177
+ const merges = queue.entries;
178
+ const queuedRoles = new Set(merges.map((e) => e.role));
179
+ const roleRows = collectRoleRows(root, cfg, slots, modelsPath, markerSet, queuedRoles);
179
180
  // Every role whose notes file runs past the read bound (status-role-notes.ts): the standing
180
181
  // twin of the doctor `role notes` row, raised as the amber role-notes-truncated alert.
181
182
  const roleNotesOverCap = collectRoleNotesOverCap(root);
@@ -189,13 +190,11 @@ export function snapshot(root, modelsPath = piModelsPath(), now = Date.now()) {
189
190
  // One read of the pending scheduled wakes feeds both the earliest and the full list, so they
190
191
  // cannot disagree.
191
192
  const wakes = scheduledWakes(root, now);
192
- // One merge queue pass per poll (merge-queue.ts's stat cache keeps an unchanged queue at one
193
- // stat per file) serves the depth; inFlight is the 4/5 marker only when a live orchestrator
194
- // still has a queue entry for what the marker names (per change, for a batch) — the
195
- // cross-check makes every crash ordering self-healing (a stale marker alone never displays,
193
+ // The merge queue read is hoisted above the rows collector (one pass serves the depth, the
194
+ // rows' surplus check, and the quota below); inFlight is the 4/5 marker only when a live
195
+ // orchestrator still has a queue entry for what the marker names (per change, for a batch) —
196
+ // the cross-check makes every crash ordering self-healing (a stale marker alone never displays,
196
197
  // and needs no cleanup pass).
197
- const queue = readMergeQueue(root);
198
- const merges = queue.entries;
199
198
  const mergeQueue = {
200
199
  depth: merges.length + queue.unreadable,
201
200
  };
@@ -225,21 +224,18 @@ export function snapshot(root, modelsPath = piModelsPath(), now = Date.now()) {
225
224
  // The spend/billing block (status-budget.ts): the budget object, the per-backend
226
225
  // subscription windows and the team-global free-ness answer, and the two per-role pause
227
226
  // verdicts, all from one readPiProviders and one readAccount per backend in use per poll.
228
- const { budget, subscriptionWindows, subscriptionOnly, budgetPausedRoles, capPaused } = collectBudget(root, cfg, roleRows, roles, running, info, modelsPath, now);
229
- // The one-release claude spelling (see StatusSnapshot.claudeWindow), derived from the claude
230
- // block so `subscriptionWindows` stays the single source of the window data.
231
- const claudeBlock = subscriptionWindows.find((b) => b.backend === "claude");
227
+ const { budget, budgetPausedRoles, capPaused } = collectBudget(root, cfg, roleRows, roles, running, info, modelsPath, now);
232
228
  // The maintenance allowance (plans/work-ratio.md, part 1b/4): the same pure verdict the
233
229
  // gate folds, over the same event log, so the dashboards show the hold the scheduler
234
230
  // enforces. `merges` is the one merge queue snapshot read above, so in-flight maintenance
235
231
  // merges count once here and in mergeQueue.
236
- const maintenanceQuota = maintenanceQuotaForPoll(root, cfg.maintenancePerWorkMerge, roleRows, new Set(merges.map((e) => e.role)), now);
232
+ const maintenanceQuota = maintenanceQuotaForPoll(root, cfg.maintenancePerWorkMerge, roleRows, queuedRoles, now);
237
233
  return {
238
234
  ...snapshotRunFields(running, info),
239
235
  ...snapshotInboxFields(root, { inbox, inboxPrompts, inboxFiles, inboxQueuedAt, inboxNotBefore, roleInbox, roleInboxPrompts }),
240
236
  roles: roleRows,
241
- ...snapshotBudgetFields(budget, subscriptionWindows, subscriptionOnly, claudeBlock, running, info),
242
- ...snapshotPauseFields(teamPause, markerRoles, roles, markerSet, capPaused, maintenanceQuota, bootstrapNow, roleRows, budgetPausedRoles, cfg, root, wakes, quiet),
237
+ ...snapshotBudgetFields(budget, running, info),
238
+ ...snapshotPauseFields(teamPause, markerRoles, markerRolesUntil, roles, markerSet, capPaused, maintenanceQuota, bootstrapNow, roleRows, budgetPausedRoles, cfg, root, wakes, quiet),
243
239
  ...snapshotHealthFields(root, cfg, mergeQueue, roleNotesOverCap),
244
240
  ...snapshotResourceFields(root, running, info, rootDrift),
245
241
  };
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { readTextOrNull } from "../files/files.js";
3
+ import { readRegularTextOrNull } from "../files/files.js";
4
4
  import { cachedByStat } from "../files/stat-cache.js";
5
5
  import { roleNotesPath } from "../paths.js";
6
6
  import { ROLE_NOTES_MAX_BYTES } from "../agent/role-notes-write.js";
@@ -34,7 +34,7 @@ export function readRoleNotesOverCap(root) {
34
34
  continue;
35
35
  const file = path.join(notesDir, name);
36
36
  const chars = cachedByStat(noteLengthCache, file, file, () => {
37
- const text = readTextOrNull(file);
37
+ const text = readRegularTextOrNull(file);
38
38
  return text === null ? null : text.length;
39
39
  }, (n) => n);
40
40
  if (chars === null)