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
@@ -65,17 +65,28 @@ export function increment(map, key) {
65
65
  * step (the seed matters because `noUncheckedIndexedAccess` reads every entry as possibly
66
66
  * undefined), shared by the failure digest's outcome tally per role (failure-data.ts), the
67
67
  * usage report's window role totals (report-render.ts), and its per-day fold's run and cost
68
- * tallies (report-fold.ts), so those sites cannot drift on how a missing key is seeded. The
68
+ * tallies (report-usage-fold.ts), so those sites cannot drift on how a missing key is seeded. The
69
69
  * test duration reporter (test/test-durations-reporter.ts) keeps its own copy: it imports no
70
70
  * tumwater module by design, since node --test loads it into its own parent process.
71
71
  *
72
72
  * The seed reads an OWN property (Object.hasOwn), not a bare `record[key] ?? 0`: the
73
73
  * custom-role name charset admits "constructor", an Object.prototype key, whose inherited
74
74
  * function would otherwise be the seed — storing `function Object() …5` and turning every
75
- * later `roleSum`/report total into string concatenation (BUGS.md 2026-10-10). */
75
+ * later `roleSum`/report total into string concatenation (BUGS.md 2026-10-10). The write is
76
+ * an `Object.defineProperty`, not a plain `record[key] = …`: the latter assignment to
77
+ * `__proto__` (another Object.prototype key, reachable through a failing-test name) would
78
+ * trigger the inherited setter and create no own key, silently dropping the count. A
79
+ * defineProperty with the default attribute flags (writable, enumerable, configurable)
80
+ * writes every other key exactly as plain assignment would, so an own `__proto__` key is
81
+ * the only behavior this changes. */
76
82
  export function addTo(record, key, amount) {
77
83
  const current = Object.hasOwn(record, key) ? record[key] : 0;
78
- record[key] = (current ?? 0) + amount;
84
+ Object.defineProperty(record, key, {
85
+ value: (current ?? 0) + amount,
86
+ writable: true,
87
+ enumerable: true,
88
+ configurable: true,
89
+ });
79
90
  }
80
91
  /** Set `key` to `value` in a bounded memo `cache`, moving an existing key to the LRU tail
81
92
  * (delete + re-set, so a re-read at the cap does not evict itself) and, when the map would
@@ -127,6 +138,18 @@ export function trimToLast(arr, max) {
127
138
  export function rankByCount(items, count, key) {
128
139
  return [...items].sort((a, b) => count(b) - count(a) || key(a).localeCompare(key(b)));
129
140
  }
141
+ /** Split a list into its first `top` entries and how many were cut off — the pairing five call
142
+ * sites built by hand as `items.slice(0, N)` beside `Math.max(0, items.length - N)`. `visible`
143
+ * aliases `items` when it already fits; `hidden` is `max(0, items.length - top)`, which the
144
+ * slice-length arithmetic yields without the explicit clamp (a slice never returns more than
145
+ * `top` even when the list is shorter). Shared by the merge-intent and conflict-hunk caps
146
+ * (merge-git.ts), the failure digest's loss-cause and message-cluster tops
147
+ * (time-spend.ts, failure-cluster.ts), and the answered-questions cap (question-answer.ts),
148
+ * so those cuts cannot drift on how the remainder is counted. */
149
+ export function topAndHidden(items, top) {
150
+ const visible = items.slice(0, top);
151
+ return { visible, hidden: items.length - visible.length };
152
+ }
130
153
  /** The lower median of a non-empty numeric list: the middle value, or the smaller of the
131
154
  * middle two for an even count. Returns a fresh sorted copy; the input is never mutated.
132
155
  * Shared by report-data.ts's per-day medians, doctor-project-checks.ts's and doctor-backlog.ts's
@@ -137,18 +160,18 @@ export function lowerMedian(values) {
137
160
  return sorted[Math.floor((sorted.length - 1) / 2)];
138
161
  }
139
162
  /** Most-days-waited-first comparator, ties in ascending index (file order) — `b.days - a.days ||
140
- * a.index - b.index`, the single home of that one-line ordering. Shared by the stale-bug
141
- * alert (src/ui/status-payload.ts's staleOpenBugs) and the stale-waiting-bug doctor row
142
- * (src/doctor/doctor-backlog-bugs.ts's staleWaitingBugRows), which both build `{index, days}`
143
- * rows off the same backlog scan, so the two cannot drift on how ties order. The generic holds
163
+ * a.index - b.index`, the single home of that one-line ordering. Shared by the stale-questions
164
+ * feed (src/ui/alert-feeds.ts's staleOpenQuestions), which builds `{index, days}`
165
+ * rows off the same backlog scan, so its tie order cannot drift. The generic holds
144
166
  * any row type carrying both fields; a row without a natural index passes its first-seen order. */
145
167
  export function byDaysThenIndex(a, b) {
146
168
  return b.days - a.days || a.index - b.index;
147
169
  }
148
170
  /** Ascending string comparator by UTF-16 code unit — the locale-independent order a bare `.sort()`
149
171
  * uses, unlike `localeCompare` (which `rankByCount` uses for its tiebreak). Used directly by
150
- * src/cli/cli-start-summary.ts, src/cli/log-commands.ts and src/history/history-data.ts, and by
151
- * `sortByValueDesc` for its tiebreak. */
172
+ * src/cli/cli-start-summary.ts, src/cli/completion.ts, src/cli/log-commands.ts,
173
+ * src/history/history-data.ts, src/review/post-merge-review.ts, src/review/quarantine-render.ts,
174
+ * src/budget/budget.ts and src/change/change-data.ts, and by `sortByValueDesc` for its tiebreak. */
152
175
  export function compareCodeUnits(a, b) {
153
176
  return a < b ? -1 : a > b ? 1 : 0;
154
177
  }
@@ -162,6 +185,20 @@ export function compareCodeUnits(a, b) {
162
185
  export function sortByValueDesc(entries) {
163
186
  return entries.sort((a, b) => b[1] - a[1] || compareCodeUnits(a[0], b[0]));
164
187
  }
188
+ /** Sort `[key, …]` entry pairs by their string key ascending — the single home of the
189
+ * `entries.sort(([a], [b]) => a.localeCompare(b))` step, shared by the budget check's
190
+ * capped-role rows (doctor-holds.ts's checkBudget), the permit pool's provider fragment
191
+ * (ui/badges.ts's permitsBadge), and the doctor's rate-limit standing-holds rows
192
+ * (doctor-holds.ts's checkRateLimitHolds), so those sites cannot drift on how a possibly
193
+ * `undefined` key (a rate-limit hold recorded with no provider) orders. A missing key sorts
194
+ * first, matching the `(a ?? "")` guard the hold site alone needed; on a defined string the
195
+ * guard is a no-op, so the comparator is identical for the other two. Returns a fresh sorted
196
+ * array; the input is never mutated. (Deliberately unlike the value-descending `sortByValueDesc`
197
+ * and the count-then-key `rankCountEntries` above, which sort by a different axis. Also used to
198
+ * stabilize a Map's insertion order before an assertion in test/once-round.test.ts.) */
199
+ export function sortEntriesByKey(entries) {
200
+ return [...entries].sort(([a], [b]) => (a ?? "").localeCompare(b ?? ""));
201
+ }
165
202
  /** rankByCount for `[key, count]` entry pairs (count first): src/report/report-render.ts's
166
203
  * rankedRoleMap, src/team/failure-spread.ts's strongest-kind pick, and
167
204
  * src/doctor/doctor-contention-checks.ts's check-wait and provider-wait rankings. */
@@ -80,10 +80,6 @@ const BORROW_ORDER = {
80
80
  default: ["default", "strong", "small"],
81
81
  strong: ["strong", "default"],
82
82
  };
83
- /** Exported for imageRerouteTarget (src/agent/image-input.ts), which walks a rerouting role's
84
- * tier through the same borrow order the budget fallback uses (skipping the tier itself), and
85
- * for the doctor's `image input` check naming the tier a text-only role reroutes to. */
86
- export { BORROW_ORDER };
87
83
  /** A tier's OWN fallback chain, first step first — what the `fallback` key declares at that
88
84
  * tier's map entry (a selector or a list of them), or (default only) the string/list form of
89
85
  * `fallback` or the legacy `fallbackModel` object (a one-element chain). Empty when the tier
@@ -47,163 +47,195 @@ export function newLiveConfigReload(deps) {
47
47
  // the config application that follows the emission. bestEffortEventLogger (events.ts) holds
48
48
  // the shared "logging must not throw" policy.
49
49
  const { emit, warn } = bestEffortEventLogger(deps.root);
50
+ // Warn once per vanish and once per reappearance of tumwater.json.
51
+ function noteConfigPresence(reloaded) {
52
+ // A missing file keeps the last-known-good config too, and never reloads as defaults: the
53
+ // team was started on a real file (startup refuses to run without one), so its vanishing
54
+ // mid-run is an incident — a merge's fast-forward deleted it on 2026-09-22 and the team
55
+ // silently ran 8.6 h on defaults (BUGS.md 2026-09-23). One warning per vanish, not per
56
+ // poll; its return logs one line, then the normal reload below diffs it against the
57
+ // retained config — so config_changed names only what the returned file really changed.
58
+ if (reloaded.missing) {
59
+ if (!configMissing) {
60
+ warn(`tumwater.json missing — keeping current config until it returns (${configPath(deps.root)})`);
61
+ configMissing = true;
62
+ lastConfigError = null; // Whatever state it returns in is stated afresh.
63
+ }
64
+ }
65
+ else if (configMissing) {
66
+ warn("tumwater.json reappeared — reloading it");
67
+ configMissing = false;
68
+ }
69
+ }
70
+ // Refuse a live edit that routes a seam to an API-key-logged-in codex, keeping prevLive.
71
+ function refuseApiKeyCodex(nextConfig, prevBackends) {
72
+ // A live edit that routes a seam to a codex logged in with an API key is refused before
73
+ // the reload is applied: startup refuses the same codex seam (decision 5), and here a
74
+ // warn-only would let the running team spend per-token Codex the budget cap cannot see
75
+ // (review objection 1). Refusing keeps the last-known-good config, like a missing or
76
+ // broken file. The seed runs once per root (`codex login status` is a real probe); the
77
+ // persisted account file is the voice of the refusal on later polls, re-probing a stale
78
+ // api answer (codex-account.ts) so the operator's `codex login --device-auth` remedy
79
+ // clears it at the next seed. A subscription codex passes through to the backend loop.
80
+ const newlyRoutedCodex = !prevBackends.has("codex") && backendsInUse(nextConfig).includes("codex");
81
+ if (newlyRoutedCodex && !liveCodexSeedAttempted.has(deps.root)) {
82
+ liveCodexSeedAttempted.add(deps.root);
83
+ seedCodexAccount(deps.root, nextConfig);
84
+ }
85
+ if (newlyRoutedCodex && readCodexAccount(deps.root)?.billing === "api") {
86
+ if (codexLiveRefused)
87
+ return true;
88
+ codexLiveRefused = true;
89
+ warn("refusing live edit: Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run `CODEX_HOME=<home> codex login --device-auth`");
90
+ return true;
91
+ }
92
+ // A poll that did not refuse clears the warn-once marker so a later re-entry into the
93
+ // refusal (the operator reverts, then re-adds the API-billed codex seam) warns again.
94
+ codexLiveRefused = false;
95
+ return false;
96
+ }
97
+ // Emit config_changed and model_changed for what this reload actually changed.
98
+ function emitConfigChangeEvents(nextConfig) {
99
+ // A live edit that changes behavior elsewhere logs one event naming the settings that
100
+ // changed (maxConcurrent and sessionRetentionDays have their own events elsewhere).
101
+ const changedKeys = changedConfigKeys(prevLive, nextConfig);
102
+ if (changedKeys.length > 0) {
103
+ emit({ source: TUMWATER_SOURCE, type: "config_changed", keys: changedKeys });
104
+ // A real model-wiring change gets its own event naming the new selector(s), which the
105
+ // key list cannot show; a non-model edit (instructions, an interval) emits nothing.
106
+ const fromModel = teamModelLabel(prevLive);
107
+ const toModel = teamModelLabel(nextConfig);
108
+ const roleDiffs = changedKeys
109
+ .filter((key) => key.startsWith("roles."))
110
+ .map((key) => {
111
+ const role = key.slice("roles.".length);
112
+ const from = modelSelectorField(configForRole(prevLive, role)).model ?? null;
113
+ const to = modelSelectorField(configForRole(nextConfig, role)).model ?? null;
114
+ return { role, from, to };
115
+ })
116
+ .filter((d) => d.from !== d.to);
117
+ if (fromModel !== toModel || roleDiffs.length > 0)
118
+ emit({
119
+ source: TUMWATER_SOURCE,
120
+ type: "model_changed",
121
+ from: fromModel,
122
+ to: toModel,
123
+ ...(roleDiffs.length > 0 ? { roles: roleDiffs } : {}),
124
+ });
125
+ }
126
+ }
127
+ // Verify and seed each backend a live edit newly routes to.
128
+ function seedNewlyRoutedBackends(nextConfig, prevBackends) {
129
+ // A live edit that routes a seam to a backend the previous config did not use: the
130
+ // team keeps running, but that backend must be verified and its account (claude) or
131
+ // models catalog (opencode) seeded, exactly as startup would — so a claude reviewer
132
+ // added to a pi team does not start uncheckable. A missing binary is a warning event,
133
+ // never a crash; a present one logs its verdict as a backend_binary event. The seed is
134
+ // attempted once per root. (The codex seed moved to the refusal check at the top of
135
+ // this block, which also refuses an API-key-logged-in codex seam.)
136
+ for (const backend of backendsInUse(nextConfig)) {
137
+ if (prevBackends.has(backend))
138
+ continue;
139
+ const resolved = resolveBackendBin(nextConfig, backend);
140
+ if (findBackendBinary(resolved)) {
141
+ emit({ source: TUMWATER_SOURCE, type: "backend_binary", agent: backend, found: true });
142
+ }
143
+ else {
144
+ warn(`newly routed ${backend} backend: ${backendMissingMessage(resolved, backend)}`);
145
+ }
146
+ if (backend === "claude" && !liveClaudeSeedAttempted.has(deps.root)) {
147
+ liveClaudeSeedAttempted.add(deps.root);
148
+ seedClaudeAccount(deps.root, nextConfig);
149
+ }
150
+ if (backend === "opencode" && !liveOpencodeSeedAttempted.has(deps.root)) {
151
+ liveOpencodeSeedAttempted.add(deps.root);
152
+ // Not awaited: poll() is synchronous and a models.dev fetch must not stall the loop.
153
+ void seedOpencodeCatalog(deps.root, nextConfig, { ...deps.opencodeSeed, warn }).catch((err) => warn(`open code model catalog seed failed: ${errorMessage(err)}`));
154
+ }
155
+ }
156
+ }
157
+ // Live-resize the concurrency cap and the per-provider key caps.
158
+ function resizeConcurrency(nextConfig) {
159
+ // Live-resize the concurrency cap: a mid-run edit changes how many pi runs execute
160
+ // concurrently within this poll — no restart. Growing admits already-queued runs;
161
+ // shrinking never preempts in-flight work, it only caps future grants.
162
+ const newMaxConcurrent = Math.max(1, nextConfig.maxConcurrent);
163
+ if (newMaxConcurrent !== lastMaxConcurrent) {
164
+ deps.semaphore.setCapacity(newMaxConcurrent);
165
+ emit({ source: TUMWATER_SOURCE, type: "max_concurrent_changed", from: lastMaxConcurrent, to: newMaxConcurrent });
166
+ lastMaxConcurrent = newMaxConcurrent;
167
+ }
168
+ // Live-resize the per-provider caps (plans/provider-concurrency.md §7): an edit
169
+ // re-applies the whole map to the pool, and each changed provider fires its own
170
+ // `provider_concurrency_changed` event. setKeyCaps never preempts — shrinking only
171
+ // withholds future grants on that key until releases drain it under the new cap.
172
+ const nextKeyCaps = nextConfig.maxConcurrentPerProvider ?? {};
173
+ deps.semaphore.setKeyCaps(nextKeyCaps);
174
+ for (const provider of new Set([...Object.keys(lastKeyCaps), ...Object.keys(nextKeyCaps)])) {
175
+ const from = lastKeyCaps[provider];
176
+ const to = nextKeyCaps[provider];
177
+ if (from !== to) {
178
+ emit({
179
+ source: TUMWATER_SOURCE,
180
+ type: "provider_concurrency_changed",
181
+ provider,
182
+ from: from ?? 0,
183
+ to: to ?? 0,
184
+ });
185
+ }
186
+ }
187
+ lastKeyCaps = nextKeyCaps;
188
+ }
189
+ // Sync the runner list to newly enabled roles and warn on enable/disable.
190
+ function syncRunners(nextConfig) {
191
+ const nowEnabled = runnerIds(nextConfig);
192
+ // Enabling a role — or raising `roles.<id>.instances` — mid-run starts it: create the
193
+ // runner for each newly present role id (its persisted state survives). A lowered
194
+ // instance count keeps its runner; `runnerEnabled` skips its future runs. A scoped round
195
+ // skips every other id: the enabling is logged (the warn loop below), but its runner
196
+ // waits for the next unscoped round.
197
+ for (const role of nowEnabled) {
198
+ if (deps.roleFilter !== undefined && !deps.roleFilter.includes(role))
199
+ continue;
200
+ if (!deps.runners.some((r) => r.role === role))
201
+ deps.runners.push(new RoleRunner(deps.root, role, nextConfig, deps.mainBranch, deps.runnerSignal));
202
+ }
203
+ for (const role of prevEnabled)
204
+ if (!nowEnabled.includes(role))
205
+ warn(`role ${role} disabled — no new runs`);
206
+ for (const role of nowEnabled)
207
+ if (!prevEnabled.has(role))
208
+ warn(`role ${role} enabled — starting runs`);
209
+ prevEnabled = new Set(nowEnabled);
210
+ }
211
+ // Warn once per distinct broken-file error text.
212
+ function noteInvalidConfig(reloaded) {
213
+ if (reloaded.error && reloaded.error !== lastConfigError) {
214
+ warn(`tumwater.json invalid — keeping current config: ${reloaded.error}`);
215
+ lastConfigError = reloaded.error;
216
+ }
217
+ }
50
218
  return {
51
219
  poll() {
52
220
  const reloaded = loadConfigCached(deps.root);
53
- // A missing file keeps the last-known-good config too, and never reloads as defaults: the
54
- // team was started on a real file (startup refuses to run without one), so its vanishing
55
- // mid-run is an incident — a merge's fast-forward deleted it on 2026-09-22 and the team
56
- // silently ran 8.6 h on defaults (BUGS.md 2026-09-23). One warning per vanish, not per
57
- // poll; its return logs one line, then the normal reload below diffs it against the
58
- // retained config — so config_changed names only what the returned file really changed.
59
- if (reloaded.missing) {
60
- if (!configMissing) {
61
- warn(`tumwater.json missing — keeping current config until it returns (${configPath(deps.root)})`);
62
- configMissing = true;
63
- lastConfigError = null; // Whatever state it returns in is stated afresh.
64
- }
65
- }
66
- else if (configMissing) {
67
- warn("tumwater.json reappeared — reloading it");
68
- configMissing = false;
69
- }
221
+ noteConfigPresence(reloaded);
70
222
  if (reloaded.config) {
71
223
  const nextConfig = reloaded.config;
72
- // A live edit that routes a seam to a codex logged in with an API key is refused before
73
- // the reload is applied: startup refuses the same codex seam (decision 5), and here a
74
- // warn-only would let the running team spend per-token Codex the budget cap cannot see
75
- // (review objection 1). Refusing keeps the last-known-good config, like a missing or
76
- // broken file. The seed runs once per root (`codex login status` is a real probe); the
77
- // persisted account file is the voice of the refusal on later polls, re-probing a stale
78
- // api answer (codex-account.ts) so the operator's `codex login --device-auth` remedy
79
- // clears it at the next seed. A subscription codex passes through to the backend loop.
80
224
  const prevBackends = new Set(backendsInUse(prevLive));
81
- const newlyRoutedCodex = !prevBackends.has("codex") && backendsInUse(nextConfig).includes("codex");
82
- if (newlyRoutedCodex && !liveCodexSeedAttempted.has(deps.root)) {
83
- liveCodexSeedAttempted.add(deps.root);
84
- seedCodexAccount(deps.root, nextConfig);
85
- }
86
- if (newlyRoutedCodex && readCodexAccount(deps.root)?.billing === "api") {
87
- if (codexLiveRefused)
88
- return prevLive;
89
- codexLiveRefused = true;
90
- warn("refusing live edit: Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run `CODEX_HOME=<home> codex login --device-auth`");
225
+ if (refuseApiKeyCodex(nextConfig, prevBackends))
91
226
  return prevLive;
92
- }
93
- // A poll that did not refuse clears the warn-once marker so a later re-entry into the
94
- // refusal (the operator reverts, then re-adds the API-billed codex seam) warns again.
95
- codexLiveRefused = false;
96
- live = reloaded.config;
97
- // A live edit that changes behavior elsewhere logs one event naming the settings that
98
- // changed (maxConcurrent and sessionRetentionDays have their own events elsewhere).
99
- const changedKeys = changedConfigKeys(prevLive, reloaded.config);
100
- if (changedKeys.length > 0) {
101
- emit({ source: TUMWATER_SOURCE, type: "config_changed", keys: changedKeys });
102
- // A real model-wiring change gets its own event naming the new selector(s), which the
103
- // key list cannot show; a non-model edit (instructions, an interval) emits nothing.
104
- const fromModel = teamModelLabel(prevLive);
105
- const toModel = teamModelLabel(reloaded.config);
106
- const roleDiffs = changedKeys
107
- .filter((key) => key.startsWith("roles."))
108
- .map((key) => {
109
- const role = key.slice("roles.".length);
110
- const from = modelSelectorField(configForRole(prevLive, role)).model ?? null;
111
- const to = modelSelectorField(configForRole(nextConfig, role)).model ?? null;
112
- return { role, from, to };
113
- })
114
- .filter((d) => d.from !== d.to);
115
- if (fromModel !== toModel || roleDiffs.length > 0)
116
- emit({
117
- source: TUMWATER_SOURCE,
118
- type: "model_changed",
119
- from: fromModel,
120
- to: toModel,
121
- ...(roleDiffs.length > 0 ? { roles: roleDiffs } : {}),
122
- });
123
- }
124
- // A live edit that routes a seam to a backend the previous config did not use: the
125
- // team keeps running, but that backend must be verified and its account (claude) or
126
- // models catalog (opencode) seeded, exactly as startup would — so a claude reviewer
127
- // added to a pi team does not start uncheckable. A missing binary is a warning event,
128
- // never a crash; a present one logs its verdict as a backend_binary event. The seed is
129
- // attempted once per root. (The codex seed moved to the refusal check at the top of
130
- // this block, which also refuses an API-key-logged-in codex seam.)
131
- for (const backend of backendsInUse(reloaded.config)) {
132
- if (prevBackends.has(backend))
133
- continue;
134
- const resolved = resolveBackendBin(reloaded.config, backend);
135
- if (findBackendBinary(resolved)) {
136
- emit({ source: TUMWATER_SOURCE, type: "backend_binary", agent: backend, found: true });
137
- }
138
- else {
139
- warn(`newly routed ${backend} backend: ${backendMissingMessage(resolved, backend)}`);
140
- }
141
- if (backend === "claude" && !liveClaudeSeedAttempted.has(deps.root)) {
142
- liveClaudeSeedAttempted.add(deps.root);
143
- seedClaudeAccount(deps.root, reloaded.config);
144
- }
145
- if (backend === "opencode" && !liveOpencodeSeedAttempted.has(deps.root)) {
146
- liveOpencodeSeedAttempted.add(deps.root);
147
- // Not awaited: poll() is synchronous and a models.dev fetch must not stall the loop.
148
- void seedOpencodeCatalog(deps.root, reloaded.config, { ...deps.opencodeSeed, warn }).catch((err) => warn(`open code model catalog seed failed: ${errorMessage(err)}`));
149
- }
150
- }
151
- prevLive = reloaded.config;
227
+ live = nextConfig;
228
+ emitConfigChangeEvents(nextConfig);
229
+ seedNewlyRoutedBackends(nextConfig, prevBackends);
230
+ prevLive = nextConfig;
152
231
  for (const r of deps.runners)
153
- r.config = reloaded.config;
154
- // Live-resize the concurrency cap: a mid-run edit changes how many pi runs execute
155
- // concurrently within this poll — no restart. Growing admits already-queued runs;
156
- // shrinking never preempts in-flight work, it only caps future grants.
157
- const newMaxConcurrent = Math.max(1, reloaded.config.maxConcurrent);
158
- if (newMaxConcurrent !== lastMaxConcurrent) {
159
- deps.semaphore.setCapacity(newMaxConcurrent);
160
- emit({ source: TUMWATER_SOURCE, type: "max_concurrent_changed", from: lastMaxConcurrent, to: newMaxConcurrent });
161
- lastMaxConcurrent = newMaxConcurrent;
162
- }
163
- // Live-resize the per-provider caps (plans/provider-concurrency.md §7): an edit
164
- // re-applies the whole map to the pool, and each changed provider fires its own
165
- // `provider_concurrency_changed` event. setKeyCaps never preempts — shrinking only
166
- // withholds future grants on that key until releases drain it under the new cap.
167
- const nextKeyCaps = reloaded.config.maxConcurrentPerProvider ?? {};
168
- deps.semaphore.setKeyCaps(nextKeyCaps);
169
- for (const provider of new Set([...Object.keys(lastKeyCaps), ...Object.keys(nextKeyCaps)])) {
170
- const from = lastKeyCaps[provider];
171
- const to = nextKeyCaps[provider];
172
- if (from !== to) {
173
- emit({
174
- source: TUMWATER_SOURCE,
175
- type: "provider_concurrency_changed",
176
- provider,
177
- from: from ?? 0,
178
- to: to ?? 0,
179
- });
180
- }
181
- }
182
- lastKeyCaps = nextKeyCaps;
183
- const nowEnabled = runnerIds(reloaded.config);
184
- // Enabling a role — or raising `roles.<id>.instances` — mid-run starts it: create the
185
- // runner for each newly present role id (its persisted state survives). A lowered
186
- // instance count keeps its runner; `runnerEnabled` skips its future runs. A scoped round
187
- // skips every other id: the enabling is logged (the warn loop below), but its runner
188
- // waits for the next unscoped round.
189
- for (const role of nowEnabled) {
190
- if (deps.roleFilter !== undefined && !deps.roleFilter.includes(role))
191
- continue;
192
- if (!deps.runners.some((r) => r.role === role))
193
- deps.runners.push(new RoleRunner(deps.root, role, reloaded.config, deps.mainBranch, deps.runnerSignal));
194
- }
195
- for (const role of prevEnabled)
196
- if (!nowEnabled.includes(role))
197
- warn(`role ${role} disabled — no new runs`);
198
- for (const role of nowEnabled)
199
- if (!prevEnabled.has(role))
200
- warn(`role ${role} enabled — starting runs`);
201
- prevEnabled = new Set(nowEnabled);
232
+ r.config = nextConfig;
233
+ resizeConcurrency(nextConfig);
234
+ syncRunners(nextConfig);
202
235
  lastConfigError = null;
203
236
  }
204
- else if (reloaded.error && reloaded.error !== lastConfigError) {
205
- warn(`tumwater.json invalid — keeping current config: ${reloaded.error}`);
206
- lastConfigError = reloaded.error;
237
+ else {
238
+ noteInvalidConfig(reloaded);
207
239
  }
208
240
  return live;
209
241
  },