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
@@ -74,10 +74,10 @@ export const GUI_CLIENT_PENDING_JS = String.raw ` let pendingTeam = null;
74
74
  // optional merge label, and summary. Shared by the Merge queue card and the role drawer so
75
75
  // the two cannot drift; a malformed entry renders stand-ins rather than undefined/NaN.
76
76
  function queueEntryLineHtml(e, position, now, label, cancel, top) {
77
- const sec = Number.isFinite(e.enqueuedAt) ? Math.max(0, Math.round((now - e.enqueuedAt) / 1000)) : 0;
77
+ const sec = Number.isFinite(e.enqueuedAt) ? secsSince(e.enqueuedAt, now) : 0;
78
78
  const run = Number.isFinite(e.run) ? e.run : "?";
79
79
  const note = Number.isFinite(e.revisionRound) ? " · revision " + e.revisionRound + (Number.isFinite(e.priorObjections) && e.priorObjections > 0 ? " · " + plural(e.priorObjections, "prior objection") : "") : "";
80
- const retry = typeof e.retry === "string" ? " · re-queued after " + e.retry + (Number.isFinite(e.retryCount) && e.retryCount > 1 ? " · ×" + e.retryCount : "") + (Number.isFinite(e.notBefore) && e.notBefore > now ? " · eligible in " + humanSeconds(Math.max(0, Math.round((e.notBefore - now) / 1000))) : "") : "";
80
+ const retry = typeof e.retry === "string" ? " · re-queued after " + e.retry + (Number.isFinite(e.retryCount) && e.retryCount > 1 ? " · ×" + e.retryCount : "") + (Number.isFinite(e.notBefore) && e.notBefore > now ? " · eligible in " + humanSeconds(secsUntil(e.notBefore, now)) : "") : "";
81
81
  let line = "<div class='mono clamp1' style='padding:3px 0'>" + position + ". " + esc(e.role ? roleLabel(e.role) : "?") +
82
82
  " run #" + esc(run) + " · " + esc(shortSha(e.sha)) + " · " + esc(humanSeconds(sec)) +
83
83
  " ago" + (label && typeof label === "string" ? " · [" + esc(label) + "]" : "") +
@@ -238,7 +238,7 @@ export const GUI_CLIENT_PENDING_JS = String.raw ` let pendingTeam = null;
238
238
  // verdict-less failed-review-run count when positive, else "". Hand-kept copy because the
239
239
  // script cannot import the server renderer; pinned by the client test against the CLI wording.
240
240
  const squashText = (s, max) => {
241
- const t = String(s).replace(/\s+/g, " ").trim();
241
+ const t = squash(s);
242
242
  if (t.length <= max) return t;
243
243
  return t.slice(0, max - 1).trimEnd() + "…";
244
244
  };
@@ -297,7 +297,7 @@ export const GUI_CLIENT_PENDING_JS = String.raw ` let pendingTeam = null;
297
297
  const lines = kept.map((e) => {
298
298
  const label = postReviewStatusLabel(e);
299
299
  const age = typeof e.enqueuedAt === "number" && Number.isFinite(now)
300
- ? " · " + esc(humanSeconds(Math.max(0, Math.round((now - e.enqueuedAt) / 1000)))) + " ago"
300
+ ? " · " + esc(humanSeconds(secsSince(e.enqueuedAt, now))) + " ago"
301
301
  : "";
302
302
  let line = "<div class='queue-entry mono clamp1'>" + esc(e.role ? roleLabel(e.role) : "?") + " " + esc(shortSha(e.commit)) +
303
303
  age +
@@ -1,10 +1,12 @@
1
1
  /** The dashboard Usage view's chart builders, browser-side: the report-chart region. Pure
2
2
  * functions from /api/report's day series to SVG strings — the per-day bar and stacked charts,
3
3
  * their role/provider/model orderings, the shared role palette, the has-data predicates that
4
- * decide which charts draw, and the merge-share line's wording — with no DOM access, so the
5
- * tests exercise them in isolation.
4
+ * decide which charts draw, the merge-share line's wording, and the cost-per-commit /
5
+ * estimated-per-commit lines (costPerCommitParts, costPerCommitText, estimatedPerCommitText) —
6
+ * with no DOM access, so the tests exercise them in isolation.
6
7
  * Spliced into gui-client.ts's script ahead of gui-client-report.ts's hover chip, Role picker,
7
8
  * and view code, which call them through that concatenation. */
9
+ import { INSTANCE_ID_SOURCE } from "../../roles/runner-ids.js";
8
10
  export const GUI_CLIENT_REPORT_CHART_JS = String.raw `
9
11
  // report-chart:start
10
12
  // One color per role, assigned in the report's role order so a role keeps its color across
@@ -12,6 +14,12 @@ export const GUI_CLIENT_REPORT_CHART_JS = String.raw `
12
14
  const REPORT_PALETTE = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#0ea5e9", "#a855f7", "#f97316", "#14b8a6",
13
15
  "#ec4899", "#84cc16", "#06b6d4", "#eab308", "#64748b", "#d946ef"];
14
16
 
17
+ // A [key, number][] ordering by total desc then key asc — the shared shape of every
18
+ // role/provider/model ordering (tallyAcross's role/provider/model rows and costPerCommitParts's
19
+ // mergeDone and idle pairs each spelled the b[1]-a[1] || a[0].localeCompare(b[0]) comparator
20
+ // inline), so none of them can drift on the count-then-name rule.
21
+ const byTotalThenName = (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]);
22
+
15
23
  // One [key, total][] for a day series, ordered by total desc then name asc — the shared
16
24
  // shape of the role/provider/model orderings. sumFields are the fields that contribute a
17
25
  // running total (runsByRole, costByProvider, runsByModel); seedFields are fields whose
@@ -31,7 +39,7 @@ export const GUI_CLIENT_REPORT_CHART_JS = String.raw `
31
39
  for (const k of Object.keys(d[field] || {})) if (!Object.hasOwn(by, k)) by[k] = 0;
32
40
  }
33
41
  }
34
- return Object.entries(by).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
42
+ return Object.entries(by).sort(byTotalThenName);
35
43
  }
36
44
 
37
45
  // Window totals per role — runs desc, then name — so the legend and the stack order match
@@ -40,7 +48,7 @@ export const GUI_CLIENT_REPORT_CHART_JS = String.raw `
40
48
  // Object.prototype key whose inherited value would otherwise seed the counter and satisfy
41
49
  // the membership test (the browser twin of addTo's fix; BUGS.md 2026-10-10).
42
50
  function reportRoleOrder(data) {
43
- return tallyAcross(data.series, ["runsByRole"], ["costByRole", "notionalByRole", "rejectionsByRole", "revisionsByRole", "refusalsByRole", "fallbacksByRole", "imageReroutesByRole", "failedRunsByRole", "mergeFailedByRole", "commitsByRole", "mergedByRole"]);
51
+ return tallyAcross(data.series, ["runsByRole"], ["costByRole", "notionalByRole", "rejectionsByRole", "revisionsByRole", "refusalsByRole", "fallbacksByRole", "failedRunsByRole", "mergeFailedByRole", "commitsByRole", "mergedByRole"]);
44
52
  }
45
53
 
46
54
  // X-axis labels: MM-DD, thinned to at most seven, counted back from the newest day so today
@@ -155,8 +163,8 @@ export const GUI_CLIENT_REPORT_CHART_JS = String.raw `
155
163
  countAxis, true);
156
164
  }
157
165
  // Main's red wall-clock per day, from mainRedMs (the same main_check/main_red_hold rule G1
158
- // uses): the span every code role was held. A day without one keeps its empty slot, like the
159
- // median charts; the red color reads beside the red-check-count chart it follows.
166
+ // uses): the span main had no passing full check; a day without one keeps its empty slot,
167
+ // like the median charts; the red color reads beside the red-check-count chart it follows.
160
168
  function chartMainRedMs(data) {
161
169
  return reportSvg(data.series, (d) => [{ value: Number(d.mainRedMs) || 0, color: "#dc2626", title: d.date + ": " + fmtSpan(Number(d.mainRedMs) || 0) + " main red" }],
162
170
  (v) => fmtSpan(v), false);
@@ -280,6 +288,21 @@ export const GUI_CLIENT_REPORT_CHART_JS = String.raw `
280
288
  return roleStackChart(data, "tokensByRole", (n) => fmtTokens(Number(n) || 0) + " output tokens", (v) => fmtTokens(Math.round(v)), true);
281
289
  }
282
290
 
291
+ // The day's prompt tokens, per role, the browser twin of the report's "Prompt tokens by
292
+ // role" line — same roleStackChart shape, palette, legend order and integer count axis as
293
+ // chartTokensByRole, so a role keeps its palette color and legend order from
294
+ // reportRoleOrder.
295
+ function chartPromptTokensByRole(data) {
296
+ return roleStackChart(data, "promptTokensByRole", (n) => fmtTokens(Number(n) || 0) + " prompt tokens", (v) => fmtTokens(Math.round(v)), true);
297
+ }
298
+
299
+ // The day's cache-read prompt tokens, per role, the browser twin of the report's "Prompt
300
+ // tokens by role" line's cache-read share — the same shape over cacheReadTokensByRole with
301
+ // the " cached prompt tokens" label, keeping the same palette order and count axis.
302
+ function chartCacheReadTokensByRole(data) {
303
+ return roleStackChart(data, "cacheReadTokensByRole", (n) => fmtTokens(Number(n) || 0) + " cached prompt tokens", (v) => fmtTokens(Math.round(v)), true);
304
+ }
305
+
283
306
  // The API-equivalent (notional) spend of subscription runs, per role, on the same axis as
284
307
  // the cap spend.
285
308
  function chartNotionalByRole(data) {
@@ -353,10 +376,6 @@ export const GUI_CLIENT_REPORT_CHART_JS = String.raw `
353
376
  function chartFallbacksByRole(data) {
354
377
  return roleStackChart(data, "fallbacksByRole", (n) => plural(n, "fallback"), countAxis, true);
355
378
  }
356
- // Image reroutes per role, drawn the same way: one segment per (day, role) with a reroute.
357
- function chartImageReroutesByRole(data) {
358
- return roleStackChart(data, "imageReroutesByRole", (n) => plural(n, "image reroute"), countAxis, true);
359
- }
360
379
  // Runs that ended in an error, per role, drawn the same way.
361
380
  function chartFailedRunsByRole(data) {
362
381
  return roleStackChart(data, "failedRunsByRole", (n) => plural(n, "failed run"), countAxis, true);
@@ -385,6 +404,32 @@ export const GUI_CLIENT_REPORT_CHART_JS = String.raw `
385
404
  function chartValidationGaps(data) {
386
405
  return stackChart(data, reportValidationGapOrder(data), "validationGapsByTag", (n) => plural(n, "validation gap"), countAxis, true);
387
406
  }
407
+ // Window tallies per main-red failing test, count desc then name asc — the browser twin of
408
+ // the day report's "Main red by test" line. Test keys are dynamic, so reads and seeds go
409
+ // through tallyAcross's Object.hasOwn checks as its siblings do; a test no day recorded
410
+ // still gets a row only if some day carries it.
411
+ function reportMainRedTestOrder(data) {
412
+ return tallyAcross(data.series, ["mainRedByTest"], []);
413
+ }
414
+ // The team's main-red failing tests, per day, stacked by test: each segment one (day, test),
415
+ // the dashboard twin of the day report's "Main red by test" line. Sparse like the model
416
+ // chart; test names are the legend labels.
417
+ function chartMainRedByTest(data) {
418
+ return stackChart(data, reportMainRedTestOrder(data), "mainRedByTest", (n) => plural(n, "red check"), countAxis, true);
419
+ }
420
+ // Window tallies per main-red blamed merge (the report's "<role> <shortSha>" label), count
421
+ // desc then name asc — the browser twin of the day report's "Main red by merge" line.
422
+ // Culprit keys are dynamic, so reads and seeds go through tallyAcross's Object.hasOwn checks
423
+ // as its siblings do; a culprit no day recorded still gets a row only if some day carries it.
424
+ function reportMainRedCulpritOrder(data) {
425
+ return tallyAcross(data.series, ["mainRedByCulprit"], []);
426
+ }
427
+ // The team's main-red blamed merges, per day, stacked by culprit: each segment one
428
+ // (day, culprit), the dashboard twin of the day report's "Main red by merge" line. Sparse
429
+ // like the model chart; the culprit labels are the legend labels.
430
+ function chartMainRedByMerge(data) {
431
+ return stackChart(data, reportMainRedCulpritOrder(data), "mainRedByCulprit", (n) => plural(n, "blame"), countAxis, true);
432
+ }
388
433
  // The day's merged outcomes per role, the success sibling of chartMergeFailuresByRole,
389
434
  // drawn exactly like it (same roleStackChart shape, palette, legend order and integer count
390
435
  // axis) so the two read as one pair.
@@ -415,6 +460,62 @@ export const GUI_CLIENT_REPORT_CHART_JS = String.raw `
415
460
  function mergeShareText(t) {
416
461
  return "of which merge runs: " + t.mergeRuns + " runs" + ((t.mergeFailed || 0) > 0 ? " (" + t.mergeFailed + " failed)" : "") + " · " + fmtTokens(t.mergeTokens || 0) + " tokens · " + fmtUsd(t.mergeCostUsd || 0) + " (reviewer + conflict resolution)";
417
462
  }
463
+
464
+ // The instance-id matcher for the cost-per-commit lines below, the same pattern
465
+ // gui-client-labels.ts uses, named distinctly from that region's INSTANCE_ID so the
466
+ // concatenated script has no duplicate const. Interpolated from the server's
467
+ // INSTANCE_ID_SOURCE so the base-role fold cannot drift from baseRoleOf.
468
+ const COST_INSTANCE_ID = new RegExp(${JSON.stringify(INSTANCE_ID_SOURCE)});
469
+
470
+ // Cost per merged commit by base role, the browser twin of report-render.ts's
471
+ // costPerCommitPairs: spend folded to its base role (feature-2 joins feature), commits read
472
+ // as-is (already base-keyed). mergeDone is [role, cost per commit, commits] for roles with
473
+ // both, ranked by cost per commit desc then name asc; idle is [role, cost] for roles that
474
+ // spent and merged nothing, ranked by spend. pick selects another per-role-id spend map
475
+ // (e.g. notionalByRole); a role with commits but no spend in the picked map appears in
476
+ // neither list.
477
+ function costPerCommitParts(series, pick) {
478
+ const cost = {};
479
+ const commits = {};
480
+ for (const d of series) {
481
+ const pickSpend = pick || ((day) => day.costByRole);
482
+ for (const [id, c] of Object.entries(pickSpend(d) || {})) {
483
+ const m = COST_INSTANCE_ID.exec(id);
484
+ const base = m ? m[1] : id;
485
+ cost[base] = (Object.hasOwn(cost, base) ? cost[base] : 0) + c;
486
+ }
487
+ for (const [role, n] of Object.entries(d.commitsByRole || {})) {
488
+ commits[role] = (Object.hasOwn(commits, role) ? commits[role] : 0) + n;
489
+ }
490
+ }
491
+ const mergeDone = [];
492
+ const idle = [];
493
+ for (const [role, c] of Object.entries(cost)) {
494
+ if (!(c > 0)) continue;
495
+ const n = commits[role] || 0;
496
+ if (n > 0) mergeDone.push([role, c / n, n]);
497
+ else idle.push([role, c]);
498
+ }
499
+ mergeDone.sort(byTotalThenName);
500
+ idle.sort(byTotalThenName);
501
+ return { mergeDone, idle };
502
+ }
503
+
504
+ // One "label: role — cost (n) · role — cost, no commits" line from costPerCommitParts, the
505
+ // browser twin of report-render.ts's costPerCommitLine; "-" when nothing to show.
506
+ function costPerCommitText(label, series, pick) {
507
+ const parts = costPerCommitParts(series, pick);
508
+ const lines = [];
509
+ for (const [r, per, n] of parts.mergeDone) lines.push(r + " — " + fmtUsd(per) + " (" + n + ")");
510
+ for (const [r, c] of parts.idle) lines.push(r + " — " + fmtUsd(c) + ", no commits");
511
+ return label + ": " + (lines.length === 0 ? "-" : lines.join(" · "));
512
+ }
513
+
514
+ // The Estimated per commit line, over notionalByRole, the browser twin of
515
+ // renderReportMarkdown's notional-gated line; the caller decides when it prints.
516
+ function estimatedPerCommitText(series) {
517
+ return costPerCommitText("Estimated per commit", series, (d) => d.notionalByRole);
518
+ }
418
519
  // The per-day filing-stamp twin of chartBacklogItems: plans added and bugs filed, the same
419
520
  // two colors as the done chart so the opened and done charts read as a pair. A day with
420
521
  // neither carries an empty slot, and zero segments draw no rect.
@@ -9,8 +9,8 @@
9
9
  * CLI; the since shape draws the returned Markdown instead of the charts. Failures renders
10
10
  * the failure digest (/api/failures, the Markdown `tumwater report --failures` prints) through
11
11
  * the page's Markdown renderer, over the same kind of window and Role pickers. The Failures view
12
- * also paints the current flake quarantine below the digest, the read twin of the amber quarantine
13
- * alert. Both fetch when
12
+ * also paints the current flake quarantine below the digest, the interactive release twin of
13
+ * `tumwater quarantine --release <n>` (each row's Release button). Both fetch when
14
14
  * their view opens, on Refresh, and — Usage only, throttled — when new events arrive while it
15
15
  * is open. On a trailing window, ◀ Earlier / Later ▶ step it back by its own length through
16
16
  * `?sinceMs=&untilMs=`, the twin of `report --since --until`. The chart builders are pure and
@@ -206,6 +206,12 @@ export const GUI_CLIENT_REPORT_JS = String.raw `// report-tip:start
206
206
  async function fetchView(throttled) {
207
207
  if (throttled && Date.now() - fetchedAt < 60000) return;
208
208
  fetchedAt = Date.now();
209
+ // A re-render replaces every Release button with a fresh "Release" label, so the armed
210
+ // state (whichever button previously carried "Click again to release") no longer refers
211
+ // to a live button — clear it just before the rebuild, or a single post-render click
212
+ // would pass for the second. Only a fetch that actually rebuilds does this: a throttled
213
+ // fetch that returns early above must not clear a button the user has armed.
214
+ disarmQuarantineRelease();
209
215
  buildOnce(o.panelId, viewHead(o.title, o.blurb, o.pickerId, o.choices, days, o.refreshId, o.rolePickerId, o.sinceChoices) +
210
216
  "<div id='" + o.bodyId + "'><div class='empty'>Loading…</div></div>");
211
217
  // Exactly one of the two controls is active: a live since window clears the day buttons
@@ -235,6 +241,17 @@ export const GUI_CLIENT_REPORT_JS = String.raw `// report-tip:start
235
241
  fetchView();
236
242
  });
237
243
  container.addEventListener("click", (ev) => {
244
+ // The flake-quarantine card's Release buttons (Failures view): the first click arms,
245
+ // the second posts and re-fetches only after the post resolves (quarantineReleaseClick)
246
+ // so the released row disappears; any other click disarms.
247
+ const rel = clickClosest(ev, "[data-action='quarantine-release']");
248
+ if (rel) {
249
+ quarantineReleaseClick(rel,
250
+ (test) => postAction("/api/quarantine-release", { test }, (d) => d && d.message),
251
+ fetchView);
252
+ return;
253
+ }
254
+ disarmQuarantineRelease();
238
255
  const st = clickClosest(ev, "[data-step]");
239
256
  if (st) {
240
257
  if (st.dataset.step === "earlier") {
@@ -310,16 +327,15 @@ export const GUI_CLIENT_REPORT_JS = String.raw `// report-tip:start
310
327
  "</h2><span class='muted'>" + esc(sub || "per day") + "</span></header><div class='chart'>" + svg + "</div></section>";
311
328
  // A scoped report drops to the four scoped tiles and says what it left out, mirroring the
312
329
  // CLI's --role report. The team-wide render is unchanged.
313
- return "<div class='stats" + (d.role ? "" : " six") + "'>" + reportSummary(d) + "</div>" + (d.totals.mergeRuns > 0 ? "<p class='muted merge-share'>" + esc(mergeShareText(d.totals)) + "</p>" : "") + "<div class='grid-2 even'>" +
330
+ return "<div class='stats" + (d.role ? "" : " six") + "'>" + reportSummary(d) + "</div>" + (d.totals.mergeRuns > 0 ? "<p class='muted merge-share'>" + esc(mergeShareText(d.totals)) + "</p>" : "") + "<p class='muted cost-per-commit'>" + esc(costPerCommitText("Cost per commit", d.series)) + "</p>" + (d.totals.notionalCostUsd > 0 ? "<p class='muted cost-per-commit'>" + esc(estimatedPerCommitText(d.series)) + "</p>" : "") + "<div class='grid-2 even'>" +
314
331
  block("Work vs maintenance commits", chartCommitTiers(d), "per day · work + maintenance") + block("Merged commits", chartCommits(d)) + block("Commits by role", chartCommitsByRole(d)) + (seriesHasBacklogAdded(d) ? block("Backlog items opened", chartBacklogItemsOpened(d), "per day · plans added + bugs filed") : "") + (seriesHasBacklogItems(d) ? block("Backlog items done", chartBacklogItems(d), "per day · features done + bugs fixed") : "") + (seriesHasBacklogLagMedian(d) ? block("Backlog lag median", chartBacklogLagMedian(d), "per day · filing to done") : "") + block("Runs by role", chartRunsByRole(d)) +
315
- block("Output tokens", chartTokens(d)) + block("Tokens by role", chartTokensByRole(d)) + block("Spend by role", chartCostByRole(d)) +
332
+ block("Output tokens", chartTokens(d)) + block("Tokens by role", chartTokensByRole(d)) + (seriesHasAny(d, "promptTokensByRole") ? block("Prompt tokens by role", chartPromptTokensByRole(d)) : "") + (seriesHasAny(d, "cacheReadTokensByRole") ? block("Cached prompt tokens by role", chartCacheReadTokensByRole(d)) : "") + block("Spend by role", chartCostByRole(d)) +
316
333
  (!d.role && seriesHasAny(d, "costByProvider") ? block("Spend by provider", chartCostByProvider(d)) : "") +
317
334
  block("Rejections by role", chartRejectionsByRole(d)) +
318
335
  (seriesHasAny(d, "postMergeRejectionsByRole") ? block("Post-merge rejections by role", chartPostMergeRejectionsByRole(d)) : "") +
319
336
  (seriesHasAny(d, "revisionsByRole") ? block("Revisions by role", chartRevisionsByRole(d)) : "") +
320
337
  (seriesHasAny(d, "refusalsByRole") ? block("Refusals by role", chartRefusalsByRole(d)) : "") +
321
338
  (seriesHasAny(d, "fallbacksByRole") ? block("Fallbacks by role", chartFallbacksByRole(d)) : "") +
322
- (seriesHasAny(d, "imageReroutesByRole") ? block("Image reroutes by role", chartImageReroutesByRole(d)) : "") +
323
339
  (seriesHasAny(d, "failedRunsByRole") ? block("Failed runs by role", chartFailedRunsByRole(d)) : "") +
324
340
  (seriesHasAny(d, "mergedByRole") ? block("Merged by role", chartMergedByRole(d)) : "") +
325
341
  (!d.role && seriesHasAny(d, "runsByModel") ? block("Runs by model", chartRunsByModel(d)) : "") +
@@ -330,6 +346,8 @@ export const GUI_CLIENT_REPORT_JS = String.raw `// report-tip:start
330
346
  (seriesHasCheckMedian(d) ? block("Merge check median", chartCheckMedian(d), "per day · passing full-suite checks") : "") +
331
347
  (seriesHasCheckFailures(d) ? block("Red merge checks", chartCheckFailures(d), "per day · failed full-suite checks") : "") +
332
348
  (seriesHasMainRedMs(d) ? block("Main red time", chartMainRedMs(d), "per day · no passing full check") : "") +
349
+ (!d.role && seriesHasAny(d, "mainRedByTest") ? block("Main red by test", chartMainRedByTest(d), "per day · failing tests main's red checks named") : "") +
350
+ (!d.role && seriesHasAny(d, "mainRedByCulprit") ? block("Main red by merge", chartMainRedByMerge(d), "per day · merges the main watch blamed") : "") +
333
351
  (seriesHasMergeWaitMedian(d) ? block("Merge wait median", chartMergeWaitMedian(d), "per day · enqueue to merged on main") : "") +
334
352
  (seriesHasMergeSlotMedian(d) ? block("Merge slot median", chartMergeSlotMedian(d), "per day · verification start to outcome") : "") +
335
353
  (seriesHasRunMedian(d) ? block("Run duration median", chartRunMedian(d), "per day · run_start to run_end") : "") +
@@ -350,12 +368,53 @@ export const GUI_CLIENT_REPORT_JS = String.raw `// report-tip:start
350
368
 
351
369
  // report-quarantine:start
352
370
  // The Failures view's flake-quarantine list, painted as its own card below the digest: the
353
- // read twin of the amber quarantine alert (whose See-failures action lands here). An empty
354
- // or absent section (a clean quarantine) renders nothing, so a window with a digest but no
355
- // quarantine is unchanged. Built over the same renderQuarantineSection the server serves.
356
- function quarantineCardHtml(md) {
357
- const q = String(md || "").trim();
358
- return q ? "<article class='card md'>" + renderMarkdown(q, true) + "</article>" : "";
371
+ // interactive twin of the doctor's flaky-checks row (whose Release buttons land here). An
372
+ // empty or absent section (a clean quarantine) renders nothing, so a window with a digest
373
+ // but no quarantine is unchanged. The card is structured ({ warning, rows } from
374
+ // /api/failures) rather than Markdown, so each entry carries a Release button that runs the
375
+ // same releaseQuarantinedTest core as tumwater quarantine --release <n>.
376
+ let quarantineReleaseArmed = null;
377
+ // Restore the armed button's label and clear the arm. Safe when nothing is armed, and safe
378
+ // after a re-render rebuilds the Failures body (the armed element is detached then, so its
379
+ // label is moot); keeps the card from showing a stale "Click again to release" on a button
380
+ // the user no longer intends to commit.
381
+ function disarmQuarantineRelease() {
382
+ if (quarantineReleaseArmed) {
383
+ quarantineReleaseArmed.textContent = "Release";
384
+ quarantineReleaseArmed = null;
385
+ }
386
+ }
387
+ function quarantineCardHtml(q) {
388
+ const rows = (q && q.rows) || [];
389
+ if (!rows || rows.length === 0) return "";
390
+ return "<article class='card md'><h2>Flake quarantine</h2>" +
391
+ (q.warning ? "<p>" + esc(q.warning) + "</p>" : "") +
392
+ "<ul>" + rows.map((r) =>
393
+ "<li>" + esc(r.test) + " — " + esc(r.detail) +
394
+ "<button type='button' class='btn btn-sm' data-action='quarantine-release' data-test='" +
395
+ esc(r.test) + "'>Release</button></li>",
396
+ ).join("") + "</ul></article>";
397
+ }
398
+ // A Release click on the card: the first click on a row arms it (relabelling to confirm,
399
+ // like the other committing reaps); a second click on that same row posts the release and
400
+ // only then re-fetches, so the released row disappears once the server has released it; any
401
+ // other click, or a re-render, disarms and restores the armed button's label. Module state
402
+ // plus the injected post/refetch, so the browser's wireClicks and the test drive one
403
+ // decision. Returns "armed" / "released" / "disarmed".
404
+ async function quarantineReleaseClick(rel, post, refetch) {
405
+ const test = rel.dataset.test || "";
406
+ if (!test) return "disarmed";
407
+ if (quarantineReleaseArmed !== rel) {
408
+ disarmQuarantineRelease();
409
+ quarantineReleaseArmed = rel;
410
+ rel.textContent = "Click again to release";
411
+ return "armed";
412
+ }
413
+ quarantineReleaseArmed = null;
414
+ rel.textContent = "Release";
415
+ await post(test);
416
+ refetch();
417
+ return "released";
359
418
  }
360
419
  // report-quarantine:end
361
420
 
@@ -30,6 +30,14 @@ export const GUI_CLIENT_ROLES_JS = String.raw `
30
30
  // abort fires, while a first click on another role's abort re-arms instead of posting.
31
31
  const confirmAbortSlot = confirmSlot(rerender);
32
32
  const abortConfirming = (role) => confirmAbortSlot.armed(role);
33
+ // The abort-all slot: keyed "all" (no role id), so a first click on any other action's
34
+ // button disarms a pending abort-all — the same per-key arming as the per-role slot above.
35
+ const confirmAbortAllSlot = confirmSlot(rerender);
36
+ const abortAllConfirming = () => confirmAbortAllSlot.armed("all");
37
+ // The Roles card header's Abort all button (inserted into the card head at script load):
38
+ // one click arms it, a second within 5 s hits POST /api/abort-all. Danger tone; the title
39
+ // names the confirm-again contract, like the per-row stop's.
40
+ const rolesAbortAllButton = () => "<button type='button' class='btn btn-sm btn-danger' id='abortall' data-action='abort-all' title='Click again to stop every running role&#39;s run'>" + icon("stop") + "<span>Abort all</span></button>";
33
41
  // The drawer's Disable slot: a second click on the same role's Disable fires, a first click
34
42
  // on another role's re-arms — the same per-role keying as the abort slot it sits beside.
35
43
  const confirmDisableSlot = confirmSlot(rerender);
@@ -44,6 +52,21 @@ export const GUI_CLIENT_ROLES_JS = String.raw `
44
52
  // wake, pause/resume (the target state is always explicit), and abort, which discards the
45
53
  // run's work — the first click arms it, a second within 5 s fires it.
46
54
  async function rowAction(action, role, forSeconds) {
55
+ if (action === "abort-all") {
56
+ if (!abortAllConfirming()) {
57
+ // First click arms it; any other click (handled below) disarms it.
58
+ confirmAbortAllSlot.arm("all");
59
+ confirmAbortSlot.disarm();
60
+ confirmDisableSlot.disarm();
61
+ roleChooser = null;
62
+ return;
63
+ }
64
+ confirmAbortAllSlot.disarm();
65
+ await postAction("/api/abort-all", {}, (d) =>
66
+ (d && typeof d.message === "string" ? d.message : "abort requested for every running role"));
67
+ return;
68
+ }
69
+ confirmAbortAllSlot.disarm();
47
70
  if (action === "prompt") {
48
71
  setTarget(role);
49
72
  focusComposer();
@@ -105,7 +128,7 @@ export const GUI_CLIENT_ROLES_JS = String.raw `
105
128
  const detail = phaseDetailBase(l, info, d);
106
129
  const w = scheduledWakeFor(l, d);
107
130
  if (!w) return detail;
108
- return (detail ? detail + " · " : "") + "your wake in " + humanSeconds(Math.max(0, Math.round((w.atMs - Date.now()) / 1000)));
131
+ return (detail ? detail + " · " : "") + "your wake in " + humanSeconds(secsUntil(w.atMs, Date.now()));
109
132
  }
110
133
  function phaseDetailBase(l, info, d) {
111
134
  const raw = fmtNextRun(l, d.running);
@@ -114,18 +137,22 @@ export const GUI_CLIENT_ROLES_JS = String.raw `
114
137
  const backoff = next.startsWith("sleeping ") ? "sleeping — next check in " + next.slice(9) : "";
115
138
  // A yield-scaled clock: the role's recent runs merged nothing, so it runs less often.
116
139
  const note = slowed ? " · slowed ×" + slowed[1] + ", nothing merged lately" : "";
117
- // The consecutive no-change streak (status-payload noChangeStreak, mirrored from
118
- // status-model rolePhase's · no change ×N suffix): how many completed runs in a row
119
- // returned nothing to do. Shown only on a resting idle cell (sleeping/queued/idle/
120
- // deferred) — never on live work, a pause, a failure, or main-red, which the status
121
- // label likewise leaves bare. Observational only: a legitimately idle maintenance role
122
- // can hold a long streak.
123
- const noChange = ["sleeping", "queued", "idle", "deferred"].includes(info.key) && (l.noChangeStreak || 0) >= 2
140
+ // The consecutive cut-off and no-change streaks (status-payload cutOffStreak/noChangeStreak,
141
+ // mirrored from status-model rolePhase's suffix): how many completed runs in a row hit the
142
+ // context ceiling or returned nothing to do. Shown only on a resting idle cell
143
+ // (sleeping/queued/idle/deferred) — never on live work, a pause, a failure, or main-red,
144
+ // which the status label likewise leaves bare. A cut-off run is also a no_change, so the
145
+ // cut-off streak wins when both read 2+ — it names the costly cause the plain suffix would
146
+ // bury. Observational only, like the phase text.
147
+ const cutOff = ["sleeping", "queued", "idle", "deferred"].includes(info.key) && (l.cutOffStreak || 0) >= 2
148
+ ? " · cut off ×" + l.cutOffStreak : "";
149
+ const noChange = !cutOff && ["sleeping", "queued", "idle", "deferred"].includes(info.key) && (l.noChangeStreak || 0) >= 2
124
150
  ? " · no change ×" + l.noChangeStreak : "";
125
- if (info.key === "sleeping") return (backoff || (next === "-" ? "" : next === "now" ? "wakes now" : "wakes in " + next)) + note + noChange;
126
- if (info.key === "queued") return (backoff || info.detail) + note + noChange;
127
- if (info.key === "paused" && d.pausedRolesUntil && !d.paused && (d.pausedLoops || []).includes(l.role)) return info.detail + " · resumes in " + humanSeconds(Math.max(0, Math.round((d.pausedRolesUntil - Date.now()) / 1000))); // retired-name-ok: serialized /api key
128
- return info.detail + noChange;
151
+ const streak = cutOff || noChange;
152
+ if (info.key === "sleeping") return (backoff || (next === "-" ? "" : next === "now" ? "wakes now" : "wakes in " + next)) + note + streak;
153
+ if (info.key === "queued") return (backoff || info.detail) + note + streak;
154
+ if (info.key === "paused" && d.pausedRolesUntil && !d.paused && (d.pausedLoops || []).includes(l.role)) return info.detail + " · resumes in " + humanSeconds(secsUntil(d.pausedRolesUntil, Date.now())); // retired-name-ok: serialized /api key
155
+ return info.detail + streak;
129
156
  }
130
157
  function roleCells(l, d) {
131
158
  const info = phaseInfo(l.phase);
@@ -231,6 +258,19 @@ export const GUI_CLIENT_ROLES_JS = String.raw `
231
258
  postAction("/api/reset-counters", {}, (d) => (d && typeof d.message === "string" ? d.message : "counters reset")));
232
259
  $("wakeall").addEventListener("click", () =>
233
260
  postAction("/api/wake", {}, (d) => (d && typeof d.message === "string" ? d.message : "every role woken")));
261
+ // Insert the Abort all button into the Roles card header beside the card title/refresh
262
+ // control (the same card head that holds Reset counters / Wake later / Wake all), and arm
263
+ // its two-click confirm through the shared rowAction abort-all branch. One insert on load;
264
+ // re-renders leave the header alone.
265
+ (function () {
266
+ const head = $("roles-title") && $("roles-title").parentElement;
267
+ if (!head || document.getElementById("abortall")) return;
268
+ head.insertAdjacentHTML("beforeend", rolesAbortAllButton());
269
+ document.getElementById("abortall").addEventListener("click", (ev) => {
270
+ ev.preventDefault();
271
+ rowAction("abort-all", "all");
272
+ });
273
+ })();
234
274
  // wake-later:start
235
275
  const WAKE_LATER_CHOICES = [[1800, "In 30 minutes"], [3600, "In 1 hour"], [7200, "In 2 hours"], [28800, "In 8 hours"]];
236
276
  function openWakeLaterMenu() {
@@ -11,9 +11,9 @@
11
11
  * gui-client.ts's script, reaching its helpers (esc, $, getJson, postJson, showFlash) through
12
12
  * that concatenation. A second card, "Health check", runs the same checks as `tumwater doctor`
13
13
  * through GET /api/doctor on each open (and on its Re-run button) and paints the report (its
14
- * Re-probe models button POSTs /api/doctor-reprobe instead, spending the image probes; its
15
14
  * Commit backlog button POSTs /api/doctor-commit-backlog instead, the same commitUncommittedBacklog
16
- * `tumwater doctor --commit-backlog` runs); a
15
+ * `tumwater doctor --commit-backlog` runs; its Archive backlog button POSTs
16
+ * /api/doctor-archive-backlog instead, the same archiveFinishedOnMain `tumwater archive` runs); a
17
17
  * third, "Disabled roles", lists every disabled role (GET /api/retire), each with an Enable
18
18
  * button (POST /api/role-enable) and — for a role that still keeps worktree/branch leftovers — a
19
19
  * two-click Retire button (POST /api/retire). */
@@ -73,12 +73,12 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
73
73
  "<span>The same checks as <code>tumwater doctor</code>.</span>" +
74
74
  "<label><input type='checkbox' data-action='doctor-problems'> Problems only</label>" +
75
75
  "<button type='button' class='btn btn-sm' data-action='doctor-rerun'>Re-run</button>" +
76
- "<button type='button' class='btn btn-sm' data-action='doctor-reprobe' title='Re-probe every image-input model (spends one model call each), like tumwater doctor --reprobe'>Re-probe models</button>" +
77
76
  "<button type='button' class='btn btn-sm' data-action='doctor-kill-orphans'>Kill orphans</button>" +
78
77
  "<button type='button' class='btn btn-sm' data-action='doctor-drop-broken-pins' title=\"Drops each refs/tumwater/ pin whose commit is gone - the manual git update-ref -d the tumwater pins row names, like tumwater doctor --drop-broken-pins\">Drop broken pins</button>" +
79
78
  "<button type='button' class='btn btn-sm' data-action='doctor-commit-backlog' title='Commit each uncommitted tracked backlog or brief file on main, like tumwater doctor --commit-backlog'>Commit backlog</button>" +
80
79
  "<button type='button' class='btn btn-sm' data-action='doctor-resync-install' title=\"Re-sync the root checkout's install after a failed post-merge re-sync, like tumwater doctor --resync-install\">Re-sync install</button>" +
81
- "<button type='button' class='btn btn-sm' data-action='doctor-retire-leftovers' title=\"Retire every disabled role's leftover worktree, branch and merge ref in one pass, like tumwater doctor --retire-leftovers (or tumwater retire --role <id> per role)\">Retire leftovers</button></div>" +
80
+ "<button type='button' class='btn btn-sm' data-action='doctor-retire-leftovers' title=\"Retire every disabled role's leftover worktree, branch and merge ref in one pass, like tumwater doctor --retire-leftovers (or tumwater retire --role <id> per role)\">Retire leftovers</button>" +
81
+ "<button type='button' class='btn btn-sm' data-action='doctor-archive-backlog' title=\"Move the finished entries under PLANS.md ## Done and BUGS.md ## Fixed into the archive files and commit them on main, like tumwater archive\">Archive backlog</button></div>" +
82
82
  "<div id='doctorbody'><div class='empty'>Checking…</div></div></div>" +
83
83
  "<div class='card' id='retirecard'><div class='empty'>Loading…</div></div>" +
84
84
  "<div class='card' id='briefcard'><div class='empty'>Loading…</div></div>");
@@ -97,7 +97,7 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
97
97
  "<div class='settings-row'><span class='pill t-" + (tones[c.level] || "amber") + "'>" + esc(c.level) + "</span>" +
98
98
  "<strong>" + esc(c.name) + "</strong><span>" + esc(c.detail) + "</span></div>").join("");
99
99
  return "<p>" + esc(d.header) + "</p>" + rows +
100
- (problems && hidden > 0 ? "<p class='empty'>" + hidden + " " + (hidden === 1 ? "ok check" : "ok checks") + " hidden</p>" : "") +
100
+ (problems && hidden > 0 ? "<p class='empty'>" + plural(hidden, "ok check") + " hidden</p>" : "") +
101
101
  "<p>" + esc(d.verdict) + "</p>";
102
102
  }
103
103
  // The last resolved doctor report and whether the card paints only its problems; the
@@ -117,27 +117,6 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
117
117
  body.innerHTML = errorPanel("Health checks could not run", err, "empty");
118
118
  }
119
119
  }
120
- let doctorReprobing = false;
121
- async function reprobeDoctorClick() {
122
- if (doctorReprobing) return;
123
- doctorReprobing = true;
124
- try {
125
- buildSettingsView();
126
- const body = $("doctorbody");
127
- body.innerHTML = "<div class='empty'>Re-probing…</div>";
128
- showFlash("Re-probing image-input models…");
129
- try {
130
- const report = await postJson("/api/doctor-reprobe", {});
131
- lastDoctor = report;
132
- body.innerHTML = renderDoctorReport(report, doctorProblems);
133
- } catch (err) {
134
- body.innerHTML = errorPanel("Health checks could not run", err, "empty");
135
- showFlash("error: " + clientErrorMessage(err));
136
- }
137
- } finally {
138
- doctorReprobing = false;
139
- }
140
- }
141
120
  function toggleDoctorProblems(checked) {
142
121
  doctorProblems = checked;
143
122
  const body = $("doctorbody");
@@ -280,6 +259,32 @@ export const GUI_CLIENT_SETTINGS_JS = String.raw ` // settings-view:start
280
259
  }
281
260
  await fetchDoctor();
282
261
  }
262
+ // The Health card's Archive backlog button: a two-click confirm, then POST
263
+ // /api/doctor-archive-backlog runs the same archive as "tumwater archive" (holding the merge
264
+ // lock) and the card re-runs its checks so the backlog archive row repaints. ok is false
265
+ // when the archive was skipped (an off-base checkout or uncommitted backlog/archive files),
266
+ // and its reason still flashes so the operator sees why nothing moved.
267
+ function paintArchiveBacklogButton() {
268
+ const btn = document.querySelector("button[data-action='doctor-archive-backlog']");
269
+ if (btn) btn.textContent = archiveBacklogSlot.armed("archive-backlog") ? "Confirm archive" : "Archive backlog";
270
+ }
271
+ const archiveBacklogSlot = confirmSlot(paintArchiveBacklogButton);
272
+ async function archiveBacklogClick() {
273
+ if (!archiveBacklogSlot.armed("archive-backlog")) {
274
+ archiveBacklogSlot.arm("archive-backlog");
275
+ return;
276
+ }
277
+ archiveBacklogSlot.disarm();
278
+ paintArchiveBacklogButton();
279
+ showFlash("Archiving the finished backlog entries…");
280
+ try {
281
+ const d = await postJson("/api/doctor-archive-backlog", {});
282
+ showFlash((d.ok ? "" : "error: ") + d.message);
283
+ } catch (err) {
284
+ showFlash("error: " + clientErrorMessage(err));
285
+ }
286
+ await fetchDoctor();
287
+ }
283
288
  // The Health card's Commit backlog button: a single click, because the commit is the doctor's
284
289
  // own recommended repair and is reversible (unlike the two-click Kill orphans arm), POSTing
285
290
  // /api/doctor-commit-backlog — the same commitUncommittedBacklog "tumwater doctor
@@ -29,7 +29,7 @@ export const GUI_CLIENT_TEAM_JS = String.raw ` // ---- sidebar: project, team s
29
29
  // label style. One home for the rule, so the preformatted badges the payload ships to the
30
30
  // sidebar (disk, bootstrap, main check) cannot drift in how the row presents them.
31
31
  const stripBadge = (badge) => String(badge || "").replace(/^ · /, "");
32
- const badgeLabel = (badge) => stripBadge(badge).replace(/^./, (c) => c.toUpperCase());
32
+ const badgeLabel = (badge) => capitalize(stripBadge(badge));
33
33
  function renderSidebar(d) {
34
34
  const project = $("project");
35
35
  const name = d && d.project ? d.project : "";
@@ -49,7 +49,7 @@ export const GUI_CLIENT_TEAM_JS = String.raw ` // ---- sidebar: project, team s
49
49
  // decoration.
50
50
  const lead = stale ? restartButton(icon("refresh")) : icon("refresh");
51
51
  rows += row(stale ? (d.build.restartBlocked ? "amber" : "blue") : "", lead,
52
- "Version <span class='mono'>" + esc(shortSha(d.build.sha)) + "</span>" + (stale ? " · " + esc(d.build.aheadCommits || 0) + " behind" : ""),
52
+ "Version <span class='mono'>" + esc(shortSha(d.build.sha)) + "</span>" + (stale ? " · " + esc(plural(d.build.aheadCommits || 0, "commit")) + " behind" : ""),
53
53
  localizeInstants(("Running version" + (d.buildBadge || "").replace(/^, build/, "")).trim()));
54
54
  }
55
55
  if (d.mainCheck) {
@@ -150,6 +150,7 @@ export const GUI_CLIENT_TEAM_JS = String.raw ` // ---- sidebar: project, team s
150
150
  // sidebar:end
151
151
 
152
152
  // ---- alerts ----
153
+ // alerts:start
153
154
  // Alerts are patched by key, and only their text is rewritten in place: a stall alert's
154
155
  // "no pi output for 9m47s" updates every second, and rebuilding it would swap its buttons out
155
156
  // from under a click in progress.
@@ -171,6 +172,9 @@ export const GUI_CLIENT_TEAM_JS = String.raw ` // ---- sidebar: project, team s
171
172
  const alerts = pageAlerts(d, offline);
172
173
  const box = $("alerts");
173
174
  const keep = new Set(alerts.map((a) => a.key));
175
+ // doctor-band is managed below, not by the alert-patch loop (its key would not survive a
176
+ // keep pass built only from the feed's alert keys).
177
+ keep.add("doctor-band");
174
178
  for (const el of Array.from(box.children)) if (!keep.has(el.dataset.key)) el.remove();
175
179
  alerts.forEach((a, i) => {
176
180
  const parts = alertParts(a, a.key === "build" && d && d.build && d.build.stale);
@@ -190,6 +194,27 @@ export const GUI_CLIENT_TEAM_JS = String.raw ` // ---- sidebar: project, team s
190
194
  detail.hidden = !a.detail;
191
195
  if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null);
192
196
  });
197
+ // The band's one link to where every alert's detail lives: the Settings view's Health check
198
+ // card (#doctorcard), which runs the same checks as tumwater doctor. It rides the existing
199
+ // view/hash routing (data-act='view' data-arg='settings' -> location.hash; gui-client-boot).
200
+ // It appears only while the band has alerts — an empty feed paints nothing, so there is no
201
+ // "0 alerts" copy.
202
+ const doctorEl = box.querySelector("[data-key='doctor-band']");
203
+ if (alerts.length > 0) {
204
+ if (!doctorEl) {
205
+ const band = document.createElement("div");
206
+ band.dataset.key = "doctor-band";
207
+ band.className = "alert t-blue";
208
+ band.innerHTML = "<span class='alert-icon'>" + icon("settings") + "</span>" +
209
+ "<div class='alert-body'><div class='alert-title'>Details in Doctor</div>" +
210
+ "<div class='alert-detail'>The Settings view's Health check card keeps each alert's detail.</div></div>" +
211
+ "<div class='alert-actions'><button type='button' class='btn btn-sm' data-act='view' data-arg='settings' " +
212
+ "title='Open the Health check card'>Open Health check</button></div>";
213
+ box.appendChild(band);
214
+ }
215
+ } else if (doctorEl) {
216
+ doctorEl.remove();
217
+ }
193
218
  const n = needsYou(alerts);
194
219
  const title = (n ? "(" + n + ") " : "") + (d && d.project ? d.project + " · " : "") + "tumwater";
195
220
  if (document.title !== title) document.title = title;
@@ -198,6 +223,7 @@ export const GUI_CLIENT_TEAM_JS = String.raw ` // ---- sidebar: project, team s
198
223
  if (badge.textContent !== String(n)) badge.textContent = String(n);
199
224
  badge.title = plural(n, "thing needs", "things need") + " you";
200
225
  }
226
+ // alerts:end
201
227
 
202
228
  // ---- today's progress ----
203
229
  // /api/report?days=1, re-read when new events arrive (throttled) — never on a timer.