tumwater 0.2.0 → 0.3.0

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 (446) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +42 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +4 -3
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +402 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +246 -55
  41. package/dist/src/backlog/backlog-cli-view.js +280 -0
  42. package/dist/src/backlog/backlog-cli.js +85 -274
  43. package/dist/src/backlog/backlog-commit.js +4 -5
  44. package/dist/src/backlog/backlog-eligibility.js +175 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +254 -0
  48. package/dist/src/backlog/backlog-release.js +117 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-culprit.js +94 -0
  56. package/dist/src/baseline/main-guardrails.js +209 -0
  57. package/dist/src/baseline/main-red.js +139 -32
  58. package/dist/src/baseline/main-revert.js +133 -0
  59. package/dist/src/baseline/main-watch.js +184 -0
  60. package/dist/src/brief/brief-edit.js +9 -11
  61. package/dist/src/brief/brief.js +31 -51
  62. package/dist/src/budget/budget.js +30 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +1 -2
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +118 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +150 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +308 -590
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-role-command.js +420 -0
  84. package/dist/src/cli/cli-start-summary.js +160 -0
  85. package/dist/src/cli/{cli-run.js → cli-start.js} +165 -237
  86. package/dist/src/cli/completion.js +149 -22
  87. package/dist/src/cli/config-commands.js +41 -18
  88. package/dist/src/cli/follow-view.js +75 -0
  89. package/dist/src/cli/help.js +189 -73
  90. package/dist/src/cli/log-commands.js +148 -82
  91. package/dist/src/cli/question-commands.js +205 -10
  92. package/dist/src/cli.js +106 -43
  93. package/dist/src/collections.js +26 -10
  94. package/dist/src/concurrency/check-permit.js +20 -3
  95. package/dist/src/concurrency/lock.js +17 -13
  96. package/dist/src/concurrency/provider-lease.js +44 -8
  97. package/dist/src/concurrency/semaphore.js +102 -26
  98. package/dist/src/config/config-aliases.js +0 -1
  99. package/dist/src/config/config-editable-keys.js +13 -1
  100. package/dist/src/config/config-fallbacks.js +93 -56
  101. package/dist/src/config/config-field-checks.js +40 -10
  102. package/dist/src/config/config-keys.js +30 -8
  103. package/dist/src/config/config-live.js +63 -24
  104. package/dist/src/config/config-request.js +137 -0
  105. package/dist/src/config/config-schema.js +31 -24
  106. package/dist/src/config/config-selector-checks.js +123 -0
  107. package/dist/src/config/config-validation-agents.js +80 -0
  108. package/dist/src/config/config-validation-model.js +116 -0
  109. package/dist/src/config/config-validation.js +141 -199
  110. package/dist/src/config/config-views.js +127 -53
  111. package/dist/src/config/config-write-core.js +135 -0
  112. package/dist/src/config/config-write.js +131 -264
  113. package/dist/src/config/config.js +48 -38
  114. package/dist/src/config/model-selector.js +34 -34
  115. package/dist/src/doctor/doctor-backlog-bugs.js +287 -0
  116. package/dist/src/doctor/doctor-backlog-plans.js +263 -0
  117. package/dist/src/doctor/doctor-backlog.js +100 -496
  118. package/dist/src/doctor/doctor-check-names.js +81 -0
  119. package/dist/src/doctor/doctor-checks.js +52 -579
  120. package/dist/src/doctor/doctor-claude-checks.js +145 -0
  121. package/dist/src/doctor/doctor-codex-checks.js +279 -0
  122. package/dist/src/doctor/doctor-contention-checks.js +380 -0
  123. package/dist/src/doctor/doctor-holds.js +82 -66
  124. package/dist/src/doctor/doctor-install.js +2 -1
  125. package/dist/src/doctor/doctor-model-checks.js +236 -402
  126. package/dist/src/doctor/doctor-notify.js +3 -4
  127. package/dist/src/doctor/doctor-opencode-checks.js +162 -0
  128. package/dist/src/doctor/doctor-orphans.js +182 -63
  129. package/dist/src/doctor/doctor-project-checks.js +303 -0
  130. package/dist/src/doctor/doctor.js +121 -77
  131. package/dist/src/doctor/image-probe.js +21 -8
  132. package/dist/src/doctor/outcome.js +3 -2
  133. package/dist/src/doctor/probe.js +7 -11
  134. package/dist/src/events/event-format.js +181 -86
  135. package/dist/src/events/event-labels.js +12 -5
  136. package/dist/src/events/event-legacy.js +39 -15
  137. package/dist/src/events/event-read.js +39 -39
  138. package/dist/src/events/event-tally.js +16 -0
  139. package/dist/src/events/event-window.js +60 -24
  140. package/dist/src/events/events.js +30 -13
  141. package/dist/src/events/notify.js +7 -7
  142. package/dist/src/failure/failure-cluster.js +12 -12
  143. package/dist/src/failure/failure-data.js +56 -56
  144. package/dist/src/failure/failure-render.js +31 -23
  145. package/dist/src/failure/failure-state-change.js +17 -12
  146. package/dist/src/failure/time-spend.js +99 -35
  147. package/dist/src/files/files.js +111 -35
  148. package/dist/src/files/json-files.js +3 -3
  149. package/dist/src/files/json-object.js +28 -7
  150. package/dist/src/files/stat-cache.js +2 -2
  151. package/dist/src/files/tail.js +1 -1
  152. package/dist/src/gates/bootstrap-gates.js +4 -5
  153. package/dist/src/gates/budget-gates.js +177 -84
  154. package/dist/src/gates/disk-gate.js +1 -1
  155. package/dist/src/gates/gate-polls.js +108 -58
  156. package/dist/src/gates/maintenance-quota.js +10 -13
  157. package/dist/src/gates/pause-gates.js +28 -10
  158. package/dist/src/gates/readiness.js +7 -3
  159. package/dist/src/gates/role-cap-gates.js +8 -8
  160. package/dist/src/gates/startup-gate.js +94 -32
  161. package/dist/src/gates/streak-gate.js +54 -30
  162. package/dist/src/git/commit-message.js +77 -46
  163. package/dist/src/git/git-diff.js +16 -10
  164. package/dist/src/git/git-run.js +79 -38
  165. package/dist/src/git/git.js +36 -34
  166. package/dist/src/git/slot-salvage.js +253 -0
  167. package/dist/src/git/slots-state.js +6 -6
  168. package/dist/src/git/worktree-pool.js +132 -38
  169. package/dist/src/git/worktree-use.js +4 -11
  170. package/dist/src/git/worktree.js +94 -37
  171. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  172. package/dist/src/gui/gui-args.js +79 -8
  173. package/dist/src/gui/gui-backlog-commands.js +42 -18
  174. package/dist/src/gui/gui-backlog-endpoints.js +155 -0
  175. package/dist/src/gui/gui-command.js +8 -13
  176. package/dist/src/gui/gui-config-commands.js +94 -72
  177. package/dist/src/gui/gui-endpoint-commands.js +4 -3
  178. package/dist/src/gui/gui-endpoint-common.js +4 -4
  179. package/dist/src/gui/gui-endpoints.js +11 -380
  180. package/dist/src/gui/gui-history-endpoints.js +301 -0
  181. package/dist/src/gui/gui-operator-commands.js +108 -13
  182. package/dist/src/gui/gui-prompt-commands.js +11 -14
  183. package/dist/src/gui/gui-report-endpoints.js +90 -0
  184. package/dist/src/gui/gui-server.js +11 -4
  185. package/dist/src/gui/gui-team-commands.js +7 -7
  186. package/dist/src/gui/http-body.js +1 -1
  187. package/dist/src/history/history-data.js +122 -234
  188. package/dist/src/history/history-queued.js +147 -0
  189. package/dist/src/history/history-render.js +42 -22
  190. package/dist/src/history/history.js +193 -49
  191. package/dist/src/inbox/inbox-attachments.js +29 -30
  192. package/dist/src/inbox/inbox-cancel.js +10 -11
  193. package/dist/src/inbox/inbox-edit.js +9 -9
  194. package/dist/src/inbox/inbox-submit.js +18 -18
  195. package/dist/src/inbox/inbox-top.js +25 -9
  196. package/dist/src/inbox/inbox.js +42 -43
  197. package/dist/src/inbox/pending-prompt.js +26 -26
  198. package/dist/src/inbox/prompt-commands.js +62 -49
  199. package/dist/src/inbox/prompt-not-before.js +2 -2
  200. package/dist/src/init/init-templates.js +1 -2
  201. package/dist/src/init/init.js +28 -23
  202. package/dist/src/merge/backlog-conflicts.js +215 -35
  203. package/dist/src/merge/merge-batch.js +14 -8
  204. package/dist/src/merge/merge-cancel.js +53 -0
  205. package/dist/src/merge/merge-check-failures.js +44 -22
  206. package/dist/src/merge/merge-core.js +81 -32
  207. package/dist/src/merge/merge-diff.js +5 -4
  208. package/dist/src/merge/merge-drain.js +150 -11
  209. package/dist/src/merge/merge-git.js +177 -38
  210. package/dist/src/merge/merge-pipeline.js +15 -3
  211. package/dist/src/merge/merge-post-review.js +90 -0
  212. package/dist/src/merge/merge-queue-render.js +49 -11
  213. package/dist/src/merge/merge-queue.js +31 -15
  214. package/dist/src/merge/merge-requeue.js +102 -0
  215. package/dist/src/merge/merge-slot.js +61 -16
  216. package/dist/src/merge/merge-stack.js +33 -11
  217. package/dist/src/merge/merge-to-main.js +86 -27
  218. package/dist/src/merge/merge-top.js +69 -0
  219. package/dist/src/merge/merge-verify.js +12 -10
  220. package/dist/src/notes-cli.js +30 -15
  221. package/dist/src/operator/operator-commands.js +192 -81
  222. package/dist/src/operator/operator-intent.js +69 -42
  223. package/dist/src/operator/operator-requests.js +35 -40
  224. package/dist/src/operator/retire.js +5 -6
  225. package/dist/src/operator/revert.js +142 -34
  226. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  227. package/dist/src/orchestrator/orchestrator-poll.js +320 -0
  228. package/dist/src/orchestrator/orchestrator-scheduling.js +68 -369
  229. package/dist/src/orchestrator/orchestrator.js +128 -369
  230. package/dist/src/orchestrator/retention.js +42 -8
  231. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  232. package/dist/src/orchestrator/scheduling-scope.js +201 -0
  233. package/dist/src/orchestrator/scheduling-skip.js +185 -0
  234. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  235. package/dist/src/orchestrator/startup-migrations.js +11 -7
  236. package/dist/src/paths.js +116 -49
  237. package/dist/src/pi/command-shape.js +12 -1
  238. package/dist/src/pi/pi-args.js +6 -4
  239. package/dist/src/pi/pi-backend.js +6 -6
  240. package/dist/src/pi/pi-bin.js +54 -63
  241. package/dist/src/pi/pi-listed-models.js +53 -0
  242. package/dist/src/pi/pi-models.js +63 -30
  243. package/dist/src/pi/pi-run-result.js +1 -1
  244. package/dist/src/pi/pi-settings.js +1 -1
  245. package/dist/src/pi/pi-stream.js +6 -6
  246. package/dist/src/pi/pi-watchdogs.js +18 -19
  247. package/dist/src/pi-extension/bounded-output.js +5 -3
  248. package/dist/src/pi-extension/context-budget.js +2 -2
  249. package/dist/src/pi-extension/context-shake.js +1 -1
  250. package/dist/src/pi-extension/context-window.js +0 -1
  251. package/dist/src/pi-extension/full-output.js +3 -10
  252. package/dist/src/pi-extension/strip-thinking.js +0 -1
  253. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  254. package/dist/src/pi-extension/write-guard.js +37 -0
  255. package/dist/src/process/launch-services.js +1 -2
  256. package/dist/src/process/node-bin.js +16 -0
  257. package/dist/src/process/process-group.js +8 -18
  258. package/dist/src/process/process.js +41 -2
  259. package/dist/src/process/run-marker.js +18 -6
  260. package/dist/src/process/supervisor.js +22 -21
  261. package/dist/src/prompt/dialect.js +17 -2
  262. package/dist/src/prompt/gate-notes.js +203 -0
  263. package/dist/src/prompt/gate-prompts.js +121 -211
  264. package/dist/src/prompt/notes-backlog.js +181 -0
  265. package/dist/src/prompt/principles.js +1 -2
  266. package/dist/src/prompt/prompt-followup.js +19 -14
  267. package/dist/src/prompt/prompt.js +129 -204
  268. package/dist/src/redeploy/redeploy-policy.js +24 -19
  269. package/dist/src/redeploy/redeploy-probes.js +1 -1
  270. package/dist/src/redeploy/redeploy.js +2 -3
  271. package/dist/src/redeploy/redeployer.js +20 -20
  272. package/dist/src/redeploy/self-reload.js +2 -1
  273. package/dist/src/report/report-data.js +161 -77
  274. package/dist/src/report/report-fold.js +129 -52
  275. package/dist/src/report/report-main-red.js +55 -0
  276. package/dist/src/report/report-render.js +133 -64
  277. package/dist/src/report/report-types.js +1 -0
  278. package/dist/src/report/report.js +26 -9
  279. package/dist/src/request-timeouts.js +2 -2
  280. package/dist/src/review/known-flakes.js +100 -1
  281. package/dist/src/review/post-merge-review.js +106 -0
  282. package/dist/src/review/protected-edits.js +4 -5
  283. package/dist/src/review/review-followup.js +8 -8
  284. package/dist/src/review/review-precheck.js +66 -23
  285. package/dist/src/review/review-run.js +395 -0
  286. package/dist/src/review/review-verdict.js +2 -2
  287. package/dist/src/review/review.js +155 -360
  288. package/dist/src/review/secret-leaks.js +36 -16
  289. package/dist/src/review/suite-rerun.js +0 -1
  290. package/dist/src/roles/legacy-role-migration.js +80 -7
  291. package/dist/src/roles/role-catalog.js +69 -12
  292. package/dist/src/roles/role-guidance.js +55 -20
  293. package/dist/src/roles/role-labels.js +1 -2
  294. package/dist/src/roles/role-render.js +67 -50
  295. package/dist/src/roles/role-view.js +59 -47
  296. package/dist/src/roles/roles.js +31 -31
  297. package/dist/src/roles/runner-ids.js +97 -0
  298. package/dist/src/run/acceptance-greps.js +119 -0
  299. package/dist/src/run/feedback-filings.js +51 -0
  300. package/dist/src/{tick → run}/qa-coverage.js +9 -27
  301. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  302. package/dist/src/run/run-detail-data.js +179 -0
  303. package/dist/src/run/run-detail-render.js +98 -0
  304. package/dist/src/run/run-detail.js +168 -0
  305. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +72 -60
  306. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  307. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +108 -73
  308. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  309. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +108 -50
  310. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  311. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  312. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +91 -56
  313. package/dist/src/run/selfcheck-digest.js +40 -0
  314. package/dist/src/run/stage-backlog-checks.js +249 -0
  315. package/dist/src/run/stage-check.js +172 -0
  316. package/dist/src/run/stage-path-checks.js +730 -0
  317. package/dist/src/{loop → runner}/leftover.js +80 -25
  318. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  319. package/dist/src/runner/revision.js +202 -0
  320. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  321. package/dist/src/runner/runner-outcome.js +107 -0
  322. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  323. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +21 -15
  324. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +218 -240
  325. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  326. package/dist/src/runner/runner-state.js +102 -0
  327. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  328. package/dist/src/scheduling/backoff.js +38 -38
  329. package/dist/src/scheduling/claims.js +35 -22
  330. package/dist/src/scheduling/once-round.js +22 -22
  331. package/dist/src/scheduling/quiet-hours.js +5 -6
  332. package/dist/src/scheduling/scheduling.js +74 -64
  333. package/dist/src/scheduling/work-merged-cache.js +2 -2
  334. package/dist/src/status/status-budget.js +55 -47
  335. package/dist/src/status/status-data.js +43 -16
  336. package/dist/src/status/status-inbox.js +2 -2
  337. package/dist/src/status/status-install.js +0 -1
  338. package/dist/src/status/status-polls.js +28 -28
  339. package/dist/src/status/status-roles.js +39 -13
  340. package/dist/src/team/error-storm.js +8 -8
  341. package/dist/src/team/failure-spread.js +2 -2
  342. package/dist/src/team/orchestrator-info.js +15 -9
  343. package/dist/src/team/reclaim.js +5 -6
  344. package/dist/src/team/team-hold.js +7 -7
  345. package/dist/src/team/team-polls.js +91 -50
  346. package/dist/src/team/team-state.js +8 -2
  347. package/dist/src/text/datetime.js +63 -17
  348. package/dist/src/text/format.js +48 -11
  349. package/dist/src/text/markdown.js +8 -5
  350. package/dist/src/text/phrases.js +57 -23
  351. package/dist/src/text/suggest.js +1 -1
  352. package/dist/src/text/text.js +67 -13
  353. package/dist/src/ui/badges.js +65 -37
  354. package/dist/src/ui/gui/gui-client-backlog.js +292 -0
  355. package/dist/src/ui/gui/gui-client-boot.js +32 -26
  356. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  357. package/dist/src/ui/gui/gui-client-drawer-config.js +212 -0
  358. package/dist/src/ui/gui/gui-client-drawer.js +126 -285
  359. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  360. package/dist/src/ui/gui/gui-client-history.js +434 -67
  361. package/dist/src/ui/gui/gui-client-labels.js +1 -2
  362. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  363. package/dist/src/ui/gui/gui-client-model.js +27 -36
  364. package/dist/src/ui/gui/gui-client-operator.js +55 -51
  365. package/dist/src/ui/gui/gui-client-pending.js +227 -23
  366. package/dist/src/ui/gui/gui-client-report-chart.js +464 -0
  367. package/dist/src/ui/gui/gui-client-report.js +87 -427
  368. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  369. package/dist/src/ui/gui/gui-client-settings.js +56 -4
  370. package/dist/src/ui/gui/gui-client-team.js +25 -465
  371. package/dist/src/ui/gui/gui-client.js +27 -25
  372. package/dist/src/ui/gui/gui-icons.js +0 -2
  373. package/dist/src/ui/gui/gui-page.js +9 -7
  374. package/dist/src/ui/gui/gui-styles.js +34 -34
  375. package/dist/src/ui/progress-data.js +9 -11
  376. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +31 -31
  377. package/dist/src/ui/status-model.js +133 -125
  378. package/dist/src/ui/status-payload.js +136 -49
  379. package/dist/src/ui/status-render.js +101 -82
  380. package/dist/src/ui/team-alerts.js +248 -57
  381. package/dist/src/ui/tone.js +5 -6
  382. package/dist/src/ui/transcript-run.js +58 -0
  383. package/dist/src/ui/transcript-tail.js +28 -20
  384. package/dist/src/ui/transcript.js +63 -23
  385. package/dist/src/ui/tui/tui-app.js +0 -1
  386. package/dist/src/ui/tui/tui-backlog.js +51 -14
  387. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  388. package/dist/src/ui/tui/tui-editor-attach.js +3 -5
  389. package/dist/src/ui/tui/tui-editor-backends.js +132 -0
  390. package/dist/src/ui/tui/tui-editor-brief.js +10 -12
  391. package/dist/src/ui/tui/tui-editor-budget.js +35 -31
  392. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  393. package/dist/src/ui/tui/tui-editor-concurrency.js +24 -0
  394. package/dist/src/ui/tui/tui-editor-config.js +86 -0
  395. package/dist/src/ui/tui/tui-editor-entry.js +36 -47
  396. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  397. package/dist/src/ui/tui/tui-editor-instances.js +37 -0
  398. package/dist/src/ui/tui/tui-editor-instructions.js +136 -0
  399. package/dist/src/ui/tui/tui-editor-interval.js +11 -9
  400. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  401. package/dist/src/ui/tui/tui-editor-notes.js +3 -5
  402. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  403. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  404. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  405. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  406. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  407. package/dist/src/ui/tui/tui-editor-quiet.js +99 -35
  408. package/dist/src/ui/tui/tui-editor-retire.js +4 -6
  409. package/dist/src/ui/tui/tui-editor-role-key.js +76 -0
  410. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  411. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  412. package/dist/src/ui/tui/tui-editor-session.js +60 -34
  413. package/dist/src/ui/tui/tui-editor-slots.js +23 -0
  414. package/dist/src/ui/tui/tui-editors.js +35 -2
  415. package/dist/src/ui/tui/tui-frame.js +56 -11
  416. package/dist/src/ui/tui/tui-history.js +8 -9
  417. package/dist/src/ui/tui/tui-input.js +13 -11
  418. package/dist/src/ui/tui/tui-keymap.js +0 -1
  419. package/dist/src/ui/tui/tui-keys.js +24 -9
  420. package/dist/src/ui/tui/tui-pane-activity.js +14 -11
  421. package/dist/src/ui/tui/tui-pane-backlog.js +171 -126
  422. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  423. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  424. package/dist/src/ui/tui/tui-pane-health.js +58 -8
  425. package/dist/src/ui/tui/tui-pane-history.js +402 -82
  426. package/dist/src/ui/tui/tui-pane-pending.js +308 -13
  427. package/dist/src/ui/tui/tui-pane-queued.js +71 -55
  428. package/dist/src/ui/tui/tui-pane-report.js +46 -14
  429. package/dist/src/ui/tui/tui-pane-session.js +25 -6
  430. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  431. package/dist/src/ui/tui/tui-pane.js +80 -23
  432. package/dist/src/ui/tui/tui-panes.js +100 -30
  433. package/dist/src/ui/tui/tui.js +14 -14
  434. package/dist/src/verdict/fix-claim.js +82 -32
  435. package/dist/src/verdict/no-change.js +4 -4
  436. package/dist/src/verdict/refusal.js +40 -16
  437. package/dist/src/verdict/reply-contract.js +42 -31
  438. package/dist/src/version.js +0 -1
  439. package/package.json +2 -1
  440. package/dist/src/loop/revision.js +0 -70
  441. package/dist/src/roles/loop-ids.js +0 -77
  442. package/dist/src/tick/stage-check.js +0 -419
  443. package/dist/src/tick/telemetry-digest.js +0 -25
  444. package/dist/src/tick/tick-detail-data.js +0 -108
  445. package/dist/src/tick/tick-detail.js +0 -163
  446. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -0,0 +1,380 @@
1
+ /** The worktree/process contention checks, split out of doctor-checks.ts (the project's
2
+ * largest module, ~6x the file-size median, holding four unrelated check domains): the merge
3
+ * lock, the worktree pool, the three `_wait` contention rows (slot, provider, check), and the
4
+ * parallel-work-instance, tumwater-pin, leftover-role-branch and provider-concurrency audits.
5
+ * Read-only like every doctor check; they share the wait fold (sumWaits) and the pin/worktree
6
+ * leftovers helpers, all of which live here beside the checks that use them. The environment
7
+ * and repo-setup checks stay in doctor-checks.ts; the project's own state, disk and build
8
+ * checks live in doctor-project-checks.ts; model readiness lives in doctor-model-checks.ts;
9
+ * the per-document backlog checks live in doctor-backlog-plans.ts and doctor-backlog-bugs.ts
10
+ * beside doctor-backlog.ts's shared helpers and queue checks;
11
+ * doctor.ts remains the composition layer
12
+ * that calls each check in fixed order. */
13
+ import path from "node:path";
14
+ import { defaultConfig, knownRoleIds, slotCount } from "../config/config.js";
15
+ import { CUSTOM_NAME_RE } from "../config/config-schema.js";
16
+ import { subdirectoryNames } from "../files/files.js";
17
+ import { finiteNumber, stringList } from "../files/json-object.js";
18
+ import { commitExists, listBranches, refsUnder } from "../git/git.js";
19
+ import { readSlotsState } from "../git/slots-state.js";
20
+ import { eventsSince } from "../events/event-window.js";
21
+ import { DAY_MS } from "../text/datetime.js";
22
+ import { shortSha } from "../text/format.js";
23
+ import { errorMessage, joinParts, textOr } from "../text/text.js";
24
+ import { rankCountEntries } from "../collections.js";
25
+ import { DIRECTOR_ROLE } from "../roles/roles.js";
26
+ import { INSTANCE_ROLES, runnerEnabled, runnerIds } from "../roles/runner-ids.js";
27
+ import { CLAIM_IDLE_MAX_MS, listedKeys } from "../scheduling/claims.js";
28
+ import { piModelsPath } from "../pi/pi-models.js";
29
+ import { piSettingsPath, readPiDefaultModel } from "../pi/pi-settings.js";
30
+ import { seamConcurrencyKey } from "../config/config-views.js";
31
+ import { seamsInUse } from "../config/config-fallbacks.js";
32
+ import { enforcedKeyCaps } from "../concurrency/semaphore.js";
33
+ import { classifyLock, readLockPid } from "../concurrency/lock.js";
34
+ import { branchName, mergeLockDir, worktreesDir } from "../paths.js";
35
+ import { loadRunnerState } from "../runner/runner-state-load.js";
36
+ import { agree, plural, shortSpanPhrase } from "../text/phrases.js";
37
+ import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
38
+ /** Merge lock — read-only classification via classifyLock (the same three cases the breaker
39
+ * uses). A stale lock is a warning, not a failure: it self-heals on the next merge. */
40
+ export function checkMergeLock(root) {
41
+ const dir = mergeLockDir(root);
42
+ switch (classifyLock(dir)) {
43
+ case "absent":
44
+ return okOutcome("not held");
45
+ case "live": {
46
+ const pid = readLockPid(dir);
47
+ return okOutcome(pid !== null ? `held by running role (pid ${pid})` : "held (pid not yet written)");
48
+ }
49
+ case "stale":
50
+ return warnOutcome("stale — will be broken on next merge");
51
+ }
52
+ }
53
+ /** A pin older than a day is the doctor's stuck-pin signal: a paused or retired role is holding
54
+ * a pooled checkout nobody will reuse (plans/worktree-pool.md, "Observability"). */
55
+ const PIN_STALE_MS = DAY_MS;
56
+ /** Directory names under the worktrees root that the pool no longer owns: a legacy per-role
57
+ * `<role>` checkout (the pre-pool layout) or a `_land-<role>` checkout from the merge queue. The
58
+ * dedicated `director`, `_slot-<n>`, `_merge`, `_main`, `_gate-main` and `_build` checkouts are
59
+ * expected and never reported. An unreadable worktrees dir means nothing to report. */
60
+ function leftoverWorktreeDirs(root) {
61
+ return subdirectoryNames(worktreesDir(root), (name) => name !== DIRECTOR_ROLE && !(name.startsWith("_") && !name.startsWith("_land-")));
62
+ }
63
+ /** The window of `slot_wait` events the pool row sums, and the total wait inside it that warns. */
64
+ const SLOT_WAIT_CHECK_WINDOW_MS = DAY_MS;
65
+ const SLOT_WAIT_WARN_TOTAL_MS = 10 * 60_000;
66
+ const PROVIDER_WAIT_CHECK_WINDOW_MS = DAY_MS;
67
+ const PROVIDER_WAIT_WARN_TOTAL_MS = 10 * 60_000;
68
+ const CHECK_WAIT_CHECK_WINDOW_MS = DAY_MS;
69
+ const CHECK_WAIT_WARN_TOTAL_MS = 10 * 60_000;
70
+ /** Fold a window's `*_wait` events into the three numbers every contention check reads: how
71
+ * many waits there were (each event counts once), the total ms they waited, and the longest
72
+ * single wait — plus the longest event itself, for the check that names the role/purpose off
73
+ * it. The single home of that fold for the three contention checks (checkWorktreePool's
74
+ * `slot_wait` half, checkProviderContention, checkCheckContention), so the sum and the
75
+ * longest-wait selection cannot drift between them. A wait that omits or corrupts `waitedMs`
76
+ * reads as 0 for the sum, exactly as each inline `finiteNumber(ev.waitedMs, 0)` fold did;
77
+ * the longest-wait tie keeps the first event that reached it. */
78
+ function sumWaits(waits) {
79
+ let count = 0;
80
+ let totalMs = 0;
81
+ let longestMs = 0;
82
+ let longest = waits[0];
83
+ for (const ev of waits) {
84
+ count += 1;
85
+ const waited = finiteNumber(ev.waitedMs, 0);
86
+ totalMs += waited;
87
+ if (waited > longestMs) {
88
+ longestMs = waited;
89
+ longest = ev;
90
+ }
91
+ }
92
+ return { count, totalMs, longestMs, longest };
93
+ }
94
+ /** Worktree pool (plans/worktree-pool.md, part 5/5): report the pooled slots and warn on the
95
+ * states an operator should act on — a legacy `<role>` or `_land-*` checkout the pool migration
96
+ * left behind, a pin older than PIN_STALE_MS (a paused role holding a checkout), and the last
97
+ * 24 h of `slot_wait` events totalling SLOT_WAIT_WARN_TOTAL_MS (the pool is too small). Read-only,
98
+ * like every doctor check; `now` is a test seam for the pin-age arithmetic. */
99
+ export function checkWorktreePool(root, now = Date.now()) {
100
+ const slots = readSlotsState(root).slots;
101
+ const leased = slots.filter((slot) => slot.lease !== null);
102
+ const pins = slots.filter((slot) => slot.pinnedFor !== null);
103
+ const stale = pins.filter((slot) => slot.pinnedAt != null && now - slot.pinnedAt > PIN_STALE_MS);
104
+ const leftover = leftoverWorktreeDirs(root);
105
+ const parts = [`${plural(slots.length, "workspace")}, ${leased.length} leased, ${pins.length} pinned`];
106
+ if (leftover.length > 0)
107
+ parts.push(`legacy worktrees still present: ${leftover.join(", ")}`);
108
+ if (stale.length > 0) {
109
+ parts.push(stale
110
+ .map((slot) => `${textOr(slot.pinnedFor)} pinned on ${path.basename(slot.dir)} for ${Math.round((now - (slot.pinnedAt ?? now)) / 3_600_000)}h`)
111
+ .join("; "));
112
+ }
113
+ let contended = false;
114
+ try {
115
+ const waits = eventsSince(root, SLOT_WAIT_CHECK_WINDOW_MS, now, (ev) => ev.type === "slot_wait");
116
+ if (waits.length > 0) {
117
+ const { count, totalMs, longestMs, longest } = sumWaits(waits);
118
+ contended = totalMs >= SLOT_WAIT_WARN_TOTAL_MS;
119
+ const purpose = longest.purpose === "verify" ? "check" : "run";
120
+ parts.push(`${plural(count, "wait")} to start in the last 24h, ${shortSpanPhrase(totalMs)} total, longest ${shortSpanPhrase(longestMs)} (${textOr(longest.role)} ${purpose})${contended ? ` — raise worktreeSlots above ${slots.length}` : ""}`);
121
+ }
122
+ }
123
+ catch (err) {
124
+ parts.push(`could not read the event log for waits to start: ${errorMessage(err)}`);
125
+ }
126
+ const warn = leftover.length > 0 || stale.length > 0 || contended;
127
+ return (warn ? warnOutcome : okOutcome)(parts.join(" — "));
128
+ }
129
+ /** Provider-slot contention: sums the last 24 h of `provider_wait` events (each a role run
130
+ * whose semaphore admission waited PROVIDER_WAIT_EVENT_MS or more, recorded in
131
+ * orchestrator-launch.ts) and warns when the total reaches PROVIDER_WAIT_WARN_TOTAL_MS — the
132
+ * provider cap is too small. Names every key that waited, busiest first (the key-less
133
+ * bucket, which also takes every team-cap wait whatever its key, reads "the run cap") and, on
134
+ * a warn, each key's maxConcurrentPerProvider cap to raise, plus maxConcurrent for the run cap.
135
+ * A counterpoint to checkWorktreePool's slot-wait half; read-only, like every doctor check.
136
+ * `now` is a test seam for the window. */
137
+ export function checkProviderContention(root, config, now = Date.now()) {
138
+ const perKey = new Map();
139
+ let count = 0;
140
+ let totalMs = 0;
141
+ let longestMs = 0;
142
+ try {
143
+ const waits = eventsSince(root, PROVIDER_WAIT_CHECK_WINDOW_MS, now, (ev) => ev.type === "provider_wait");
144
+ const sum = sumWaits(waits);
145
+ count = sum.count;
146
+ totalMs = sum.totalMs;
147
+ longestMs = sum.longestMs;
148
+ for (const ev of waits) {
149
+ const waited = finiteNumber(ev.waitedMs, 0);
150
+ const key = ev.cap !== "team" && typeof ev.key === "string" && ev.key.length > 0 ? ev.key : "";
151
+ perKey.set(key, (perKey.get(key) ?? 0) + waited);
152
+ }
153
+ }
154
+ catch (err) {
155
+ // A read error leaves the level ok, exactly like the slot-wait branch's own read: the
156
+ // check cannot measure, so it says so rather than crying wolf.
157
+ return okOutcome(`could not read the event log for provider waits: ${errorMessage(err)}`);
158
+ }
159
+ if (count === 0)
160
+ return okOutcome("no provider waits");
161
+ // Rank the buckets by wait total, ties breaking on key name so the detail is deterministic.
162
+ // The key-less bucket reads "the run cap" — maxConcurrent, since no seam keys to a provider.
163
+ const ranked = rankCountEntries(perKey).map(([key, total]) => ({ key, total }));
164
+ const bucketLabel = (key) => (key === "" ? "the run cap" : `the ${key} slot`);
165
+ const cells = ranked.map((b) => `${bucketLabel(b.key)} ${shortSpanPhrase(b.total)}`);
166
+ // A single bucket keeps the pre-existing wording, a bare `(the X slot)` without the total
167
+ // (the total is already named by the `shortSpanPhrase(total)` in the clause before it);
168
+ // two or more name each bucket's total so the ranking reads at a glance.
169
+ const parenthetical = ranked.length === 1
170
+ ? `(${bucketLabel(ranked[0].key)})`
171
+ : `(${cells[0]}, also ${cells.slice(1).join(", ")})`;
172
+ let detail = `${plural(count, "wait")} for a provider slot in the last 24h, ${shortSpanPhrase(totalMs)} total, longest ${shortSpanPhrase(longestMs)} ${parenthetical}`;
173
+ const contended = totalMs >= PROVIDER_WAIT_WARN_TOTAL_MS;
174
+ if (contended) {
175
+ const advice = ranked
176
+ .filter((b) => b.key !== "" && config?.maxConcurrentPerProvider?.[b.key] !== undefined)
177
+ .map((b) => `raise maxConcurrentPerProvider.${b.key} above ${config?.maxConcurrentPerProvider?.[b.key]}`)
178
+ .join(" and ");
179
+ const teamAdvice = perKey.has("") && config !== undefined ? [`raise maxConcurrent above ${config.maxConcurrent}`] : [];
180
+ const all = [...(advice ? [advice] : []), ...teamAdvice].join(" and ");
181
+ if (all)
182
+ detail += ` — ${all}`;
183
+ }
184
+ return (contended ? warnOutcome : okOutcome)(detail);
185
+ }
186
+ /** Names the kind of check a `heldBy` label stands for, in checkCheckContention's tally. */
187
+ function checkHolderKind(label) {
188
+ if (label === "main baseline")
189
+ return "the main baseline";
190
+ const scope = label.slice(label.lastIndexOf(" ") + 1);
191
+ return scope === "gate" || scope === "merge" || scope === "batch" ? `${scope} checks` : "other checks";
192
+ }
193
+ /** Check-slot contention: sums the last 24 h of `check_wait` events (each a scoped check whose
194
+ * permit wait reached CHECK_WAIT_EVENT_MS, recorded in build-check-events.ts) and warns when
195
+ * the total reaches CHECK_WAIT_WARN_TOTAL_MS — maxConcurrentChecks is too small. The third
196
+ * sibling of checkWorktreePool's slot-wait half and checkProviderContention; read-only.
197
+ * When events carry `heldBy` (the labels of the permit holders the check parked behind), the
198
+ * detail gains `(behind <kind> <span>, …)` after the longest clause, kinds busiest first: a
199
+ * wait counts once toward each distinct kind that held a slot. `now` is a test seam for the
200
+ * window. */
201
+ export function checkCheckContention(root, config, now = Date.now()) {
202
+ const perKind = new Map();
203
+ let count = 0;
204
+ let totalMs = 0;
205
+ let longestMs = 0;
206
+ try {
207
+ const waits = eventsSince(root, CHECK_WAIT_CHECK_WINDOW_MS, now, (e) => e.type === "check_wait");
208
+ const sum = sumWaits(waits);
209
+ count = sum.count;
210
+ totalMs = sum.totalMs;
211
+ longestMs = sum.longestMs;
212
+ for (const ev of waits) {
213
+ const waited = finiteNumber(ev.waitedMs, 0);
214
+ for (const kind of new Set(stringList(ev.heldBy).map(checkHolderKind))) {
215
+ perKind.set(kind, (perKind.get(kind) ?? 0) + waited);
216
+ }
217
+ }
218
+ }
219
+ catch (err) {
220
+ return okOutcome(`could not read the event log for check waits: ${errorMessage(err)}`);
221
+ }
222
+ if (count === 0)
223
+ return okOutcome("no check waits");
224
+ let detail = `${plural(count, "wait")} for a check slot in the last 24h, ${shortSpanPhrase(totalMs)} total, longest ${shortSpanPhrase(longestMs)}`;
225
+ if (perKind.size > 0) {
226
+ const ranked = rankCountEntries(perKind);
227
+ detail += ` (behind ${ranked.map(([kind, ms]) => `${kind} ${shortSpanPhrase(ms)}`).join(", ")})`;
228
+ }
229
+ if (totalMs < CHECK_WAIT_WARN_TOTAL_MS)
230
+ return okOutcome(detail);
231
+ detail += ` — raise maxConcurrentChecks above ${config?.maxConcurrentChecks ?? defaultConfig().maxConcurrentChecks}`;
232
+ return warnOutcome(detail);
233
+ }
234
+ /** The "leftover role branches" row: `tumwater/<id>` branches whose role is no longer enabled
235
+ * (disabled in tumwater.json, or an instance above the configured count). Retirable ids are
236
+ * the ones `tumwater retire --role <id>` accepts (knownRoleIds, or any well-formed custom-role name
237
+ * with a leftover branch); any other id is listed by
238
+ * full branch name with a manual-delete hint. The director and enabled roles are skipped.
239
+ * Read-only, like every check: it only lists refs; an unreadable tumwater.json skips it. */
240
+ export async function checkLeftoverRoleBranches(root, config) {
241
+ if (!config)
242
+ return okOutcome("no readable tumwater.json — nothing to verify");
243
+ const prefix = branchName("");
244
+ const known = new Set(knownRoleIds(config));
245
+ const retirable = [];
246
+ const unknown = [];
247
+ for (const branch of await listBranches(root)) {
248
+ if (!branch.startsWith(prefix))
249
+ continue;
250
+ const id = branch.slice(prefix.length);
251
+ if (!id || id.includes("/") || id === DIRECTOR_ROLE || runnerEnabled(config, id))
252
+ continue;
253
+ if (known.has(id) || CUSTOM_NAME_RE.test(id))
254
+ retirable.push(id);
255
+ else
256
+ unknown.push(branch);
257
+ }
258
+ const parts = [];
259
+ if (retirable.length > 0) {
260
+ const n = retirable.length;
261
+ parts.push(`${retirable.join(", ")} ${agree(n, "is", "are")} disabled but still ${agree(n, "has", "have")} a branch — \`tumwater retire --role <id>\` removes each (\`--dry-run\` previews)`);
262
+ }
263
+ if (unknown.length > 0) {
264
+ parts.push(`${unknown.join(", ")} ${agree(unknown.length, "names", "name")} no role retire accepts — delete with \`git branch -D\` once its commits are not needed`);
265
+ }
266
+ return parts.length === 0 ? okOutcome("no branches left by disabled roles") : warnOutcome(parts.join("; "));
267
+ }
268
+ /** The "tumwater pins" row: fails when a `refs/tumwater/` pin (merge, rejected or legacy landing)
269
+ * names a commit that is gone. It fails rather than warns because such a pin stops `git gc`
270
+ * (and the `gc --auto` commits trigger), and a slot reset or leftover recovery only drops one
271
+ * when it reaches it. Read-only: it lists refs and names the `git update-ref -d` fix. */
272
+ export async function checkTumwaterPins(root) {
273
+ const pins = await refsUnder(root, "refs/tumwater/");
274
+ const broken = [];
275
+ for (const pin of pins)
276
+ if (!(await commitExists(root, pin.sha)))
277
+ broken.push(pin);
278
+ if (broken.length === 0) {
279
+ return okOutcome(pins.length === 0 ? "no tumwater pins" : `${plural(pins.length, "tumwater pin")}, each naming a commit that exists`);
280
+ }
281
+ const n = broken.length;
282
+ return failOutcome(`${broken.map((p) => `${p.ref} names ${shortSha(p.sha)}, which is gone`).join("; ")} — git gc and a detached checkout abort on ${agree(n, "it", "them")}; \`git update-ref -d <ref>\` drops each (the work ${agree(n, "it", "they")} pinned is already lost)`);
283
+ }
284
+ /** The ok detail of the instance check: non-zero counts, plans first, each pluralized. */
285
+ function claimCounts(claims) {
286
+ const bugs = claims.filter((c) => c.file === "BUGS.md").length;
287
+ const plans = claims.length - bugs;
288
+ const joined = joinParts([
289
+ plans > 0 ? plural(plans, "claimed plan") : "",
290
+ bugs > 0 ? plural(bugs, "claimed bug") : "",
291
+ ], ", ");
292
+ return joined || "no claimed plans or bugs";
293
+ }
294
+ /** Parallel work instances (plans/parallel-work-instances.md "Observability", part 6/7):
295
+ * each instance's claimed plan or bug and the two states an operator should act on — a claim
296
+ * whose key is no longer listed under its role's section (its instance is stuck on an entry
297
+ * that moved), and a claim idle for more than CLAIM_IDLE_MAX_MS (the scheduler releases
298
+ * these, so seeing one means the reader's view and the scheduler's disagree). An enabled
299
+ * multi-instance role configured with fewer pooled slots than maxConcurrent also warns:
300
+ * instances then contend for the same checkouts. A disabled role runs none of its
301
+ * configured instances, so it never contends and
302
+ * stays out of that warning.
303
+ * Read-only, like every check; `now` is the claim-age test seam, and an unreadable
304
+ * tumwater.json (config undefined) skips the walk — checkInit reports the config itself. */
305
+ export function checkWorkInstances(root, config, now = Date.now()) {
306
+ if (!config)
307
+ return okOutcome("no readable tumwater.json — nothing to verify");
308
+ const claims = [];
309
+ for (const id of runnerIds(config)) {
310
+ const claim = loadRunnerState(root, id).claim;
311
+ if (claim)
312
+ claims.push({ id, title: claim.title, key: claim.key, at: claim.at, file: claim.file });
313
+ }
314
+ const gone = claims.filter((c) => !listedKeys(root, c.id).has(c.key));
315
+ const stale = claims.filter((c) => !gone.includes(c) && now - c.at > CLAIM_IDLE_MAX_MS);
316
+ const multi = Object.entries(config.roles)
317
+ .filter(([id, role]) => INSTANCE_ROLES.has(id) && role.enabled && (role.instances ?? 1) > 1)
318
+ .map(([id]) => id);
319
+ const slots = slotCount(config);
320
+ const smallPool = multi.length > 0 && slots < config.maxConcurrent;
321
+ if (gone.length === 0 && stale.length === 0 && !smallPool)
322
+ return okOutcome(claimCounts(claims));
323
+ const noun = (c) => (c.file === "BUGS.md" ? "bug" : "plan");
324
+ const parts = [
325
+ ...gone.map((c) => `claimed ${noun(c)} "${c.title}" (${c.id}) names an entry no longer listed`),
326
+ ...stale.map((c) => `claimed ${noun(c)} "${c.title}" (${c.id}) has been idle ${Math.round((now - c.at) / 3_600_000)}h`),
327
+ ];
328
+ if (smallPool)
329
+ parts.push(`${multi.join(", ")} run instances but worktreeSlots ${slots} is below maxConcurrent ${config.maxConcurrent}`);
330
+ return warnOutcome(parts.join("; "));
331
+ }
332
+ /** Provider concurrency caps (plans/provider-concurrency.md §8): audit the
333
+ * `maxConcurrentPerProvider` map against the seams the config can actually spawn — each role,
334
+ * the review gate, the resolver, the director, and each tier's fallback — so the operator
335
+ * sees whether every cap names a provider in use and whether any cap actually binds. Warns on
336
+ * a cap key no seam resolves to (a typo, or a provider not in use), a cap at or above
337
+ * `maxConcurrent` (which never binds — the team total is the real ceiling), and a capped
338
+ * team whose seams include one with no key (pi's default provider is unreadable, so that
339
+ * seam cannot be capped). A config with no per-provider map or no caps is fine, and so is a
340
+ * well-formed map; a broken config is reported by the init check. Takes `root` to read pi's
341
+ * own default model (piSettingsPath), the same seam the orchestrator's admission uses, so a
342
+ * bare no-model pi seam keys exactly as the team would key it. */
343
+ export function checkProviderConcurrency(config, root) {
344
+ if (!config)
345
+ return okOutcome("no config to audit");
346
+ const caps = config.maxConcurrentPerProvider;
347
+ const capEntries = enforcedKeyCaps(caps);
348
+ if (capEntries.length === 0)
349
+ return okOutcome("no per-provider caps configured");
350
+ // The pi default model the orchestrator's admission seam carries: a bare no-model pi seam
351
+ // resolves its key only through it, so a team whose default is readable has keyed seams and
352
+ // only an unreadable default leaves a key-less seam to warn about.
353
+ const piDefault = readPiDefaultModel(piSettingsPath(piModelsPath()), root);
354
+ const seams = seamsInUse(config);
355
+ const keyed = [];
356
+ for (const s of seams) {
357
+ const k = seamConcurrencyKey(s, piDefault);
358
+ if (k !== undefined && !keyed.includes(k))
359
+ keyed.push(k);
360
+ }
361
+ const keySet = new Set(keyed);
362
+ const capKeys = capEntries.map(([k]) => k);
363
+ const warnings = [];
364
+ for (const key of capKeys) {
365
+ if (!keySet.has(key))
366
+ warnings.push(`cap key "${key}" resolves to no model job (a typo, or a provider not in use)`);
367
+ }
368
+ for (const [key, cap] of capEntries) {
369
+ if (cap >= config.maxConcurrent)
370
+ warnings.push(`cap "${key}" ${cap} is at or above maxConcurrent ${config.maxConcurrent} — it never binds`);
371
+ }
372
+ const capSet = new Set(capKeys);
373
+ const keyless = seams.filter((s) => seamConcurrencyKey(s, piDefault) === undefined);
374
+ if (capSet.size > 0 && keyless.length > 0) {
375
+ warnings.push(`${keyless.length} model job(s) resolve to no provider key (pi's default provider is unreadable, so they cannot be capped): ${keyless.map((s) => s.label).join(", ")}`);
376
+ }
377
+ if (warnings.length === 0)
378
+ return okOutcome(`${plural(capEntries.length, "provider cap")} — all model jobs resolve`);
379
+ return warnOutcome(warnings.join("; "));
380
+ }
@@ -1,4 +1,3 @@
1
- // vocabulary: plain
2
1
  import { roleLabel } from "../roles/role-labels.js";
3
2
  import { pausedRoles, pausedRolesUntil, standingTeamPause } from "../team/team-state.js";
4
3
  import { MODEL_TIERS } from "../config/config-schema.js";
@@ -9,34 +8,37 @@ import { roleCapVerdicts } from "../gates/role-cap-gates.js";
9
8
  import { readMergeQueue } from "../merge/merge-queue.js";
10
9
  import { MAINTENANCE_DAILY_FLOOR } from "../gates/maintenance-quota.js";
11
10
  import { heldByMainRed, mainCheckForPoll, maintenanceQuotaForPoll } from "../status/status-polls.js";
12
- import { loadLoopState } from "../loop/loop-state.js";
13
- import { baseRoleOf, loopIds, loopPaused } from "../roles/loop-ids.js";
14
- import { ERROR_STREAK_BREAKER, ERROR_STREAK_WARN } from "../tick/tick-apply.js";
11
+ import { loadRunnerState } from "../runner/runner-state-load.js";
12
+ import { baseRoleOf, runnerIds, runnerPaused } from "../roles/runner-ids.js";
13
+ import { ERROR_STREAK_BREAKER, ERROR_STREAK_WARN } from "../run/run-apply.js";
15
14
  import { squash } from "../text/text.js";
16
15
  import { usd } from "../text/format.js";
17
16
  import { parseQuietHours, quietHoursStatus, quietWindowEnd, roleQuietHold } from "../scheduling/quiet-hours.js";
18
- import { humanUntil } from "../text/datetime.js";
17
+ import { DAY_MS, HOUR_MS, humanUntil } from "../text/datetime.js";
19
18
  import { budgetPhrase, pauseReasonSuffix, plural } from "../text/phrases.js";
20
- import { claudeUtilizationPct, claudeWindowEntries, claudeWindowLabel, readClaudeAccount } from "../agent/claude/claude-account.js";
21
- import { claudeWindowObservation } from "../team/team-polls.js";
19
+ import { readAccount, utilizationPct, windowEntries, windowLabel } from "../agent/subscription-account.js";
20
+ import { backendDisplayName } from "../agent/backend-traits.js";
21
+ import { windowObservation } from "../team/team-polls.js";
22
22
  import { readEventsSince } from "../events/event-window.js";
23
23
  import { errorMessage } from "../text/text.js";
24
24
  import { okOutcome, warnOutcome } from "./outcome.js";
25
+ import { redMainWarning, revertWarnings } from "../baseline/main-guardrails.js";
26
+ import { loadQuarantine } from "../review/known-flakes.js";
25
27
  /** The standing-hold checks: `pause` (checkPause), `quiet hours` (checkQuietHours), and `budget`
26
- * (checkBudget), `claude window` (checkClaudeWindow), `maintenance quota`
27
- * (checkMaintenanceQuota), and `error streaks` (checkErrorStreaks), all read-only reports of
28
- * holds on ticking. */
29
- export function loadLoopStates(root, config) {
30
- return loopIds(config)
31
- .map((id) => loadLoopState(root, id))
28
+ * (checkBudget), the `claude window`/`codex window` rows (checkSubscriptionWindow),
29
+ * `maintenance quota` (checkMaintenanceQuota), and `error streaks` (checkErrorStreaks), all
30
+ * read-only reports of holds on running. */
31
+ export function loadRunnerStates(root, config) {
32
+ return runnerIds(config)
33
+ .map((id) => loadRunnerState(root, id))
32
34
  .sort((a, b) => a.role.localeCompare(b.role));
33
35
  }
34
36
  /** The `pause` check: names a standing operator pause so doctor does not print "ready to run"
35
- * over a team that starts no tick. Read-only: it reads the same team and per-role pause
37
+ * over a team that starts no run. Read-only: it reads the same team and per-role pause
36
38
  * markers the scheduler reads (standingTeamPause, pausedRoles, pausedRolesUntil — all treat
37
39
  * an expired marker as absent), so its verdict cannot disagree with what the team does. A
38
- * team pause is a warn because it stops every loop; paused roles are ok because the operator
39
- * chose them by name and every other loop keeps ticking. `now` only drives the countdowns. */
40
+ * team pause is a warn because it stops every role; paused roles are ok because the operator
41
+ * chose them by name and every other role keeps running. `now` only drives the countdowns. */
40
42
  export function checkPause(root, now = Date.now()) {
41
43
  const team = standingTeamPause(root);
42
44
  const roles = pausedRoles(root);
@@ -53,10 +55,10 @@ export function checkPause(root, now = Date.now()) {
53
55
  return warnOutcome(`${head}${pauseReasonSuffix(team.reason)}${rolesPhrase ? `; ${rolesPhrase}` : ""}`);
54
56
  }
55
57
  /** The `quiet hours` check: names the team quiet window and any per-role windows holding now,
56
- * so doctor does not print "ready to run" over a team that starts no tick until the window
58
+ * so doctor does not print "ready to run" over a team that starts no run until the window
57
59
  * ends. Read-only: membership comes from the scheduler's own parser and predicate
58
60
  * (quietHoursStatus, roleQuietHold), so the verdict cannot disagree with the gate. A team
59
- * window holding is a warn because it stops every loop (the director is exempt); held per-role
61
+ * window holding is a warn because it stops every role (the director is exempt); held per-role
60
62
  * windows are ok because the operator chose those roles by name. A malformed value reads as
61
63
  * unset here — checkInit owns that error. `now` is injected so tests never read the clock. */
62
64
  export function checkQuietHours(config, now = new Date()) {
@@ -89,13 +91,13 @@ export function checkQuietHours(config, now = new Date()) {
89
91
  * own `maxDailyCostUsdPerRole` cap. Read-only and built on the scheduler's own definitions
90
92
  * (teamDailyCost, budgetReached, roleCapVerdicts), so the verdict cannot disagree with the gate.
91
93
  * `published` is the running scheduler's figure (it includes runs charged since the last save)
92
- * and wins over the persisted sum, as in status. A reached team cap is a warn because role loops
94
+ * and wins over the persisted sum, as in status. A reached team cap is a warn because roles
93
95
  * run on their tier fallback or hold until local midnight (the `fallback` row says which);
94
96
  * per-role caps reached alone are ok because the operator chose them by name. */
95
97
  export function checkBudget(root, config, published, now = Date.now()) {
96
98
  if (config === undefined)
97
99
  return okOutcome("not checked — tumwater.json unreadable");
98
- const states = loadLoopStates(root, config);
100
+ const states = loadRunnerStates(root, config);
99
101
  const spent = published?.spentUsd ?? teamDailyCost(states, now);
100
102
  const caps = config.maxDailyCostUsdPerRole;
101
103
  const capped = [...roleCapVerdicts(states.map((s) => ({ role: s.role, state: s })), caps, now)]
@@ -116,63 +118,65 @@ export function checkBudget(root, config, published, now = Date.now()) {
116
118
  return okOutcome(`${phrase} today${cappedPhrase}`);
117
119
  }
118
120
  /** The `maintenance quota` check: rolling-24 h maintenance merges against the allowance
119
- * (`maintenancePerWorkMerge` per work merge + the daily floor), naming the maintenance loops held
121
+ * (`maintenancePerWorkMerge` per work merge + the daily floor), naming the maintenance roles held
120
122
  * when it is full. Read-only and built on the same verdict the scheduler and status use
121
- * (maintenanceQuotaForPoll). Only a live team's running loops and merge queue count as in flight,
123
+ * (maintenanceQuotaForPoll). Only a live team's running roles and merge queue count as in flight,
122
124
  * since a dead team's leftover `running` flag and queue drain nothing. A held allowance is a warn
123
125
  * because it stops a whole tier. */
124
126
  export function checkMaintenanceQuota(root, config, aliveTeam, now = Date.now()) {
125
127
  if (config === undefined)
126
128
  return okOutcome("not checked — tumwater.json unreadable");
127
- const loops = loadLoopStates(root, config).map((s) => ({ role: s.role, running: aliveTeam && s.running === true }));
129
+ const runners = loadRunnerStates(root, config).map((s) => ({ role: s.role, running: aliveTeam && s.running === true }));
128
130
  const queued = aliveTeam ? new Set(readMergeQueue(root).entries.map((e) => e.role)) : new Set();
129
- const { allowance, used, held } = maintenanceQuotaForPoll(root, config.maintenancePerWorkMerge, loops, queued, now);
131
+ const { allowance, used, held } = maintenanceQuotaForPoll(root, config.maintenancePerWorkMerge, runners, queued, now);
130
132
  if (held.length > 0) {
131
133
  return warnOutcome(`allowance reached — ${used}/${allowance} maintenance merges in the last 24h; held until the window rolls under: ${[...held].sort().join(", ")}`);
132
134
  }
133
135
  const base = `${used}/${allowance} maintenance merges in the last 24h (${config.maintenancePerWorkMerge} per work merge + ${MAINTENANCE_DAILY_FLOOR} floor)`;
134
- const anyMaintenance = loops.some((l) => commitTier(l.role) === "maintenance");
136
+ const anyMaintenance = runners.some((l) => commitTier(l.role) === "maintenance");
135
137
  return okOutcome(anyMaintenance ? base : `${base}; no maintenance role enabled`);
136
138
  }
137
- /** The `claude window` check: whether a rejected subscription window holds the subscription claude
138
- * seams. Read-only and built on claudeWindowObservation, the verdict the scheduler's team hold
139
- * uses, so it cannot disagree with the hold. A hold is a warn because it stops every subscription
140
- * claude seam until the window resets; otherwise the most constrained window's state is reported
141
- * as ok (a rejected window spending overage reads `rejected` here: overage spends, it does not
142
- * hold). API-billed claude never holds. */
143
- export function checkClaudeWindow(root, now = Date.now(), chain, config) {
144
- const account = readClaudeAccount(root, now);
139
+ /** The `<backend> window` check, one row per subscription backend in use (claude and codex):
140
+ * whether a rejected subscription window holds the subscription `<backend>` seams. Read-only and
141
+ * built on windowObservation (the backend's own), the verdict the scheduler's team hold uses, so
142
+ * it cannot disagree with the hold. A hold is a warn because it stops every subscription seam on
143
+ * that backend until the window resets; otherwise the most constrained window's state is
144
+ * reported as ok (a rejected window spending overage reads `rejected` here: overage spends, it
145
+ * does not hold). Returns null (omitting the row from doctor) when the backend has no
146
+ * subscription account — the same omit-a-row pattern as `pi extensions` — because an absent or
147
+ * API-billed account never holds. */
148
+ export function checkSubscriptionWindow(backend, root, now = Date.now(), chain, config) {
149
+ const account = readAccount(root, backend, now);
145
150
  if (account?.billing !== "subscription")
146
- return okOutcome("no subscription window reported");
147
- const held = claudeWindowObservation(account, now);
151
+ return null;
152
+ const held = windowObservation(backend, account, now);
148
153
  if (held?.kind === "subscription-window" && held.windowType !== undefined && held.resetsAt !== undefined) {
149
- const label = claudeWindowLabel(held.windowType);
154
+ const label = windowLabel(held.windowType);
150
155
  const resets = humanUntil(held.resetsAt, now);
151
- const moved = MODEL_TIERS.filter((t) => {
152
- const reason = chain?.[t]?.reason;
153
- return reason === "window" || reason === "reserve";
154
- });
156
+ const moved = MODEL_TIERS.filter((t) => (chain?.[t]?.reason === "window" || chain?.[t]?.reason === "reserve") &&
157
+ config !== undefined &&
158
+ withModelOverrides(config, t, {}).agent === backend);
155
159
  if (moved.length > 0) {
156
- const waiting = MODEL_TIERS.filter((t) => !chain?.[t] && config !== undefined && withModelOverrides(config, t, {}).agent === "claude");
160
+ const waiting = MODEL_TIERS.filter((t) => !chain?.[t] && config !== undefined && withModelOverrides(config, t, {}).agent === backend);
157
161
  return warnOutcome(`${label} window rejected — ${moved.join(", ")} moved to fallbacks until it resets in ${resets}${waiting.length > 0 ? `; ${waiting.join(", ")} wait` : ""}`);
158
162
  }
159
- return warnOutcome(`${label} window rejected — subscription claude model jobs wait until it resets in ${resets}`);
163
+ return warnOutcome(`${label} window rejected — subscription ${backendDisplayName(backend)} model jobs wait until it resets in ${resets}`);
160
164
  }
161
- const top = claudeWindowEntries(account)[0];
165
+ const top = windowEntries(account)[0];
162
166
  if (top === undefined)
163
167
  return okOutcome("subscription; no window reported yet");
164
- const pct = typeof top.utilization === "number" ? ` (${claudeUtilizationPct(top.utilization)}% used)` : "";
165
- const label = claudeWindowLabel(top.type);
168
+ const pct = typeof top.utilization === "number" ? ` (${utilizationPct(top.utilization)}% used)` : "";
169
+ const label = windowLabel(top.type);
166
170
  return okOutcome(top.status === "allowed_warning" ? `${label} window nearing its limit${pct}` : `${label} window ${top.status}${pct}`);
167
171
  }
168
172
  /** How far back the `provider hold` check reads for hold/resume crossings. The longest a
169
173
  * hold can stay is HOLD_CAP_MS (team-hold's 15-minute cap that escalation doubles against),
170
174
  * so a window a comfortable multiple of that covers any standing hold's crossing event plus
171
175
  * its resume — an older resumed event must not be missed or a re-opened provider reads held. */
172
- const HOLD_CHECK_WINDOW_MS = 2 * 3600_000;
176
+ const HOLD_CHECK_WINDOW_MS = 2 * HOUR_MS;
173
177
  /** The `provider hold` check: names each provider whose team-wide failure hold
174
178
  * (src/team/team-hold.ts, polled by pollTeamHold) stands now — a 429/backend-failure storm
175
- * that stops the roles on that provider from starting new ticks until it re-opens. Read-only:
179
+ * that stops the roles on that provider from starting new runs until it re-opens. Read-only:
176
180
  * it infers the standing set from the event log's own `rate_limit_hold`/`rate_limit_resumed`
177
181
  * crossings, the events pollTeamHold writes, so it cannot disagree with the hold. A standing
178
182
  * hold is a warn (as with the `claude window` row) because it stops every role on that
@@ -210,16 +214,16 @@ export function checkProviderHolds(root, now = Date.now()) {
210
214
  });
211
215
  return warnOutcome(lines.join("; "));
212
216
  }
213
- /** The `error streaks` check: names each enabled loop whose `consecutiveErrors` is at or past
217
+ /** The `error streaks` check: names each enabled role whose `consecutiveErrors` is at or past
214
218
  * ERROR_STREAK_WARN, with its streak and last error, and whether it is paused. Read-only; it reads
215
219
  * the same `consecutiveErrors` the error-streak breaker and `teamAlerts` read, and the same
216
220
  * `pausedRoles` marker the scheduler reads. The marker does not record who wrote it, so a paused
217
- * loop at ERROR_STREAK_BREAKER's bar is described, not attributed to the breaker. A warn, not a
218
- * fail: a failing loop does not stop the team from running. */
221
+ * role at ERROR_STREAK_BREAKER's bar is described, not attributed to the breaker. A warn, not a
222
+ * fail: a failing role does not stop the team from running. */
219
223
  export function checkErrorStreaks(root, config) {
220
224
  if (config === undefined)
221
225
  return okOutcome("not checked — tumwater.json unreadable");
222
- const failing = loadLoopStates(root, config)
226
+ const failing = loadRunnerStates(root, config)
223
227
  .filter((s) => (s.consecutiveErrors ?? 0) >= ERROR_STREAK_WARN);
224
228
  if (failing.length === 0)
225
229
  return okOutcome("no role failing repeatedly");
@@ -227,7 +231,7 @@ export function checkErrorStreaks(root, config) {
227
231
  const phrases = failing.map((s) => {
228
232
  const n = s.consecutiveErrors ?? 0;
229
233
  let phrase = `${roleLabel(s.role)}: ${n} failed runs`;
230
- if (loopPaused(paused, s.role)) {
234
+ if (runnerPaused(paused, s.role)) {
231
235
  const key = paused.has(s.role) ? s.role : paused.has(baseRoleOf(s.role)) ? baseRoleOf(s.role) : `${s.role}-1`;
232
236
  phrase +=
233
237
  n >= ERROR_STREAK_BREAKER
@@ -240,23 +244,35 @@ export function checkErrorStreaks(root, config) {
240
244
  });
241
245
  return warnOutcome(`failing run after run: ${phrases.join("; ")}`);
242
246
  }
243
- /** The `main red` check: names the code loops the red-main gate (mainRedGate) holds, ending
244
- * each blocked tick `main_red` while main's suite is red. Read-only: it asks heldByMainRed,
245
- * the predicate the status phase's `main red` label reads, so the two cannot disagree. A warn,
246
- * not a fail: a red main does not stop the team from running. */
247
- export function checkMainRed(root, config) {
247
+ /** The `main red` check: names the code roles the red-main gate (mainRedGate) holds, ending
248
+ * each blocked run `main_red` while main's suite is red, and warns when main has been red over
249
+ * RED_MAIN_WARN_MS even with no role held (the red-main gate's guardrail, G1). Read-only: it
250
+ * asks heldByMainRed, the predicate the status phase's `main red` label reads, so the two
251
+ * cannot disagree on the held roles, and redMainWarning over the event trail, so both phrases
252
+ * can appear in one row. A warn, not a fail: a red main does not stop the team from running.
253
+ * `now` is injected so a test can pin a red's age. */
254
+ export function checkMainRed(root, config, now = Date.now()) {
248
255
  if (config === undefined)
249
256
  return okOutcome("not checked — tumwater.json unreadable");
250
257
  const mainCheck = mainCheckForPoll(root, config);
251
- const ids = loopIds(config);
252
- const held = loadLoopStates(root, config)
258
+ const ids = runnerIds(config);
259
+ const held = loadRunnerStates(root, config)
253
260
  .filter((s) => heldByMainRed(s, mainCheck));
254
- if (held.length === 0)
261
+ const phrases = [];
262
+ if (held.length > 0) {
263
+ const err = held.find((s) => s.lastError)?.lastError;
264
+ const cause = err ? ` — ${squash(err, 160)}` : "";
265
+ const healer = ids.some((id) => baseRoleOf(id) === "bugfix")
266
+ ? "; the bugfix role is exempt and repairs it"
267
+ : "; no bugfix role is enabled to repair it — fix main by hand or enable bugfix";
268
+ phrases.push(`main's check is failing; ${plural(held.length, "code role")} waiting: ${held.map((s) => roleLabel(s.role)).join(", ")}${cause}${healer}`);
269
+ }
270
+ const events = readEventsSince(root, DAY_MS).events;
271
+ const red = redMainWarning(events, now);
272
+ if (red !== null)
273
+ phrases.push(red);
274
+ phrases.push(...revertWarnings(events, loadQuarantine(root), now));
275
+ if (phrases.length === 0)
255
276
  return okOutcome("no role waiting on main's check");
256
- const err = held.find((s) => s.lastError)?.lastError;
257
- const cause = err ? ` — ${squash(err, 160)}` : "";
258
- const healer = ids.some((id) => baseRoleOf(id) === "bugfix")
259
- ? "; the bugfix role is exempt and repairs it"
260
- : "; no bugfix role is enabled to repair it — fix main by hand or enable bugfix";
261
- return warnOutcome(`main's check is failing; ${plural(held.length, "code role")} waiting: ${held.map((s) => roleLabel(s.role)).join(", ")}${cause}${healer}`);
277
+ return warnOutcome(phrases.join("; "));
262
278
  }