tumwater 0.4.0 → 0.4.2

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 (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -5,1000 +5,32 @@
5
5
  * as input; `tone` ranks an
6
6
  * alert (red and amber ask for action, indigo asks a question, blue and gray inform), and
7
7
  * `actions` are the dashboard's buttons (`act` names a page action — open a role, a view,
8
- * the cap editor…). */
9
- import { stripEntryStamp } from "../backlog/backlog-structure.js";
10
- import { stalePinPhrase } from "../doctor/doctor-contention-checks.js";
11
- import { entryKindLabel } from "../backlog/backlog-eligibility.js";
12
- import { staleClaimDetail } from "../scheduling/claim-entries.js";
13
- import { quietWindowEnd } from "../scheduling/quiet-hours.js";
14
- import { chainFragments, pauseCountdown } from "./badges.js";
15
- import { CI_PARITY_STALE_MS, ciParityStalled } from "../run/ci-parity.js";
16
- import { QA_COVERAGE_STALE_MS } from "../run/qa-coverage.js";
17
- import { fallbackTierEntries } from "../budget/budget.js";
18
- import { fallbackDisplayPair } from "../config/config-views.js";
19
- import { agree, pauseReasonSuffix, plural } from "../text/phrases.js";
20
- import { missingSymbolNames } from "../verdict/fix-claim.js";
21
- import { STALL_RE, duration } from "./run-progress-model.js";
22
- import { formatGB, shortSha, usd, usdCap } from "../text/format.js";
23
- import { DAY_MS, formatTimestamp, humanAgo, humanUntil } from "../text/datetime.js";
24
- import { joinWithMore, squash } from "../text/text.js";
25
- import { roleLabel } from "../roles/role-labels.js";
26
- /** The alert detail head for a bounded entry list (open questions, refused entries): the
27
- * first title stripped of its stamp, plus ` — and N more` when others await, so the two
28
- * alert kinds cannot drift on the separator or the count. The caller appends its own
29
- * action suffix (the refused alert's " · no role takes it…" clause). */
30
- function entryListDetail(titles) {
31
- const first = titles[0] === undefined ? "" : stripEntryStamp(titles[0]);
32
- return titles.length > 1 ? `${first} — and ${titles.length - 1} more` : first;
33
- }
34
- /** Append the folded names of the entries past an alert's first one, after a ` · ` separator,
35
- * when there are any, and the empty string otherwise — the shared idiom every amber
36
- * "name the oldest, fold the rest" alert (stale-bugs, waiting-bugs, stale-prompts,
37
- * stale-merges, stranded-plans, skipped-merges, backlog-headings) uses to compose its
38
- * `detail`, so the separator, the empty case, and the default fold (entryListDetail;
39
- * joinWithMore for the merge-queue and heading alerts) cannot drift. Callers pre-map each
40
- * rest element to the label the first clause already named. */
41
- function moreDetail(restLabels, fold = entryListDetail) {
42
- return restLabels.length > 0 ? ` · ${fold(restLabels)}` : "";
43
- }
44
- /** The dashboard's "Open <role>" buttons for an alert about a set of roles: one button per
45
- * role (capped to the first three so the banner does not overflow), each opening that role's
46
- * pane. Shared by the failing, stuck, and no-image-target alerts, so the button shape and its
47
- * cap cannot drift between them. */
48
- function openRoleActions(roles) {
49
- return roles.slice(0, 3).map((l) => ({ label: `Open ${roleLabel(l.role)}`, act: "role", arg: l.role }));
50
- }
51
- /** The dashboard's "See failures" button: opens the failures view. Shared by every alert
52
- * whose remedy is inspecting the failing runs/checks (main-red, quarantine, flapped checks,
53
- * post-merge churn and risk, regressed fixes, image-probe), so the button's label
54
- * and action cannot drift between them. */
55
- function seeFailures() {
56
- return [{ label: "See failures", act: "view", arg: "failures" }];
57
- }
58
- /** Natural English list of the given names: “a and b” for one or two, “a, b, and c”
59
- * beyond that. Shared by the role-row and held-id banners so the wording cannot drift. */
60
- function andJoin(names) {
61
- return names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
62
- }
63
- function listRoles(roles) {
64
- return andJoin(roles.map((l) => roleLabel(l.role)));
65
- }
66
- /** The `listRoles` rendering for a set of held role ids (status-data.ts's `capPaused` and
67
- * `maintenanceQuota.held`, the scheduler's enabled-but-held sets rather than rendered role
68
- * rows): names every held id through roleLabel even when a stale id matches no live role, so
69
- * the role-cap and maintenance banners never drop a held role from their copy. */
70
- function listRoleIds(ids) {
71
- return andJoin(ids.map(roleLabel));
72
- }
73
- /** The stuck alert's per-role detail: the stall phrase the phase rendered, plus — when that
74
- * phrase does not already carry one — the honest silence span from the live tail (stuckMs,
75
- * ms since the role's last pi log write, capped to the run by progressOfRun). A
76
- * "no agent output for <d>" phrase already carries its duration, so only a tool-call stall gets
77
- * a " for <duration>" suffix, and only when the caller holds a live tail (stuckMs present). */
78
- function stuckDetail(l) {
79
- const cause = (STALL_RE.exec(l.phase)?.[0] ?? "").trim();
80
- if (cause && !cause.startsWith("no agent output for") && l.stuckMs !== undefined) {
81
- return `${roleLabel(l.role)}: ${cause} for ${duration(l.stuckMs)}`;
82
- }
83
- return `${roleLabel(l.role)}: ${cause}`;
84
- }
85
- /** ISO instants in server text (a restart cooldown's deadline) in local time. */
86
- function localizeInstants(text) {
87
- return text.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, (iso) => formatTimestamp(Date.parse(iso)));
88
- }
89
- /** The status/capacity and role-phase alerts, in emission order: a spent budget
90
- * (chain/fallback/budget), per-role cap, disk, install, and main-red holds, roles
91
- * failing run after run or stuck, and a stale running build. Extracted from
92
- * teamAlerts (part 1/5 of the teamAlerts split) so the shared module stays small;
93
- * the input names exactly the fields this run reads. The caller pushes the returned
94
- * alerts at the position this run used to occupy, so the emitted key order is
95
- * unchanged. */
96
- function buildStatusAlerts(input) {
97
- const { snap, roles, staleClaims } = input;
98
- const out = [];
99
- const b = snap.budget;
100
- const onFallback = chainFragments(snap.chain);
101
- if (onFallback) {
102
- out.push({
103
- key: "chain",
104
- tone: "blue",
105
- title: "Running on fallback",
106
- detail: `running on fallback: ${onFallback}`,
107
- actions: [],
108
- });
109
- }
110
- if (!b.free && b.capUsd > 0 && b.spentUsd >= b.capUsd) {
111
- // Tiered fallbacks (part 7b/8): the snapshot's tier map stands exactly when the
112
- // tiers resolve to two or more distinct pairs, so list them and let the borrowed
113
- // ones' `(from …)` suffixes mark the borrow; a tier-free snapshot keeps the
114
- // single-pair sentence.
115
- const fallbackDetail = b.tiers
116
- ? `${fallbackTierEntries(b.tiers).join(", ")} carry them at no cost until midnight.`
117
- : `${fallbackDisplayPair(b.fallback, "The fallback")} carries them at no cost until midnight.`;
118
- out.push(b.fallback
119
- ? {
120
- key: "fallback",
121
- tone: "blue",
122
- title: "Today's budget is spent — roles switched to the over-cap model",
123
- // The roles whose tier has no usable free pair (gate-polls.ts's budgetPausedRoles)
124
- // don't move: they sit on `budget paused` rows while the banner says everyone
125
- // switched. One sentence names both halves of the split so the spent-cap banner
126
- // doesn't read as if every role kept working.
127
- detail: snap.budgetPausedRoles.length > 0
128
- ? `${fallbackDetail} ${listRoleIds(snap.budgetPausedRoles)} ${agree(snap.budgetPausedRoles.length, "has", "have")} no free pair and ${agree(snap.budgetPausedRoles.length, "stays", "stay")} paused until local midnight.`
129
- : fallbackDetail,
130
- actions: [{ label: "Raise the cap", act: "budget" }],
131
- }
132
- : {
133
- key: "budget",
134
- tone: "red",
135
- title: "Today's budget is spent, so the roles are paused",
136
- detail: `Spent ${usd(b.spentUsd)} of the ${usdCap(b.capUsd)} cap. They resume at midnight, or as soon as you raise the cap.`,
137
- actions: [{ label: "Raise the cap", act: "budget" }],
138
- });
139
- }
140
- // A role over its maxDailyCostUsdPerRole cap (status-model.ts "waiting: daily cap spent") is
141
- // a per-role hold — one role sits idle while the rest of the team keeps working — so the team
142
- // banner names it the way the budget, disk, and quiet banners name theirs, instead of leaving
143
- // an operator to read every row. An amber ask (it needs a raise to resume); clears on its own
144
- // at local midnight when the spend window rolls over.
145
- const capPausedRoles = roles.filter((l) => snap.capPaused.includes(l.role));
146
- if (snap.capPaused.length > 0) {
147
- out.push({
148
- key: "role-cap",
149
- tone: "amber",
150
- title: `${listRoleIds(snap.capPaused)} ${agree(snap.capPaused.length, "is over its daily cap", "are over their daily caps")}`,
151
- 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")}.`,
152
- actions: openRoleActions(capPausedRoles),
153
- });
154
- }
155
- // The disk floor holding new work (plans/disk-floor.md, part 4/4): the header badge and the
156
- // role rows say what is happening, but the banner names the threshold and the way out, so an
157
- // operator hitting a full volume does not have to read events. An amber ask — the team is
158
- // stopped and needs room, not merely informed. Only while the hold is on; below the reclaim
159
- // threshold but not yet held is the badge's quieter business.
160
- if (snap.disk?.held) {
161
- out.push({
162
- key: "disk",
163
- tone: "amber",
164
- title: `Waiting: low disk — the team is holding new work at ${formatGB(snap.disk.freeGB)} free`,
165
- detail: `Free space is below the ${snap.disk.holdGB} GB floor. Build outputs are reclaimed first; new work starts again once space recovers.`,
166
- actions: [],
167
- });
168
- }
169
- // The root checkout's node_modules is behind package-lock.json (a failed post-merge re-sync):
170
- // checks install into their own trees until it is fixed. Clears on drift, so a hand fix
171
- // clears it too.
172
- const drift = snap.rootInstallDrift ?? [];
173
- if (drift.length) {
174
- const names = joinWithMore(drift, 5);
175
- out.push({
176
- key: "install",
177
- tone: "amber",
178
- title: "The root install is behind package-lock.json",
179
- detail: `${plural(drift.length, "direct dependency", "direct dependencies")} not installed as pinned (${names}). Run npm install in the project's root checkout; until then each check installs into its own tree, and \`tumwater doctor\` names the last re-sync failure.`,
180
- actions: [],
181
- });
182
- }
183
- // No deterministic build check is declared (detectBuildCheck returned null): the review
184
- // gate's build pre-check, the red-main baseline and redeploy's green check have nothing to
185
- // run, and the header badge renders nothing (mainCheckBadge needs a build_check event).
186
- // The same state the doctor `check` row warns on, raised as a standing alert on the always-on
187
- // surfaces. Independent of team state (running or stopped), like the install alert beside it.
188
- if (snap.noDeclaredCheck) {
189
- out.push({
190
- key: "no-check",
191
- tone: "amber",
192
- title: "No project check is declared — the safety gates are off",
193
- detail: "The review gate's build pre-check, the red-main baseline and redeploy's green check have nothing to run. Set \`check.command\` in tumwater.json (or add a test/typecheck/build npm script) so merges are verified.",
194
- actions: [],
195
- });
196
- }
197
- const red = roles.filter((l) => l.phase === "main's check is failing");
198
- if (red.length) {
199
- out.push({
200
- key: "mainred",
201
- tone: "red",
202
- title: `main's check is failing — ${plural(red.length, "role is", "roles are")} blocked`,
203
- detail: "Code roles stop authoring until main's check passes, and pick up on their own once it does.",
204
- actions: seeFailures(),
205
- });
206
- }
207
- // A role whose own failure streak is at or past the alarm threshold (onErrorStreak — an
208
- // error streak at ERROR_STREAK_WARN, or a quiet-kill streak): the red "failing run after
209
- // run" banner. Keyed on the streak, not on the rendered phase, so it stays up while the
210
- // role's next run is in progress — where rolePhase reads "working"/"merge"/"reviewing" rather
211
- // than "failing" — and clears only when the streak resets (BUGS.md 2026-10-14). The
212
- // wording names what happened (a streak of failed or rejected runs), not a single repeated
213
- // error, because a rejection streak is three different rejected changes.
214
- const failing = roles.filter((l) => l.errorStreak);
215
- if (failing.length) {
216
- out.push({
217
- key: "failing",
218
- tone: "red",
219
- title: `${listRoles(failing)} ${agree(failing.length, "is", "are")} failing run after run`,
220
- detail: failing.some((l) => l.lastError)
221
- ? failing.map((l) => `${roleLabel(l.role)}: ${l.lastError || "see its transcript"}`).join(" · ")
222
- : "The streak clears only on the next healthy run; the transcript names each failure.",
223
- actions: openRoleActions(failing),
224
- });
225
- }
226
- // A stall marker rides only a running label. The director is excluded from `inFlight` (it
227
- // holds no maxConcurrent permit), so its own running phase is accepted too — otherwise a
228
- // stalled director would go unalerted (BUGS.md 2026-10-06).
229
- const stuck = roles.filter((l) => (l.inFlight || l.phase.startsWith("director working")) && STALL_RE.test(l.phase));
230
- if (stuck.length) {
231
- out.push({
232
- key: "stuck",
233
- tone: "amber",
234
- title: `${listRoles(stuck)} ${agree(stuck.length, "looks", "look")} stuck`,
235
- detail: stuck.map(stuckDetail).join(" · "),
236
- actions: openRoleActions(stuck),
237
- });
238
- }
239
- if (snap.build?.stale) {
240
- const behind = plural(snap.build.aheadCommits ?? 0, "commit");
241
- // The cooldown scopes the deferral to the redeploy, not the team (PLANS.md 2026-10-23): a
242
- // restart held by the cooldown leaves the roles running on the stale build, so its alert
243
- // reads blue "work continues" with the deadline, while a genuinely blocked restart (red main,
244
- // a failed build, a boot refusal) stays the amber "restart blocked", and the momentary drain
245
- // of an in-flight restart — the one stale state that actually holds new runs and merges —
246
- // keeps the plain wording.
247
- const deferred = snap.build.restartBlocked?.startsWith("cooldown until ");
248
- out.push(deferred
249
- ? {
250
- key: "build",
251
- tone: "blue",
252
- title: `Update available (${behind} behind) — restarts after cooldown; work continues`,
253
- detail: `Work continues on the running build; the restart lands when the cooldown lapses or you force it (${localizeInstants(snap.build.restartBlocked ?? "")}).`,
254
- actions: [],
255
- }
256
- : snap.build.restartBlocked
257
- ? {
258
- key: "build",
259
- tone: "amber",
260
- title: `Update available (${behind} behind) — restart blocked`,
261
- detail: localizeInstants(snap.build.restartBlocked),
262
- actions: [],
263
- }
264
- : {
265
- key: "build",
266
- tone: "blue",
267
- title: `Update available (${behind} behind)${snap.build.restartPending ? " — restarts when idle" : ""}`,
268
- detail: snap.build.restartPending ? "The team restarts onto it once running work finishes." : "Restart tumwater start to pick it up.",
269
- actions: [],
270
- });
271
- }
272
- // A work-instance claim the operator should act on without running `tumwater doctor`
273
- // (src/scheduling/claim-entries.ts, the doctor's `parallel instances` row): a stopped
274
- // instance holding an entry that left its role's section, or idle past the scheduler's
275
- // release window, is an amber ask. One detail line per claim, worded like the doctor row so
276
- // the two cannot drift; actions open the first three claimed roles. Nothing when empty.
277
- if (staleClaims.length > 0) {
278
- out.push({
279
- key: "stale-claims",
280
- tone: "amber",
281
- title: `${plural(staleClaims.length, "work-instance claim is", "work-instance claims are")} stale`,
282
- detail: staleClaims.map(staleClaimDetail).join("; "),
283
- actions: openRoleActions(staleClaims.map((c) => ({ role: c.id }))),
284
- });
285
- }
286
- return out;
287
- }
288
- /** The backlog alerts, in emission order: open questions, refused entries, operator-held
289
- * entries, stalled entries, a broken plan-dependency clause, stranded plans, duplicated
290
- * backlog headings, finished history still in the live backlog files, and Fixed records with a
291
- * bad Validation gap trace. Extracted from
292
- * teamAlerts (part 2/5 of the teamAlerts split) so the shared module stays small; the input
293
- * names exactly the fields this run reads. The caller pushes the returned alerts at the
294
- * position this run used to occupy, so the emitted key order is unchanged. */
295
- function buildBacklogAlerts(input) {
296
- const out = [];
297
- const { questions, staleQuestions, refused, operatorHeld, stalledEntries, planDependency, strandedPlans, duplicatedHeadings, liveFinished, validationGaps, fixClaims, } = input;
298
- if (questions.length) {
299
- out.push({
300
- key: "questions",
301
- tone: "indigo",
302
- title: `${plural(questions.length, "question needs", "questions need")} your answer`,
303
- detail: entryListDetail(questions),
304
- actions: [{ label: "Answer", act: "questions" }],
305
- });
306
- }
307
- // A stale question (doctor's STALE_QUESTION_DAYS rule, src/doctor/doctor-backlog-bugs.ts
308
- // checkStaleQuestions) has waited a week without an answer — a role is stuck behind a decision,
309
- // the exact class every other backlog row raises as an amber alert — so it asks (amber) the way
310
- // the stale-bug alert does, naming the oldest question, its age, and the `tumwater questions
311
- // answer` fix. Its callers pass it oldest-first (status-payload.ts staleOpenQuestions sorts
312
- // most-days-waited first), so the first element is the most-stale question and is named with its
313
- // position and days; the rest are folded through entryListDetail like the questions and refused
314
- // alerts, so the wording and counts cannot drift. The `questions` action opens the Questions tab,
315
- // so the alert carries no row address.
316
- if (staleQuestions.length) {
317
- const first = staleQuestions[0];
318
- const rest = staleQuestions.slice(1);
319
- out.push({
320
- key: "stale-questions", // vocabulary-ok: the stale-questions amber alert's key
321
- tone: "amber",
322
- title: `${plural(staleQuestions.length, "open question waited", "open questions waited")} 7+ days for an answer`,
323
- detail: `#${first.position} "${first.title}" has waited ${first.days}d for an answer` +
324
- moreDetail(rest.map((q) => q.title)) +
325
- ` · answer it with \`tumwater questions answer ${first.position} "<decision>"\`, or close it in QUESTIONS.md`,
326
- actions: [{ label: "Answer", act: "questions" }],
327
- });
328
- }
329
- // Refused entries hold until a human decides; amber because the operator has to act.
330
- if (refused.length) {
331
- const first = refused[0];
332
- out.push({
333
- key: "refused",
334
- tone: "amber",
335
- title: `${plural(refused.length, "refused entry waits", "refused entries wait")} on your decision`,
336
- detail: `Refused ${entryKindLabel(first.file).toLowerCase()}: ${entryListDetail(refused.map((r) => r.title))} · no role takes it until you release it (tumwater plan|bug --release <n>, or the entry's drawer) or cancel it`,
337
- // The action lands the operator on the named entry: open the backlog card on the
338
- // file's tab and open that row (the dashboard's backlog arm reads `file:index`).
339
- actions: [{ label: "Open backlog", act: "backlog", arg: `${first.file}:${first.index}` }],
340
- });
341
- }
342
- // A Needs-review/Needs-replan entry whose held count reached the operator threshold parks
343
- // until a human releases it (tumwater plan|bug --release, part 2/3); amber because only the
344
- // operator can clear it, right after the refused alert, naming the entry like the refused
345
- // alert does. Unlike refused, a held-for-operator entry is still in the normal review
346
- // cycle below the threshold — only the at-past-threshold list reaches here (operatorHeldEntries).
347
- if (operatorHeld.length) {
348
- const first = operatorHeld[0];
349
- out.push({
350
- key: "operator-held", // vocabulary-ok: the operator-held amber alert's key
351
- tone: "amber",
352
- title: `${plural(operatorHeld.length, "backlog entry is held", "backlog entries are held")} for you to release`,
353
- detail: operatorHeld.some((r) => r.file === "bugs") && operatorHeld.some((r) => r.file === "plans")
354
- ? operatorHeld.map((r) => `${entryKindLabel(r.file)} ${stripEntryStamp(r.title)} (tumwater ${entryKindLabel(r.file).toLowerCase()} --release)`).join("; ")
355
- : `${entryKindLabel(first.file)} ${entryListDetail(operatorHeld.map((r) => r.title))} — release it with tumwater ${entryKindLabel(first.file).toLowerCase()} --release`,
356
- // The action lands the operator on the named entry: open the backlog card on the
357
- // file's tab and open that row (the dashboard's backlog arm reads `file:index`).
358
- actions: [{ label: "Open backlog", act: "backlog", arg: `${first.file}:${first.index}` }],
359
- });
360
- }
361
- // An entry parked by a Needs-review/Needs-replan note (the doctor's `stalled entries` row,
362
- // behind the held threshold) sits until the plan role splits or replans it — amber because
363
- // the plan role is the only one that can clear it, right after the operator-held alert,
364
- // naming the first such entry in file order, its file, its note kind, and the fix. Same
365
- // `entryHold` classification as `operatorHeldEntries` and `checkStalledEntries`, so the
366
- // always-on alert and the doctor cannot disagree on which holds are role-resolvable. A
367
- // held-for-operator entry is excluded here.
368
- if (stalledEntries.length) {
369
- const first = stalledEntries[0];
370
- out.push({
371
- key: "stalled-entries",
372
- tone: "amber",
373
- title: `${plural(stalledEntries.length, "backlog entry is stalled", "backlog entries are stalled")} in review`,
374
- detail: `${entryKindLabel(first.file)} ${entryListDetail(stalledEntries.map((p) => p.title))} — ${first.hold === "needs-replan" ? "a Needs replan note parks it" : "a Needs review note parks it"}; the plan role splits or replans the entry, so one that keeps sitting here means the plan role is not running or keeps deferring`,
375
- // The action lands the operator on the named entry: open the backlog card on the
376
- // file's tab and open that row (the dashboard's backlog arm reads `file:index`).
377
- actions: [{ label: "Open backlog", act: "backlog", arg: `${first.file}:${first.index}` }],
378
- });
379
- }
380
- // A broken plan-dependency clause (the doctor's `plan dependencies` rule,
381
- // src/doctor/doctor-backlog-plans.ts planDependencyProblem, via status-payload.ts's
382
- // planDependencyAlert) is invisible in the backlog listings — the claim allocator keeps
383
- // acting on the misread clause — so it asks (amber) like the stale-bug alert, named by type
384
- // and carrying the doctor's exact message. One alert at most (the problem set is the single
385
- // most serious rule), and the fix is editing PLANS.md's heading, so there are no actions.
386
- if (planDependency !== null) {
387
- const titles = {
388
- cycle: "Plan dependency cycle blocks a whole series",
389
- dangling: "A plan requires a part no heading carries",
390
- unparsed: "A plan's requires clause names no readable part",
391
- misworded: "A plan's dependency wording holds nothing",
392
- };
393
- out.push({
394
- key: "plan-dependencies",
395
- tone: "amber",
396
- title: titles[planDependency.kind],
397
- detail: planDependency.message,
398
- // The fix is editing PLANS.md, and the message names the heading and ref.
399
- actions: [],
400
- });
401
- }
402
- // A plan heading filed under the wrong ## section (the doctor's `plans stuck in Planned`
403
- // rule, checkStrandedPlans, src/doctor/doctor-backlog-plans.ts, via the shared
404
- // strandedPlanEntriesFor) sits invisible to every reader that scans the right section: a
405
- // `(planned …)` heading under ## Done never enters a Planned reader, and a done-dated heading
406
- // still under ## Planned invites a second implementation. The clean role repairs it only when
407
- // it happens to run and the doctor warns only on request, so it asks (amber) the way the
408
- // plan-dependency alert does, naming the first stranded heading, its file, its section, and
409
- // the section it belongs under; the rest are folded through joinWithMore.
410
- if (strandedPlans.length) {
411
- const first = strandedPlans[0];
412
- const rest = strandedPlans.slice(1);
413
- const more = moreDetail(rest.map((e) => `"${e.title}" in ${e.file ?? "PLANS.md"} ## ${e.section}`), (labels) => joinWithMore(labels, 3));
414
- out.push({
415
- key: "stranded-plans",
416
- tone: "amber",
417
- title: `${strandedPlans.length === 1 ? "A plan sits" : "Plans sit"} under the wrong backlog section`,
418
- 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)`,
419
- // The fix is editing a backlog file, and a PLANS-DONE.md entry has no PLANS.md drawer index.
420
- actions: [],
421
- });
422
- }
423
- // A duplicated `## ` section heading already on main (the doctor's `backlog headings` row,
424
- // checkBacklogHeadings, src/doctor/doctor-backlog-plans.ts, via the shared
425
- // duplicatedBacklogHeadings) sits until a deliberate removal edit — the merge gate blocks
426
- // only a merge that ADDS another copy, so a duplicate already on main persists and hides
427
- // every later entry under its name from the `## ` section readers. It asks (amber) the way
428
- // the stranded-plan alert does, naming the first file-heading pair and folding the rest
429
- // through joinWithMore; the fix is editing a backlog file, so there are no actions.
430
- if (duplicatedHeadings.length) {
431
- const first = duplicatedHeadings[0];
432
- const rest = duplicatedHeadings.slice(1);
433
- const more = moreDetail(rest.map((d) => `"## ${d.heading}" in ${d.file}`), (labels) => joinWithMore(labels, 3));
434
- out.push({
435
- key: "backlog-headings",
436
- tone: "amber",
437
- title: plural(duplicatedHeadings.length, "duplicated backlog heading", "duplicated backlog headings"),
438
- detail: `${first.file} "## ${first.heading}" appears more than once${more} — readers take the first section of a name, so later entries are invisible; remove the duplicate in a deliberate edit`,
439
- actions: [],
440
- });
441
- }
442
- // Finished history still in the live backlog files (the doctor's `backlog archive` rule,
443
- // checkLiveFinished, src/doctor/doctor-backlog-plans.ts, via the shared liveFinishedEntriesFor)
444
- // is the one backlog-hygiene rule the doctor warned but no always-on alert surfaced — every
445
- // other rule (stranded plans, duplicated headings, validation gaps, plan dependencies, stale
446
- // bugs and questions) has one, so an operator watching `tumwater status` or the dashboard never
447
- // sees the growing finished sections until they run `tumwater doctor`. It asks (amber) the way
448
- // the duplicated-heading alert does, naming each live file, its finished section, its entry
449
- // count, and its size in KB; the fix is the `tumwater archive` command, so there are no actions.
450
- if (liveFinished.length) {
451
- out.push({
452
- key: "backlog-archive",
453
- tone: "amber",
454
- title: `${plural(liveFinished.length, "live backlog file still holds", "live backlog files still hold")} finished history`,
455
- detail: liveFinished.map((l) => `${l.file} ## ${l.section} holds ${plural(l.entries, "finished entry", "finished entries")} (${Math.round(l.bytes / 1024)} KB)`).join("; ") +
456
- " — every run that reads the file pays for them; `tumwater archive` moves them to the archive files",
457
- // The fix is a command, not a drawer row.
458
- actions: [],
459
- });
460
- }
461
- // A Fixed record with a bad Validation gap trace (the doctor's `validation gaps` check,
462
- // src/doctor/doctor-backlog-plans.ts checkValidationGaps) is the always-on twin of that
463
- // doctor row: a newest Fixed record that omits its **Validation gap:** line, names a tag
464
- // outside the closed vocabulary, or declares `unclear-invariant` without pinning a
465
- // docs/invariants.md section is invisible or misleading to every closed-vocabulary consumer,
466
- // so it asks (amber) the way the stranded-plan alert does, naming the first offending record
467
- // and the fix. It is fed from the same scan (validationGapProblems over validationGapRecords)
468
- // the doctor's check uses, so the alert and the doctor cannot drift on the problem set, and
469
- // that scan runs no sourceHaystack walk, so it adds only the same two backlog-file reads the
470
- // doctor's check already makes to the poll path. The fix is editing BUGS.md, so it has no
471
- // buttons.
472
- if (validationGaps.length) {
473
- const first = validationGaps[0];
474
- const rest = validationGaps.slice(1);
475
- const reason = first.kind === "missing"
476
- ? "has no **Validation gap:** line"
477
- : first.kind === "unrecognized"
478
- ? `names unrecognized Validation gap tag "${first.tag}"`
479
- : "is an unclear-invariant fix but names no docs/invariants.md section";
480
- const more = rest.length ? ` · ${joinWithMore(rest.map((v) => `"${v.title}"`), 3)}` : "";
481
- out.push({
482
- key: "validation-gaps",
483
- tone: "amber",
484
- title: plural(validationGaps.length, "Fixed record has a validation-gap problem", "Fixed records have validation-gap problems"),
485
- detail: `BUGS.md "${first.title}" ${reason}${more} — every Fixed entry writes exactly one known **Validation gap:** tag (write \`none\` rather than omitting it), and an unclear-invariant fix pins its reconstructed guarantee in docs/invariants.md`,
486
- // The fix is editing BUGS.md.
487
- actions: [],
488
- });
489
- }
490
- // A newest Fixed record whose Fix paragraph names only code absent from the tree (the
491
- // doctor's `claimed bugs` check, src/doctor/doctor-backlog-plans.ts checkFixClaims) is the
492
- // always-on twin of that doctor row: a fix claim that names no live symbol is invisible to
493
- // every consumer that reads BUGS.md (BUGS-FIXED.md's long fight over an already-fixed entry),
494
- // so it asks (amber) the way the validation-gaps alert does, naming the first record and the
495
- // missing symbols. It is fed from the same scan (fixClaimProblems over fixClaimRecords) the
496
- // doctor's check uses, so the alert and the doctor cannot drift on the problem set, and that
497
- // scan reads the tree only when a file changed (cachedSourceHaystack), so it adds no re-read
498
- // to the 1 Hz poll path. The fix is editing BUGS.md, so it has no buttons.
499
- if (fixClaims.length) {
500
- const first = fixClaims[0];
501
- const rest = fixClaims.slice(1);
502
- const more = rest.length ? ` · ${joinWithMore(rest.map((p) => `"${p.heading}"`), 3)}` : "";
503
- out.push({
504
- key: "false-fixes",
505
- tone: "amber",
506
- title: plural(fixClaims.length, "Fixed record names code that is not on this tree", "Fixed records name code that is not on this tree"),
507
- detail: `BUGS.md records "${first.heading}" as Fixed, but none of the symbols its Fix paragraph names exist on this tree: ${missingSymbolNames(first.missing)}${more} — land the fix or keep the bug Open / refresh a stale record`,
508
- // The fix is editing BUGS.md.
509
- actions: [],
510
- });
511
- }
512
- return out;
513
- }
514
- /** The backlog-feed alerts, in emission order: stale open bugs, a failed notify page, waiting
515
- * bugs, stranded inbox prompts, a stalled merge queue, skipped merge-queue files, and merges
516
- * refused by an uncommitted primary checkout. Extracted from teamAlerts (part 3/5 of the
517
- * teamAlerts split) so the shared module stays small; the input names exactly the fields this
518
- * run reads. The caller pushes the returned alerts at the position this run used to occupy,
519
- * so the emitted key order is unchanged. */
520
- function buildBacklogFeedAlerts(input) {
521
- const out = [];
522
- const { staleBugs, notifyFailure, staleWaitingBugs, stalePrompts, staleMerges, skippedMergeFiles, primaryOverwrite, now, } = input;
523
- // A stale bug (doctor's STALE_BUG_DAYS rule, src/doctor/doctor-backlog-bugs.ts) sits when no
524
- // role fixes it — the bugfix role is disabled or failing — so it asks (amber) the way the
525
- // refused alert does, naming the oldest, its age, and the doctor's retract advice. Its
526
- // callers pass it oldest-first (status-payload.ts staleOpenBugs sorts most-days-waited first),
527
- // so the first element is the most-stale bug and is named with its position and age; the rest
528
- // are folded through entryListDetail like the questions and refused alerts, so the wording and
529
- // counts cannot drift.
530
- if (staleBugs.length) {
531
- const first = staleBugs[0];
532
- const rest = staleBugs.slice(1);
533
- out.push({
534
- key: "stale-bugs",
535
- tone: "amber",
536
- title: `${plural(staleBugs.length, "open bug waited", "open bugs waited")} 3+ days for a fix`,
537
- detail: `#${first.position} "${first.title}" has waited ${first.days}d for a fix` +
538
- moreDetail(rest.map((r) => r.title)) +
539
- ` · check that the bugfix role is enabled and not failing, or retract it with \`tumwater bug --cancel ${first.position}\``,
540
- // The action lands the operator on the first stale bug: the dashboard's backlog arm reads
541
- // `bugs:index` with a 0-based row, matching the drawer address.
542
- actions: [{ label: "Open backlog", act: "backlog", arg: `bugs:${first.index}` }],
543
- });
544
- }
545
- // A failed notify page (doctor's NOTIFY_CHECK_WINDOW_MS rule, src/doctor/doctor-notify.ts
546
- // checkNotify) leaves the team unsupervised — a dead webhook must not page the operator when
547
- // something else goes wrong — so a page that failed within the last day asks (amber) the way
548
- // the stale-bug alert does, naming the count, the age of the latest, its squashed message,
549
- // and the `tumwater notify --test` fix. Its callers feed it the latest failure's message via
550
- // status-payload.ts's notifyFailures (the same isNotifyFailure filter and window), so the
551
- // alert and the doctor cannot drift.
552
- if (notifyFailure) {
553
- out.push({
554
- key: "notify",
555
- tone: "amber",
556
- title: `${plural(notifyFailure.count, "failed notify page", "failed notify pages")} in the last day`,
557
- detail: `the notify hook could not reach you ${humanAgo(notifyFailure.latestTs, now)}: ${squash(notifyFailure.message, 160)} — check it with \`tumwater notify --test\``,
558
- actions: [],
559
- });
560
- }
561
- // A waiting bug (doctor's STALE_WAIT_DAYS rule, src/doctor/doctor-backlog-bugs.ts
562
- // checkWaitingBugs) whose flake has not come back parks indefinitely, so a hold that has
563
- // outlived a week asks (amber) the way the stale-bug alert does, naming the oldest hold, its
564
- // age, and the release-or-keep advice. Its callers pass it most-days-first (status-payload.ts
565
- // staleWaitingBugs sorts by days descending, ties in file order), so the first element is the
566
- // longest-held bug and is named with its position and days; the rest are folded through
567
- // entryListDetail like the questions and refused alerts, so the wording and counts cannot
568
- // drift.
569
- if (staleWaitingBugs.length) {
570
- const first = staleWaitingBugs[0];
571
- const rest = staleWaitingBugs.slice(1);
572
- out.push({
573
- key: "waiting-bugs", // vocabulary-ok: the waiting-bugs amber alert's key
574
- tone: "amber",
575
- title: `${plural(staleWaitingBugs.length, "waiting bug held", "waiting bugs held")} 7+ days`,
576
- detail: `#${first.position} "${first.title}" has waited ${first.days}d for its next failure` +
577
- moreDetail(rest.map((r) => `#${r.position} "${r.title}"`)) +
578
- ` · the hold has outlived a week and this surface omits the doctor's recurrence scan — \`tumwater doctor\` reports whether the flake came back; release it with \`tumwater bug --release ${first.position}\`, or keep the hold if the failure is still expected`,
579
- // The action lands the operator on the first stale waiting bug: the dashboard's backlog arm
580
- // reads `bugs:index` with a 0-based row, matching the drawer address.
581
- actions: [{ label: "Open backlog", act: "backlog", arg: `bugs:${first.index}` }],
582
- });
583
- }
584
- // A stranded inbox prompt (doctor's STALE_PROMPT_MS rule, src/doctor/doctor-backlog.ts) sits
585
- // when a role is paused, disabled, or failing and never reaches the run that would dequeue the
586
- // prompt, so it asks (amber) the way the stale-bug alert does, naming the oldest prompt, its
587
- // wait, and the deliver-or-drop advice. Its callers feed it oldest-first (status-payload.ts
588
- // stalePrompts sorts by queuedAtMs ascending), so the first element is the most-stale prompt
589
- // and is named with its role, position and wait; the rest are folded through entryListDetail
590
- // like the questions and refused alerts, so the wording and counts cannot drift.
591
- if (stalePrompts.length) {
592
- const first = stalePrompts[0];
593
- const rest = stalePrompts.slice(1);
594
- out.push({
595
- key: "stale-prompts",
596
- tone: "amber",
597
- title: `${plural(stalePrompts.length, "queued prompt waited", "queued prompts waited")} 1+ day for its role`,
598
- detail: `inbox prompt for ${roleLabel(first.role)} #${first.position} has waited ${duration(first.waitedMs)} — the role is not dequeuing it (paused, disabled, or failing); resume or enable it, or drop the prompt with \`tumwater prompt --cancel ${first.position} --role ${first.role}\`` +
599
- moreDetail(rest.map((p) => `${roleLabel(p.role)} #${p.position}`)),
600
- // The action lands the operator on the Inbox tab (the dashboard's backlog arm reads `act:
601
- // "queued"` as the Inbox view), where the stranded prompt is cancellable.
602
- actions: [{ label: "Open inbox", act: "queued" }],
603
- });
604
- }
605
- // A stalled merge queue (the doctor's MERGE_QUEUE_STALE_MS rule,
606
- // src/doctor/doctor-backlog.ts, set by the stale-merges alert) sits when a queued change
607
- // never starts — neither verified nor flagged — so a drain that has stopped reads healthy
608
- // except for this row. Its callers feed it oldest-first (status-payload.ts staleMerges reads
609
- // queuedMerges in queue-file order), so the first element is the longest-waiting merge and is
610
- // named with its role, summary and wait; the rest are folded through joinWithMore like the
611
- // actions and no-image alerts, so the wording and counts cannot drift.
612
- if (staleMerges.length) {
613
- const first = staleMerges[0];
614
- const rest = staleMerges.slice(1);
615
- out.push({
616
- key: "stale-merges",
617
- tone: "amber",
618
- title: `${plural(staleMerges.length, "queued merge waited", "queued merges waited")} 6+ hours to start`,
619
- detail: `${roleLabel(first.role)} — "${first.summary}" — has waited ${duration(first.waitedMs)} to start` +
620
- moreDetail(rest.map((m) => roleLabel(m.role)), (labels) => joinWithMore(labels, 3)) +
621
- ` — the merge queue has stopped moving; run \`tumwater queue\` to see it, or drop the entry with \`tumwater queue --cancel <n>\``,
622
- // The header's `merge queue: N` badge and the Pending view already carry the drill-in.
623
- actions: [],
624
- });
625
- }
626
- // A merge-queue file `readMergeQueue` skips (torn, foreign, or vanished mid-listing) is the
627
- // always-on twin of the doctor's `merge-queue files` row (doctor-backlog.ts
628
- // skippedMergeQueueFiles, feeding this from status-payload.ts's skippedMergeFiles): it sits
629
- // in .tumwater/merge-queue/ forever because the drain drops only an unreadable head, so a
630
- // skipped file after a healthy entry strands its merge until someone removes it. The stale-
631
- // merges alert above reads only readable entries, so it can never cover one. The doctor's row
632
- // and this alert share the one relative-path list, so they name the same files; the count and
633
- // the first skipped file are shown, the rest folded through joinWithMore.
634
- if (skippedMergeFiles.length) {
635
- const [first, ...rest] = skippedMergeFiles;
636
- out.push({
637
- key: "skipped-merges",
638
- tone: "amber",
639
- title: `${plural(skippedMergeFiles.length, "merge-queue file is", "merge-queue files are")} unreadable`,
640
- detail: `the queue reader skips ${first} — a torn or foreign file never clears itself, so its merge is stranded; remove it (a crashed enqueue's merge ref still recovers on the next run)` +
641
- moreDetail(rest, (labels) => joinWithMore(labels, 3)),
642
- actions: [],
643
- });
644
- }
645
- // A merge the primary checkout blocked (merge-git.ts primaryCheckoutBlockers, the
646
- // overwriteBlockedReason `warning` the merge path writes) — the always-on twin of the doctor's
647
- // `uncommitted backlog` row, but read off events so it reaches the status poll (the doctor's
648
- // git read is async and never gets there). A team stuck retrying one dirty file is a single
649
- // amber banner until the file is committed and the refusals age out; status-payload.ts's
650
- // primaryOverwriteBlocks feeds it the last day's refusals, counting them and naming the
651
- // latest's age, squashed message, and the commit fix.
652
- if (primaryOverwrite) {
653
- out.push({
654
- key: "primary-overwrite",
655
- tone: "amber",
656
- title: `${plural(primaryOverwrite.count, "merge was", "merges were")} refused by files uncommitted in your primary checkout`,
657
- detail: `most recently ${humanAgo(primaryOverwrite.latestTs, now)}: ${squash(primaryOverwrite.message, 160)} — roles cannot commit your checkout; commit the file(s) on main with \`tumwater doctor --commit-backlog\` (or by hand) so merges can proceed`,
658
- actions: [],
659
- });
660
- }
661
- return out;
662
- }
663
- /** The quarantine, flapped-check, and post-merge review alerts, in emission order: a
664
- * stale or oversized flake quarantine, checks flapping more than once, a
665
- * burst of post-merge review rejections, a post-merge review one failed run from being
666
- * dropped, a post-merge review the drain dropped leaves its commit on main unreviewed, a
667
- * main tip the watch's diff rules rejected, and the ci role's full suite stalled over three
668
- * days without covering a new tip.
669
- * Extracted from teamAlerts (part 4/5 of the teamAlerts split) so the shared module stays
670
- * small; the input names exactly the fields this run reads (including `now`, which the
671
- * quarantine and dropped-post-reviews age lines need). The caller pushes the returned alerts
672
- * at the position this run used to occupy, so the emitted key order is unchanged. */
673
- function buildMergeAlerts(input) {
674
- const out = [];
675
- const { quarantined, flappedChecks, postMergeChurn, postReviewRisk, droppedPostReviews, mainRuleFailure, ciParity, qaCoverage, now } = input;
676
- // A stale or oversized flake quarantine — the always-on twin of the doctor's `flaky checks`
677
- // row (main-guardrails.ts checkFlakyChecks, quarantineWarning): a quarantined test counts as
678
- // a known failure until its filed bug leaves BUGS.md `## Open`, so an old entry means the
679
- // team is merging on a suite whose red is being excused. status-payload.ts's quarantineAlert
680
- // feeds it from the same quarantineWarning the doctor calls, so the alert and the doctor
681
- // cannot drift on when the list is a problem; the warning phrase and the oldest test's age
682
- // are shown, folded through squash like other long names.
683
- if (quarantined) {
684
- out.push({
685
- key: "quarantine",
686
- tone: "amber",
687
- title: `${plural(quarantined.count, "test")} in the flake quarantine — the gate is excusing their failures`,
688
- detail: `${quarantined.warning} · oldest: ${squash(quarantined.oldestTest, 80)} (${humanAgo(quarantined.oldestFirstSeen, now)}) — a quarantined test counts as a known failure until its filed bug leaves BUGS.md \`## Open\`, so fix the test or file the missing bug`,
689
- actions: seeFailures(),
690
- });
691
- }
692
- // Checks that flapped more than once in the last day — the always-on twin of the doctor's
693
- // `flaky checks` row (doctor-project-checks.ts checkFlakyChecks): the gate's single re-run
694
- // absorbs a one-off flake, but a check flapping twice silently pays that re-run on every
695
- // merge it touches, so the tally belongs on every observer surface while it happens.
696
- // status-payload.ts's flappedChecksAlert feeds it from the same repeatedFlakeCounts and
697
- // flappedChecksPhrase the doctor calls, so the alert and the doctor cannot drift on the
698
- // count or the wording.
699
- if (flappedChecks) {
700
- out.push({
701
- key: "flapped-checks",
702
- tone: "amber",
703
- title: `${plural(flappedChecks.count, "check has", "checks have")} flapped more than once in the last day`,
704
- detail: `${flappedChecks.detail} — a flapping check pays the gate's one re-run on every merge it touches; fix it or file it with \`tumwater bug\``,
705
- actions: seeFailures(),
706
- });
707
- }
708
- // Last day's post-merge review rejections — the reviewer-fix signal for the `review.afterMerge`
709
- // tiering (src/review/post-merge-review.ts `reviewMergedCommit`). A post-merge rejection is the
710
- // costly kind: the commit already reached main, so the rejection is answered only by the
711
- // reviewer's own fix, and the always-on signal for a day crossing POST_MERGE_REJECT_WARN is
712
- // this alert. status-payload.ts's postMergeChurnAlert feeds it
713
- // from the same `afterMerge: true` `review_rejected` events the report's
714
- // postMergeRejectionsByRole folds, so the alert and the report cannot disagree on which
715
- // rejections are post-merge.
716
- if (postMergeChurn) {
717
- out.push({
718
- key: "post-merge-churn",
719
- tone: "amber",
720
- title: `${plural(postMergeChurn.count, "post-merge review rejection", "post-merge review rejections")} in the last day`,
721
- detail: `${postMergeChurn.roles.map((r) => `${roleLabel(r.role)} ×${r.count}`).join(", ")} — each commit already reached main and stays there with the reviewer's recorded reasons (never re-reviewed — Forward, never back); narrow \`review.afterMerge\` for the noisy role or raise the authoring bar`,
722
- actions: seeFailures(),
723
- });
724
- }
725
- // A post-merge review one failed run from being dropped — the always-on twin of the doctor's
726
- // `post-merge reviews` row (src/doctor/doctor-backlog.ts checkPostReviews) — the last moment
727
- // an operator can fix the reviewer backend or deliberately accept the commit unreviewed.
728
- // postReviewRiskAlert feeds it from the same atRiskPostReviews traversal and phrase the doctor
729
- // calls, so the alert and the doctor cannot drift on the trigger or the wording.
730
- if (postReviewRisk) {
731
- out.push({
732
- key: "post-merge-risk",
733
- tone: "amber",
734
- title: `${plural(postReviewRisk.count, "post-merge review is", "post-merge reviews are")} one failed run from being dropped`,
735
- detail: postReviewRisk.detail,
736
- actions: seeFailures(),
737
- });
738
- }
739
- // A post-merge review the drain actually dropped — the commit it leaves on main unreviewed
740
- // (src/merge/merge-post-review.ts `startPostReview` at POST_REVIEW_FAILURE_LIMIT). Reads only
741
- // the `warning` event the drop branch writes, after the at-risk alert is moot: the entry is
742
- // gone, so no surface names the commit without this. droppedPostReviewsAlert feeds it from
743
- // the same DROPPED_POST_REVIEW_MARKER the drain's message carries, so the alert and the drain
744
- // cannot drift on what a drop is (the per-run failed-review warning never matches).
745
- if (droppedPostReviews) {
746
- out.push({
747
- key: "dropped-post-reviews",
748
- tone: "amber",
749
- title: `${plural(droppedPostReviews.count, "post-merge review was dropped", "post-merge reviews were dropped")} in the last day — ${plural(droppedPostReviews.count, "the commit sits", "the commits sit")} on main unreviewed`,
750
- detail: `most recently ${humanAgo(droppedPostReviews.latestTs, now)}: ${squash(droppedPostReviews.latestMessage, 160)} — the reviewer kept failing past \`POST_REVIEW_FAILURE_LIMIT\`, so the commit is beyond the review gate; fix the reviewer backend so later commits are reviewed`,
751
- actions: [{ label: "See failures", act: "view", arg: "failures" }],
752
- });
753
- }
754
- // A main tip the watch's five diff rules rejected in the trailing day — the always-on
755
- // surface for the `warning` main-watch.ts's `watchRules` logs (`main's tip <short sha> fails
756
- // the diff rules: <reason>`). The gate judges one change; the watch judges each first-parent
757
- // commit's own diff, so a conflict marker, a protected edit, a leaked secret, a phantom fix
758
- // claim or a broken backlog structure that reached main through a gate blind spot sits on
759
- // main with only that event log to say so. status-payload.ts's mainRuleFailures feeds it
760
- // from the same marker, so the alert and the watch cannot drift on what a rule failure is (a
761
- // `main_check` or `review_rejected` carrying the same text never matches).
762
- if (mainRuleFailure) {
763
- out.push({
764
- key: "main-diff-rules",
765
- tone: "amber",
766
- title: `${plural(mainRuleFailure.count, "diff-rule failure on main", "diff-rule failures on main")} in the last day`,
767
- detail: `most recently ${humanAgo(mainRuleFailure.latestTs, now)}: ${squash(mainRuleFailure.latestMessage, 160)} — the gate approved a diff the watch's five rules reject, so main carries what the gate would refuse; repair the tip or revert it with \`tumwater revert <sha>\``,
768
- actions: seeFailures(),
769
- });
770
- }
771
- // The ci role's full suite silently stopped covering new tips — the always-on twin of the
772
- // doctor's `ci parity` row (doctor-project-checks.ts checkCiParity), read from the same
773
- // ciParityStalled predicate and CI_PARITY_STALE_MS threshold so the alert and the row cannot
774
- // disagree on when a post-merge gap is a stall. A `stale` record is normal right after a
775
- // merge (main moves on every merge and the role runs on its own clock), but one that stays
776
- // stale past three days means the role that is now the full suite's own runner has silently
777
- // stopped covering new tips (disabled, paused, wedged, or failing before it records). The
778
- // fix is a role toggle the role drawer already has, so there is no action button here.
779
- if (ciParity && ciParityStalled(ciParity, now)) {
780
- out.push({
781
- key: "ci-stale",
782
- tone: "amber",
783
- title: `main's full suite has not covered a tip in over ${CI_PARITY_STALE_MS / DAY_MS} days`,
784
- detail: `the ci role's last full-suite run covered ${shortSha(ciParity.covered)} ${humanAgo(ciParity.lastRunAt, now)}; main has moved since, so no full-suite run covers the current tip — the ci role is disabled, paused, or unable to record; resume or enable it, or check \`tumwater doctor\``,
785
- actions: [],
786
- });
787
- }
788
- // The qa role's flow coverage silently stopped advancing — the always-on twin of the
789
- // doctor's `qa coverage` row (doctor-project-checks.ts checkQaCoverage), fed from the same
790
- // qaCoverageStalled predicate via status-payload.ts's qaCoverageAlert so the alert and the
791
- // row cannot disagree on when a quiet qa is a stall. A missing ledger (no record yet) is not
792
- // a stall, so this fires only once a recorded flow's newest exercise is over three days old —
793
- // the team's only flow-level observer has silently stopped recording (disabled, paused,
794
- // wedged, or failing before it records). The fix is a role toggle the role drawer already has,
795
- // so there is no action button here.
796
- if (qaCoverage) {
797
- out.push({
798
- key: "qa-stale",
799
- tone: "amber",
800
- title: `qa's flow coverage has not advanced in over ${QA_COVERAGE_STALE_MS / DAY_MS} days`,
801
- detail: `the qa role's newest flow ${qaCoverage.newestFlow} was exercised ${humanAgo(qaCoverage.newestAt, now)}; coverage has not advanced since, so the qa role is disabled, paused, or unable to record; resume or enable it, or check \`tumwater doctor\``,
802
- actions: [],
803
- });
804
- }
805
- return out;
806
- }
807
- /** The quiet-hours, maintenance, provider-hold, pause, capacity-contention, stale-workspace-pin,
808
- * regressed-fix, stopped and image-input alerts, in emission order: a run of quiet hours
809
- * holding the team, a
810
- * maintenance allowance held at its rolling cap, a standing provider failure hold, an operator
811
- * pause with its countdown, the last day's capacity contention, a stale workspace pin, a
812
- * fixed test that regressed, a
813
- * stopped tumwater, and roles that cannot or may not read images. Extracted from teamAlerts
814
- * (part 5/5 of the teamAlerts split) so the shared module stays small; the input names exactly
815
- * the fields this run reads, including `now`, which the pause countdown and provider-hold
816
- * re-open time need. The caller pushes the returned alerts at the position this run used to
817
- * occupy, so the emitted key order is unchanged. */
818
- function buildTailAlerts(input) {
819
- const out = [];
820
- const { snap, now, contention, regressedFixes, roles, workspacePins } = input;
821
- // Quiet hours (plans: "Quiet hours … part 2/2, observability") informs rather than asks:
822
- // the team holding to a schedule is not a problem, but an operator watching idle roles
823
- // inside the window needs the same explanation the pause alert gives a paused team —
824
- // why nothing runs, and when it starts again. The window end comes from quiet-hours.ts's
825
- // quietWindowEnd, the same helper quietBadge uses for the header.
826
- if (snap.inQuietHours && snap.quietHours) {
827
- const end = quietWindowEnd(snap.quietHours);
828
- out.push({
829
- key: "quiet",
830
- tone: "blue",
831
- title: `Quiet hours — roles start no new runs until ${end}`,
832
- detail: `The schedule (${snap.quietHours} local time, quietHours in tumwater.json) holds them. Runs in progress finish, and the director still runs your prompts.`,
833
- actions: [],
834
- });
835
- }
836
- // The maintenance allowance (plans/work-ratio.md, part 1b/4): by design and self-clearing —
837
- // the scheduler holds maintenance roles at the rolling 24 h allowance until merges free room,
838
- // then they run again on their own — so it informs (gray, no actions), the quiet alert's
839
- // tone, rather than amber like the failed/refused asks. An operator watching idle maintenance
840
- // roles gets the same explanation the hold's own row carries, team-wide.
841
- if (snap.maintenanceQuota.held.length > 0) {
842
- const mq = snap.maintenanceQuota;
843
- out.push({
844
- key: "maintenance",
845
- tone: "gray",
846
- title: "Maintenance is held at its rolling allowance",
847
- detail: `${mq.used} of ${mq.allowance} maintenance allowance used over the last 24 h; ${listRoleIds(mq.held)} ${agree(mq.held.length, "resumes", "resume")} as work merges.`,
848
- actions: [],
849
- });
850
- }
851
- // A standing provider failure hold (src/team/team-hold.ts `standingHolds`, published through
852
- // the orchestrator info file): a provider storm holds roles on that provider, so an operator
853
- // watching idle roles with no log open sees why nothing new starts there. Self-clearing by
854
- // design — the hold re-opens at its own deadline — so it informs (blue, no actions), the
855
- // quiet and maintenance alerts' tone, rather than asking. The snapshot carries the holds only
856
- // while a live orchestrator publishes one.
857
- if (snap.providerHolds && snap.providerHolds.length > 0) {
858
- const phrased = snap.providerHolds
859
- .map((h) => `${h.provider ?? "pi's default backend"} (${h.kind}) until it re-opens in ${humanUntil(h.until, now)}`)
860
- .join("; ");
861
- out.push({
862
- key: "provider-hold",
863
- tone: "blue",
864
- title: agree(snap.providerHolds.length, "Provider limit is holding new runs", "Provider limits are holding new runs"),
865
- detail: `${phrased}. Runs in progress finish; roles on a held provider start no new runs until it re-opens.`,
866
- actions: [],
867
- });
868
- }
869
- if (snap.paused) {
870
- const left = pauseCountdown(snap.pausedUntil, now);
871
- out.push({
872
- key: "paused",
873
- tone: "amber",
874
- // The operator's pause reason (`pause --reason <text>`) states verbatim, so an
875
- // operator returning to a paused team knows why without digging through history.
876
- title: left
877
- ? `The team is paused and resumes in ${left}${pauseReasonSuffix(snap.pauseReason)}`
878
- : `The team is paused${pauseReasonSuffix(snap.pauseReason)}`,
879
- detail: "Roles start no new runs. Runs in progress finish, and the director still runs your prompts.",
880
- actions: [{ label: "Resume now", act: "resume" }],
881
- });
882
- }
883
- // Last day's capacity contention — the always-on twin of the doctor's `workspaces`,
884
- // `provider contention` and `check contention` rows (doctor-contention-checks.ts). The
885
- // instantaneous badges show only current waits, and the 24 h total is the tuning signal that
886
- // says a cap is too small; status-payload.ts's capacityContention feeds it from the same
887
- // `*_wait` events and ten-minute thresholds the doctor sums, so the alert and the doctor
888
- // cannot drift on the trigger or the knob-raising advice.
889
- if (contention.length > 0) {
890
- out.push({
891
- key: "contention",
892
- tone: "amber",
893
- title: plural(contention.length, "capacity bottleneck in the last day", "capacity bottlenecks in the last day"),
894
- detail: contention.map((c) => `${c.name}: ${squash(c.detail, 200)}`).join(" · "),
895
- actions: [],
896
- });
897
- }
898
- // A stale workspace pin — the always-on twin of the doctor's `worktree pool` row's stuck-pin
899
- // clause (doctor-contention-checks.ts checkWorktreePool): a slot pinned past a day by a run
900
- // that never resumed silently shrinks the pool, and no other observer surface names it until
901
- // an operator runs `tumwater doctor`. status-payload.ts feeds it the same staleWorkspacePins
902
- // predicate the doctor's row uses, so the alert and the row cannot drift on what is stale;
903
- // the "Open <role>" buttons reach the role drawer like the other role alerts.
904
- if (workspacePins.length > 0) {
905
- out.push({
906
- key: "workspace-pins",
907
- tone: "amber",
908
- title: plural(workspacePins.length, "workspace pinned past a day", "workspaces pinned past a day"),
909
- detail: `${workspacePins
910
- .map(stalePinPhrase)
911
- .join("; ")} — a pinned workspace is held out of the pool; resume the role, or retire its leftovers and run \`tumwater doctor\``,
912
- actions: openRoleActions(workspacePins),
913
- });
914
- }
915
- // A Fixed record whose quoted test failed again in the trailing day — the always-on twin of
916
- // the doctor's `regressed fixes` row (doctor-backlog-bugs.ts checkRegressedFixes).
917
- // status-payload.ts's regressedFixesAlert feeds it the same candidate selection, failureMentions
918
- // match and verbatim sentence, so the alert and the doctor cannot drift on the trigger or the
919
- // wording; the Failures view shows the naming events.
920
- if (regressedFixes) {
921
- out.push({
922
- key: "regressed-fixes",
923
- tone: "amber",
924
- title: plural(regressedFixes.count, "Fixed test has regressed", "Fixed tests have regressed"),
925
- detail: regressedFixes.detail,
926
- actions: seeFailures(),
927
- });
928
- }
929
- if (!snap.running) {
930
- out.push({
931
- key: "stopped",
932
- tone: "gray",
933
- title: "tumwater is not running — start it with `tumwater start`",
934
- detail: "Start it with tumwater start in the project directory; the dashboards pick it up on their own.",
935
- actions: [{ label: "Copy the command", act: "copy", arg: "tumwater start" }],
936
- });
937
- }
938
- // Image input (plans/image-input.md "Surfaces", part 5/5): a role whose model cannot read
939
- // images. Blue when every text-only role has a reroute target (a run that reads an image
940
- // stops and reruns — nothing fails); amber when some text-only role has none (a blind read
941
- // fails and image submissions are refused); a separate amber when a claimed model failed
942
- // doctor's live probe (the claim is contradicted, whatever any role resolved to). No alert
943
- // when every enabled role reads images (imageInput true or unknown — an unknown seam is not
944
- // counted as text-only, mirroring the submit gate and the doctor's `tier models` stance).
945
- const textOnly = roles.filter((l) => l.imageInput === false);
946
- const noTarget = textOnly.filter((l) => l.imageReroute == null || l.imageReroute === "");
947
- if (noTarget.length > 0) {
948
- out.push({
949
- key: "images",
950
- tone: "amber",
951
- title: `No configured model can read images for ${listRoles(noTarget)}`,
952
- detail: "A run that reads an image fails, and image attachments to these roles are refused. Declare image input on a vision model in pi's models.json, or point a tier at one.",
953
- actions: openRoleActions(noTarget),
954
- });
955
- }
956
- else if (textOnly.length > 0) {
957
- const targets = [...new Set(textOnly.map((l) => l.imageReroute ?? ""))].filter(Boolean);
958
- out.push({
959
- key: "images",
960
- tone: "blue",
961
- title: `${listRoles(textOnly)} ${agree(textOnly.length, "runs", "run")} on models that can't read images`,
962
- detail: `A run that reads an image stops and reruns on ${targets.join(", ")}.`,
963
- actions: [],
964
- });
965
- }
966
- if (snap.imageProbeFailures.length > 0) {
967
- out.push({
968
- key: "images-probe",
969
- tone: "amber",
970
- title: `A claimed image-capable model failed doctor's probe`,
971
- detail: `${snap.imageProbeFailures.join(", ")} declare image input but failed the live probe — run \`tumwater doctor --reprobe\` to re-check.`,
972
- actions: seeFailures(),
973
- });
974
- }
975
- return out;
976
- }
8
+ * the cap editor…).
9
+ *
10
+ * This module is the composition root: it imports the three per-domain builder modules
11
+ * (team-alert-status, team-alert-backlog,
12
+ * team-alert-tail) and runs them in emission order, and it re-exports the shared alert types
13
+ * (defined in team-alert-shared.ts) so consumers keep importing them from this one path. */
14
+ import { buildStatusAlerts } from "./team-alert-status.js";
15
+ import { buildBacklogAlerts } from "./team-alert-backlog.js";
16
+ import { buildTailAlerts } from "./team-alert-tail.js";
977
17
  /** Everything about the team that needs the operator, most urgent first: a spent budget, a red
978
18
  * main, roles failing run after run, roles that look stuck, a stale running build, open
979
- * questions, refused entries, stale bugs, a failed notify page, a dirty primary
980
- * checkout refusing merges, a stranded inbox
981
- * prompt, a stalled merge queue, a
982
- * waiting bug held a week, a merge-queue file the reader skips, a plan stalled by a Needs
983
- * review or Needs replan note, checks that flapped more than once in the last day, a stale or
984
- * oversized flake quarantine, a burst of post-merge review rejections, each a commit that
985
- * already reached main, a post-merge review one failed run from being dropped, a post-merge
986
- * review dropped after repeated failures, leaving its commit on main unreviewed, a main tip the
987
- * watch's five diff rules rejected, the team
19
+ * questions, refused entries, a plan stalled by a Needs
20
+ * review note, the team
988
21
  * waiting on capacity slots (workspace, provider or
989
- * check), a plan filed under the wrong backlog section, a duplicated
22
+ * check), parallel instances sharing a workspace pool smaller than the run cap, a plan
23
+ * filed under the wrong backlog section, a duplicated
990
24
  * backlog section heading, finished history still under a live backlog file's `## Done` or
991
25
  * `## Fixed`, a Fixed record with a bad Validation gap trace, a pause, a
992
26
  * stopped team.
993
27
  * `now` is passed so a timed
994
28
  * pause's countdown agrees with the rest of the frame. */
995
- export function teamAlerts(snap, questions, roles, now, refused = [], operatorHeld = [], staleBugs = [], stalePrompts = [], staleMerges = [], planDependency = null, staleWaitingBugs = [], skippedMergeFiles = [], stalledEntries = [], quarantined = null, strandedPlans = [], duplicatedHeadings = [], validationGaps = [], notifyFailure = null, primaryOverwrite = null, contention = [], flappedChecks = null, regressedFixes = null, postMergeChurn = null, postReviewRisk = null, droppedPostReviews = null, mainRuleFailure = null, staleClaims = [], staleQuestions = [], fixClaims = [], qaCoverage = null, workspacePins = [], liveFinished = []) {
29
+ export function teamAlerts(snap, questions, roles, now, refused = [], planDependency = null, stalledEntries = [], strandedPlans = [], duplicatedHeadings = [], validationGaps = [], contention = [], regressedFixes = null, staleClaims = [], staleQuestions = [], fixClaims = [], workspacePins = [], liveFinished = [], instancePool = [], providerCaps = []) {
996
30
  const out = [];
997
31
  out.push(...buildStatusAlerts({ snap, roles, staleClaims }));
998
- out.push(...buildBacklogAlerts({ questions, staleQuestions, refused, operatorHeld, stalledEntries, planDependency, strandedPlans, duplicatedHeadings, liveFinished, validationGaps, fixClaims }));
999
- out.push(...buildBacklogFeedAlerts({ staleBugs, notifyFailure, staleWaitingBugs, stalePrompts, staleMerges, skippedMergeFiles, primaryOverwrite, now }));
1000
- out.push(...buildMergeAlerts({ quarantined, flappedChecks, postMergeChurn, postReviewRisk, droppedPostReviews, mainRuleFailure, ciParity: snap.ciParity, qaCoverage, now }));
1001
- out.push(...buildTailAlerts({ snap, now, contention, regressedFixes, roles, workspacePins }));
32
+ out.push(...buildBacklogAlerts({ questions, staleQuestions, refused, stalledEntries, planDependency, strandedPlans, duplicatedHeadings, liveFinished, validationGaps, fixClaims }));
33
+ out.push(...buildTailAlerts({ snap, now, contention, regressedFixes, roles, workspacePins, instancePool, providerCaps }));
1002
34
  return out;
1003
35
  }
1004
36
  /** Does this alert ask something of the operator (red, amber, indigo), rather than inform? */