tumwater 0.4.2 → 0.4.3

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 (305) 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} +25 -12
  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 +1 -8
  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 +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -86,7 +86,7 @@ function freshProgress(quietMs, agent) {
86
86
  /** The event types feedLine acts on — everything else (streaming deltas, turn/agent
87
87
  * bookkeeping) is ignored. Also passed as parsePiEventLine's pre-filter to skip JSON.parse for
88
88
  * pi lines whose type is verifiably not one of these; a new case in the switch must be added
89
- * here too. `tumwater_run` is the harness's own marker (src/agent/run-spawn.ts), read for its
89
+ * here too. `tumwater_run` is the harness's own marker (src/agent/agent-spawn.ts), read for its
90
90
  * run kind: a kind-bearing marker flips the demux and starts that accumulator fresh before
91
91
  * its `session` event lands, and a legacy label-only marker ("review") still flips the demux
92
92
  * to gate. */
@@ -98,54 +98,67 @@ const PROGRESS_TYPES = new Set([
98
98
  "tool_execution_end",
99
99
  "message_end",
100
100
  ]);
101
+ /** A new run starts: everything before it was a previous run — restore the at-time-zero state. */
102
+ function applySessionEvent(progress) {
103
+ Object.assign(progress, freshProgress(progress.quietMs));
104
+ }
105
+ /** A tool execution starts: count the call, record its label as the last tool, and seed the
106
+ * open-call entry the stall flag reads. Track the open call for the stall flag — pi runs a
107
+ * message's tool calls concurrently by default, so several can be open at once and end in
108
+ * completion order. The state machine is shared with runPi's stall warning
109
+ * (src/pi/pi-watchdogs.ts); only the fallback label differs here. The full raw command rides
110
+ * along because the flag classifies the command's shape, and describeToolCall's label
111
+ * truncates at 32 chars — an operator past that point would be invisible there. */
112
+ function applyToolStart(progress, event) {
113
+ progress.toolCalls += 1;
114
+ const label = event.toolName ? describeToolCall(event.toolName, event.args) : undefined;
115
+ if (label)
116
+ progress.lastTool = label;
117
+ applyToolExecutionEvent(progress.openToolCalls ??= [], event.type, event.toolCallId, event.partialResult, label ?? "tool", toolCallCommand(event.args));
118
+ }
119
+ /** A tool update or end folds through the same call: `applyToolExecutionEvent` reads `event.type`
120
+ * to decide what to do (a content-bearing update moves the activity clock; end closes the
121
+ * call), and both no-op when no call is tracked. Only content-bearing updates prove the
122
+ * command is alive — bash emits one empty-content update right after start, and a
123
+ * content-free keepalive must not mask a hang — a rule applyToolExecutionEvent owns. */
124
+ function applyToolProgress(progress, event) {
125
+ if (progress.openToolCalls)
126
+ applyToolExecutionEvent(progress.openToolCalls, event.type, event.toolCallId, event.partialResult);
127
+ }
128
+ /** An assistant message ends: count the turn and fold usage/peak/context and the first work item.
129
+ * Guard each count like pi-stream's usageNumber does: a string-serialized or corrupt wire value
130
+ * would otherwise poison the live cell — `+=` would concatenate output into a string and
131
+ * Math.max would yield NaN. An unusable total keeps the previous context (this cell is "latest
132
+ * value"), while output and the peak floor treat it as absent. The first text the role speaks
133
+ * in this run is its work item ("I'll implement plan X"); later messages never replace it. */
134
+ function applyAssistantTurn(progress, event) {
135
+ if (event.message?.role !== "assistant")
136
+ return;
137
+ progress.turns += 1;
138
+ const usage = event.message.usage;
139
+ if (usage) {
140
+ progress.contextTokens = nonNegativeNumber(usage.totalTokens, progress.contextTokens);
141
+ progress.outputTokens += nonNegativeNumber(usage.output, 0);
142
+ progress.peakContextTokens = Math.max(progress.peakContextTokens, nonNegativeNumber(usage.totalTokens, 0));
143
+ }
144
+ if (!progress.currentWork)
145
+ progress.currentWork = workItemFromContent(event.message.content);
146
+ }
101
147
  /** Apply one parsed progress event to a progress object (mutates it). */
102
148
  function feedLine(progress, event) {
103
149
  switch (event.type) {
104
150
  case "session": // A new run starts: everything before it was a previous run — restore the at-time-zero state.
105
- Object.assign(progress, freshProgress(progress.quietMs));
151
+ applySessionEvent(progress);
106
152
  break;
107
- case "tool_execution_start": {
108
- progress.toolCalls += 1;
109
- const label = event.toolName ? describeToolCall(event.toolName, event.args) : undefined;
110
- if (label)
111
- progress.lastTool = label;
112
- // Track the open call for the stall flag — pi runs a message's tool calls concurrently
113
- // by default, so several can be open at once and end in completion order. The state
114
- // machine is shared with runPi's stall warning (src/pi/pi-watchdogs.ts); only the
115
- // fallback label differs here. The full raw command rides along because the flag
116
- // classifies the command's shape, and describeToolCall's label truncates at 32 chars —
117
- // an operator past that point would be invisible there.
118
- applyToolExecutionEvent(progress.openToolCalls ??= [], event.type, event.toolCallId, event.partialResult, label ?? "tool", toolCallCommand(event.args));
153
+ case "tool_execution_start":
154
+ applyToolStart(progress, event);
119
155
  break;
120
- }
121
- // Updates and ends fold through the same call: `applyToolExecutionEvent` reads `event.type`
122
- // to decide what to do (a content-bearing update moves the activity clock; end closes the
123
- // call), and both no-op when no call is tracked. Only content-bearing updates prove the
124
- // command is alive — bash emits one empty-content update right after start, and a
125
- // content-free keepalive must not mask a hang — a rule applyToolExecutionEvent owns.
126
156
  case "tool_execution_update":
127
157
  case "tool_execution_end":
128
- if (progress.openToolCalls)
129
- applyToolExecutionEvent(progress.openToolCalls, event.type, event.toolCallId, event.partialResult);
158
+ applyToolProgress(progress, event);
130
159
  break;
131
160
  case "message_end":
132
- if (event.message?.role === "assistant") {
133
- progress.turns += 1;
134
- const usage = event.message.usage;
135
- if (usage) {
136
- // Guard each count like pi-stream's usageNumber does: a string-serialized or corrupt
137
- // wire value would otherwise poison the live cell — `+=` would concatenate output into
138
- // a string and Math.max would yield NaN. An unusable total keeps the previous context
139
- // (this cell is "latest value"), while output and the peak floor treat it as absent.
140
- progress.contextTokens = nonNegativeNumber(usage.totalTokens, progress.contextTokens);
141
- progress.outputTokens += nonNegativeNumber(usage.output, 0);
142
- progress.peakContextTokens = Math.max(progress.peakContextTokens, nonNegativeNumber(usage.totalTokens, 0));
143
- }
144
- // The first text the role speaks in this run is its work item ("I'll implement plan X");
145
- // later messages never replace it.
146
- if (!progress.currentWork)
147
- progress.currentWork = workItemFromContent(event.message.content);
148
- }
161
+ applyAssistantTurn(progress, event);
149
162
  break;
150
163
  }
151
164
  }
@@ -251,7 +264,7 @@ function feedDemuxed(tail, line, gateCwd) {
251
264
  * run in the role's own worktree, the default; `gate` — the review gate's runs in a leased pool
252
265
  * slot, what the reviewing cell shows), or null when there is no log yet. The raw log is
253
266
  * append-only while pi runs and every run starts with a `tumwater_run` marker
254
- * (src/agent/run-spawn.ts), so after seeding from the tail window once we only read and parse
267
+ * (src/agent/agent-spawn.ts), so after seeding from the tail window once we only read and parse
255
268
  * bytes appended since the last poll — observers that call this every second (TUI, GUI) stop
256
269
  * rescanning up to TAIL_BYTES
257
270
  * of JSON per role per poll. */
@@ -1,3 +1,4 @@
1
+ import { callBuffersOutput } from "../pi/command-shape.js";
1
2
  import { readLiveProgress } from "./progress-data.js";
2
3
  import { compactTokens } from "../text/format.js";
3
4
  import { pad2 } from "../text/datetime.js";
@@ -83,8 +84,14 @@ export function inFlightDetail(head, p) {
83
84
  else if (p.lastTool)
84
85
  parts.push(p.lastTool);
85
86
  // Silence under five minutes is normal (slow local-model prefills, long tool calls);
86
- // only flag a stall once at least five minutes have passed without any pi output.
87
- if (p.quietMs >= 300_000)
87
+ // only flag a stall once at least five minutes have passed without any pi output. While a
88
+ // tool call that buffers its output (a redirect or pipe — the run prompt's prescribed
89
+ // `> check.log` verify shape) is open, that flat threshold would cry wolf inside the call's
90
+ // own generous wall-clock stall window: the silence is the prescribed shape, not a hang,
91
+ // and a real stall past that window has already named itself as `tool call stalled: <tool>`
92
+ // above. Defer to the call so a slow suite stops being read as a hang (BUGS.md 2026-10-28).
93
+ const hasOpenBuffered = p.openToolCalls?.some((call) => callBuffersOutput(p.agent, call.command || call.label)) ?? false;
94
+ if (p.quietMs >= 300_000 && !hasOpenBuffered)
88
95
  parts.push(`no agent output for ${duration(p.quietMs)}`);
89
96
  return parts.join(" · ");
90
97
  }
@@ -84,7 +84,7 @@ export function onErrorStreak(s) {
84
84
  return (errorStreakAtWarn(s) ||
85
85
  (s.lastResult === "quiet_killed" && (s.quietKillStreak ?? 0) >= QUIET_KILL_RESUME_LIMIT));
86
86
  }
87
- export function rolePhase(s, orchestratorRunning, root, budgetPaused = false, live, userPaused = false, merge, mainCheck, capPaused = false, quietHold, diskHeld = false, bootstrapHeld = false, maintenanceQuotaHold, permits, pausedUntilMs) {
87
+ export function rolePhase(s, orchestratorRunning, root, budgetPaused = false, live, userPaused = false, merge, mainCheck, capPaused = false, quietHold, diskHeld = false, bootstrapHeld = false, maintenanceQuotaHold, pausedUntilMs) {
88
88
  if (!orchestratorRunning)
89
89
  return "stopped";
90
90
  // Merge queue 4/5 — the marker-driven merge label: only a role whose change record was
@@ -114,16 +114,6 @@ export function rolePhase(s, orchestratorRunning, root, budgetPaused = false, li
114
114
  // parked waiter visibly does not (BUGS.md 2026-09-24).
115
115
  if (s.parkedSince) {
116
116
  const parkedTime = duration(Date.now() - s.parkedSince);
117
- // The parked waiter's provider key, named only while that key is the binding limit in
118
- // the published permit snapshot (plans/provider-concurrency.md §8): the key is at its
119
- // cap, so THIS waiter is queued on its provider, not on the team total — `awaiting
120
- // omlx slot` tells the operator exactly which cap is full. A role parked on the total
121
- // (no key, or an uncapped one) reads today's `awaiting slot` as before.
122
- if (s.parkedOn !== undefined &&
123
- permits !== undefined &&
124
- isBindingLimit(permits, s.parkedOn)) {
125
- return `waiting for ${s.parkedOn} ${parkedTime}`;
126
- }
127
117
  return `waiting to start ${parkedTime}`;
128
118
  }
129
119
  // The director bypasses maxConcurrent (`usesSlot` is false for it), so its in-flight run
@@ -322,9 +312,6 @@ export function roleRowCells(snap, root, s) {
322
312
  snap.maintenanceQuota.held.includes(s.role)
323
313
  ? `waiting: maintenance allowance used ${snap.maintenanceQuota.used}/${snap.maintenanceQuota.allowance}`
324
314
  : undefined,
325
- // The permit pool's live state (plans/provider-concurrency.md §8): names the parked
326
- // waiter's provider key in `awaiting <key> slot` when that key is at its cap.
327
- snap.permits,
328
315
  // A timed per-role pause's deadline — only when the role itself is individually paused
329
316
  // (in `pausedRoles`) and not under a team-wide pause, matching the GUI's phaseDetail
330
317
  // guard, so the team pause (whose countdown rides the header badge) never double-appends.
@@ -336,15 +323,6 @@ export function roleRowCells(snap, root, s) {
336
323
  * gui-client-model.ts interpolates ROLE_RANK_RULES, so the two dashboards cannot disagree
337
324
  * about which phases are in flight. */
338
325
  export const ACTIVE_PHASE_PREFIXES = ["working", "reviewing", "merging"];
339
- /** Whether `key` is the binding limit in the published permit snapshot: its cap is set and
340
- * its in-use already meets or exceeds it, so the pool is granting nothing more on that key and
341
- * a waiter carrying it is queued on the key, not on the team total. A key without a cap in
342
- * the snapshot (uncapped) is never binding. One home for the rule, shared by status-model's
343
- * `awaiting <key> slot` and the GUI chip's provider detail. */
344
- function isBindingLimit(permits, key) {
345
- const entry = permits.byKey[key];
346
- return entry?.limit !== undefined && entry.inUse >= entry.limit;
347
- }
348
326
  /** Is this phase one of the PERMIT-HOLDER in-flight states — the rows an operator counts
349
327
  * against maxConcurrent? The three labels come from rolePhase: `working …`, `reviewing …`,
350
328
  * and the marker-driven `merge …` (merge queue 4/5) — an approved change waiting for the merge
@@ -375,7 +353,7 @@ export function isActivePhase(phase) {
375
353
  export const ROLE_RANK_RULES = [
376
354
  { rank: -1, prefixes: ["director working"] },
377
355
  { rank: 0, active: true },
378
- { rank: 1, prefixes: ["approved", "waiting to start", "waiting for "] },
356
+ { rank: 1, prefixes: ["approved", "waiting to start"] },
379
357
  { rank: 2, phases: ["failing", "main's check is failing"] },
380
358
  { rank: 3, phases: ["paused"] },
381
359
  { rank: 3, prefixes: ["paused", "waiting:"] },
@@ -1,4 +1,4 @@
1
- import { answeredQuestionSummaries } from "../backlog/question-answer.js";
1
+ import { answeredQuestionSummaries } from "../backlog/backlog-question-answer.js";
2
2
  import { completionDate, donePlanEntries, DONE_DATE_RE, FIXED_DATE_RE, fixedBugEntries, openBugs, openQuestions, plannedPlans } from "../backlog/backlog.js";
3
3
  import { backlogHolds, backlogInProgress, backlogMarks, refusedEntries, stalledEntries } from "../backlog/backlog-eligibility.js";
4
4
  import { finishedTitle, strandedPlanEntriesFor } from "../backlog/backlog-structure.js";
@@ -15,11 +15,10 @@ import { scopeSnapshotToRoles, snapshot } from "../status/status-data.js";
15
15
  import { bootstrapBadge, buildBadge, budgetBadge, diskBadge, mergeBadge, mainCheckBadge, mainCountsFragment, ciParityBadge, ciParityTone, permitsBadge, checksBadge, quietBadge, runWindowBadge, workspacesBadge } from "./badges.js";
16
16
  import { isActivePhase, roleRowCells, onErrorStreak } from "./status-model.js";
17
17
  import { yieldMultiplierFor } from "../scheduling/backoff.js";
18
- import { staleClaimEntries } from "../scheduling/claim-entries.js";
19
18
  import { teamAlerts } from "./team-alerts.js";
20
19
  import { duplicatedBacklogHeadings, liveFinishedEntriesFor } from "../doctor/doctor-backlog-plans.js";
21
20
  import { readPostReviews } from "../review/post-merge-review.js";
22
- import { capacityContention, instancePoolProblems, planDependencyAlert, providerCapProblems, regressedFixesAlert, staleOpenQuestions, staleWorkspacePins } from "./alert-feeds.js";
21
+ import { getOrphanAlert, planDependencyAlert, staleOpenQuestions } from "./alert-feeds.js";
23
22
  /** How many recent events the payload carries — enough for the dashboard's activity feed to
24
23
  * still show a screenful of notable events (merges, failures, questions) after it filters
25
24
  * out the routine ones (run starts, wakes, passing checks). */
@@ -61,9 +60,6 @@ function roleRows(snap, root) {
61
60
  // the dashboard can mark a row whose model tumwater.json did not name. Same conditional
62
61
  // spread as `model`, present on exactly the rows that carry the flag.
63
62
  ...(s.modelFromPi ? { modelFromPi: true } : {}),
64
- // Model failure fallback (PLANS.md "Model failure fallback, part 2/2"): present only
65
- // while the role's runs use another model, so the common row shape is unchanged.
66
- ...(s.fallback ? { fallback: s.fallback } : {}),
67
63
  ...(s.slot ? { slot: s.slot, slotPinned: s.slotPinned === true } : {}),
68
64
  // Parallel work instances (plans/parallel-work-instances.md "Observability", part
69
65
  // 6/7): the backlog entry this instance claimed, shown by the dashboards while it is
@@ -419,7 +415,7 @@ function alertFields(root, snap, questions, roles, now) {
419
415
  return {
420
416
  // What needs the operator (team-alerts.ts's teamAlerts): the dashboard's alert banners and
421
417
  // the TUI's attention lines, phrased once.
422
- alerts: teamAlerts(snap, questions, roles, now, refusedEntries(root), planDependencyAlert(root), stalledEntries(root), strandedPlanEntriesFor(root), duplicatedBacklogHeadings(root), validationGapProblems(root), capacityContention(root, now), regressedFixesAlert(root, now), staleClaimEntries(root, roles.map((r) => r.role), now), staleOpenQuestions(root, now), fixClaimProblems(root), staleWorkspacePins(root, now), liveFinishedEntriesFor(root), instancePoolProblems(root), providerCapProblems(root)),
418
+ alerts: teamAlerts(snap, questions, roles, now, refusedEntries(root), planDependencyAlert(root), stalledEntries(root), strandedPlanEntriesFor(root), duplicatedBacklogHeadings(root), validationGapProblems(root), staleOpenQuestions(root, now), fixClaimProblems(root), liveFinishedEntriesFor(root), getOrphanAlert(root)),
423
419
  };
424
420
  }
425
421
  /** The one team-state document both observer surfaces carry: `GET /api/status` (gui/gui-server.ts)
@@ -9,12 +9,11 @@ import { runProgress } from "./run-progress-model.js";
9
9
  import { eventTypeLabel } from "../events/event-labels.js";
10
10
  import { roleLabel } from "../roles/role-labels.js";
11
11
  import { yieldMultiplierFor, yieldMultiplierSuffix } from "../scheduling/backoff.js";
12
- import { staleClaimEntries } from "../scheduling/claim-entries.js";
13
12
  import { phaseTone, resultTone } from "./tone.js";
14
13
  import { teamAlerts } from "./team-alerts.js";
15
14
  import { openQuestions } from "../backlog/backlog.js";
16
15
  import { refusedEntries, stalledEntries } from "../backlog/backlog-eligibility.js";
17
- import { capacityContention, instancePoolProblems, planDependencyAlert, providerCapProblems, regressedFixesAlert, staleOpenQuestions, staleWorkspacePins } from "./alert-feeds.js";
16
+ import { getOrphanAlert, planDependencyAlert, staleOpenQuestions } from "./alert-feeds.js";
18
17
  import { strandedPlanEntriesFor } from "../backlog/backlog-structure.js";
19
18
  import { duplicatedBacklogHeadings, fixClaimProblems, liveFinishedEntriesFor, validationGapProblems } from "../doctor/doctor-backlog-plans.js";
20
19
  /** The status RENDER layer: time/token cell formatters and the width-aware table shared by
@@ -238,8 +237,8 @@ function statusHeaderLine(root, snap, now) {
238
237
  // comfortable or unmeasured state, so an unaffected header stays byte-identical.
239
238
  ...badgeSpans(diskBadge(snap.disk), snap.disk?.held ? "yellow" : "blue"),
240
239
  // The permit pool's live state (plans/provider-concurrency.md §8): the team line reads
241
- // `slots 5/8 · omlx 3/3` only while the per-provider cap map is set (permitsBadge already
242
- // omits the providers on a map-less team, so the header stays byte-identical).
240
+ // `running 5 of 8` — the team total's in-use/cap — for any team with a published permit
241
+ // snapshot; empty until one has been published.
243
242
  ...badgeSpans(permitsBadge(snap.permits), "blue"),
244
243
  // Checks running/waiting for a check permit; empty while the check pool is idle.
245
244
  ...badgeSpans(checksBadge(snap.checkPermits), snap.checkPermits && snap.checkPermits.waiting > 0 ? "yellow" : "blue"),
@@ -306,14 +305,11 @@ function statusRoleRows(root, snap, now, withMetrics, sharedModel) {
306
305
  // today's.
307
306
  const inner = roleModelLabel(s);
308
307
  const modelSuffix = inner && !sharedModel ? ` (${inner})` : "";
309
- // Model failure fallback, part 2/2: while the role runs off-model its name cell says so,
310
- // so the operator can see which roles the fallback pair is carrying without reading logs.
311
- const fallbackSuffix = s.fallback ? " (over-cap model)" : "";
312
308
  // User-defined roles (snapshot's `custom` flag) get an asterisk beside their name — the
313
309
  // dashboards' at-a-glance marker. The name column (index 0) is not in FLEXIBLE_COLUMNS;
314
310
  // its width derives from row content and it shrinks only after those,
315
311
  // so the extra character widens it.
316
- const name = (s.custom ? `${roleLabel(s.role)}*` : roleLabel(s.role)) + modelSuffix + fallbackSuffix;
312
+ const name = (s.custom ? `${roleLabel(s.role)}*` : roleLabel(s.role)) + modelSuffix;
317
313
  const state = stateCell(root, s, phase, live);
318
314
  const tone = phaseTone(phase);
319
315
  // The whole state cell carries the phase's tone: a work item leads the cell (so it
@@ -509,7 +505,7 @@ export function renderStatus(root, snap, maxWidth, scoped = false) {
509
505
  // Composed here, NOT inside renderStatusSpans: the TUI re-slices that function's shared
510
506
  // `lines` (lines[0], its own attention block, then lines.slice(1)), so a banner embedded
511
507
  // there would render twice in the TUI.
512
- const alerts = teamAlerts(snap, openQuestions(root), roles, Date.now(), refusedEntries(root), planDependencyAlert(root), stalledEntries(root), strandedPlanEntriesFor(root), duplicatedBacklogHeadings(root), validationGapProblems(root), capacityContention(root), regressedFixesAlert(root, Date.now()), staleClaimEntries(root, roles.map((r) => r.role), Date.now()), staleOpenQuestions(root), fixClaimProblems(root), staleWorkspacePins(root, Date.now()), liveFinishedEntriesFor(root), instancePoolProblems(root), providerCapProblems(root)).filter((a) => a.key !== "stopped");
508
+ const alerts = teamAlerts(snap, openQuestions(root), roles, Date.now(), refusedEntries(root), planDependencyAlert(root), stalledEntries(root), strandedPlanEntriesFor(root), duplicatedBacklogHeadings(root), validationGapProblems(root), staleOpenQuestions(root), fixClaimProblems(root), liveFinishedEntriesFor(root), getOrphanAlert(root)).filter((a) => a.key !== "stopped");
513
509
  const banner = alertLines(alerts, maxWidth ?? Number.MAX_SAFE_INTEGER, alerts.length).map(plainText);
514
510
  const text = lines.map(plainText);
515
511
  return [...text.slice(0, 2), ...banner, ...text.slice(2)].join("\n");
@@ -7,7 +7,7 @@ import { entryListDetail, firstAndMore, openBacklogAction } from "./team-alert-s
7
7
  import { entryKindLabel } from "../backlog/backlog-eligibility.js";
8
8
  import { missingSymbolNames } from "../verdict/fix-claim.js";
9
9
  import { joinWithMore } from "../text/text.js";
10
- import { plural } from "../text/phrases.js";
10
+ import { agree, plural } from "../text/phrases.js";
11
11
  import { liveFinishedRowList } from "../backlog/backlog-archive.js";
12
12
  function pushQuestionsAlert(out, questions) {
13
13
  if (questions.length) {
@@ -112,7 +112,7 @@ function pushStrandedPlansAlert(out, strandedPlans) {
112
112
  out.push({
113
113
  key: "stranded-plans",
114
114
  tone: "amber",
115
- title: `${strandedPlans.length === 1 ? "A plan sits" : "Plans sit"} under the wrong backlog section`,
115
+ title: `${agree(strandedPlans.length, "A plan sits", "Plans sit")} under the wrong backlog section`,
116
116
  detail: `${first.file ?? "PLANS.md"} has a plan stranded under ## ${first.section}: "${first.title}"${more} — move it under ## ${first.section === "Done" ? "Planned" : "Done"}${first.file === undefined || first.file === "PLANS.md" ? "" : " in PLANS.md"} (the clean role repairs these)`,
117
117
  // The fix is editing a backlog file, and a PLANS-DONE.md entry has no PLANS.md drawer index.
118
118
  actions: [],
@@ -140,10 +140,10 @@ function pushBacklogHeadingsAlert(out, duplicatedHeadings) {
140
140
  }
141
141
  // Finished history still in the live backlog files (the doctor's `backlog archive` rule,
142
142
  // checkLiveFinished, src/doctor/doctor-backlog-plans.ts, via the shared liveFinishedEntriesFor)
143
- // is the one backlog-hygiene rule the doctor warned but no always-on alert surfaced — every
144
- // other rule (stranded plans, duplicated headings, validation gaps, plan dependencies, stale
145
- // bugs and questions) has one, so an operator watching `tumwater status` or the dashboard never
146
- // sees the growing finished sections until they run `tumwater doctor`. It asks (amber) the way
143
+ // is a backlog-hygiene rule the doctor warned but no always-on alert surfaced — stranded
144
+ // plans, duplicated headings, validation gaps, plan dependencies and questions each have one,
145
+ // so an operator watching `tumwater status` or the dashboard never sees the growing finished
146
+ // sections until they run `tumwater doctor`. It asks (amber) the way
147
147
  // the duplicated-heading alert does, naming each live file, its finished section, its entry
148
148
  // count, and its size in KB; the fix is the `tumwater archive` command, so there are no actions.
149
149
  function pushBacklogArchiveAlert(out, liveFinished) {
@@ -1,20 +1,16 @@
1
1
  /** The status/capacity and role-phase team alerts, in emission order: a spent budget
2
- * (chain/fallback/budget), per-role cap, disk, install, and main-red holds, roles failing run
3
- * after run or stuck, and a stale running build. One of the per-domain builders split out of
4
- * src/ui/team-alerts.ts; the shared types and wording helpers live in team-alert-shared.ts. */
5
- import { staleClaimDetail } from "../scheduling/claim-entries.js";
6
- import { openRoleActions, seeFailures, listRoles, listRoleIds, stuckDetail, localizeInstants, } from "./team-alert-shared.js";
2
+ * (chain/fallback/budget), disk, install, and main-red holds, roles failing run after run or
3
+ * stuck. One of the per-domain builders split out of src/ui/team-alerts.ts; the shared types
4
+ * and wording helpers live in team-alert-shared.ts. */
5
+ import { openRoleActions, seeFailures, listRoles, listRoleIds, stuckDetail, } from "./team-alert-shared.js";
7
6
  import { chainFragments } from "./badges.js";
8
7
  import { fallbackTierEntries } from "../budget/budget.js";
9
8
  import { fallbackDisplayPair } from "../config/config-views.js";
10
9
  import { agree, plural } from "../text/phrases.js";
11
- import { BRIEF_TEXT_MAX_CHARS } from "../brief/brief.js";
12
10
  import { formatGB, shortSha, usd, usdCap } from "../text/format.js";
13
11
  import { joinWithMore, squash } from "../text/text.js";
14
12
  import { roleLabel } from "../roles/role-labels.js";
15
13
  import { STALL_RE } from "./run-progress-model.js";
16
- import { ROLE_NOTES_MAX_BYTES } from "../agent/role-notes-write.js";
17
- import { PRINCIPLES_MAX_CHARS } from "../prompt/principles.js";
18
14
  function pushChainAlert(out, snap) {
19
15
  const onFallback = chainFragments(snap.chain);
20
16
  if (onFallback) {
@@ -60,23 +56,6 @@ function pushSpentBudgetAlert(out, snap) {
60
56
  });
61
57
  }
62
58
  }
63
- // A role over its maxDailyCostUsdPerRole cap (status-model.ts "waiting: daily cap spent") is
64
- // a per-role hold — one role sits idle while the rest of the team keeps working — so the team
65
- // banner names it the way the budget, disk, and quiet banners name theirs, instead of leaving
66
- // an operator to read every row. An amber ask (it needs a raise to resume); clears on its own
67
- // at local midnight when the spend window rolls over.
68
- function pushRoleCapAlert(out, snap, roles) {
69
- const capPausedRoles = roles.filter((l) => snap.capPaused.includes(l.role));
70
- if (snap.capPaused.length > 0) {
71
- out.push({
72
- key: "role-cap",
73
- tone: "amber",
74
- title: `${listRoleIds(snap.capPaused)} ${agree(snap.capPaused.length, "is over its daily cap", "are over their daily caps")}`,
75
- detail: `The daily cap (maxDailyCostUsdPerRole) holds ${listRoleIds(snap.capPaused)}: no new runs until local midnight. Raise the cap to resume ${agree(snap.capPaused.length, "it", "them")}.`,
76
- actions: openRoleActions(capPausedRoles),
77
- });
78
- }
79
- }
80
59
  // The disk floor holding new work (plans/disk-floor.md, part 4/4): the header badge and the
81
60
  // role rows say what is happening, but the banner names the threshold and the way out, so an
82
61
  // operator hitting a full volume does not have to read events. An amber ask — the team is
@@ -109,86 +88,6 @@ function pushInstallDriftAlert(out, snap) {
109
88
  });
110
89
  }
111
90
  }
112
- // No deterministic build check is declared (detectBuildCheck returned null): the review
113
- // gate's build pre-check, the red-main baseline and redeploy's green check have nothing to
114
- // run, and the header badge renders nothing (mainCheckBadge needs a build_check event).
115
- // The same state the doctor `check` row warns on, raised as a standing alert on the always-on
116
- // surfaces. Independent of team state (running or stopped), like the install alert beside it.
117
- function pushNoCheckAlert(out, snap) {
118
- if (snap.noDeclaredCheck) {
119
- out.push({
120
- key: "no-check",
121
- tone: "amber",
122
- title: "No project check is declared — the safety gates are off",
123
- detail: "The review gate's build pre-check, the red-main baseline and redeploy's green check have nothing to run. The plan role will plan this project's check and the feature role declare it — a test/typecheck/build npm script, or check.command. To skip the wait, declare it yourself; a project with nothing to verify yet stays as it is.",
124
- actions: [],
125
- });
126
- }
127
- }
128
- // A brief candidate exists but none resolves to a usable prompt block (briefBlockMissing):
129
- // every run reaches its model with no project prompt, so the team burns tokens producing
130
- // work unrelated to the project. The same marker-less/unreadable/empty-block state the doctor
131
- // `brief` row warns on, raised as a standing alert on the always-on surfaces (the bare
132
- // pre-init repo stays silent). Independent of team state, like the no-check alert beside it.
133
- function pushNoBriefAlert(out, snap) {
134
- if (snap.noBrief) {
135
- out.push({
136
- key: "no-brief",
137
- tone: "amber",
138
- title: "The project brief's prompt block is missing or empty — every role runs without its project prompt",
139
- detail: "A brief file exists but carries no usable tumwater:prompt block (no markers, or no text between them), so each run's prompt reaches the model with no project brief. Restore the block in the file the doctor `brief` row names, or re-run `tumwater init`.",
140
- actions: [],
141
- });
142
- }
143
- }
144
- // A brief block past the read cap (briefBlockOverCap): every run's prompt silently carries
145
- // only the head plus a truncation marker, so each role plans and codes against a cut brief.
146
- // The same over-cap state the doctor `brief` row warns on, raised as a standing alert on the
147
- // always-on surfaces; mutually exclusive with `no-brief` (an over-cap block is non-empty).
148
- function pushBriefTruncatedAlert(out, snap) {
149
- if (snap.briefTruncated) {
150
- out.push({
151
- key: "brief-truncated",
152
- tone: "amber",
153
- title: "The project brief is over its prompt cap — every role runs on a truncated brief",
154
- detail: "The brief block exceeds the read cap, so each run's prompt carries only its head plus a truncation marker. Shorten the block to at most " + BRIEF_TEXT_MAX_CHARS + " characters in the file the doctor `brief` row names (a hand edit, or the dashboard's Brief card), then save it.",
155
- actions: [],
156
- });
157
- }
158
- }
159
- // A role note past the read bound (collectRoleNotesOverCap's predicate, the doctor `role
160
- // notes` row's own): `readRoleNote` truncates that role's note on read, so the role's next
161
- // prompt carries only the note's head plus a truncation marker while the operator sees the
162
- // file whole — a silently degraded next run on a green-looking status. The standing twin of
163
- // the doctor `role notes` row's warn, raised as an amber alert on the always-on surfaces.
164
- // Independent of team state, like the no-brief alert beside it.
165
- function pushRoleNotesTruncatedAlert(out, snap) {
166
- const notesOver = snap.roleNotesOverCap ?? [];
167
- if (notesOver.length > 0) {
168
- out.push({
169
- key: "role-notes-truncated",
170
- tone: "amber",
171
- title: `${plural(notesOver.length, "role note is", "role notes are")} over the read bound — those roles run on truncated notes`,
172
- detail: `${joinWithMore(notesOver.map((o) => `${o.role} (${o.chars})`), 3)} exceed the ${ROLE_NOTES_MAX_BYTES}-character read bound, so each of those roles' prompt carries only the note's head plus a truncation marker. Rewrite them under the cap (the run's \`tumwater-notes write\` shim, \`tumwater role <id> --notes-file <path>\`, the dashboard drawer, or the TUI notes editor).`,
173
- actions: [],
174
- });
175
- }
176
- }
177
- // PRINCIPLES.md is over its prompt cap (principlesBlockOverCap): every role's <principles>
178
- // block carries only its head plus a truncation marker, so the team runs on truncated design
179
- // guidance. The same over-cap state the doctor `principles` row warns on, raised as a standing
180
- // alert on the always-on surfaces. Independent of team state, like the no-brief alert beside it.
181
- function pushPrinciplesTruncatedAlert(out, snap) {
182
- if (snap.principlesTruncated) {
183
- out.push({
184
- key: "principles-truncated",
185
- tone: "amber",
186
- title: "PRINCIPLES.md is over its prompt cap — every role runs on truncated design guidance",
187
- detail: "The file exceeds the " + PRINCIPLES_MAX_CHARS + "-character prompt cap, so every run's <principles> block carries only its head plus a truncation marker and only the leading bullets survive. Keep PRINCIPLES.md to its list bullets and move long-form rationale into docs/, which is not injected.",
188
- actions: [],
189
- });
190
- }
191
- }
192
91
  function pushMainRedAlert(out, snap, roles) {
193
92
  const red = roles.filter((l) => l.phase === "main's check is failing");
194
93
  if (red.length) {
@@ -246,81 +145,21 @@ function pushStuckRoleAlert(out, roles) {
246
145
  });
247
146
  }
248
147
  }
249
- function pushBuildAlert(out, snap) {
250
- if (snap.build?.stale) {
251
- const behind = plural(snap.build.aheadCommits ?? 0, "commit");
252
- // The cooldown scopes the deferral to the redeploy, not the team (PLANS.md 2026-10-23): a
253
- // restart held by the cooldown leaves the roles running on the stale build, so its alert
254
- // reads blue "work continues" with the deadline, while a genuinely blocked restart (red main,
255
- // a failed build, a boot refusal) stays the amber "restart blocked", and the momentary drain
256
- // of an in-flight restart — the one stale state that actually holds new runs and merges —
257
- // keeps the plain wording.
258
- const deferred = snap.build.restartBlocked?.startsWith("cooldown until ");
259
- out.push(deferred
260
- ? {
261
- key: "build",
262
- tone: "blue",
263
- title: `Update available (${behind} behind) — restarts after cooldown; work continues`,
264
- detail: `Work continues on the running build; the restart lands when the cooldown lapses or you force it (${localizeInstants(snap.build.restartBlocked ?? "")}).`,
265
- actions: [],
266
- }
267
- : snap.build.restartBlocked
268
- ? {
269
- key: "build",
270
- tone: "amber",
271
- title: `Update available (${behind} behind) — restart blocked`,
272
- detail: localizeInstants(snap.build.restartBlocked),
273
- actions: [],
274
- }
275
- : {
276
- key: "build",
277
- tone: "blue",
278
- title: `Update available (${behind} behind)${snap.build.restartPending ? " — restarts when idle" : ""}`,
279
- detail: snap.build.restartPending ? "The team restarts onto it once running work finishes." : "Restart tumwater start to pick it up.",
280
- actions: [],
281
- });
282
- }
283
- }
284
- // A work-instance claim the operator should act on without running `tumwater doctor`
285
- // (src/scheduling/claim-entries.ts, the doctor's `parallel instances` row): a stopped
286
- // instance holding an entry that left its role's section, or idle past the scheduler's
287
- // release window, is an amber ask. One detail line per claim, worded like the doctor row so
288
- // the two cannot drift; actions open the first three claimed roles. Nothing when empty.
289
- function pushStaleClaimsAlert(out, staleClaims) {
290
- if (staleClaims.length > 0) {
291
- out.push({
292
- key: "stale-claims",
293
- tone: "amber",
294
- title: `${plural(staleClaims.length, "work-instance claim is", "work-instance claims are")} stale`,
295
- detail: staleClaims.map(staleClaimDetail).join("; "),
296
- actions: openRoleActions(staleClaims.map((c) => ({ role: c.id }))),
297
- });
298
- }
299
- }
300
148
  /** The status/capacity and role-phase alerts, in emission order: a spent budget
301
- * (chain/fallback/budget), per-role cap, disk, install, and main-red holds, roles
302
- * failing run after run or stuck, and a stale running build. Extracted from
303
- * teamAlerts (part 1/5 of the teamAlerts split) so the shared module stays small;
304
- * the input names exactly the fields this run reads. The caller pushes the returned
305
- * alerts at the position this run used to occupy, so the emitted key order is
149
+ * (chain/fallback/budget), disk, install, and main-red holds, roles failing run after run or
150
+ * stuck. Extracted from teamAlerts (part 1/5 of the teamAlerts split) so the shared module
151
+ * stays small; the input names exactly the fields this run reads. The caller pushes the
152
+ * returned alerts at the position this run used to occupy, so the emitted key order is
306
153
  * unchanged. */
307
154
  export function buildStatusAlerts(input) {
308
- const { snap, roles, staleClaims } = input;
155
+ const { snap, roles } = input;
309
156
  const out = [];
310
157
  pushChainAlert(out, snap);
311
158
  pushSpentBudgetAlert(out, snap);
312
- pushRoleCapAlert(out, snap, roles);
313
159
  pushDiskAlert(out, snap);
314
160
  pushInstallDriftAlert(out, snap);
315
- pushNoCheckAlert(out, snap);
316
- pushNoBriefAlert(out, snap);
317
- pushBriefTruncatedAlert(out, snap);
318
- pushRoleNotesTruncatedAlert(out, snap);
319
- pushPrinciplesTruncatedAlert(out, snap);
320
161
  pushMainRedAlert(out, snap, roles);
321
162
  pushFailingStreakAlert(out, roles);
322
163
  pushStuckRoleAlert(out, roles);
323
- pushBuildAlert(out, snap);
324
- pushStaleClaimsAlert(out, staleClaims);
325
164
  return out;
326
165
  }