tumwater 0.4.2 → 0.4.4

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 (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -1,12 +1,9 @@
1
- import { errorStorm, errorStormKnob } from "./error-storm.js";
1
+ import { errorStorm, errorStormKnob } from "./team-error-storm.js";
2
2
  import { logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
3
3
  import { teamHold, TEAM_OPEN } from "./team-hold.js";
4
- import { claudeWindowUsedUp, windowReserved } from "../agent/claude/claude-account.js";
5
4
  import { sortedRoles } from "../failure/failure-cluster.js";
6
- import { FAILURE_SPREAD_WINDOW_MS, failureSpread } from "./failure-spread.js";
5
+ import { FAILURE_SPREAD_WINDOW_MS, failureSpread } from "./team-failure-spread.js";
7
6
  import { groupBy } from "../collections.js";
8
- import { eventsSince } from "../events/event-window.js";
9
- import { DAY_MS } from "../text/datetime.js";
10
7
  /** The HoldObservation list both team-wide failure polls step their reducer with, gathered
11
8
  * from each runner's two episodic fields: lastRateLimit (stamped "rate-limit", carrying the
12
9
  * server's retry-after when present) and lastBackendFailure. One copy of that flatMap so the
@@ -37,15 +34,14 @@ function holdObservations(runners) {
37
34
  * heldProviders() (until non-null), never bare key presence: a lifted hold must never
38
35
  * keep blocking. Events carry the provider when one is configured (an undefined provider
39
36
  * — pi's default — omits it, so an unconfigured team's events render exactly as before).
40
- * `windowTiers` is the budget poll's window move (tier → the pair its work went to), stamped on
41
- * the subscription window's limited event. `observations` is the per-subscription-backend hold
42
- * observations (one per backend in use; part 2e passes them), each stamped by the caller from
43
- * its account via windowObservation — the account's own `claudeWindowReserve`: crossings held
44
- * like a rejected window (but never the director) log pollClaudeWindowReserve's event instead
45
- * of the limited one. Returns the new map for the caller to keep. Exported as a unit-test
46
- * seam, like its sibling polls below. */
47
- export function pollTeamHold(root, prev, runners, now, observations = [], windowTiers = {}) {
48
- const steps = [...holdObservations(runners), ...observations];
37
+ * Each provider's hold observations come only from its runners (holdObservations): the
38
+ * per-backend subscription-account observations and their subscription-window crossing
39
+ * events were removed with their last reader (Subtract parts 5u/6, 5w/6 and 5x/6), so the
40
+ * hold poll logs only the rate-limit/backend-failure hold events. Returns the new map for
41
+ * the caller to keep.
42
+ * Exported as a unit-test seam, like its sibling polls below. */
43
+ export function pollTeamHold(root, prev, runners, now) {
44
+ const steps = holdObservations(runners);
49
45
  const byProvider = groupBy(steps, (o) => o.provider);
50
46
  const next = new Map();
51
47
  // Every provider the map already knows, plus every provider this poll observed: a hold
@@ -55,195 +51,51 @@ export function pollTeamHold(root, prev, runners, now, observations = [], window
55
51
  const pPrev = prev.get(provider) ?? { ...TEAM_OPEN, provider };
56
52
  const pNext = teamHold(pPrev, byProvider.get(provider) ?? [], now);
57
53
  next.set(provider, pNext);
58
- const providerFields = provider === undefined ? {} : { provider };
59
- // The subscription-window observation (if any) for THIS provider: the window that opened
60
- // (or would open) its hold, whose window type the limited event names. The events' backend
61
- // is the provider itself (a subscription-window hold always carries a string provider — a
62
- // rejected window or reserve crossing never keys the undefined-pi default) — the reset, in
63
- // particular, fires after the window's observation has already gone (the account flipped to
64
- // reset), so it cannot read its backend back from a still-present observation.
65
- const subObs = byProvider.get(provider)?.find((o) => o.kind === "subscription-window");
66
- const limitedEvent = () => {
67
- // The subscription window's own crossing event, once per rejected window rather than
68
- // once per failure storm. It names the window type and the deadline the hold lifts at.
69
- logEventBestEffort(root, {
70
- source: TUMWATER_SOURCE,
71
- type: "subscription_window_limited",
72
- backend: provider,
73
- ...providerFields,
74
- windowType: subObs?.windowType,
75
- resetsAt: pNext.until,
76
- // Where each moved tier's work went (the budget poll's window move); absent when
77
- // nothing moved, so a no-fallback team's event is today's.
78
- ...(Object.keys(windowTiers).length > 0 ? { tiers: windowTiers } : {}),
79
- });
80
- };
81
- if (pPrev.until !== null && pPrev.kind === "subscription-window" && pPrev.reserve === true &&
82
- pNext.until !== null && pNext.kind === "subscription-window" && pNext.reserve !== true) {
83
- // A reserve hold hardened into a rejected window: the operator hears the limit now.
84
- limitedEvent();
85
- }
86
- else if (pPrev.until === null && pNext.until !== null) {
87
- if (pNext.kind === "subscription-window" && pNext.reserve === true) {
88
- // A reserve crossing logs subscription_window_reserved (pollClaudeWindowReserve), not this.
89
- }
90
- else if (pNext.kind === "subscription-window") {
91
- limitedEvent();
92
- }
93
- else {
94
- logEventBestEffort(root, {
95
- source: TUMWATER_SOURCE,
96
- type: "rate_limit_hold",
97
- ...providerFields,
98
- kind: pNext.kind,
99
- roles: pNext.roles,
100
- holdMs: pNext.until - now,
101
- escalation: pNext.escalation,
102
- });
103
- }
54
+ if (pPrev.until === null && pNext.until !== null) {
55
+ logHoldStart(root, provider, pNext, now);
104
56
  }
105
57
  else if (pPrev.until !== null && pNext.until === null) {
106
- if (pNext.kind === "subscription-window") {
107
- logEventBestEffort(root, { source: TUMWATER_SOURCE, type: "subscription_window_reset", backend: provider, ...providerFields });
108
- }
109
- else {
110
- // The ended hold's kind rides the resumed event: the hold's own event names its kind, so
111
- // the lift must be able to name what actually ended too — "429 hold lifted" after a
112
- // connection-error hold is the same lie the hold line's kind split removed
113
- // (BUGS.md 2026-09-29).
114
- logEventBestEffort(root, {
115
- source: TUMWATER_SOURCE,
116
- type: "rate_limit_resumed",
117
- ...providerFields,
118
- kind: pNext.kind,
119
- });
120
- }
58
+ logHoldEnd(root, provider, pNext);
121
59
  }
122
60
  }
123
61
  return next;
124
62
  }
125
- /** The hold observation one subscription account's usage windows imply, or null: a window
126
- * reading `rejected` with no overage and a future `resetsAt` holds every seam on provider
127
- * `backend` until the last such deadline — the account-level throttle of plans/agent-backends.md
128
- * §8. Only subscription billing produces one: an API-billed run's windows never hold (the plan's
129
- * "API claude seams run"), and a missing account file reads exactly as today's behavior. The
130
- * reader has already flipped a past-`resetsAt` window to `reset`, and the `resetsAt > now` guard
131
- * here lets the hold lift on its own even from a stale file. */
132
- export function windowObservation(backend, account, now, reserve) {
133
- const furthest = claudeWindowUsedUp(account, now, false, reserve);
134
- if (furthest === null)
135
- return null;
136
- return {
137
- role: backend,
138
- provider: backend,
139
- kind: "subscription-window",
140
- at: now,
141
- windowType: furthest.type,
142
- resetsAt: furthest.resetsAt,
143
- ...(furthest.reserve === true ? { reserve: true } : {}),
144
- };
145
- }
146
- /** A claude usage window is open for at most `seven_day`, so any window still open was logged
147
- * within this span of the event log. */
148
- const CLAUDE_WINDOW_LOOKBACK_MS = 7 * DAY_MS;
149
- /** The `backend:windowType:resetsAt` keys of the still-open windows whose `type` event is already
150
- * in the event log, so a restart does not re-announce a window that is still open. Keyed by the
151
- * event's own `backend` (falling back to `"claude"` for an event written before the window
152
- * events carried one), so two backends' windows of the same type and reset instant keep their
153
- * own keys. Events without a numeric `resetsAt` cannot name their window and are skipped. */
154
- export function loggedWindowKeys(root, type, now) {
155
- const keys = new Set();
156
- for (const e of eventsSince(root, CLAUDE_WINDOW_LOOKBACK_MS, now, (ev) => ev.type === type)) {
157
- if (typeof e.windowType === "string" && typeof e.resetsAt === "number" && e.resetsAt > now) {
158
- keys.add(`${typeof e.backend === "string" ? e.backend : "claude"}:${e.windowType}:${e.resetsAt}`);
159
- }
160
- }
161
- return keys;
162
- }
163
- /** Log each subscription-window event once per window key. The reserve and the warning polls
164
- * (below) both walk every window of the account and log one event for each that crosses its mark
165
- * this poll, deduplicated by the `backend:type:resetsAt` key so many polls across one window log
166
- * once (a later window's crossing is its own key). They differ only in WHICH window marks (the
167
- * reserve fraction vs the `allowed_warning` status) and the event's payload, so the traversal, the
168
- * "reset instant already past" skip, the key build, the key dedup, and the returned-set contract
169
- * live here and each caller supplies the mark-and-payload predicate (`eventFor` returns the event
170
- * to log, or null to skip that window). The returned set drops keys whose reset instant has passed,
171
- * and is `logged` itself when nothing was added or dropped. */
172
- function logWindowEventOnce(root, logged, account, now, backend, eventFor) {
173
- if (account?.billing !== "subscription")
174
- return logged;
175
- let next = null;
176
- for (const key of logged) {
177
- const suffix = key.slice(key.lastIndexOf(":") + 1);
178
- if (suffix !== "" && Number(suffix) <= now)
179
- (next ??= new Set(logged)).delete(key);
180
- }
181
- for (const [type, w] of Object.entries(account.windows)) {
182
- const ev = eventFor(type, w);
183
- if (ev === null)
184
- continue;
185
- // A window whose reset instant is in the past (or unreadable) no longer marks a crossing.
186
- if (typeof w.resetsAt === "number" && w.resetsAt <= now)
187
- continue;
188
- // Keyed by this poll's backend (the `backend:windowType:resetsAt` shape loggedWindowKeys
189
- // seeds), so a different backend's identical window keeps its own key. The backend rides
190
- // the event, stamped here so each caller's event needs no backend of its own.
191
- const key = `${backend}:${type}:${w.resetsAt ?? ""}`;
192
- if ((next ?? logged).has(key))
193
- continue;
194
- logEventBestEffort(root, { ...ev, backend });
195
- (next ??= new Set(logged)).add(key);
196
- }
197
- return next ?? logged;
198
- }
199
- /** One poll of the reserve crossing: each window at or past its windowReserve fraction (read
200
- * through `windowReserveFor(config, "claude")`) logs `subscription_window_reserved` once per
201
- * window key (`backend:type:resetsAt`, kept in `logged`), so several windows crossing in one
202
- * poll each log their own event. Returns the keys to keep. */
203
- export function pollClaudeWindowReserve(root, logged, account, now, backend, reserve) {
204
- if (reserve === undefined)
205
- return logged;
206
- return logWindowEventOnce(root, logged, account, now, backend, (type, w) => {
207
- const fraction = reserve[type];
208
- if (!windowReserved(w, fraction) || typeof w.resetsAt !== "number")
209
- return null;
210
- return {
211
- source: TUMWATER_SOURCE,
212
- type: "subscription_window_reserved",
213
- windowType: type,
214
- utilization: w.utilization,
215
- reserve: fraction,
216
- resetsAt: w.resetsAt,
217
- };
63
+ /** The quiet→held crossing event: a provider whose hold just opened logs one `rate_limit_hold`
64
+ * naming the kind, the roles, how long it holds (until the re-open), and the escalation depth.
65
+ * The provider is carried only when one is configured (an undefined provider — pi's default —
66
+ * omits it, so an unconfigured team's events render exactly as before). */
67
+ function logHoldStart(root, provider, pNext, now) {
68
+ logEventBestEffort(root, {
69
+ source: TUMWATER_SOURCE,
70
+ type: "rate_limit_hold",
71
+ ...(provider === undefined ? {} : { provider }),
72
+ kind: pNext.kind,
73
+ roles: pNext.roles,
74
+ holdMs: pNext.until - now,
75
+ escalation: pNext.escalation,
218
76
  });
219
77
  }
220
- /** One poll of the subscription-window warning: each `allowed_warning` window logs
221
- * `subscription_window_warning` once per window key (`backend:type:resetsAt`, kept in `logged`;
222
- * carrying the window's utilization when the account reported one), so many polls across one
223
- * window warn once and several windows warning in one poll each log their own event. Returns
224
- * the keys to keep. */
225
- export function pollClaudeWindowWarning(root, logged, account, now, backend) {
226
- return logWindowEventOnce(root, logged, account, now, backend, (type, w) => {
227
- if (w.status !== "allowed_warning")
228
- return null;
229
- return {
230
- source: TUMWATER_SOURCE,
231
- type: "subscription_window_warning",
232
- windowType: type,
233
- ...(typeof w.utilization === "number" ? { utilization: w.utilization } : {}),
234
- ...(typeof w.resetsAt === "number" ? { resetsAt: w.resetsAt } : {}),
235
- };
78
+ /** The held→quiet lift event: a provider whose hold just re-opened logs one `rate_limit_resumed`.
79
+ * The ended hold's kind rides the resumed event: the hold's own event names its kind, so the
80
+ * lift must be able to name what actually ended too — "429 hold lifted" after a
81
+ * connection-error hold is the same lie the hold line's kind split removed (BUGS.md 2026-09-29). */
82
+ function logHoldEnd(root, provider, pNext) {
83
+ logEventBestEffort(root, {
84
+ source: TUMWATER_SOURCE,
85
+ type: "rate_limit_resumed",
86
+ ...(provider === undefined ? {} : { provider }),
87
+ kind: pNext.kind,
236
88
  });
237
89
  }
238
- /** One poll of the team-wide error-storm warning (src/team/error-storm.ts): gather each runner's
239
- * current error streak (RunnerState.consecutiveErrors/lastError — the same fields the per-role
240
- * "consecutive run failures" warning reads, the director's included, since its failures are
241
- * the team's evidence too), step the pure reducer, and log exactly one warning per episode,
90
+ /** One poll of the team-wide error-storm warning (src/team/team-error-storm.ts): gather each
91
+ * runner's current error streak (RunnerState.consecutiveErrors/lastError — the same fields the
92
+ * per-role "consecutive run failures" warning reads, the director's included, since its failures
93
+ * are the team's evidence too), step the pure reducer, and log exactly one warning per episode,
242
94
  * like the per-role warning's once-per-streak-crossing shape: on the quiet→storm crossing,
243
- * naming the roles, the shared normalized cause, and the config knob when the cause has one
244
- * (a team timing out together means runTimeoutSeconds does not fit the serving model).
245
- * Storms clear silently — the members' own recoveries already tell that story — and the
246
- * reducer's `prev` pass-through keeps a held storm event-free. Returns the new storm for the
95
+ * naming the roles, the shared normalized cause, and the config knob when the cause has one (a
96
+ * team timing out together means runTimeoutSeconds does not fit the serving model). Storms clear
97
+ * silently — the members' own recoveries already tell that story — and the reducer's `prev`
98
+ * pass-through keeps a held storm event-free. Returns the new storm for the
247
99
  * caller to keep. Exported as a unit-test seam, like pollTeamHold above. */
248
100
  export function pollErrorStorm(root, prev, runners) {
249
101
  const observations = runners.map((r) => ({
@@ -267,15 +119,15 @@ export function pollErrorStorm(root, prev, runners) {
267
119
  }
268
120
  return next;
269
121
  }
270
- /** One poll of the team-wide wide-shallow storm alarm (src/team/failure-spread.ts): gather each
271
- * runner's latest provider-failure observations — the same two the team hold reads, the
272
- * director's included, since its failures are the team's evidence too — step the pure
273
- * reducer, and log exactly one warning per episode: on the quiet→active crossing (and again
274
- * when a DIFFERENT kind becomes the episode while one is sounding, the reducer's new-episode
275
- * rule), naming the failure count, the kind, the window, and the roles behind it. The wiring
276
- * keys its log off active/kind, not object identity — `recent` moves almost every poll, so
277
- * unlike the hold's poll there is no prev pass-through to compare. Exported as a unit-test
278
- * seam, like pollTeamHold above. */
122
+ /** One poll of the team-wide wide-shallow storm alarm (src/team/team-failure-spread.ts): gather
123
+ * each runner's latest provider-failure observations — the same two the team hold reads, the
124
+ * director's included, since its failures are the team's evidence too — step the pure reducer,
125
+ * and log exactly one warning per episode: on the quiet→active crossing (and again when a
126
+ * DIFFERENT kind becomes the episode while one is sounding, the reducer's new-episode rule),
127
+ * naming the failure count, the kind, the window, and the roles behind it. The wiring keys its
128
+ * log off active/kind, not object identity — `recent` moves almost every poll, so unlike the
129
+ * hold's poll there is no prev pass-through to compare. Exported as a unit-test seam, like
130
+ * pollTeamHold above. */
279
131
  export function pollFailureSpread(root, prev, runners, now) {
280
132
  const observations = holdObservations(runners);
281
133
  const next = failureSpread(prev, observations, now);
@@ -6,14 +6,14 @@
6
6
  * an interrupted run's uncommitted edits survive.
7
7
  *
8
8
  * The candidate list and the guard that keeps a clean off the primary checkout live here; the
9
- * in-use half lives in src/git/worktree-use.ts and the hold half in src/gates/disk-gate.ts. */
9
+ * in-use half lives in src/git/worktree-use.ts and the hold half in src/gates/disk-gates.ts. */
10
10
  import path from "node:path";
11
11
  import { HOUR_MS } from "../text/datetime.js";
12
12
  import { worktreesDir } from "../paths.js";
13
13
  import { git, gitTry } from "../git/git-run.js";
14
14
  import { logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
15
15
  import { loadRunnerState } from "../runner/runner-state-load.js";
16
- import { BYTES_PER_GB, sampleFreeBytes } from "../gates/disk-gate.js";
16
+ import { BYTES_PER_GB, sampleFreeBytes } from "../gates/disk-gates.js";
17
17
  import { subdirectoryNames } from "../files/files.js";
18
18
  import { claimForReclaim, isReclaimInProgress, isWorktreeInUse, readWorktreeUse, releaseReclaim, updateWorktreeUse, } from "../git/worktree-use.js";
19
19
  /** Worktree basenames that pressure reclaim never touches: the self-hosting mirrors the
@@ -126,55 +126,85 @@ async function cleanIgnored(dir) {
126
126
  const out = await git(dir, "clean", "-fdX");
127
127
  return out.split("\n").some((line) => line.startsWith("Removing "));
128
128
  }
129
- /** Clean pressure candidates until free space reaches `reclaimGB`, re-sampling after each, and
130
- * log exactly one `disk_reclaim` when anything was cleaned. `idle` mode ignores the threshold
131
- * and walks every candidate once (part 3/4). Returns null when nothing was reclaimed. */
132
- export async function reclaimPass(root, mode, opts) {
133
- const sample = opts.sample ?? sampleFreeBytes;
134
- const candidates = opts.candidates ?? reclaimCandidates(root);
135
- const startedAt = Date.now();
136
- const before = sample(root);
129
+ /** True when a pressure or manual pass should stop: the sampler reports free space at or
130
+ * above `reclaimGB`. A null sample (sampling failed) is not the floor, so it keeps reclaiming. */
131
+ function pressureFloorReached(free, reclaimGB) {
132
+ return free !== null && reclaimGB > 0 && free >= reclaimGB * BYTES_PER_GB;
133
+ }
134
+ /** Clean one candidate, treating a guard failure as a skip, not a reason to abort the pass. */
135
+ async function reclaimOneCandidate(root, candidate) {
136
+ try {
137
+ return await reclaimWorktree(root, candidate.dir);
138
+ }
139
+ catch {
140
+ // A guard failure (a directory that stopped being a linked worktree) is not a reason to
141
+ // abort the pass; the remaining candidates are still safe to clean.
142
+ return false;
143
+ }
144
+ }
145
+ /** Walk the candidates once, collecting the ones actually cleaned. Idle mode keeps only the
146
+ * `isIdleExpired` candidates and never re-samples; pressure and manual modes re-sample before
147
+ * each and stop once the pressure floor is reached. */
148
+ async function collectReclaimed(root, candidates, mode, idleHours, reclaimGB, sample, startedAt) {
137
149
  const cleaned = [];
138
150
  for (const candidate of candidates) {
139
151
  if (mode === "idle") {
140
- if (!isIdleExpired(candidate, opts.idleHours ?? 0, startedAt))
152
+ if (!isIdleExpired(candidate, idleHours, startedAt))
141
153
  continue;
142
154
  }
143
- else {
144
- const free = sample(root);
145
- if (free !== null && opts.reclaimGB > 0 && free >= opts.reclaimGB * BYTES_PER_GB)
146
- break;
147
- }
148
- try {
149
- if (await reclaimWorktree(root, candidate.dir))
150
- cleaned.push(candidate.name);
151
- }
152
- catch {
153
- // A guard failure (a directory that stopped being a linked worktree) is not a reason to
154
- // abort the pass; the remaining candidates are still safe to clean.
155
+ else if (pressureFloorReached(sample(root), reclaimGB)) {
156
+ break;
155
157
  }
158
+ if (await reclaimOneCandidate(root, candidate))
159
+ cleaned.push(candidate.name);
156
160
  }
157
- if (cleaned.length === 0)
158
- return null;
159
- const after = sample(root);
161
+ return cleaned;
162
+ }
163
+ /** Freed space as the before/after free-space difference, clamped at zero: another writer can
164
+ * consume space during the pass, and a negative "freed" would render as nonsense in the event
165
+ * and the digest. A null sample keeps the zero fallback. */
166
+ function reclaimFreedGB(before, after) {
167
+ return before !== null && after !== null ? Math.max(0, (after - before) / BYTES_PER_GB) : 0;
168
+ }
169
+ /** The pass result: the cleaned worktrees, the clamped freed and current free figures, and the
170
+ * pass duration. `freeGB` falls back to whichever sample we have when the other is null. */
171
+ function buildReclaimResult(cleaned, before, after, startedAt) {
160
172
  const freeBytes = after ?? before ?? 0;
161
- const result = {
173
+ return {
162
174
  worktrees: cleaned,
163
- // Clamped: another writer can consume space during the pass, and a negative "freed"
164
- // would render as nonsense in the event and the digest.
165
- freedGB: before !== null && after !== null ? Math.max(0, (after - before) / BYTES_PER_GB) : 0,
175
+ freedGB: reclaimFreedGB(before, after),
166
176
  freeGB: freeBytes / BYTES_PER_GB,
167
177
  durationMs: Date.now() - startedAt,
168
178
  };
179
+ }
180
+ /** Log exactly one `disk_reclaim` event with the result's figures. */
181
+ function logReclaim(root, mode, result) {
169
182
  logEventBestEffort(root, {
170
183
  source: TUMWATER_SOURCE,
171
184
  type: "disk_reclaim",
172
185
  mode,
173
- worktrees: cleaned,
186
+ worktrees: result.worktrees,
174
187
  freedGB: result.freedGB,
175
188
  freeGB: result.freeGB,
176
189
  durationMs: result.durationMs,
177
190
  });
191
+ }
192
+ /** Clean pressure candidates until free space reaches `reclaimGB`, re-sampling after each, and
193
+ * log exactly one `disk_reclaim` when anything was cleaned. `idle` mode ignores the threshold
194
+ * and walks every candidate once (part 3/4). Returns null when nothing was reclaimed. */
195
+ export async function reclaimPass(root, mode, opts) {
196
+ const sample = opts.sample ?? sampleFreeBytes;
197
+ const candidates = opts.candidates ?? reclaimCandidates(root);
198
+ const startedAt = Date.now();
199
+ const before = sample(root);
200
+ // With no candidates the pass resolves synchronously (no re-sampling or cleaning to await),
201
+ // so a no-live-team Ctrl+E flashes "nothing to reclaim" within the caller's same turn.
202
+ const cleaned = candidates.length === 0 ? [] : await collectReclaimed(root, candidates, mode, opts.idleHours ?? 0, opts.reclaimGB, sample, startedAt);
203
+ if (cleaned.length === 0)
204
+ return null;
205
+ const after = sample(root);
206
+ const result = buildReclaimResult(cleaned, before, after, startedAt);
207
+ logReclaim(root, mode, result);
178
208
  return result;
179
209
  }
180
210
  /** Dry run: every reclaim candidate with its idle age and what `git clean -ndX` would remove,
@@ -1,7 +1,6 @@
1
- import fs from "node:fs";
2
1
  import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
3
2
  import { finiteNumber, stringList } from "../files/json-object.js";
4
- import { removeQuiet } from "../files/files.js";
3
+ import { removeIfPresent, removeQuiet } from "../files/files.js";
5
4
  import { withStateLock } from "../concurrency/lock.js";
6
5
  import { collapseWhitespace, truncate } from "../text/text.js";
7
6
  import { pausedPath, pausedRolesLockPath, pausedRolesPath, scheduledStopPath } from "../paths.js";
@@ -128,15 +127,11 @@ export function pauseTeam(root, untilMs, reason) {
128
127
  return true;
129
128
  }
130
129
  /** Resume the team by removing its marker (a no-op if absent); returns whether a marker was
131
- * there to lift, mirroring pauseTeam's changed-state contract. Uses removeQuiet's
130
+ * there to lift, mirroring pauseTeam's changed-state contract. Uses removeIfPresent's
132
131
  * never-throws delete: a marker vanishing between the check and the unlink is success, not a
133
132
  * failure worth surfacing from a toggle. */
134
133
  export function resumeTeam(root) {
135
- const marker = pausedPath(root);
136
- if (!fs.existsSync(marker))
137
- return false;
138
- removeQuiet(marker);
139
- return true;
134
+ return removeIfPresent(pausedPath(root));
140
135
  }
141
136
  /** Schedule a graceful team stop at `untilMs` (epoch ms): `tumwater stop --in`. Overwrites the
142
137
  * marker, so the last write wins. */
@@ -156,11 +151,7 @@ export function scheduledStopAt(root) {
156
151
  }
157
152
  /** Drop the scheduled stop; returns whether a marker was there (resumeTeam's contract). */
158
153
  export function cancelScheduledStop(root) {
159
- const marker = scheduledStopPath(root);
160
- if (!fs.existsSync(marker))
161
- return false;
162
- removeQuiet(marker);
163
- return true;
154
+ return removeIfPresent(scheduledStopPath(root));
164
155
  }
165
156
  /** The roles the operator has individually paused (`tumwater pause --role <id>`): a role in
166
157
  * this set starts no new runs — in-flight ones finish, every other role keeps running, the
@@ -98,7 +98,7 @@ export function daysBetween(fromKey, toKey) {
98
98
  const [y = 0, m = 1, d = 1] = key.split("-").map(Number);
99
99
  return Date.UTC(y, m - 1, d);
100
100
  };
101
- return Math.round((utc(toKey) - utc(fromKey)) / 86_400_000);
101
+ return Math.round((utc(toKey) - utc(fromKey)) / DAY_MS);
102
102
  }
103
103
  /** The `MM-DD` half of a `YYYY-MM-DD` day key (dayKey/formatDate): the redundant year
104
104
  * dropped for a line whose date lies inside the current window. Shared by the failure
@@ -168,15 +168,24 @@ export function humanSeconds(s) {
168
168
  export function humanUntil(atMs, now) {
169
169
  return humanSeconds(secondsUntil(atMs, now));
170
170
  }
171
- /** `humanUntil`'s past-direction twin: how long since an epoch-ms instant, as `NS ago` —
172
- * the past-direction phrase five renderers each built as `{humanSeconds(secondsSince(...))} ago`
173
- * (status-render.ts's ago against the real clock, role-render.ts's last-run and
174
- * merge-queue-render.ts's enqueue ages, inbox.ts's queued-age suffix, and doctor-notify.ts's
175
- * latest-failed-page age, which had inlined the clamp itself), so the clamp, the rounding,
171
+ /** Human duration since a past epoch-ms instant, as a bare span — "how long ago" without
172
+ * the ` ago` wording: the `humanUntil` past-direction twin, collapsing the four sites that
173
+ * spelled the two-step `humanSeconds(secondsSince(...))` composition out (merge-queue-render.ts's
174
+ * merge and stage elapseds and doctor-backlog.ts's stale-queue and inbox-prompt waits), so the
175
+ * s/m/h/d bucketing cannot drift per consumer. Lives here beside the other time phrasing. */
176
+ export function humanSince(sinceMs, now) {
177
+ return humanSeconds(secondsSince(sinceMs, now));
178
+ }
179
+ /** `humanSince`'s past-direction phrase with the ` ago` suffix: how long since an epoch-ms
180
+ * instant, as `NS ago` — the phrase five renderers each built as
181
+ * `{humanSeconds(secondsSince(...))} ago` (status-render.ts's ago against the real clock,
182
+ * role-render.ts's last-run and merge-queue-render.ts's enqueue ages, inbox.ts's queued-age
183
+ * suffix, and doctor-notify.ts's latest-failed-page age, which had inlined the clamp itself),
184
+ * so the clamp, the rounding,
176
185
  * and the " ago" wording cannot drift per consumer. Lives here beside the other time
177
186
  * phrasing so core modules (which may not import src/ui) share the same form. */
178
187
  export function humanAgo(sinceMs, now) {
179
- return `${humanSeconds(secondsSince(sinceMs, now))} ago`;
188
+ return `${humanSince(sinceMs, now)} ago`;
180
189
  }
181
190
  /** A compact age clamped at zero and floored to whole units — `12m`, `4h`, `6d` — the bare
182
191
  * (no `ago`) parenthetical the `qa` coverage block and the `feedback` "previously filed" block
@@ -22,7 +22,7 @@ export function markdownTable(header, rows, align = []) {
22
22
  * the `map((item, i) => `${i + 1}. ${item}`).join("\n")` step, shared by the review prompt's
23
23
  * prior objections, its stage-fix findings, and the rejected/revision notes
24
24
  * (prompt/gate-notes.ts), `tumwater questions`' open-question list
25
- * (backlog/question-answer.ts) and `tumwater wake --list`'s wake lines
25
+ * (backlog/backlog-question-answer.ts) and `tumwater wake --list`'s wake lines
26
26
  * (operator/operator-intent.ts `renderWakeList`), so the numbering shape cannot drift between
27
27
  * those surfaces (the TUI queued-prompt rows and the merge queue rows number their own
28
28
  * lines). `empty` is returned verbatim when `items` is empty
@@ -25,8 +25,9 @@ export const PREFILL_REASON = "it rides into every run's prefill";
25
25
  * drift across the four surfaces that show just that first reason: the review gate's returned
26
26
  * detail (review.ts), the event feed's one-line reject (event-format.ts), the failure digest's
27
27
  * rejection clusters (failure-data.ts), and the error a role's next run records (run-apply.ts).
28
- * Sites that render the whole reason list, or intentionally omit the fallback (time-spend.ts's
29
- * first-reason example, review.ts's rejection event `reason`), keep their own handling. */
28
+ * Sites that render the whole reason list, or intentionally omit the fallback
29
+ * (failure-time-spend.ts's first-reason example, review.ts's rejection event `reason`), keep
30
+ * their own handling. */
30
31
  export function firstReason(reasons) {
31
32
  const first = reasons?.[0];
32
33
  return typeof first === "string" ? first : NO_REASONS_GIVEN;
@@ -169,6 +170,15 @@ export function rolesPhrase(roles, fallback, max, format = String) {
169
170
  list.length = Math.min(list.length, max);
170
171
  return list.length > 0 ? list.join(", ") : fallback;
171
172
  }
173
+ /** The trailing-day median-and-extreme clause every doctor timing row renders — `… median, …
174
+ * slowest|longest (N unit)` — the one home of that bucketing, shared by the `check` row's
175
+ * checkDurationPhrase ("passing merge checks … took … slowest") and the `merge queue` row's
176
+ * mergeWaitPhrase ("merges … waited … longest"), so the `shortSpanPhrase` median/extreme shape
177
+ * cannot drift between them. Each caller supplies its own verbed prefix ("took"/"waited", the
178
+ * one differing word) and the extreme's label and unit. */
179
+ export function trailingDayMedianPhrase(medianMs, extremeMs, extreme, n, unit) {
180
+ return `${shortSpanPhrase(medianMs)} median, ${shortSpanPhrase(extremeMs)} ${extreme} (${plural(n, unit)})`;
181
+ }
172
182
  /** The `$<spent> of $<cap>` fragment every budget-transition event renders — the one home of that
173
183
  * phrasing, shared by the event feed (event-format.ts) and the failure digest's Team state changes
174
184
  * lines (src/failure/failure-state-change.ts), so a budget transition reads the same on both
@@ -241,6 +251,21 @@ function withoutLeadingCd(command) {
241
251
  rest = rest.replace(LEADING_CD, "");
242
252
  return rest.trim() ? rest : command;
243
253
  }
254
+ /** The first present candidate string in a tool call's args, or "" when none is a string.
255
+ * The read order — path, file_path, command, cmd, pattern, url — is the order INV-AE4 pins. */
256
+ function toolCallCandidate(args) {
257
+ const candidate = args.path ?? args.file_path ?? args.command ?? args.cmd ?? args.pattern ?? args.url;
258
+ return typeof candidate === "string" ? candidate : "";
259
+ }
260
+ /** How one candidate reads as a detail: a path-like key reduces to its file name, a
261
+ * command-like key drops a leading `cd`, and any other key shows verbatim. */
262
+ function toolCallDetail(candidate, args) {
263
+ if (candidate === args.path || candidate === args.file_path)
264
+ return path.basename(candidate);
265
+ if (candidate === args.command || candidate === args.cmd)
266
+ return withoutLeadingCd(candidate);
267
+ return candidate;
268
+ }
244
269
  /** One-line description of a tool call from its name and args — shared by live progress data
245
270
  * collection (LiveProgress.lastTool), transcript rendering, and the harness's stalled-tool-call
246
271
  * warning (src/pi/pi-stream.ts builds the label src/pi/pi-watchdogs.ts renders). Path-like keys
@@ -248,19 +273,8 @@ function withoutLeadingCd(command) {
248
273
  * is dropped from a command, because the run's cwd is implied and the path would use up the
249
274
  * 32-character budget. */
250
275
  export function describeToolCall(toolName, args) {
251
- let detail = "";
252
- if (isJsonObject(args)) {
253
- const candidate = args.path ?? args.file_path ?? args.command ?? args.cmd ?? args.pattern ?? args.url;
254
- if (typeof candidate === "string") {
255
- detail =
256
- candidate === args.path || candidate === args.file_path
257
- ? path.basename(candidate)
258
- : candidate === args.command || candidate === args.cmd
259
- ? withoutLeadingCd(candidate)
260
- : candidate;
261
- }
262
- }
263
- detail = squash(detail, 32);
276
+ const raw = isJsonObject(args) ? toolCallDetail(toolCallCandidate(args), args) : "";
277
+ const detail = squash(raw, 32);
264
278
  // An empty name (pi omits toolName on some start events) yields the bare detail — no
265
279
  // leading space in front of it.
266
280
  return detail ? (toolName ? `${toolName} ${detail}` : detail) : toolName;
@@ -21,7 +21,7 @@ function editDistance(a, b) {
21
21
  }
22
22
  /** The candidate closest to a mistyped `input` by case-insensitive edit distance, or null
23
23
  * when nothing is close enough to suggest: the "did you mean" behind typo'd command names
24
- * (help.ts) and config keys (config-write.ts, config-commands.ts, config-validation's
24
+ * (help.ts) and config keys (config-write.ts, cli-config-commands.ts, config-validation's
25
25
  * checkKnownKeys/checkKnownRoleId). Capped at `maxDistance`
26
26
  * (two edits by default — a typo's distance, not a different word's), so only a near miss
27
27
  * gets a hint and the suggestion can never fire as an auto-correction; the caller still