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
@@ -59,6 +59,7 @@ function scanMainCheck(root) {
59
59
  // The final window's merged events, in scan order — collected in the same pass that
60
60
  // finds the check instead of a second full scan of the window below.
61
61
  let mergeDone = [];
62
+ let culprit;
62
63
  const grewFull = mainCheckGrewFull.has(root);
63
64
  // A set note means the previous scan reached the cap without finding a check, so start
64
65
  // there: starting at DEFAULT_EVENT_TAIL would only re-read (and re-parse on a cache miss)
@@ -68,9 +69,11 @@ function scanMainCheck(root) {
68
69
  const events = readEvents(root, window);
69
70
  check = undefined;
70
71
  mergeDone = [];
71
- // One pass collects both facts the derivation needs: the newest merge-scope check and
72
- // every merged event. The merges are re-read against the check's ts below — a pass
73
- // over the merges alone, not a second pass over every event in the window.
72
+ culprit = undefined;
73
+ // One pass collects all three facts the derivation needs: the newest merge-scope check,
74
+ // every merged event, and the newest main_culprit blame. The merges are re-read against
75
+ // the check's ts below — a pass over the merges alone, not a second pass over every
76
+ // event in the window.
74
77
  for (const e of events) {
75
78
  if (e.type === "build_check" && isFullCheckScope(e.scope, e.full)) {
76
79
  check = e;
@@ -78,6 +81,9 @@ function scanMainCheck(root) {
78
81
  else if (e.type === "merge_done" && typeof e.ts === "number") {
79
82
  mergeDone.push(e);
80
83
  }
84
+ else if (e.type === "main_culprit") {
85
+ culprit = e;
86
+ }
81
87
  }
82
88
  // Found, the log is shorter than the window (nothing older exists to find), or the cap
83
89
  // is reached: the tail is final. A stale `check` from a previous, smaller window cannot
@@ -93,7 +99,7 @@ function scanMainCheck(root) {
93
99
  else
94
100
  mainCheckGrewFull.add(root);
95
101
  if (!check || typeof check.ts !== "number" || !isMainCheckStatus(check.status)) {
96
- return { check: undefined };
102
+ return { ...(culprit ? { culprit } : {}) };
97
103
  }
98
104
  let mergeDoneAfter;
99
105
  let mergeDoneBefore;
@@ -105,6 +111,7 @@ function scanMainCheck(root) {
105
111
  }
106
112
  return {
107
113
  check,
114
+ ...(culprit ? { culprit } : {}),
108
115
  ...(typeof mergeDoneAfter?.commit === "string" ? { mergeDoneAfterCommit: mergeDoneAfter.commit } : {}),
109
116
  ...(typeof mergeDoneBefore?.commit === "string" ? { mergeDoneBeforeCommit: mergeDoneBefore.commit } : {}),
110
117
  };
@@ -113,7 +120,11 @@ function scanMainCheck(root) {
113
120
  * copies the one check event and the record, so a caller never receives the cached object
114
121
  * itself. */
115
122
  function mainCheckScanFor(root) {
116
- return (cachedByStat(mainCheckScanCache, root, eventsLogPath(root), () => scanMainCheck(root), (v) => ({ ...v, ...(v.check ? { check: { ...v.check } } : {}) })) ?? { check: undefined });
123
+ return (cachedByStat(mainCheckScanCache, root, eventsLogPath(root), () => scanMainCheck(root), (v) => ({
124
+ ...v,
125
+ ...(v.check ? { check: { ...v.check } } : {}),
126
+ ...(v.culprit ? { culprit: { ...v.culprit } } : {}),
127
+ })) ?? { check: undefined });
117
128
  }
118
129
  /** Has main's newest merge-scope check retired a role's stale `main_red` verdict? Only a
119
130
  * PASSED check stamped strictly after the role's red run ended counts — the team-level
@@ -143,11 +154,36 @@ export function mainCheckForPoll(root, cfg) {
143
154
  const branch = targetBranch(cfg.baseBranch, currentBranchFromHeadFile(root));
144
155
  const sha = scan.mergeDoneAfterCommit ?? readBranchHead(root, branch) ?? scan.mergeDoneBeforeCommit;
145
156
  const counts = asTestCounts(check.counts);
157
+ // The red evidence names the failing test and the merge the main watch blamed — and that
158
+ // data lives only on the main_culprit event (the main watch's verdict on the red tip; the
159
+ // build_check event itself carries no failing tests), attributed only when the verdict's
160
+ // commit is exactly the sha this poll derived, so a newer build_check failure can't borrow
161
+ // a blame the watch wrote for a different head. A passing/skipped check, or a failing one
162
+ // whose tip the watch hasn't blamed, keeps today's exact MainCheckStatus shape (no `red`).
163
+ let red;
164
+ if (check.status === "failed" && sha !== undefined && scan.culprit && typeof scan.culprit.commit === "string") {
165
+ const c = scan.culprit;
166
+ if (c.commit === sha) {
167
+ const failing = Array.isArray(c.failingTests) && typeof c.failingTests[0] === "string"
168
+ ? c.failingTests.filter((f) => typeof f === "string")
169
+ : undefined;
170
+ if (failing) {
171
+ red = {
172
+ failingTests: failing,
173
+ culprit: {
174
+ commit: c.commit,
175
+ ...(typeof c.runner === "string" ? { runner: c.runner } : {}),
176
+ },
177
+ };
178
+ }
179
+ }
180
+ }
146
181
  return {
147
182
  ...(sha ? { sha } : {}),
148
183
  status: check.status,
149
184
  ...(counts ? { counts } : {}),
150
185
  at: check.ts,
186
+ ...(red ? { red } : {}),
151
187
  };
152
188
  }
153
189
  /** The parity ledger's glance verdict for one status poll, or undefined when there is no
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { readTextOrNull } from "../files/files.js";
4
+ import { cachedByStat } from "../files/stat-cache.js";
5
+ import { roleNotesPath } from "../paths.js";
6
+ import { ROLE_NOTES_MAX_BYTES } from "../agent/role-notes-write.js";
7
+ /** The per-file lengths, keyed by absolute path. One stat per unchanged file per poll (the
8
+ * same stat-keyed contract status-inbox.ts relies on for the inbox's prompt cache), so the
9
+ * dashboards' once-a-second poll never re-reads an unchanged notes file. */
10
+ const noteLengthCache = new Map();
11
+ /** Every role whose notes file runs past the read bound, in descending-char order — the
12
+ * single home of the role-notes over-cap walk, shared by the status snapshot's
13
+ * `collectRoleNotesOverCap` and the doctor `role notes` row
14
+ * (src/doctor/doctor-role-notes.ts's checkRoleNotes), so the alert and the doctor row cannot
15
+ * disagree about which notes are over the bound. `readRoleNote` (src/run/run-prompt.ts) cuts
16
+ * every notes file at ROLE_NOTES_MAX_BYTES *characters* on read, but every operator surface
17
+ * reads the file whole — so a hand-edited or old-build note past the bound reaches that role's
18
+ * run prompt as only its head plus a truncation marker while `tumwater status` looks green.
19
+ * Returns null when the notes dir is missing or unreadable (the write path creates it on first
20
+ * write), whose callers each map to their own "nothing recorded yet" reading. Never throws. */
21
+ export function readRoleNotesOverCap(root) {
22
+ const notesDir = path.dirname(roleNotesPath(root, "role"));
23
+ let entries;
24
+ try {
25
+ entries = fs.readdirSync(notesDir);
26
+ }
27
+ catch {
28
+ // Missing or unreadable notes dir: nothing recorded yet (the write path creates it).
29
+ return null;
30
+ }
31
+ const over = [];
32
+ for (const name of entries) {
33
+ if (!name.endsWith(".md"))
34
+ continue;
35
+ const file = path.join(notesDir, name);
36
+ const chars = cachedByStat(noteLengthCache, file, file, () => {
37
+ const text = readTextOrNull(file);
38
+ return text === null ? null : text.length;
39
+ }, (n) => n);
40
+ if (chars === null)
41
+ continue;
42
+ if (chars > ROLE_NOTES_MAX_BYTES)
43
+ over.push({ role: name.slice(0, -3), chars });
44
+ }
45
+ over.sort((a, b) => b.chars - a.chars);
46
+ return over;
47
+ }
48
+ /** The role-notes slice of a status snapshot as a plain array for the dashboard payloads:
49
+ * every over-cap note in descending-char order, or [] when the notes dir is missing (the same
50
+ * "nothing recorded yet" degrade the doctor's checkRoleNotes treats as a pass). */
51
+ export function collectRoleNotesOverCap(root) {
52
+ return readRoleNotesOverCap(root) ?? [];
53
+ }
@@ -3,17 +3,17 @@ import { configForRole, modelSelectorField, roleSeamTier, } from "../config/conf
3
3
  import { modelFallbackView } from "../config/config-fallbacks.js";
4
4
  import { isCustomRole } from "../config/config.js";
5
5
  import { baseRoleOf, configuredInstances, instanceIndex, runnerIds, runnerPaused } from "../roles/runner-ids.js";
6
- import { ERROR_STREAK_WARN, QUIET_KILL_RESUME_LIMIT } from "../run/run-apply.js";
6
+ import { errorStreakAtWarn, QUIET_KILL_RESUME_LIMIT } from "../run/run-apply.js";
7
7
  import { queuedMergeRoles } from "../merge/merge-queue.js";
8
8
  import { hasOutstandingWork } from "../runner/runner-state.js";
9
9
  import { formatModelSelector } from "../config/model-selector.js";
10
10
  import { piSettingsPath, readPiDefaultModel } from "../pi/pi-settings.js";
11
11
  import { isJsonObject } from "../files/json-object.js";
12
12
  import { runnerStateForPoll } from "./status-polls.js";
13
- import { roleImageInfo, seamImageLabel } from "../agent/image-input.js";
13
+ import { roleImageInfo } from "../agent/image-input.js";
14
14
  import { backendDefaultLabel } from "../agent/backend-traits.js";
15
15
  import { readPiListedModels } from "../pi/pi-listed-models.js";
16
- import { imageProbeEntryFresh, readImageProbeCache } from "../doctor/image-probe.js";
16
+ import { seamContextWindowForRole } from "../pi/pi-models.js";
17
17
  /** One row per enabled role id (part 5b/7). `configForRole`, `modelFallbackView`, the leased /
18
18
  * pinned slot and the pi-default seam all resolve against the values `snapshot()` already
19
19
  * read once for the poll — the info and slots pins (threaded as `slots`) keep rows, `running`
@@ -42,13 +42,10 @@ export function collectRoleRows(root, cfg, now, slots, modelsPath, paused) {
42
42
  queued.has(r) ||
43
43
  s.claim !== undefined ||
44
44
  hasOutstandingWork(s) ||
45
- (s.consecutiveErrors ?? 0) >= ERROR_STREAK_WARN ||
45
+ errorStreakAtWarn(s) ||
46
46
  (s.lastResult === "quiet_killed" && (s.quietKillStreak ?? 0) >= QUIET_KILL_RESUME_LIMIT));
47
47
  });
48
48
  const piDefault = readPiDefaultModel(piSettingsPath(modelsPath), root);
49
- // One probe-cache read per poll serves every row's image-input surface (same no-data policy
50
- // as every other degraded read: a missing cache is an empty one).
51
- const probes = readImageProbeCache(root);
52
49
  const listed = readPiListedModels(root);
53
50
  return roles.map((r) => {
54
51
  const base = { ...runnerStateForPoll(root, r), custom: isCustomRole(cfg, r) };
@@ -65,7 +62,11 @@ export function collectRoleRows(root, cfg, now, slots, modelsPath, paused) {
65
62
  // capability and its reroute target, one shared resolver so the status rows, the role
66
63
  // page, and the submit gate cannot disagree about who reads images or where a blind
67
64
  // prompt would run.
68
- const image = roleImageInfo(cfg, r, modelsPath, probes, listed);
65
+ const image = roleImageInfo(cfg, r, modelsPath, listed);
66
+ // The peak-ctx share denominator (the role's context window, part 3c): resolved once per
67
+ // poll here, beside `eff` (the same seam the role page resolves and names) — the same seam
68
+ // `seamContextWindow` run against, so row, page and drawer cannot disagree.
69
+ const contextWindow = seamContextWindowForRole(cfg, r, root, modelsPath);
69
70
  // Model failure fallback, part 2/2: while the role's episode is active its agent runs use
70
71
  // the tier-resolved fallback pair, so the row names that pair, the episode's start, and
71
72
  // the failure that tripped it. `now` is the poll's own clock (the snapshot seam), so
@@ -106,37 +107,9 @@ export function collectRoleRows(root, cfg, now, slots, modelsPath, paused) {
106
107
  : { model: backendDefaultLabel(eff.agent) }),
107
108
  ...(isJsonObject(cfg.model) ? { modelTier: roleSeamTier(cfg, r) } : {}),
108
109
  ...(episode !== null ? { fallback: episode } : {}),
110
+ ...(contextWindow !== null ? { contextWindow } : {}),
109
111
  ...(slot ? { slot: path.basename(slot.dir), slotPinned: pinned !== undefined } : {}),
110
112
  ...(baseRoleOf(r) === r ? {} : { instanceOf: baseRoleOf(r) }),
111
113
  };
112
114
  });
113
115
  }
114
- /** The doctor's live-probe failures (plans/image-input.md "Surfaces", part 5/5): every cached
115
- * probe verdict of `fail` that is still fresh (the claim is unchanged and within the 7-day
116
- * TTL), labelled as `<provider>/<model>`. A claimed model that failed the probe reads `false`
117
- * everywhere (seamImageInput honors a fresh failure), but the observability value is naming
118
- * it — a claim the doctor contradicted is exactly what the amber probe alert and the role/status
119
- * rows should surface, not just "this role is text-only". A torn or missing cache answers
120
- * empty (the same no-data policy as the per-role read). */
121
- export function collectImageProbeFailures(root, now = Date.now()) {
122
- const probes = readImageProbeCache(root);
123
- const out = [];
124
- for (const [key, entry] of Object.entries(probes)) {
125
- if (entry.verdict !== "fail" || !imageProbeEntryFresh(entry, now))
126
- continue;
127
- // The cache key is agent/provider/model, and a pi model id is itself fully qualified (the
128
- // key's third segment can carry embedded '/' — parseModelSelector keeps the id whole), so
129
- // split only the first two separators and keep the rest as the model. A naive split("/")
130
- // destructured to three parts would truncate a qualified id to its org, mislabelling the
131
- // model the doctor flagged.
132
- const first = key.indexOf("/");
133
- const second = key.indexOf("/", first + 1);
134
- if (first === -1 || second === -1)
135
- continue;
136
- const agent = key.slice(0, first) || undefined;
137
- const provider = key.slice(first + 1, second) || undefined;
138
- const model = key.slice(second + 1) || undefined;
139
- out.push(seamImageLabel(agent, provider, model));
140
- }
141
- return out.sort();
142
- }
@@ -16,7 +16,7 @@ import { EVENTS_LOG_BASENAME } from "../paths.js";
16
16
  * (or `N * DAY_MS`) below. */
17
17
  export const HOUR_MS = 60 * 60 * 1000;
18
18
  /** Milliseconds in a day — the sibling of `HOUR_MS`: the shared day unit for the
19
- * day-sized retention and lookback constants (the opencode-catalog and image-probe TTLs,
19
+ * day-sized retention and lookback constants (the opencode-catalog TTL,
20
20
  * the doctor's stale-pin/merge/prompt/wait checks, the known-flake and selfcheck
21
21
  * windows, the dashboard-history since bound, the retention prune, the claude-window
22
22
  * lookback) so one day cannot mean slightly different spans per consumer. */
@@ -52,6 +52,13 @@ export function secondsSince(sinceMs, now) {
52
52
  export function secondsUntil(atMs, now) {
53
53
  return Math.max(0, Math.round((atMs - now) / 1000));
54
54
  }
55
+ /** Whole seconds from `now` to a unix-seconds instant (stamps providers emit as whole seconds,
56
+ * not millis), clamped at zero — the secondsUntil ×1000 fold shared by the claude and codex
57
+ * stream translators' rate-limit retry-after computations (both carry a `resetsAt`/`resets_at`
58
+ * unix-seconds stamp), so the unix-seconds-to-epoch-ms conversion lives in one place. */
59
+ export function secondsUntilUnixStamp(seconds, now) {
60
+ return secondsUntil(seconds * 1000, now);
61
+ }
55
62
  /** Calendar date as `YYYY-MM-DD` in local time — shared by the transcript run separators,
56
63
  * the daily-budget day stamp (todayStamp), and the usage report's per-day buckets, which must
57
64
  * all agree on what counts as one day. */
@@ -36,6 +36,18 @@ export function compactTokens(n) {
36
36
  return `${(n / 1_000_000).toFixed(1)}M`;
37
37
  return n >= 10_000 ? `${(n / 1000).toFixed(1)}k` : String(n);
38
38
  }
39
+ /** A peak context reading its share of the role's declared context window (`110.0k (84%)`) —
40
+ * the single home of the share format so the status table's `peak ctx` cell, the GUI/`status
41
+ * --json` role payload, the dashboard role drawer, and the `history <role> <n>` summary header
42
+ * all render a peak against a window identically. When the window is unknown
43
+ * (null/undefined/0) or the peak is 0 there is no meaningful share, so it returns exactly
44
+ * `compactTokens(peakTokens)` — the bare peak today's surfaces print. The percentage is
45
+ * floor-free rounded: `Math.round((100 * peak) / window)`. */
46
+ export function contextShareLabel(peakTokens, windowTokens) {
47
+ if (!windowTokens || !peakTokens)
48
+ return compactTokens(peakTokens);
49
+ return `${compactTokens(peakTokens)} (${Math.round((100 * peakTokens) / windowTokens)}%)`;
50
+ }
39
51
  /** The abbreviated form of a commit hash for human-facing text — its first 8 characters.
40
52
  * The single home of this format: the event feed's merged/review lines and the review gate's
41
53
  * discard warning all render hashes through it, so the abbreviation length cannot drift per
@@ -68,7 +80,7 @@ export function usdCap(n) {
68
80
  }
69
81
  /** A disk size in GB for display: one decimal and the unit (`12.3 GB`) — the single home of
70
82
  * the disk-size format shared by the event feed's disk lines (event-format.ts), the status
71
- * header's disk badge (badges.ts's `diskBadge`), the team's low-disk alert (team-alerts.ts),
83
+ * header's disk badge (badges.ts's `diskBadge`), the team's low-disk alert (team-alert-status.ts),
72
84
  * the doctor's disk check detail (doctor-project-checks.ts), and `tumwater reclaim`'s
73
85
  * freed-size line (operator-commands.ts), so the decimal width and unit cannot drift per
74
86
  * consumer. A caller
@@ -80,8 +80,9 @@ export function mainRedNotMine(sha) {
80
80
  }
81
81
  /** The ` — "<reason>"` suffix the operator's pause reason (`pause --reason <text>`) rides on —
82
82
  * one home so the CLI's confirmation line (operator/operator-commands.ts), the status header's
83
- * pause badge (ui/badges.ts), and the paused alert's title (ui/team-alerts.ts) cannot drift on the
84
- * quoting. No reason, no suffix: every caller's reasonless phrasing keeps today's byte-exact form.
83
+ * pause badge (ui/badges.ts), and the paused alert's title (ui/team-alert-tail.ts) cannot
84
+ * drift on the quoting. No reason, no suffix: every caller's reasonless phrasing keeps
85
+ * today's byte-exact form.
85
86
  * (The GUI keeps its own JS copies in gui-client.ts: a separate runtime that cannot import
86
87
  * TypeScript.) */
87
88
  export function pauseReasonSuffix(reason) {
@@ -220,6 +221,19 @@ export function backendKindPhrase(kind) {
220
221
  return "backend failure";
221
222
  }
222
223
  }
224
+ /** True when a hold/resume event's kind names an actual backend failure rather than a
225
+ * rate-limit (or an unreadable, torn-line kind). The one home of the "not a 429"
226
+ * discriminator: an absent, blank, or `"rate-limit"` kind keeps the 429 wording every
227
+ * historical event already has, and only a real non-empty backend kind names itself
228
+ * (through backendKindPhrase). Shared by the event feed (event-format.ts's
229
+ * rate_limit_hold/recovered lines) and the failure digest's Team state-change lines
230
+ * (src/failure/failure-state-change.ts's rate_limit_hold/resumed), so the two cannot
231
+ * disagree. Not to be confused with isBackendFailureKind in pi-failure.ts, which is a
232
+ * strict enum-membership guard for the 5 BACKEND_FAILURE_KINDS: this predicate instead
233
+ * accepts any non-empty kind that isn't "rate-limit". */
234
+ export function rendersAsBackendFailure(kind) {
235
+ return !!kind && kind !== "rate-limit";
236
+ }
223
237
  const LEADING_CD = /^\s*cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/;
224
238
  function withoutLeadingCd(command) {
225
239
  let rest = command;
@@ -243,6 +243,18 @@ export function parsePositiveInt(raw) {
243
243
  export function parseNonNegativeInt(raw) {
244
244
  return parseDecimalInt(raw);
245
245
  }
246
+ /** Whether a string is an exponent spelling of a number ("1e2", "2.5E-3", "4e1") rather
247
+ * than a plain decimal: a run of digits Number() represents exactly. `Number()` would read an
248
+ * exponent spelling as a real number ("1e2" → 100), which is why every integer-writing
249
+ * surface refuses it — JSON.parse would silently read it past an integer validator — while
250
+ * the plain-decimal rule above admits only the literal spelling. The two shapes are mutually
251
+ * exclusive (a plain decimal has no `e`), so the plain-decimal exclusion is kept here as the
252
+ * shape's honest statement: one home for the predicate the config writer, the TUI team-key
253
+ * editor, the per-role instances editor, and the CLI role writes' local helper each
254
+ * hand-rolled. */
255
+ export function isExponentSpelling(raw) {
256
+ return !/^\d*\.?\d+$/.test(raw) && /^\d+(\.\d+)?[eE][+-]?\d+$/.test(raw);
257
+ }
246
258
  /** The shared over-long-text message: `<subject> is <n> chars — shorten it to at most <max>`,
247
259
  * with `tail` naming where the text rides once accepted. The one home of that phrasing, used
248
260
  * by init's seed check (init.ts, "the brief text … every run's prefill"), inbox-submit's
@@ -0,0 +1,115 @@
1
+ import { openQuestionEntries, ageInDays } from "../backlog/backlog.js";
2
+ import { stripEntryStamp } from "../backlog/backlog-structure.js";
3
+ import { questionFilingDate } from "../backlog/question-answer.js";
4
+ import { byDaysThenIndex } from "../collections.js";
5
+ import { eventsSince } from "../events/event-window.js";
6
+ import { STALE_QUESTION_DAYS } from "../doctor/doctor-backlog.js";
7
+ import { regressedFixCandidates, matchedRegressedFixes, regressedFixesPhrase } from "../doctor/doctor-backlog-bugs.js";
8
+ import { planDependencyProblem } from "../doctor/doctor-backlog-plans.js";
9
+ import { checkWaitContention, providerConcurrencyProblems, providerWaitContention, slotWaitContention, smallPoolProblems, staleWorkspacePins, CHECK_WAIT_CHECK_WINDOW_MS, PROVIDER_WAIT_CHECK_WINDOW_MS, SLOT_WAIT_CHECK_WINDOW_MS } from "../doctor/doctor-contention-checks.js";
10
+ import { readSlotsState } from "../git/slots-state.js";
11
+ import { configForStatus } from "../status/status-polls.js";
12
+ import { piModelsPath } from "../pi/pi-models.js";
13
+ import { piSettingsPath, readPiDefaultModel } from "../pi/pi-settings.js";
14
+ /** This module is the alert-feed half of src/ui/status-payload.ts: each always-on team alert's
15
+ * data-sourcing function (capacityContention, …) lives here, a
16
+ * sibling of the src/ui/team-alert-*.ts builders that turn them into banners. status-payload.ts
17
+ * stays the one status-document builder, importing these feeds and folding each feed's result
18
+ * into the `alerts` field; status-render.ts reads the feeds directly for the TUI. */
19
+ /** The open questions the doctor's 7-day rule (src/doctor/doctor-backlog-bugs.ts
20
+ * checkStaleQuestions, STALE_QUESTION_DAYS) calls stale: an open QUESTIONS.md entry whose
21
+ * `(asked by …)` stamp is at least 7 days old. This is the always-on feed for the stale-questions
22
+ * team alert — the shared rule's twin on every observer surface, computed from the same threshold
23
+ * and the same questionFilingDate parser the doctor's check uses, so the two cannot drift on which
24
+ * questions are stale. An undated heading is skipped (like the doctor's check); questions carry no
25
+ * refused note to filter. The returned objects carry the 1-based position (for
26
+ * `tumwater questions answer <n>`), the 0-based index (kept only as the file-order tiebreak), the
27
+ * stamp-stripped title, and the whole-day age. Returned oldest-first — most days waited, ties in
28
+ * file order — so a caller naming the first element names the most-stale question. */
29
+ export function staleOpenQuestions(root, now = Date.now()) {
30
+ return openQuestionEntries(root)
31
+ .flatMap((entry, index) => {
32
+ const stamp = questionFilingDate(entry.title);
33
+ if (stamp === null)
34
+ return [];
35
+ const days = ageInDays(stamp, now);
36
+ return days >= STALE_QUESTION_DAYS ? [{ index, position: index + 1, title: stripEntryStamp(entry.title), days }] : [];
37
+ })
38
+ .sort(byDaysThenIndex);
39
+ }
40
+ /** A broken plan-dependency clause, the always-on twin of the doctor's `plan dependencies` row
41
+ * (src/doctor/doctor-backlog-plans.ts checkPlanDependencies): the same rule's problem set,
42
+ * severity order, and exact message, so the alert and the doctor cannot drift. The reader owns
43
+ * the rule — planDependencyAlert passes the doctor's planDependencyProblem through unchanged,
44
+ * like staleOpenQuestions and the other stale feeds do their doctor's rules. null when PLANS.md's
45
+ * planned entries have no cycle, dangling ref, unparsed clause, or misworded dependency. */
46
+ export function planDependencyAlert(root) {
47
+ return planDependencyProblem(root);
48
+ }
49
+ /** @see CapacityBottleneck */
50
+ export function capacityContention(root, now = Date.now()) {
51
+ const out = [];
52
+ /** Resolve one capacity verdict and push a "capacity bottleneck" row when it warns. Shared
53
+ * by the three capacity checks (workspace, provider, check slots) so the try/catch that keeps
54
+ * each one from crying wolf on an unreadable event log lives in one place, and the row's
55
+ * `name`/`detail` shape cannot drift between them. */
56
+ const maybeWarn = (name, windowMs, keep, resolve) => {
57
+ try {
58
+ const verdict = resolve(eventsSince(root, windowMs, now, keep));
59
+ if (verdict.warn)
60
+ out.push({ name, detail: verdict.detail });
61
+ }
62
+ catch {
63
+ // A read error skips this row, so the alert never cries wolf on an unreadable log.
64
+ }
65
+ };
66
+ maybeWarn("workspace slots", SLOT_WAIT_CHECK_WINDOW_MS, (ev) => ev.type === "slot_wait", (waits) => slotWaitContention(waits, readSlotsState(root).slots.length));
67
+ maybeWarn("provider slots", PROVIDER_WAIT_CHECK_WINDOW_MS, (ev) => ev.type === "provider_wait", (waits) => providerWaitContention(waits, configForStatus(root)));
68
+ maybeWarn("check slots", CHECK_WAIT_CHECK_WINDOW_MS, (e) => e.type === "check_wait", (waits) => checkWaitContention(waits, configForStatus(root)));
69
+ return out;
70
+ }
71
+ /** A `maxConcurrentPerProvider` map that cannot do its job — the always-on twin of the doctor's
72
+ * `provider concurrency` row (src/doctor/doctor-contention-checks.ts checkProviderConcurrency):
73
+ * a cap key no seam resolves to (a typo, or a provider the team never runs on), a cap at or
74
+ * above `maxConcurrent` (it never binds), or a keyless seam in a capped team. The `contention`
75
+ * and `provider-hold` alerts fire only once a too-small cap has already parked runs or a storm
76
+ * has struck; this standing alert names the static misconfiguration itself, which the doctor
77
+ * row is the only other surface that otherwise exposes. Reads the same last-known-good config
78
+ * the snapshot uses (configForStatus) and audits it through the row's own
79
+ * providerConcurrencyProblems, so the alert and the row cannot drift on the trigger or the
80
+ * wording; `[]` when no cap is configured or the map is well-formed. */
81
+ export function providerCapProblems(root) {
82
+ return providerConcurrencyProblems(configForStatus(root), readPiDefaultModel(piSettingsPath(piModelsPath()), root));
83
+ }
84
+ /** The always-on twin of the doctor `parallel instances` row's pool-mismatch clause
85
+ * (doctor-contention-checks.ts smallPoolProblems): an enabled `feature`/`bugfix` running
86
+ * more than one instance while slotCount(config) sits below maxConcurrent — the shared pool
87
+ * is smaller than the run cap, so those instances contend for the same checkouts. Built from
88
+ * the same smallPoolProblems selector the doctor row uses, so the alert and the row cannot
89
+ * drift on the trigger, the role list, or the wording. configForStatus never returns
90
+ * undefined (an unreadable tumwater.json falls back to defaultConfig(), whose pool is the
91
+ * default maxConcurrent + 1 — never below maxConcurrent), so the [] is the only silent
92
+ * outcome. */
93
+ export function instancePoolProblems(root) {
94
+ return smallPoolProblems(configForStatus(root));
95
+ }
96
+ /** How far back the regressed-fixes alert looks — one day, so a recently Fixed test that failed
97
+ * again is a standing amber alert until a day passes (or the failure is filed). The doctor's
98
+ * own `regressed fixes` row keeps its since-the-fix read; this is the alert's daily trigger, the
99
+ * same trailing day the regressed-fixes alert reads. */
100
+ export const REGRESSED_FIX_WINDOW_MS = 24 * 60 * 60_000;
101
+ /** The always-on twin of the doctor's `regressed fixes` row (src/doctor/doctor-backlog-bugs.ts
102
+ * checkRegressedFixes): the same candidate selection (regressedFixCandidates) and the same
103
+ * failureMentions match (matchedRegressedFixes) and sentence (regressedFixesPhrase), read off the
104
+ * trailing REGRESSED_FIX_WINDOW_MS of events (the doctor, whose window is since each fix, still
105
+ * warns on an older recurrence). An unreadable event log yields none. */
106
+ export function regressedFixesAlert(root, now = Date.now()) {
107
+ const candidates = regressedFixCandidates(root);
108
+ const rows = matchedRegressedFixes(candidates, eventsSince(root, REGRESSED_FIX_WINDOW_MS, now));
109
+ const detail = regressedFixesPhrase(rows);
110
+ return detail === null ? null : { count: rows.length, detail };
111
+ }
112
+ /** Re-export so the always-on alert's stale-pin selector stays in its one home
113
+ * (src/doctor/doctor-contention-checks.ts) while tui.tsx keeps importing every alert feed from
114
+ * this one module. */
115
+ export { staleWorkspacePins };
@@ -1,3 +1,4 @@
1
+ import { sortEntriesByKey } from "../collections.js";
1
2
  import { utilizationPct, windowLabel } from "../agent/subscription-account.js";
2
3
  import { MODEL_TIERS } from "../config/config-schema.js";
3
4
  import { budgetGate, budgetReached, fallbackTierEntries } from "../budget/budget.js";
@@ -338,7 +339,7 @@ export function pauseBadge(pausedUntil, now, reason) {
338
339
  /** The countdown a timed team pause has left, as humanSeconds — `null` when there is none
339
340
  * (no deadline, or an already-expired one: the read side treats an expired marker as
340
341
  * unpaused). The one home of the guard + ms→seconds rounding behind the team's pause
341
- * countdowns, shared by pauseBadge's header fragment and team-alerts.ts's paused alert
342
+ * countdowns, shared by pauseBadge's header fragment and team-alert-tail.ts's paused alert
342
343
  * title, so the two surfaces cannot disagree about how much time remains. The guard is this
343
344
  * function's own; the ms→seconds rounding rides datetime.ts's secondsUntil. The GUI's pause
344
345
  * control recomputes the counting-down number client-side from the payload's raw `pausedUntil`
@@ -391,9 +392,7 @@ export function diskBadge(disk) {
391
392
  export function permitsBadge(permits) {
392
393
  if (!permits)
393
394
  return "";
394
- const providers = Object.entries(permits.byKey)
395
- .filter(([, e]) => e.limit !== undefined)
396
- .sort(([a], [b]) => a.localeCompare(b))
395
+ const providers = sortEntriesByKey(Object.entries(permits.byKey).filter(([, e]) => e.limit !== undefined))
397
396
  .map(([k, e]) => `${k} ${e.inUse}/${e.limit}`)
398
397
  .join(" · ");
399
398
  if (providers.length === 0)