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
@@ -17,6 +17,9 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
17
17
  // ---- views ----
18
18
  // view-routing:start
19
19
  const VIEWS = { team: "team-view", history: "history", usage: "report", failures: "failures", pending: "pending", settings: "settings-view" }; // vocabulary-ok: view ids
20
+ // The team band's "Open Health check" control navigates to Settings and asks for the
21
+ // #doctorcard scrolled into view once the pane paints (see runAct's view branch).
22
+ let pendingDoctorScroll = false;
20
23
  function switchView(v) {
21
24
  if (v === "fleet") v = "team"; // vocabulary-ok: legacy dashboard hash, still routed
22
25
  if (!VIEWS[v]) v = "team";
@@ -34,6 +37,13 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
34
37
  if (v === "failures") fetchFailures();
35
38
  if (v === "pending") fetchPending();
36
39
  if (v === "settings") { fetchSettings(); fetchDoctor(); fetchRetirable(); fetchBrief(); }
40
+ // The band's "Open Health check" control asks for #doctorcard scrolled into view once the
41
+ // Settings pane paints; buildSettingsView ran synchronously inside fetchSettings above, so
42
+ // the card exists now. Only that control sets the flag, so a normal Settings tab never moves.
43
+ if (pendingDoctorScroll && v === "settings" && $("doctorcard")) {
44
+ pendingDoctorScroll = false;
45
+ $("doctorcard").scrollIntoView({ behavior: "smooth", block: "start" });
46
+ }
37
47
  }
38
48
  // Tabs are plain #fragment links, so Back/Forward and bookmarks work; re-clicking the open
39
49
  // tab refetches its data. #role/<role> opens that role's drawer (over Team on a fresh load),
@@ -83,12 +93,12 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
83
93
  // The Health check card's Re-run button: runs the doctor checks again.
84
94
  document.addEventListener("click", (ev) => {
85
95
  if (clickClosest(ev, "button[data-action='doctor-rerun']")) { ev.preventDefault(); fetchDoctor(); }
86
- if (clickClosest(ev, "button[data-action='doctor-reprobe']")) { ev.preventDefault(); reprobeDoctorClick(); }
87
96
  if (clickClosest(ev, "button[data-action='doctor-kill-orphans']")) { ev.preventDefault(); killOrphansClick(); }
88
97
  if (clickClosest(ev, "button[data-action='doctor-drop-broken-pins']")) { ev.preventDefault(); dropBrokenPinsClick(); }
89
98
  if (clickClosest(ev, "button[data-action='doctor-commit-backlog']")) { ev.preventDefault(); commitBacklogClick(); }
90
99
  if (clickClosest(ev, "button[data-action='doctor-resync-install']")) { ev.preventDefault(); resyncInstallClick(); }
91
100
  if (clickClosest(ev, "button[data-action='doctor-retire-leftovers']")) { ev.preventDefault(); retireLeftoversClick(); }
101
+ if (clickClosest(ev, "button[data-action='doctor-archive-backlog']")) { ev.preventDefault(); archiveBacklogClick(); }
92
102
  if (clickClosest(ev, "button[data-action='notify-test']")) { ev.preventDefault(); testNotify(); }
93
103
  const retire = clickClosest(ev, "button[data-action='retire']");
94
104
  if (retire) { ev.preventDefault(); confirmRetire(retire.dataset.role); }
@@ -103,11 +113,18 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
103
113
  });
104
114
  // ---- global actions: alert and tile buttons, role names anywhere, questions ----
105
115
  // run-act:start
116
+ // The backlog drawer rows, the refused-banner card action and the mention action all receive
117
+ // their target as a "file:index" string in a button's data-act arg; one place parses that
118
+ // shape into the file name and the 0-based row number every consumer dereferences, so the
119
+ // Number() coercion and split cannot drift across the seven call sites.
120
+ function entryRef(arg) {
121
+ const [file, index] = arg.split(":");
122
+ return { file, index: Number(index) };
123
+ }
106
124
  // The backlog drawer rows resolve a list entry's title from the last polled status by its
107
- // backlog file and 0-based position (entry-cancel, entry-edit, and the mention action). The
108
- // call sites keep their arg's "file:index" split because that shape is theirs alone.
125
+ // backlog file and 0-based position (entry-cancel, entry-edit, and the mention action).
109
126
  function entryTitleAt(file, index) {
110
- return ((lastStatus && lastStatus[file]) || [])[Number(index)];
127
+ return ((lastStatus && lastStatus[file]) || [])[index];
111
128
  }
112
129
  // The backlog drawer rows carry their file as "bugs" or "plans"; three other words derive
113
130
  // from it — the singular kind the drawer's POST carries, the display filename the toasts name,
@@ -116,6 +133,20 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
116
133
  function kindOf(file) { return file === "bugs" ? "bug" : "plan"; }
117
134
  function entryFile(file) { return file === "bugs" ? "BUGS.md" : "PLANS.md"; }
118
135
  function pickupRole(file) { return file === "bugs" ? "bugfix" : "feature"; }
136
+ // The typed-number guard the interval/instances save arms share: a type=number input
137
+ // reports "" both for a field cleared on purpose (its default) and for text the browser
138
+ // rejected (badInput), and still accepts exponent/hex spellings Number() reads that no one
139
+ // means here — so each arm maps badInput to one message and a disallowed shape to another,
140
+ // then reads the trimmed raw value. Returns undefined after flashing, so the save must
141
+ // stop; the caller turns the empty raw value into its own null (clear-the-override) and
142
+ // Number() otherwise. The team cap save does not use this — parseBudgetField owns that
143
+ // field's own 0-or-more bound and empty→0 policy (see parseBudgetField).
144
+ function typedNumberValue(input, badInputMsg, shapeMsg) {
145
+ if (input.validity && input.validity.badInput) { showFlash("error: " + badInputMsg); return undefined; }
146
+ const raw = input.value.trim();
147
+ if (raw !== "" && !/^\d*\.?\d+$/.test(raw)) { showFlash("error: " + shapeMsg); return undefined; }
148
+ return raw;
149
+ }
119
150
  function runAct(act, arg) {
120
151
  // Six drawer edits end identically: clear the edit's in-progress flag, reload the role
121
152
  // row, and toast the server's message (or a fallback when it sends none). Only the flag to
@@ -128,7 +159,13 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
128
159
  };
129
160
  if (act === "role") openRole(arg);
130
161
  else if (act === "close") closeDrawer();
131
- else if (act === "view") location.hash = arg;
162
+ else if (act === "view") {
163
+ // The band's "Open Health check" control routes to the Settings view at its Health check
164
+ // card (arg "settings") and asks switchView to scroll #doctorcard into view after it
165
+ // paints; any other view arg scrolls nowhere.
166
+ if (arg === "settings") pendingDoctorScroll = true;
167
+ location.hash = arg;
168
+ }
132
169
  else if (act === "budget") openBudgetEditor();
133
170
  else if (act === "resume") setTeamPause(false);
134
171
  else if (act === "copy") copyText(arg);
@@ -146,9 +183,9 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
146
183
  else if (act === "backlog" && arg) {
147
184
  // The refused banner's action names the entry it points at as file:index; open the
148
185
  // card on that file's tab and land on the row, the way a .backloglink click does.
149
- const [file, index] = arg.split(":");
186
+ const { file, index } = entryRef(arg);
150
187
  setBacklogTab(file);
151
- toggleEntry(file, Number(index));
188
+ toggleEntry(file, index);
152
189
  }
153
190
  $("backlogcard").scrollIntoView({ behavior: "smooth", block: "start" });
154
191
  } else if (act === "answer") {
@@ -167,35 +204,35 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
167
204
  : "That question was already answered or moved — refreshed";
168
205
  });
169
206
  } else if (act === "entry-cancel") {
170
- const [file, index] = arg.split(":");
207
+ const { file, index } = entryRef(arg);
171
208
  const title = entryTitleAt(file, index);
172
209
  if (title === undefined) return;
173
210
  if (!entryCancelArmed(arg, title)) { armEntryCancel(arg, title); return; }
174
211
  disarmEntryCancel();
175
- postAction("/api/backlog-cancel", { kind: kindOf(file), position: Number(index) + 1, title }, (d) => {
212
+ postAction("/api/backlog-cancel", { kind: kindOf(file), position: index + 1, title }, (d) => {
176
213
  closeDrawer();
177
214
  return d && d.status === "cancelled"
178
215
  ? "Cancelled “" + splitTitle(title).title + "” from " + entryFile(file) + uncommittedTail(d) + (d.workingNote ? " — " + d.workingNote : "") + (d.releasedNote ? " — " + d.releasedNote : "")
179
216
  : "That entry was already removed or moved — refreshed";
180
217
  });
181
218
  } else if (act === "entry-release") {
182
- const [file, index] = arg.split(":");
219
+ const { file, index } = entryRef(arg);
183
220
  const title = entryTitleAt(file, index);
184
221
  if (title === undefined) return;
185
- postAction("/api/backlog-release", { kind: kindOf(file), position: Number(index) + 1, title }, (d) => {
222
+ postAction("/api/backlog-release", { kind: kindOf(file), position: index + 1, title }, (d) => {
186
223
  closeDrawer();
187
224
  if (d && d.status === "released") {
188
- return "Released “" + splitTitle(title).title + "” in " + entryFile(file) + " — lifted " + (d.lifted === "refused" ? "the Refused note" : d.lifted === "waiting-for-failure" ? "the Waiting-for-the-next-failure note" : "the held-for-operator note") + " — " + (d.heldNote || pickupRole(file) + " will pick it up") + uncommittedTail(d); // vocabulary-ok: the held-for-operator note's release wording
225
+ return "Released “" + splitTitle(title).title + "” in " + entryFile(file) + " — lifted the Refused note — " + (d.heldNote || pickupRole(file) + " will pick it up") + uncommittedTail(d);
189
226
  }
190
227
  return d && d.status === "not_refused"
191
- ? "That entry carries no Refused, Waiting for the next failure, or held-for-operator note — refreshed" // vocabulary-ok: the held-for-operator note's release wording
228
+ ? "That entry carries no Refused note — refreshed"
192
229
  : "That entry was removed or moved — refreshed";
193
230
  });
194
231
  } else if (act === "entry-top") {
195
- const [file, index] = arg.split(":");
232
+ const { file, index } = entryRef(arg);
196
233
  const title = entryTitleAt(file, index);
197
234
  if (title === undefined) return;
198
- postAction("/api/backlog-top", { kind: kindOf(file), position: Number(index) + 1, title }, (d) => {
235
+ postAction("/api/backlog-top", { kind: kindOf(file), position: index + 1, title }, (d) => {
199
236
  closeDrawer();
200
237
  if (d && d.status === "moved_to_top") {
201
238
  return "Moved “" + splitTitle(title).title + "” to the top of " + entryFile(file) + " — " + (d.heldNote || pickupRole(file) + " takes it next") + uncommittedTail(d);
@@ -205,14 +242,14 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
205
242
  : "That entry was removed or moved — refreshed";
206
243
  });
207
244
  } else if (act === "entry-edit") {
208
- const [file, index] = arg.split(":");
245
+ const { file, index } = entryRef(arg);
209
246
  const title = entryTitleAt(file, index);
210
247
  if (title !== undefined) startEntryEdit(arg, title);
211
248
  } else if (act === "entry-edit-discard") {
212
249
  stopEntryEdit();
213
250
  refreshEntry();
214
251
  } else if (act === "entry-edit-save") {
215
- const [file, index] = arg.split(":");
252
+ const { file, index } = entryRef(arg);
216
253
  // The title captured when editing began, not the list's current one: a shifted list must
217
254
  // reach the server's title check as a mismatch rather than silently retarget the save.
218
255
  const title = entryEditing && entryEditing.key === arg ? entryEditing.title : undefined;
@@ -221,7 +258,7 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
221
258
  const newTitle = titleBox ? titleBox.value.trim() : "";
222
259
  if (title === undefined) return;
223
260
  if (!newTitle) { showFlash("Write a title first"); return; }
224
- postAction("/api/backlog-edit", { kind: kindOf(file), position: Number(index) + 1, title, newTitle, newBody: bodyBox ? bodyBox.value : "" }, (d) => {
261
+ postAction("/api/backlog-edit", { kind: kindOf(file), position: index + 1, title, newTitle, newBody: bodyBox ? bodyBox.value : "" }, (d) => {
225
262
  stopEntryEdit();
226
263
  closeDrawer();
227
264
  return d && d.status === "edited"
@@ -279,23 +316,16 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
279
316
  } else if (act === "interval-save") {
280
317
  const input = $("intervaleditinput");
281
318
  if (!input) return;
282
- // A type=number input reports "" both for a field cleared on purpose (the team
283
- // default) and for text the browser rejected ("$2"); validity.badInput tells them
284
- // apart, so a typo is an error instead of silently clearing the override — the cap
285
- // arm's guard, in scope from the operator blob concatenated earlier.
286
- if (input.validity && input.validity.badInput) {
287
- showFlash("error: the interval must be a number of 0 or more — leave it empty for the team default");
288
- return;
289
- }
290
- const raw = input.value.trim();
291
- // A type=number input also accepts exponent/hex spellings the browser parses ("1e2" →
292
- // 100, "0x10" → 16), so badInput alone cannot reject them — the plain-decimal shape rule
293
- // the CLI's --min-run-interval and the TUI Ctrl+S editor apply catches them here too.
294
- if (raw !== "" && !/^\d*\.?\d+$/.test(raw)) {
295
- showFlash("error: the interval must be a plain second count like 300, 0.5, or .5 — no +/-, exponent, or hex notation");
296
- return;
297
- }
298
- const value = raw === "" ? null : Number(raw);
319
+ // typedNumberValue holds the badInput and plain-decimal shape guards; undefined means
320
+ // one flashed and the save stops. The empty raw value posts null (the server clears the
321
+ // override); "1e2" and "0x10" are rejected as shape, not silently read as a count.
322
+ const typed = typedNumberValue(
323
+ input,
324
+ "the interval must be a number of 0 or more — leave it empty for the team default",
325
+ "the interval must be a plain second count like 300, 0.5, or .5 — no +/-, exponent, or hex notation",
326
+ );
327
+ if (typed === undefined) return;
328
+ const value = typed === "" ? null : Number(typed);
299
329
  postAction("/api/role-interval", { role: arg, minRunIntervalSeconds: value }, settingDone(() => { intervalEditing = null; }, "Min run interval updated"));
300
330
  } else if (act === "instances-edit") {
301
331
  startInstancesEdit(arg, drawerRole?.view?.instances ?? "");
@@ -304,27 +334,18 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
304
334
  } else if (act === "instances-save") {
305
335
  const input = $("instanceseditinput");
306
336
  if (!input) return;
307
- // A type=number input reports "" both for a field cleared on purpose (the default 1)
308
- // and for text the browser rejected ("$2"); validity.badInput tells them apart, so a
309
- // typo is an error instead of silently clearing the count — the interval arm's guard,
310
- // in scope from the operator blob concatenated earlier. (A rejected "2.5" is a real
311
- // number the browser accepts, so it posts and validateConfig's 1–8 integer rule 400s
312
- // it, flashed through postAction's error path.)
313
- if (input.validity && input.validity.badInput) {
314
- showFlash("error: the instances must be an integer from 1 to 8 — leave it empty for the default 1");
315
- return;
316
- }
317
- const raw = input.value.trim();
318
- // A type=number input also accepts exponent spellings the browser parses ("2e0" → 2), so
319
- // badInput alone cannot reject them — the plain-decimal shape rule the CLI's --instances
320
- // and the TUI Ctrl+G editor apply catches them here too, so a silent misread is an error
321
- // instead of silently writing 2. (A rejected "2.5" is a real number the browser accepts,
322
- // so it posts and validateConfig's 1–8 integer rule 400s it.)
323
- if (raw !== "" && !/^\d*\.?\d+$/.test(raw)) {
324
- showFlash("error: the instances must be a plain integer like 2 — no +/-, exponent, or hex notation — leave it empty for the default 1");
325
- return;
326
- }
327
- const value = raw === "" ? null : Number(raw);
337
+ // typedNumberValue holds the badInput and plain-decimal shape guards; undefined means
338
+ // one flashed and the save stops. The empty raw value posts null (the server restores
339
+ // the default 1); "2e0" is rejected as shape, not silently read as 2. (A rejected "2.5"
340
+ // is a real number the browser accepts, so it posts and validateConfig's 1–8 integer
341
+ // rule 400s it, flashed through postAction's error path.)
342
+ const typed = typedNumberValue(
343
+ input,
344
+ "the instances must be an integer from 1 to 8 — leave it empty for the default 1",
345
+ "the instances must be a plain integer like 2 — no +/-, exponent, or hex notation — leave it empty for the default 1",
346
+ );
347
+ if (typed === undefined) return;
348
+ const value = typed === "" ? null : Number(typed);
328
349
  postAction("/api/role-instances", { role: arg, instances: value }, settingDone(() => { instancesEditing = null; }, "Instances updated"));
329
350
  } else if (act === "instructions-edit") {
330
351
  startInstructionsEdit(arg, drawerRole?.view?.instructions ?? "");
@@ -369,7 +390,7 @@ export const GUI_CLIENT_BOOT_JS = String.raw ` // One tail for the "…written
369
390
  return d && d.message ? d.message : "Review tier updated";
370
391
  });
371
392
  } else if (act === "mention") {
372
- const [file, index] = arg.split(":");
393
+ const { file, index } = entryRef(arg);
373
394
  const title = entryTitleAt(file, index);
374
395
  if (title !== undefined) draftForDirector("About “" + splitTitle(title).title + "”: ");
375
396
  }
@@ -23,6 +23,10 @@ export const GUI_CLIENT_DRAWER_CONFIG_JS = String.raw `
23
23
  // pi provider/model cases and a budget pair (whose provider already holds the backend) stay
24
24
  // byte-identical; only a model-fallback non-pi pair (modelFallbackView drops pi's provider
25
25
  // for a non-pi backend) gains the namespace the CLI already shows.
26
+ // Local wall-clock HH:MM for the role-cap forecast's ~cap at stamp — the drawer is browser
27
+ // code and cannot import ../text/datetime.js (the quietWindowEnd precedent's own note), so
28
+ // it routes through the bundle's shared clockHm in FORMAT_JS, the same helper the operator
29
+ // view's window clock uses.
26
30
  const fallbackLabel = (fb, fbDefault) => {
27
31
  const agentBackend = fb.backend !== undefined && fb.backend !== "pi" ? fb.backend : undefined;
28
32
  if (fb.model !== undefined) {
@@ -32,10 +36,10 @@ export const GUI_CLIENT_DRAWER_CONFIG_JS = String.raw `
32
36
  if (fb.provider !== undefined) return fb.provider;
33
37
  return fbDefault;
34
38
  };
35
- const singleLine = (s) => String(s).replace(/\s+/g, " ").trim();
36
39
  // A verbatim value's single-line clamped rendering (80 chars + ellipsis) with the full
37
40
  // text riding the title tooltip — the same clamp+tooltip idiom the run summaries use.
38
- const clamp = (t) => { const one = singleLine(t); return one.length > 80 ? one.slice(0, 80) + "…" : one; };
41
+ // The single-line squash rides the shared format-region squash (text.ts's browser twin).
42
+ const clamp = (t) => { const one = squash(t); return one.length > 80 ? one.slice(0, 80) + "…" : one; };
39
43
  const rows = [];
40
44
  // A role that runs on pi's own saved default carries no provider/model but a piDefault
41
45
  // selector (rolePayload's resolvedModelFields leaves both undefined), and the CLI's
@@ -222,7 +226,8 @@ export const GUI_CLIENT_DRAWER_CONFIG_JS = String.raw `
222
226
  const editing = capEditing && capEditing.role === role;
223
227
  const capBody = dc
224
228
  ? fmtUsd(Number(dc.spentUsd) || 0) + " of " + fmtUsd(Number(dc.capUsd) || 0) + " spent today (" + (dc.over ? "holding now — no new runs until local midnight" : "not holding") + ")" +
225
- (Array.isArray(dc.roleIds) && dc.roleIds.length > 1 ? " · shared by " + dc.roleIds.join(", ") : "")
229
+ (Array.isArray(dc.roleIds) && dc.roleIds.length > 1 ? " · shared by " + dc.roleIds.join(", ") : "") +
230
+ (typeof view.dailyCapHitAt === "number" ? " · ~cap at " + clockHm(view.dailyCapHitAt) : "")
226
231
  : "no cap";
227
232
  const capControl = editing
228
233
  ? "<input id='capeditinput' type='number' min='0' step='0.01' value='" + (capEditing.value ? String(capEditing.value) : "") + "' placeholder='no cap'>" +
@@ -173,7 +173,7 @@ export const GUI_CLIENT_DRAWER_JS = String.raw ` let drawer = null; // { kind:
173
173
  : "in " + next;
174
174
  const runWord = info.live ? "this run" : "last run";
175
175
  const stats = "<dl class='kv'>" + kvRow("Commits", String(l.commits)) + kvRow("Runs", String(l.runs)) +
176
- kvRow("Tokens, " + runWord, fmtTokens(l.generated)) + kvRow("Peak context, " + runWord, fmtTokens(l.peakCtx)) +
176
+ kvRow("Tokens, " + runWord, fmtTokens(l.generated)) + kvRow("Peak context, " + runWord, (l.peakCtxLabel || fmtTokens(l.peakCtx))) +
177
177
  kvRow("Spent today", fmtUsd(l.todayUsd)) + kvRow("Spent in total", fmtUsd(l.costUsd)) +
178
178
  kvRow("Last run ended", l.lastRunEndedAt ? fmtAgo(l.lastRunEndedAt) : "never", fmtLastRun(l.lastRunEndedAt)) +
179
179
  kvRow("Last merged", l.lastMergedAt ? fmtAgo(l.lastMergedAt) : "never") +
@@ -290,6 +290,11 @@ export const GUI_CLIENT_DRAWER_JS = String.raw ` let drawer = null; // { kind:
290
290
  // order the prompt block and "tumwater role qa" use): each flow's last exercise and result.
291
291
  // Ages ride fmtAgo like the other list rows; a flow never exercised reads "never exercised".
292
292
  const flowCoverage = Array.isArray(view.flowCoverage) ? view.flowCoverage : null;
293
+ // The feedback observer's per-install filing ledger (rolePayload's filings, the same
294
+ // filingRows order the prompt block and "tumwater role feedback" use): each issue this
295
+ // install filed, newest-first, its number, title, and age. Ages ride fmtAgo like the other
296
+ // list rows.
297
+ const filings = Array.isArray(view.filings) ? view.filings : null;
293
298
  const canEdit = r.role !== "director";
294
299
  const editing = canEdit && noteEditing && noteEditing.role === r.role;
295
300
  const buttons = !canEdit ? "" : "<span style='margin-left:auto'><button type='button' class='btn btn-sm' data-act='note-edit' data-arg='" + esc(r.role) + "'>Edit</button>" +
@@ -317,6 +322,17 @@ export const GUI_CLIENT_DRAWER_JS = String.raw ` let drawer = null; // { kind:
317
322
  : "passed");
318
323
  return "<li><span class='when'>" + esc(flow) + "</span><span class='clamp1'>" + esc(text) + "</span></li>";
319
324
  }).join("") + "</ul></section>" : "") +
325
+ // The feedback filing ledger (rolePayload's filings), the drawer twin of the CLI's
326
+ // "## Filings" section — each issue this install filed, newest-first, its number, title,
327
+ // and age. No backticks allowed here (this whole script is one big String.raw template).
328
+ (filings && filings.length ? "<section><div class='sec-head'><h3>Filings" +
329
+ "<span class='muted' style='font-size:13px'>· " + filings.length + "</span></h3></div>" +
330
+ "<ul class='run-list'>" + filings.map((f) => {
331
+ const n = typeof f.issue === "number" ? String(f.issue) : "";
332
+ const title = typeof f.title === "string" ? f.title : "";
333
+ return "<li><span class='when'>" + esc(fmtAgo(Number(f.filedAt))) + "</span>" +
334
+ "<span class='clamp1'>#" + esc(n) + " " + esc(title) + "</span></li>";
335
+ }).join("") + "</ul></section>" : "") +
320
336
  // The role's recent run outcomes (rolePayload's recentOutcomes, read from the event
321
337
  // log), rendered like the Recent runs list above: each with its relative age, result
322
338
  // label, summary, and duration. Mirrors the Recent run outcomes section of
@@ -476,7 +492,7 @@ export const GUI_CLIENT_DRAWER_JS = String.raw ` let drawer = null; // { kind:
476
492
  "<button type='button' class='btn btn-sm btn-primary' data-act='answer-record' data-arg='" + index + "'>" + icon("chat") + "Record answer</button>" +
477
493
  "<button type='button' class='btn btn-sm' data-act='answer' data-arg='" + index + "'>Ask the director instead</button>"
478
494
  : "<button type='button' class='btn btn-sm' data-act='mention' data-arg='" + esc(file + ":" + index) + "'>" + icon("chat") + "Ask the director about this</button>" +
479
- (d.refused || d.waiting || d.heldForOperator ? "<button type='button' class='btn btn-sm btn-primary' data-act='entry-release' data-arg='" + esc(file + ":" + index) + "'>Release this entry</button>" : "") +
495
+ (d.refused ? "<button type='button' class='btn btn-sm btn-primary' data-act='entry-release' data-arg='" + esc(file + ":" + index) + "'>Release this entry</button>" : "") +
480
496
  (index > 0 ? "<button type='button' class='btn btn-sm' data-act='entry-top' data-arg='" + esc(file + ":" + index) + "'>Move to top</button>" : "") +
481
497
  "<button type='button' class='btn btn-sm' data-act='entry-edit' data-arg='" + esc(file + ":" + index) + "'>Edit this entry</button>" +
482
498
  "<button type='button' class='btn btn-sm btn-danger' data-act='entry-cancel' data-arg='" + esc(file + ":" + index) + "'>" + (entryCancelArmed(file + ":" + index) ? "Click again to cancel" : "Cancel this entry") + "</button>";
@@ -28,17 +28,12 @@ export const GUI_CLIENT_FEED_JS = String.raw `
28
28
  // Whether the window can step one more length back: the next earlier window's sinceMs must
29
29
  // stay within the server's cap.
30
30
  const feedCanStepEarlier = (windowMs, step) => windowMs !== 0 && (step + 2) * windowMs <= FEED_MAX_MS;
31
- // "1 hour"/"N hours", or "1 day"/"N days" for a whole number of days (histSpan's rule; that
32
- // helper lives in another region this scope does not load).
33
- function feedSpan(ms) {
34
- const d = ms % 86_400_000 === 0;
35
- const n = d ? ms / 86_400_000 : ms / 3_600_000;
36
- return n + (d ? " day" : " hour") + (n === 1 ? "" : "s");
37
- }
31
+ // The window bounds' lengths render through the format region's shared timeSpan ("1 hour"/
32
+ // "N hours", or "1 day"/"N days" for a whole number of days).
38
33
  function feedWindowPhrase() {
39
34
  const w = Number(feedWindow);
40
35
  return feedStep
41
- ? "between " + feedSpan((feedStep + 1) * w) + " and " + feedSpan(feedStep * w) + " ago"
36
+ ? betweenAgo((feedStep + 1) * w, feedStep * w)
42
37
  : (FEED_WINDOWS.find((x) => x[0] === feedWindow) || [])[2];
43
38
  }
44
39
  let feedFilter = recall("feed") === "all" ? "all" : "notable";
@@ -168,6 +168,12 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
168
168
  function histDetailKey(role, run) {
169
169
  return role + "#" + run;
170
170
  }
171
+ // The one ok entry a /api/run answer becomes: text plus the three optional detail fields,
172
+ // each guarded to its own type so a server that omits one still renders a stable card. Both
173
+ // the newest-failure fetch and the per-row fetch build it, so the two cannot drift.
174
+ function histDetailOk(d) {
175
+ return { state: "ok", text: d.text, stat: typeof d.stat === "string" ? d.stat : "", revertSha: typeof d.revertSha === "string" ? d.revertSha : null, hasCommit: d.hasCommit === true };
176
+ }
171
177
  // The pinned newest-failure card: the Newest failure toolbar button opens the team's newest
172
178
  // failing run (GET /api/run?last=1&result=problems, no role — the role-less last endpoint)
173
179
  // as a card above the table, regardless of the current role, window or count, and its ×
@@ -188,7 +194,7 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
188
194
  "/api/run answered the newest failing run without a text field — the running tumwater may not match this dashboard",
189
195
  );
190
196
  const key = histDetailKey(d.role, d.run);
191
- histDetails.set(key, { state: "ok", text: d.text, stat: typeof d.stat === "string" ? d.stat : "", revertSha: typeof d.revertSha === "string" ? d.revertSha : null, hasCommit: d.hasCommit === true });
197
+ histDetails.set(key, histDetailOk(d));
192
198
  histNewest = { state: "ok", key };
193
199
  } catch (err) {
194
200
  histNewest = { state: "error", message: errMessage(err, "Newest failure unavailable") };
@@ -449,7 +455,7 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
449
455
  throw new Error(
450
456
  "/api/run answered " + role + " run " + run + " without a text field — the running tumwater may not match this dashboard",
451
457
  );
452
- histDetails.set(key, { state: "ok", text: d.text, stat: typeof d.stat === "string" ? d.stat : "", revertSha: typeof d.revertSha === "string" ? d.revertSha : null, hasCommit: d.hasCommit === true });
458
+ histDetails.set(key, histDetailOk(d));
453
459
  } catch (err) {
454
460
  histDetails.set(key, { state: "error", message: errMessage(err, "Run detail unavailable") });
455
461
  }
@@ -508,23 +514,17 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
508
514
  // The server's LOGS_SINCE_MAX_MS (src/events/event-window.ts), the cap /api/history's sinceMs
509
515
  // rides; the two must agree (a test pins it).
510
516
  const HIST_MAX_MS = 604_800_000;
511
- // A whole-hour length as "1 day"/"N days" when it is a whole number of days, else hours.
512
- function histSpan(ms) {
513
- const d = ms % 86_400_000 === 0;
514
- const n = d ? ms / 86_400_000 : ms / 3_600_000;
515
- return n + (d ? " day" : " hour") + (n === 1 ? "" : "s");
516
- }
517
+ // The window bounds' lengths render through the format region's shared timeSpan.
517
518
  // Whether the window can step one more length back: the next earlier window's sinceMs must
518
519
  // stay under the server's cap.
519
520
  function histCanStepEarlier(sinceKey, step) {
520
521
  const w = HIST_WINDOWS[sinceKey];
521
522
  return Boolean(w) && (step + 2) * w[0] <= HIST_MAX_MS;
522
523
  }
523
- // The window in words: the trailing phrase at step 0, else the bounds in cmdLogs' wording.
524
524
  function histWindowPhrase(sinceKey, step) {
525
525
  const w = HIST_WINDOWS[sinceKey];
526
526
  if (!w) return "";
527
- return step ? "between " + histSpan((step + 1) * w[0]) + " and " + histSpan(step * w[0]) + " ago" : w[1];
527
+ return step ? betweenAgo((step + 1) * w[0], step * w[0]) : w[1];
528
528
  }
529
529
  // The note above a stepped window's table; empty at step 0 or for an unknown key.
530
530
  function historyStepHtml(sinceKey, step) {
@@ -534,7 +534,7 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
534
534
  function histWindowOptions() {
535
535
  return Object.keys(HIST_WINDOWS).map((k) => {
536
536
  const label = HIST_WINDOWS[k][1];
537
- return "<option value='" + k + "'>" + label.charAt(0).toUpperCase() + label.slice(1) + "</option>";
537
+ return "<option value='" + k + "'>" + capitalize(label) + "</option>";
538
538
  }).join("");
539
539
  }
540
540
  // The server's sparse-window note above the table; empty for no note.
@@ -586,7 +586,7 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
586
586
  "<td title='" + esc(r.time) + "'><span class='clamp1'>" + esc(fmtAgo(r.ts)) + "</span></td>" +
587
587
  "<td class='mono'>" + esc(roleLabel(r.role)) + "</td>" +
588
588
  "<td class='c-run num muted'>#" + esc(r.run) + "</td>" +
589
- "<td><span class='res t-" + res.tone + "'>" + esc(res.label) + "</span></td>" +
589
+ "<td><span class='res t-" + res.tone + "'>" + esc(res.label) + "</span>" + (r.cutOff === true ? " <span class='muted' title='cut off at the context ceiling'>cut off</span>" : "") + "</td>" +
590
590
  "<td class='c-model'><span class='clamp1' title='" + esc(r.model || "") + "'>" + (r.model ? esc(r.model) : "<span class='muted'>—</span>") + "</span></td>" +
591
591
  "<td class='c-detail'><span class='clamp2' title='" + esc(r.detail) + "'>" + (r.detail ? esc(r.detail) : "<span class='muted'>—</span>") + "</span></td>" +
592
592
  "<td class='num c-dur'>" + durCell(r.durationMs) + "</td>" +
@@ -598,8 +598,8 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
598
598
  // history-table:end
599
599
  // history-summary:start
600
600
  // The Summary table: one row per role plus the total, in the same column set the CLI's
601
- // history --summary prints (runs, changes, none, refused, problems, tokens, cost, time,
602
- // last change), so the dashboard and the CLI cannot drift column for column. Every number
601
+ // history --summary prints (runs, changes, none, refused, problems, tokens, peak ctx, cost,
602
+ // time, last change), so the dashboard and the CLI cannot drift column for column. Every number
603
603
  // comes from the
604
604
  // server's summary (built on the shared summarizeRunRows) — nothing here re-derives a
605
605
  // tally — and the cost/token cells reuse the page's report formatters. The time cell is the
@@ -612,10 +612,10 @@ export const GUI_CLIENT_HISTORY_JS = String.raw ` // The counts the "Runs to sh
612
612
  "<tr" + (e.total ? " class='hist-total'" : "") + ">" +
613
613
  "<td class='mono'>" + (e.labelHtml || esc(e.label)) + "</td>" +
614
614
  cell(r, "runs") + cell(r, "changes") + cell(r, "none") + cell(r, "refused") + cell(r, "problems") +
615
- cell(r, "tokens", (n) => fmtTokens(Number(n))) + cell(r, "costUsd", (n) => fmtUsd(Number(n))) +
615
+ cell(r, "tokens", (n) => fmtTokens(Number(n))) + cell(r, "peakContextTokens", (n) => (n === undefined || n === null ? "—" : fmtTokens(Number(n)))) + cell(r, "costUsd", (n) => fmtUsd(Number(n))) +
616
616
  "<td class='num'>" + durCell(r.durationMs) + "</td>" +
617
617
  "<td>" + esc(r.lastChangeTs === null || r.lastChangeTs === undefined ? "—" : fmtAgo(Number(r.lastChangeTs))) + "</td></tr>"); }).join("");
618
- return "<div class='table-wrap'><table class='table history'><thead><tr><th>" + esc(header) + "</th><th class='num'>Runs</th><th class='num'>Merged</th><th class='num'>None</th><th class='num'>Refused</th><th class='num'>Problems</th><th class='num'>Tokens</th><th class='num'>Cost</th><th class='num'>Time</th><th>Last change</th></tr></thead><tbody>" +
618
+ return "<div class='table-wrap'><table class='table history'><thead><tr><th>" + esc(header) + "</th><th class='num'>Runs</th><th class='num'>Merged</th><th class='num'>None</th><th class='num'>Refused</th><th class='num'>Problems</th><th class='num'>Tokens</th><th class='num'>Peak ctx</th><th class='num'>Cost</th><th class='num'>Time</th><th>Last change</th></tr></thead><tbody>" +
619
619
  rowsHtml + "</tbody></table></div>";
620
620
  }
621
621
  function histSummaryHtml(summary) {
@@ -17,11 +17,11 @@ export const GUI_CLIENT_MODEL_JS = String.raw ` // view-model:start
17
17
  // How a role's phase label (status-model.ts rolePhase) reads here: a status word and tone for
18
18
  // its pill, whether it is live work, and the detail the label carried after its first word.
19
19
  function phaseInfo(phase) {
20
- // status-model.ts rolePhase appends a " · no change ×N" streak suffix to a resting idle
21
- // cell's label. Strip it so the exact/startsWith matches and the parsed detail see the
22
- // plain phase; phaseDetailBase re-appends the streak once itself from the payload's
23
- // noChangeStreak, so the GUI renders it exactly once.
24
- const p = String(phase || "").replace(/ · no change ×\d+$/, "");
20
+ // status-model.ts rolePhase appends a " · no change ×N" or " · cut off ×N" streak suffix
21
+ // to a resting idle cell's label. Strip it so the exact/startsWith matches and the parsed
22
+ // detail see the plain phase; phaseDetailBase re-appends the streak once itself from the
23
+ // payload's noChangeStreak/cutOffStreak, so the GUI renders it exactly once.
24
+ const p = String(phase || "").replace(/ · (?:no change|cut off) ×\d+$/, "");
25
25
  const after = (n) => p.slice(n).replace(/^[\s·,]+/, "");
26
26
  if (p.startsWith("director working")) return { key: "working", label: "Working (director)", tone: "blue", live: true, detail: after(16) };
27
27
  if (p.startsWith("working")) return { key: "working", label: "Working", tone: "blue", live: true, detail: after(7) };
@@ -42,7 +42,7 @@ export const GUI_CLIENT_MODEL_JS = String.raw ` // view-model:start
42
42
  };
43
43
  }
44
44
  if (p === "failing") return { key: "failing", label: "Failing", tone: "red", live: false, detail: "failed runs in a row" };
45
- if (p === "main's check is failing") return { key: "mainred", label: "Main's check failing", tone: "red", live: false, detail: "blocked until main's check passes" };
45
+ if (p === "main's check is failing") return { key: "mainred", label: "Main's check failing", tone: "red", live: false, detail: "change pinned until main goes green" };
46
46
  if (p.startsWith("paused")) return { key: "paused", label: "Paused", tone: "amber", live: false, detail: "starts no new runs" };
47
47
  if (p === "waiting: budget spent") return { key: "budget", label: "Waiting", tone: "amber", live: false, detail: "budget" };
48
48
  if (p === "waiting: daily cap spent") return { key: "cap", label: "Waiting", tone: "amber", live: false, detail: "daily cap" };
@@ -60,7 +60,7 @@ export const GUI_CLIENT_MODEL_JS = String.raw ` // view-model:start
60
60
  }
61
61
  // A stalled tool call or a long silence, as the phase label names them (run-progress-model.ts's
62
62
  // inFlightDetail) — the cue that a working role may be stuck. The pattern is that module's
63
- // STALL_SOURCE (interpolated at module build), shared with team-alerts.ts's server-side
63
+ // STALL_SOURCE (interpolated at module build), shared with team-alert-status.ts's server-side
64
64
  // matcher so the two cannot drift from the wording the cell renders.
65
65
  const STALL = new RegExp(${JSON.stringify(STALL_SOURCE)});
66
66
  // A run result (run-outcome.ts RunResult) in words, with the tone of its outcome.
@@ -82,7 +82,7 @@ export const GUI_CLIENT_MODEL_JS = String.raw ` // view-model:start
82
82
  };
83
83
  function resultInfo(result) {
84
84
  const words = result ? eventTypeLabel(result) : "—";
85
- return { label: words.charAt(0).toUpperCase() + words.slice(1), tone: hasOwn(RESULT_TONES, result) ? RESULT_TONES[result] : "gray" };
85
+ return { label: capitalize(words), tone: hasOwn(RESULT_TONES, result) ? RESULT_TONES[result] : "gray" };
86
86
  }
87
87
  // The problem results and the named event sets are tone.ts's lists, interpolated at module
88
88
  // build (like STALL above) so the page cannot drift from the server's eventKind.
@@ -156,7 +156,7 @@ export const GUI_CLIENT_MODEL_JS = String.raw ` // view-model:start
156
156
  }
157
157
  // A backlog title's trailing "(planned 2026-09-29)"-style note, split off as metadata —
158
158
  // the pattern is backlog-structure.ts's ENTRY_STAMP_META_SOURCE (interpolated at module
159
- // load, compiled once here), the same regex team-alerts.ts strips through via
159
+ // load, compiled once here), the same regex team-alert-shared.ts strips through via
160
160
  // stripEntryStamp, so a new stamp verb reaches both dashboards from one edit.
161
161
  const ENTRY_STAMP_RE = new RegExp(${JSON.stringify(ENTRY_STAMP_META_SOURCE)}, "i");
162
162
  function splitTitle(t) {
@@ -79,10 +79,6 @@ export const GUI_CLIENT_OPERATOR_JS = String.raw ` // shared-operator:start
79
79
  if (status === "rejected") return "limited";
80
80
  return status === "reset" ? "reset" : "ok";
81
81
  }
82
- function windowClock(ms) {
83
- const d = new Date(ms);
84
- return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
85
- }
86
82
  function windowResets(ms) {
87
83
  const minutes = Math.floor((ms - Date.now()) / 60000);
88
84
  if (minutes < 1) return "resets now";
@@ -105,7 +101,7 @@ export const GUI_CLIENT_OPERATOR_JS = String.raw ` // shared-operator:start
105
101
  return ["small", "default", "strong"].filter((tier) => chain && chain[tier]).map((tier) => {
106
102
  const t = chain[tier];
107
103
  const money = t.spentUsd !== undefined && t.capUsd !== undefined ? " " + fmtUsd(t.spentUsd) + "/" + fmtCap(t.capUsd) : "";
108
- const until = t.returnsAt !== undefined ? " until " + windowClock(t.returnsAt) : "";
104
+ const until = t.returnsAt !== undefined ? " until " + clockHm(t.returnsAt) : "";
109
105
  return "<span class='side-card-value window-line'>" + esc(tier + " → " + t.pair + money + until) + "</span>";
110
106
  }).join("");
111
107
  }
@@ -114,7 +110,7 @@ export const GUI_CLIENT_OPERATOR_JS = String.raw ` // shared-operator:start
114
110
  const word = windowWord(w.status);
115
111
  parts.push(typeof w.utilization === "number" ? windowPct(w) + "%" : word === "ok" ? "ok · usage not reported" : word);
116
112
  if (w.isUsingOverage) parts.push("overage");
117
- if (w.status === "rejected" && block.heldUntil != null) parts.push((chainMoved(chain) ? "on fallback until " : block.label + " model jobs wait until ") + windowClock(block.heldUntil));
113
+ if (w.status === "rejected" && block.heldUntil != null) parts.push((chainMoved(chain) ? "on fallback until " : block.label + " model jobs wait until ") + clockHm(block.heldUntil));
118
114
  else if (w.status !== "reset" && typeof w.resetsAt === "number") parts.push(windowResets(w.resetsAt));
119
115
  return parts.join(" · ");
120
116
  }
@@ -156,8 +152,8 @@ export const GUI_CLIENT_OPERATOR_JS = String.raw ` // shared-operator:start
156
152
  // actually exists; an all-free subscription-only team (only and free, no windows) falls
157
153
  // through to the "Free models" spend card below so it never renders an empty window card.
158
154
  if (only && blocks && blocks.length > 0) {
159
- return "<div class='side-card' title='Claude.ai subscription usage windows — a rejected window pauses claude model jobs until it resets'>" +
160
- "<span class='side-card-label'>" + icon("clock") + "Claude window</span>" + windowLinesHtml(blocks, only, chain) + "</div>";
155
+ return "<div class='side-card' title='Subscription usage windows — a rejected window pauses model jobs on that backend until it resets'>" +
156
+ "<span class='side-card-label'>" + icon("clock") + "subscription window</span>" + windowLinesHtml(blocks, only, chain) + "</div>";
161
157
  }
162
158
  if (b.free) {
163
159
  return "<div class='side-card' title='Every model the team can use is cost n/a, so there is nothing to cap'>" +
@@ -170,7 +166,7 @@ export const GUI_CLIENT_OPERATOR_JS = String.raw ` // shared-operator:start
170
166
  esc(capped ? "Today's spend against the daily cap — click to change the cap" : "No daily cap — click to set one") + "'>" +
171
167
  "<span class='side-card-label'>" + icon("dollar") + "Spent today<span class='edit'>" + (capped ? "Edit cap" : "Set a cap") + "</span></span>" +
172
168
  "<span class='side-card-value'>" + esc(fmtUsd(b.spentUsd)) + " <small>" + esc(capped ? "of " + fmtCap(b.capUsd) : "no daily cap") + "</small></span>" +
173
- (capped ? meterBar(tone, pct.toFixed(1)) : "") + (b.capHitAt != null ? "<span class='side-card-value window-line'>~cap at " + esc(windowClock(b.capHitAt)) + "</span>" : "") + windowLinesHtml(blocks, only, chain) + "</button>";
169
+ (capped ? meterBar(tone, pct.toFixed(1)) : "") + (b.capHitAt != null ? "<span class='side-card-value window-line'>~cap at " + esc(clockHm(b.capHitAt)) + "</span>" : "") + windowLinesHtml(blocks, only, chain) + "</button>";
174
170
  }
175
171
  function renderBudgetBadge(d) {
176
172
  if (budgetEditing) return; // the open editor owns the slot until save or cancel
@@ -279,7 +275,7 @@ export const GUI_CLIENT_OPERATOR_JS = String.raw ` // shared-operator:start
279
275
  const PAUSE_CHOICES = [[1800, "For 30 minutes"], [3600, "For 1 hour"], [7200, "For 2 hours"], [28800, "For 8 hours"], [0, "Until I resume it"]];
280
276
  // How long a timed team pause has left (humanSeconds), or "" for a standing one.
281
277
  function pauseLeft(d) {
282
- return d.pausedUntil ? humanSeconds(Math.max(0, Math.round((d.pausedUntil - Date.now()) / 1000))) : "";
278
+ return d.pausedUntil ? humanSeconds(secsUntil(d.pausedUntil, Date.now())) : "";
283
279
  }
284
280
  // The sidebar's pause control: Resume (with a timed pause's countdown) while the team is
285
281
  // paused, otherwise the button that opens the menu of timed pauses.