tumwater 0.4.1 → 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 (186) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/backend-traits.js +14 -0
  4. package/dist/src/agent/claude/claude-stream.js +27 -31
  5. package/dist/src/agent/codex/codex-rollout.js +38 -24
  6. package/dist/src/agent/codex/codex-stream.js +71 -38
  7. package/dist/src/agent/image-input.js +18 -87
  8. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  9. package/dist/src/agent/opencode/opencode-stream.js +4 -4
  10. package/dist/src/agent/run-agent.js +11 -2
  11. package/dist/src/agent/run-settle.js +22 -13
  12. package/dist/src/agent/run-spawn.js +3 -12
  13. package/dist/src/agent/run-stream.js +2 -10
  14. package/dist/src/agent/session-ledger.js +16 -0
  15. package/dist/src/backlog/backlog-archive.js +64 -165
  16. package/dist/src/backlog/backlog-eligibility.js +50 -58
  17. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  18. package/dist/src/backlog/backlog-index-data.js +2 -2
  19. package/dist/src/backlog/backlog-md.js +38 -27
  20. package/dist/src/backlog/backlog-release.js +11 -19
  21. package/dist/src/backlog/backlog-structure.js +14 -1
  22. package/dist/src/backlog/backlog-write.js +1 -1
  23. package/dist/src/backlog/backlog.js +5 -4
  24. package/dist/src/backlog/question-answer.js +30 -14
  25. package/dist/src/baseline/main-baseline.js +30 -20
  26. package/dist/src/baseline/main-guardrails.js +54 -17
  27. package/dist/src/baseline/main-watch.js +1 -1
  28. package/dist/src/budget/budget-wiring.js +0 -5
  29. package/dist/src/budget/budget.js +2 -1
  30. package/dist/src/build/build-check-scoped.js +70 -41
  31. package/dist/src/change/change-data.js +4 -2
  32. package/dist/src/cli/cli-command-args.js +199 -98
  33. package/dist/src/cli/cli-flag-specs.js +1 -4
  34. package/dist/src/cli/cli-review-commands.js +2 -1
  35. package/dist/src/cli/cli-role-command.js +27 -50
  36. package/dist/src/cli/cli-start.js +62 -12
  37. package/dist/src/cli/follow-view.js +3 -1
  38. package/dist/src/cli/help.js +6 -7
  39. package/dist/src/cli/log-commands.js +3 -1
  40. package/dist/src/cli.js +3 -7
  41. package/dist/src/collections.js +21 -7
  42. package/dist/src/config/config-fallbacks.js +0 -4
  43. package/dist/src/config/config-live.js +179 -147
  44. package/dist/src/config/config-views.js +2 -7
  45. package/dist/src/config/config-write.js +19 -26
  46. package/dist/src/doctor/doctor-backlog-bugs.js +17 -115
  47. package/dist/src/doctor/doctor-backlog-plans.js +8 -8
  48. package/dist/src/doctor/doctor-backlog.js +22 -36
  49. package/dist/src/doctor/doctor-check-names.js +2 -2
  50. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  51. package/dist/src/doctor/doctor-contention-checks.js +1 -1
  52. package/dist/src/doctor/doctor-holds.js +13 -11
  53. package/dist/src/doctor/doctor-model-checks.js +8 -184
  54. package/dist/src/doctor/doctor-notify.js +2 -5
  55. package/dist/src/doctor/doctor-opencode-checks.js +85 -48
  56. package/dist/src/doctor/doctor-project-checks.js +66 -17
  57. package/dist/src/doctor/doctor-role-notes.js +6 -24
  58. package/dist/src/doctor/doctor.js +6 -6
  59. package/dist/src/doctor/probe.js +1 -1
  60. package/dist/src/events/event-format.js +5 -15
  61. package/dist/src/events/event-labels.js +0 -1
  62. package/dist/src/events/event-legacy.js +1 -1
  63. package/dist/src/events/event-window.js +1 -1
  64. package/dist/src/events/events.js +15 -2
  65. package/dist/src/failure/failure-state-change.js +183 -68
  66. package/dist/src/failure/time-spend.js +1 -1
  67. package/dist/src/files/json-object.js +15 -2
  68. package/dist/src/gates/startup-gate.js +28 -28
  69. package/dist/src/git/slot-salvage.js +5 -4
  70. package/dist/src/gui/gui-admin-endpoints.js +2 -3
  71. package/dist/src/gui/gui-backlog-commands.js +1 -1
  72. package/dist/src/gui/gui-backlog-endpoints.js +1 -7
  73. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  74. package/dist/src/gui/gui-operator-commands.js +0 -12
  75. package/dist/src/gui/gui-prompt-commands.js +26 -20
  76. package/dist/src/gui/gui-report-endpoints.js +2 -1
  77. package/dist/src/gui/gui-server.js +1 -2
  78. package/dist/src/history/history.js +102 -67
  79. package/dist/src/inbox/inbox-submit.js +1 -5
  80. package/dist/src/inbox/prompt-commands.js +24 -19
  81. package/dist/src/init/init.js +68 -15
  82. package/dist/src/merge/merge-core.js +1 -1
  83. package/dist/src/merge/merge-post-review-drain.js +1 -1
  84. package/dist/src/operator/operator-commands.js +68 -47
  85. package/dist/src/operator/retire.js +2 -1
  86. package/dist/src/orchestrator/retention.js +6 -5
  87. package/dist/src/paths.js +0 -6
  88. package/dist/src/pi/pi-watchdogs.js +20 -1
  89. package/dist/src/pi-extension/context-shake.js +55 -31
  90. package/dist/src/process/process.js +23 -0
  91. package/dist/src/process/supervisor.js +17 -1
  92. package/dist/src/prompt/gate-notes.js +9 -9
  93. package/dist/src/prompt/notes-backlog.js +11 -24
  94. package/dist/src/prompt/prompt-followup.js +4 -12
  95. package/dist/src/prompt/prompt.js +4 -5
  96. package/dist/src/report/report-data.js +10 -225
  97. package/dist/src/report/report-fold.js +20 -295
  98. package/dist/src/report/report-main-red.js +45 -42
  99. package/dist/src/report/report-render.js +66 -39
  100. package/dist/src/report/report-since-data.js +218 -0
  101. package/dist/src/report/report-usage-fold.js +288 -0
  102. package/dist/src/report/report.js +2 -1
  103. package/dist/src/review/known-flakes.js +3 -3
  104. package/dist/src/review/post-merge-review-accept.js +3 -2
  105. package/dist/src/review/post-merge-review.js +9 -9
  106. package/dist/src/review/quarantine-render.js +2 -1
  107. package/dist/src/roles/role-catalog.js +18 -30
  108. package/dist/src/roles/role-guidance.js +18 -43
  109. package/dist/src/roles/role-view.js +1 -2
  110. package/dist/src/run/ci-parity.js +5 -6
  111. package/dist/src/run/qa-coverage.js +4 -4
  112. package/dist/src/run/run-detail-data.js +23 -7
  113. package/dist/src/run/run-detail-render.js +6 -0
  114. package/dist/src/run/run-detail.js +44 -22
  115. package/dist/src/run/run-dismiss.js +43 -0
  116. package/dist/src/run/run-prompt.js +100 -56
  117. package/dist/src/run/run-resume.js +32 -31
  118. package/dist/src/run/run-stage.js +4 -21
  119. package/dist/src/run/run-timing.js +18 -4
  120. package/dist/src/run/run-verdict.js +8 -73
  121. package/dist/src/run/stage-backlog-checks.js +2 -29
  122. package/dist/src/run/stage-check.js +4 -12
  123. package/dist/src/runner/runner-outcome.js +0 -5
  124. package/dist/src/runner/runner-run.js +2 -79
  125. package/dist/src/runner/runner-state-load.js +0 -10
  126. package/dist/src/runner/runner.js +1 -32
  127. package/dist/src/scheduling/claims.js +3 -10
  128. package/dist/src/status/status-data.js +5 -9
  129. package/dist/src/status/status-role-notes.js +18 -14
  130. package/dist/src/status/status-roles.js +2 -35
  131. package/dist/src/text/datetime.js +8 -1
  132. package/dist/src/text/text.js +12 -0
  133. package/dist/src/ui/alert-feeds.js +7 -212
  134. package/dist/src/ui/badges.js +2 -3
  135. package/dist/src/ui/gui/gui-client-boot.js +19 -4
  136. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  137. package/dist/src/ui/gui/gui-client-report-chart.js +10 -8
  138. package/dist/src/ui/gui/gui-client-report.js +1 -2
  139. package/dist/src/ui/gui/gui-client-settings.js +0 -23
  140. package/dist/src/ui/gui/gui-client-team.js +26 -0
  141. package/dist/src/ui/gui/gui-icons.js +7 -1
  142. package/dist/src/ui/status-model.js +2 -2
  143. package/dist/src/ui/status-payload.js +3 -3
  144. package/dist/src/ui/status-render.js +3 -3
  145. package/dist/src/ui/team-alert-backlog.js +23 -54
  146. package/dist/src/ui/team-alert-shared.js +23 -10
  147. package/dist/src/ui/team-alert-status.js +8 -3
  148. package/dist/src/ui/team-alert-tail.js +1 -14
  149. package/dist/src/ui/team-alerts.js +6 -17
  150. package/dist/src/ui/transcript-tail.js +137 -94
  151. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  152. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  153. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  154. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  155. package/dist/src/ui/tui/tui-editor-budget.js +105 -93
  156. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  157. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  158. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  159. package/dist/src/ui/tui/tui-editor-pause.js +209 -183
  160. package/dist/src/ui/tui/tui-editor-prompt.js +44 -34
  161. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  162. package/dist/src/ui/tui/tui-editor-quiet.js +121 -108
  163. package/dist/src/ui/tui/tui-editor-release.js +50 -42
  164. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  165. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  166. package/dist/src/ui/tui/tui-editor-role-file.js +58 -46
  167. package/dist/src/ui/tui/tui-editor-role-key.js +49 -42
  168. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  169. package/dist/src/ui/tui/tui-frame.js +1 -1
  170. package/dist/src/ui/tui/tui-keys.js +1 -1
  171. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  172. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  173. package/dist/src/ui/tui/tui-pane-health.js +3 -24
  174. package/dist/src/ui/tui/tui-pane-history.js +20 -24
  175. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  176. package/dist/src/ui/tui/tui-pane-session.js +3 -2
  177. package/dist/src/ui/tui/tui-pane-transcript.js +151 -128
  178. package/dist/src/ui/tui/tui-pane.js +201 -174
  179. package/dist/src/ui/tui/tui-panes.js +2 -3
  180. package/dist/src/ui/tui/tui.js +3 -3
  181. package/dist/src/verdict/fix-claim.js +18 -6
  182. package/dist/src/verdict/refusal.js +30 -6
  183. package/package.json +1 -1
  184. package/dist/src/doctor/image-probe.js +0 -257
  185. package/dist/src/ui/team-alert-backlog-feed.js +0 -181
  186. package/dist/src/ui/team-alert-merge.js +0 -183
@@ -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
  },
@@ -109,12 +109,7 @@ export function tierModel(config, tier) {
109
109
  * key it overrides would.
110
110
  *
111
111
  * `tier` is the seam's effective tier, already resolved by the caller from any tier-name
112
- * override — so `o.model` here is always a selector string, never a tier reference.
113
- *
114
- * Exported so imageRerouteTarget (src/agent/image-input.ts) can probe a peer tier's
115
- * top-level model (`withModelOverrides(config, tier, {})`, the same shape resolverConfig
116
- * builds strong with) and reshape a tier's fallback pair into a runnable config — the
117
- * tier-config builder the plan names. */
112
+ * override — so `o.model` here is always a selector string, never a tier reference. */
118
113
  export function withModelOverrides(config, tier, o) {
119
114
  // The top-level model resolves at the seam's tier: a string form is the default tier's
120
115
  // selector; a map form takes the tier's entry, else `default`'s.
@@ -204,7 +199,7 @@ export function configForRole(config, role) {
204
199
  }
205
200
  /** The config a role's runs SCHEDULE from: configForRole, except that while PLANS.md still
206
201
  * has planning room (planBacklogOpen — below the plan role's eligible-plan target, or holding
207
- * a plan that needs review/needs replan) the plan role schedules with a ZERO min gap, so a
202
+ * a plan that needs review) the plan role schedules with a ZERO min gap, so a
208
203
  * productive plan run reruns on the next poll and an unproductive one drops to the idle
209
204
  * backoff. The plan role's own machine signal is `planBacklogFull` (src/scheduling/claims.ts):
210
205
  * a fresh project's empty backlog is not full, so this subsumes the old placeholder-status
@@ -13,12 +13,28 @@
13
13
  import { isDeepStrictEqual } from "node:util";
14
14
  import { modelTierMap } from "./config-schema.js";
15
15
  import { isJsonObject, objectOrEmpty } from "../files/json-object.js";
16
+ import { isExponentSpelling } from "../text/text.js";
16
17
  import { show } from "./config-field-checks.js";
17
18
  import { canonicalConfigKey } from "./config-aliases.js";
18
19
  import { unknownRoleMessage } from "../roles/roles.js";
19
20
  import { loadConfig, collapseBackendAliases, knownRoleIdsCached, } from "./config.js";
20
21
  import { checkDailyBudgetUsd, checkProviderConcurrencyCap, checkQuietHours, parseConfigKey, PER_KEY_VALIDATORS, unknownConfigKeyError, unknownRoleFieldError, } from "./config-keys.js";
21
22
  import { writeConfigMutation, fail, rawConfigObject, without, preservingFileRoles, preserveFileRoles, withSectionOrDropped, } from "./config-write-core.js";
23
+ /** Drop `field` from the `backends.<backend>` entry of the config `next`, dropping the whole
24
+ * backend entry when it empties. The one home of the "remove a nested backend field, drop the
25
+ * backend when empty" step the legacy `agentBin`/`piArgs` collapse and the `claudeWindowReserve`
26
+ * collapse share, so the empty-entry rule cannot drift between the two. */
27
+ function dropNestedBackendField(next, backend, field) {
28
+ const record = next;
29
+ const backends = { ...(record.backends ?? {}) };
30
+ const entry = { ...(backends[backend] ?? {}) };
31
+ delete entry[field];
32
+ if (Object.keys(entry).length === 0)
33
+ delete backends[backend];
34
+ else
35
+ backends[backend] = entry;
36
+ return { ...next, backends: backends };
37
+ }
22
38
  /** Set the daily cost budget cap in tumwater.json through the shared writer idiom (see
23
39
  * writeConfigMutation), after checkDailyBudgetUsd has screened the value with its own
24
40
  * actionable message — the one both dashboards flash. A cap the config already resolves to
@@ -80,10 +96,7 @@ export function setConfigKey(root, key, rawValue) {
80
96
  (parsed.kind === "map" && parsed.map === "maxConcurrentPerProvider") ||
81
97
  (parsed.kind === "role" && parsed.field === "instances") ||
82
98
  (parsed.kind === "section" && parsed.section === "bootstrap" && parsed.field === "untilPlansDone");
83
- if (isPlainIntegerKey &&
84
- trimmed !== "" &&
85
- !/^\d*\.?\d+$/.test(trimmed) &&
86
- /^\d+(\.\d+)?[eE][+-]?\d+$/.test(trimmed))
99
+ if (isPlainIntegerKey && trimmed !== "" && isExponentSpelling(trimmed))
87
100
  return `${key} must be a plain integer — no exponent notation`;
88
101
  return null;
89
102
  };
@@ -267,31 +280,11 @@ export function setConfigKey(root, key, rawValue) {
267
280
  let unchanged;
268
281
  // Writers keep one spelling: a legacy top-level set drops the matching backends.pi field, so
269
282
  // the file can never hold both (validateConfig's raw pass rejects a file that does).
270
- const dropLegacyPiAlias = (next, key) => {
271
- const record = next;
272
- const backends = { ...(record.backends ?? {}) };
273
- const entry = { ...(backends.pi ?? {}) };
274
- delete entry[key === "agentBin" ? "bin" : "args"];
275
- if (Object.keys(entry).length === 0)
276
- delete backends.pi;
277
- else
278
- backends.pi = entry;
279
- return { ...next, backends: backends };
280
- };
283
+ const dropLegacyPiAlias = (next, key) => dropNestedBackendField(next, "pi", key === "agentBin" ? "bin" : "args");
281
284
  // Writers keep one spelling: setting the top-level `claudeWindowReserve` drops the nested
282
285
  // `backends.claude.windowReserve`, so a supported `config set` never writes the rejected pair
283
286
  // (validateConfig still refuses a hand-edited file holding both).
284
- const dropNestedReserve = (next) => {
285
- const record = next;
286
- const backends = { ...(record.backends ?? {}) };
287
- const entry = { ...(backends.claude ?? {}) };
288
- delete entry.windowReserve;
289
- if (Object.keys(entry).length === 0)
290
- delete backends.claude;
291
- else
292
- backends.claude = entry;
293
- return { ...next, backends: backends };
294
- };
287
+ const dropNestedReserve = (next) => dropNestedBackendField(next, "claude", "windowReserve");
295
288
  const result = writeConfigMutation(root, (cfg) => {
296
289
  oldValue = cfg[key];
297
290
  if (isDeepStrictEqual(oldValue, value)) {
@@ -1,20 +1,18 @@
1
- import { andMore, quoteHeading, readBacklogChecked, FIX_CLAIMS_CHECKED, REFUSAL_NOTE_MAX, STALE_QUESTION_DAYS, STALE_BUG_DAYS, STALE_WAIT_DAYS } from "./doctor-backlog.js";
1
+ import { andMore, quoteHeading, readBacklogChecked, FIX_CLAIMS_CHECKED, REFUSAL_NOTE_MAX, STALE_QUESTION_DAYS, STALE_BUG_DAYS } from "./doctor-backlog.js";
2
2
  import { truncate } from "../text/text.js";
3
+ import { plural, agree } from "../text/phrases.js";
3
4
  import { ageInDays, filingDate, fixedBugEntries, openBugEntries, openQuestionEntries, plannedPlanEntries } from "../backlog/backlog.js";
4
- import { stripEntryStamp } from "../backlog/backlog-structure.js";
5
5
  import { questionFilingDate } from "../backlog/question-answer.js";
6
- import { refusalNote, waitingNote } from "../backlog/backlog-eligibility.js";
6
+ import { needsReviewNote, refusalNote } from "../backlog/backlog-eligibility.js";
7
7
  import { plannedPartKeys, plansWaitingOn, prerequisitesMerged } from "../backlog/backlog-parts.js";
8
8
  import { lastDate } from "../backlog/backlog-md.js";
9
9
  import { formatTimestamp } from "../text/datetime.js";
10
10
  import { stringList } from "../files/json-object.js";
11
- import { byDaysThenIndex } from "../collections.js";
12
11
  import { readEventsSince } from "../events/event-window.js";
13
12
  import { okOutcome, warnOutcome } from "./outcome.js";
14
13
  /** The doctor's open-bug and question checks, split out of doctor-backlog.ts: questions that
15
14
  * have waited too long for an answer (checkStaleQuestions), bugs left unfixed (checkStaleBugs),
16
- * bugs held for their next failure (checkWaitingBugs, with its quotedTestName / waitingNoteDate /
17
- * failureMentions helpers), fixed records whose quoted test regressed (checkRegressedFixes), and
15
+ * fixed records whose quoted test regressed (checkRegressedFixes), and
18
16
  * entries parked by a Refused note (checkRefusedEntries). They import the shared helpers
19
17
  * (readBacklogChecked, andMore) from doctor-backlog.ts, plus the age/trim bounds, and
20
18
  * all warn instead of failing: a stale or
@@ -48,17 +46,18 @@ export function checkStaleQuestions(root) {
48
46
  return warnOutcome(`QUESTIONS.md question ${quoteHeading(first.title)} has waited ${first.days} d for an answer${more} — ` +
49
47
  `a question that waits too long for a human answer is worth your attention`);
50
48
  }
51
- /** Stale bugs — unrefused open BUGS.md bugs whose `(found by … YYYY-MM-DD)` or `(reported …)`
52
- * stamp is at least 3 days old. The bugfix role fixes a bug within hours, so one that sits means
53
- * no role is fixing it. Positions are the 1-based `bug --list` numbers; a refused bug is skipped
54
- * (checkRefusedEntries names it), a waiting bug is skipped (checkWaitingBugs names it) and so is
55
- * an undated heading. A warn, never a fail. */
49
+ /** Stale bugs — unrefused, unheld open BUGS.md bugs whose `(found by … YYYY-MM-DD)` or
50
+ * `(reported …)` stamp is at least 3 days old. The bugfix role fixes a bug within hours, so one
51
+ * that sits means no role is fixing it. Positions are the 1-based `bug --list` numbers; a refused
52
+ * bug is skipped (checkRefusedEntries names it), a bug parked by a Needs-review note is skipped
53
+ * (the note names what would make it fail, so it is not unfixed), and so is an undated heading.
54
+ * A warn, never a fail. */
56
55
  export function checkStaleBugs(root) {
57
56
  const doc = readBacklogChecked(root, "BUGS.md");
58
57
  if (typeof doc !== "string")
59
58
  return doc;
60
59
  const stale = openBugEntries(root).flatMap((entry, index) => {
61
- if (refusalNote(entry.body) !== null || waitingNote(entry.body) !== null)
60
+ if (refusalNote(entry.body) !== null || needsReviewNote(entry.body) !== null)
62
61
  return [];
63
62
  const stamp = filingDate(entry.title);
64
63
  if (stamp === null)
@@ -85,10 +84,6 @@ export function quotedTestName(title) {
85
84
  function dayAfterMs(date) {
86
85
  return new Date(Number(date.slice(0, 4)), Number(date.slice(5, 7)) - 1, Number(date.slice(8, 10)) + 1).getTime();
87
86
  }
88
- /** The first `YYYY-MM-DD` in a Waiting for the next failure note's text, or null. */
89
- export function waitingNoteDate(note) {
90
- return /\d{4}-\d{2}-\d{2}/.exec(note)?.[0] ?? null;
91
- }
92
87
  /** True when `text` is a check-failure report that names `name` as a test that failed — the
93
88
  * doctors' recurrence/regression evidence. The recurrence scans must not count any line that
94
89
  * merely *contains* the quoted test name as the flake's return, or a warning that only quotes
@@ -127,8 +122,8 @@ export function failureMentions(events, name, sinceMs) {
127
122
  }
128
123
  /** The newest failure report at or after `since` naming `name` — `failureMentions`'s last
129
124
  * mention — or undefined when none. The single home of the "did this flake come back after
130
- * its boundary?" step, shared by the waiting-bug recurrence scan (checkWaitingBugs) and the
131
- * regressed-fix match (matchedRegressedFixes), so both keep the same last-mention rule. */
125
+ * its boundary?" step, shared by the regressed-fix match (matchedRegressedFixes), so both
126
+ * keep the same last-mention rule. */
132
127
  function lastFailureMention(events, name, sinceMs) {
133
128
  const mentions = failureMentions(events, name, sinceMs);
134
129
  return mentions[mentions.length - 1];
@@ -136,104 +131,11 @@ function lastFailureMention(events, name, sinceMs) {
136
131
  /** The read-events window (ms) that reaches the oldest of `sinces` — `Date.now()` minus the
137
132
  * oldest timestamp, floored at 0 — so a recurrence check reads events past every candidate's
138
133
  * `since`. The single home of the "oldest of a set of since-times" step, shared by
139
- * checkWaitingBugs and checkRegressedFixes, so those sites cannot drift on the clamp or the
134
+ * checkRegressedFixes, so those sites cannot drift on the clamp or the
140
135
  * time base. */
141
136
  function sinceWindowMs(sinces) {
142
137
  return Math.max(0, Date.now() - Math.min(...sinces));
143
138
  }
144
- /** Open BUGS.md bugs held by a **Waiting for the next failure …** note, in file order: the
145
- * doctor's `checkWaitingBugs` selection and anchor, factored out so the always-on stale-wait
146
- * feed (staleWaitingBugRows) reads the same held set and cannot drift from the doctor's rule.
147
- * A bug that is also Refused is left to checkRefusedEntries. `since` is the day-after the
148
- * note's date — the recurrence scan's boundary; `date` the note's own `YYYY-MM-DD` or null. */
149
- function heldWaitingBugs(root) {
150
- return openBugEntries(root).flatMap((entry, index) => {
151
- const note = waitingNote(entry.body);
152
- if (note === null || refusalNote(entry.body) !== null)
153
- return [];
154
- const name = quotedTestName(entry.title);
155
- const date = waitingNoteDate(note);
156
- const since = name !== null && date !== null ? dayAfterMs(date) : null;
157
- return [{ index, position: index + 1, title: entry.title, note, name, since, date }];
158
- });
159
- }
160
- /** Waiting bugs — open BUGS.md bugs held by a **Waiting for the next failure …** note, which
161
- * parks a bug until it fails again. Positions are the 1-based `bug --list` numbers; a bug that
162
- * is also Refused is left to checkRefusedEntries. Ok when the hold is undisturbed — doctor
163
- * then only names the bugs and how to release one — and a warn when a held bug's quoted test
164
- * has failed in a gate or merge check since the day after its note (the note carries only a
165
- * date, so a same-day failure cannot be told from the one it was written about). A warn is
166
- * never a fail, and doctor writes nothing to BUGS.md. */
167
- export function checkWaitingBugs(root) {
168
- const doc = readBacklogChecked(root, "BUGS.md");
169
- if (typeof doc !== "string")
170
- return doc;
171
- const held = heldWaitingBugs(root);
172
- const [first, ...rest] = held;
173
- if (!first)
174
- return okOutcome("no open bug is waiting for its next failure");
175
- const sinces = held.flatMap((b) => (b.since !== null && !Number.isNaN(b.since) ? [b.since] : []));
176
- if (sinces.length > 0) {
177
- const { events } = readEventsSince(root, sinceWindowMs(sinces));
178
- const recurred = held.flatMap((b) => {
179
- if (b.name === null || b.since === null)
180
- return [];
181
- const newest = lastFailureMention(events, b.name, b.since);
182
- return newest ? [{ ...b, newest }] : [];
183
- });
184
- const [again, ...others] = recurred;
185
- if (again) {
186
- const more = andMore(others, (b) => `#${b.position} ${quoteHeading(b.title)}`);
187
- return warnOutcome(`BUGS.md bug #${again.position} ${quoteHeading(again.title)} failed again since its Waiting for the next failure note ` +
188
- `(${formatTimestamp(again.newest.ts)}: ${truncate(again.newest.text, REFUSAL_NOTE_MAX)})${more} — ` +
189
- `record the occurrence under the entry and release it with \`tumwater bug --release ${again.position}\``);
190
- }
191
- }
192
- /* A Waiting hold has no expiry of its own, so a bug whose flake never comes back parks
193
- * indefinitely; a hold that has outlived STALE_WAIT_DAYS without a recurrence is escalated
194
- * from the ok below to a warn so the operator is nudged to release or re-hold it. */
195
- const lapsed = held.flatMap((b) => {
196
- /* An undated note (no YYYY-MM-DD in its text) has no hold-start marker, and checkStaleBugs
197
- * skips any bug carrying a waiting note, so without a fallback it would park indefinitely
198
- * too; the filing stamp anchors it — the hold cannot be younger than the bug it dates from. */
199
- const anchor = b.date ?? filingDate(b.title);
200
- if (anchor === null)
201
- return [];
202
- const days = ageInDays(anchor);
203
- return days >= STALE_WAIT_DAYS ? [{ ...b, days }] : [];
204
- });
205
- const [firstLapsed, ...restLapsed] = lapsed;
206
- if (firstLapsed) {
207
- const more = andMore(restLapsed, (b) => `#${b.position} ${quoteHeading(b.title)}`);
208
- return warnOutcome(`BUGS.md bug #${firstLapsed.position} ${quoteHeading(firstLapsed.title)} has waited ${firstLapsed.days} d for its next failure without one ` +
209
- `— its flake likely never comes back, so it parks indefinitely; release it with \`tumwater bug --release ${firstLapsed.position}\`${more}`);
210
- }
211
- const more = andMore(rest, (b) => `#${b.position} ${quoteHeading(b.title)}`);
212
- return okOutcome(`BUGS.md bug #${first.position} ${quoteHeading(first.title)} is held until it fails again ` +
213
- `(${truncate(first.note, REFUSAL_NOTE_MAX)})${more} — selfcheck lifts the note when it records a new occurrence; ` +
214
- `release it now with \`tumwater bug --release ${first.position}\``);
215
- }
216
- /** The always-on rows behind the dashboard/TUI/`tumwater status` waiting-bug alert: open BUGS.md
217
- * bugs held by a **Waiting for the next failure …** note whose anchor (the note's `YYYY-MM-DD`,
218
- * else the heading's filing stamp) is at least STALE_WAIT_DAYS old. Shares the doctor's
219
- * STALE_WAIT_DAYS threshold and the heldWaitingBugs selection/anchor, so the alert and the
220
- * doctor's `waiting bugs` row cannot drift on the rule; an undated note-and-heading is skipped.
221
- * The returned objects carry the 0-based index (drawer address `bugs:<index>`), the 1-based
222
- * position (`bug --release <position>`), the stamp-stripped title, and the whole-day age.
223
- * Returned most-days-waited-first, ties in file order, like staleOpenBugs. This deliberately
224
- * omits `checkWaitingBugs`'s recurrence scan, because that scan reads events back to the oldest
225
- * hold and is too costly on the status poll path; the doctor still reports a recurrence first. */
226
- export function staleWaitingBugRows(root, now = Date.now()) {
227
- return heldWaitingBugs(root)
228
- .flatMap((b) => {
229
- const anchor = b.date ?? filingDate(b.title);
230
- if (anchor === null)
231
- return [];
232
- const days = ageInDays(anchor, now);
233
- return days >= STALE_WAIT_DAYS ? [{ index: b.index, position: b.position, title: stripEntryStamp(b.title), days }] : [];
234
- })
235
- .sort(byDaysThenIndex);
236
- }
237
139
  /** The regression candidates for the `regressed fixes` feed: a newest-FIX_CLAIMS_CHECKED BUGS.md
238
140
  * Fixed record whose heading quotes a test name and carries a `YYYY-MM-DD` date, unless an Open
239
141
  * bug already quotes the same test (which means the regression is filed). Shared by the doctor's
@@ -316,12 +218,12 @@ export function checkRefusedEntries(root) {
316
218
  return okOutcome("no planned plan or open bug is held by a Refused note");
317
219
  const more = andMore(rest, (e) => `${e.file} ${e.command} #${e.position} ${quoteHeading(e.title)}` +
318
220
  (e.mergeDone.length > 0 ? " [prerequisites no longer planned]" : "") +
319
- (e.waiting > 0 ? ` [${e.waiting} part(s) wait on it]` : ""));
221
+ (e.waiting > 0 ? ` [${plural(e.waiting, "part waits on it", "parts wait on it")}]` : ""));
320
222
  const stale = first.mergeDone.length > 0
321
- ? `; its prerequisite(s) ${first.mergeDone.map((r) => `${r.series} ${r.part}/${r.of}`).join(", ")} are no longer planned, so the refusal may be stale`
223
+ ? `; its ${agree(first.mergeDone.length, "prerequisite", "prerequisites")} ${first.mergeDone.map((r) => `${r.series} ${r.part}/${r.of}`).join(", ")} ${agree(first.mergeDone.length, "is", "are")} no longer planned, so the refusal may be stale`
322
224
  : "";
323
225
  const waiting = first.waiting > 0
324
- ? `; ${first.waiting} planned part(s) wait on it, directly or through an earlier part`
226
+ ? `; ${plural(first.waiting, "planned part waits on it", "planned parts wait on it")}, directly or through an earlier part`
325
227
  : "";
326
228
  return warnOutcome(`${first.file} ${first.command} #${first.position} "${first.title}" is held by a Refused note ` +
327
229
  `(${truncate(first.note, REFUSAL_NOTE_MAX)})${stale}${waiting}${more} — no role takes a refused entry until it is edited: ` +