tumwater 0.2.0 → 0.4.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 (464) 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 +55 -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 +6 -4
  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 +404 -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 +281 -55
  41. package/dist/src/backlog/backlog-cli-view.js +255 -0
  42. package/dist/src/backlog/backlog-cli.js +91 -335
  43. package/dist/src/backlog/backlog-commit.js +20 -13
  44. package/dist/src/backlog/backlog-eligibility.js +193 -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 +264 -0
  48. package/dist/src/backlog/backlog-release.js +134 -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-guardrails.js +136 -0
  56. package/dist/src/baseline/main-red.js +153 -36
  57. package/dist/src/baseline/main-watch.js +232 -0
  58. package/dist/src/brief/brief-edit.js +12 -12
  59. package/dist/src/brief/brief.js +31 -51
  60. package/dist/src/budget/budget-fallback-view.js +288 -0
  61. package/dist/src/budget/budget-wiring.js +158 -0
  62. package/dist/src/budget/budget.js +31 -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 +65 -3
  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 +181 -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 +183 -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 +291 -598
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-review-commands.js +113 -0
  84. package/dist/src/cli/cli-role-command.js +525 -0
  85. package/dist/src/cli/cli-start-summary.js +167 -0
  86. package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
  87. package/dist/src/cli/completion.js +169 -22
  88. package/dist/src/cli/config-commands.js +58 -26
  89. package/dist/src/cli/follow-view.js +75 -0
  90. package/dist/src/cli/help.js +258 -82
  91. package/dist/src/cli/log-commands.js +164 -85
  92. package/dist/src/cli/question-commands.js +211 -10
  93. package/dist/src/cli.js +173 -45
  94. package/dist/src/collections.js +56 -13
  95. package/dist/src/concurrency/check-permit.js +20 -3
  96. package/dist/src/concurrency/lock.js +17 -13
  97. package/dist/src/concurrency/provider-lease.js +44 -8
  98. package/dist/src/concurrency/semaphore.js +102 -26
  99. package/dist/src/config/config-aliases.js +0 -1
  100. package/dist/src/config/config-editable-keys.js +16 -1
  101. package/dist/src/config/config-fallbacks.js +94 -56
  102. package/dist/src/config/config-field-checks.js +40 -10
  103. package/dist/src/config/config-keys.js +30 -8
  104. package/dist/src/config/config-live.js +63 -24
  105. package/dist/src/config/config-request.js +137 -0
  106. package/dist/src/config/config-schema.js +31 -24
  107. package/dist/src/config/config-selector-checks.js +123 -0
  108. package/dist/src/config/config-validation-agents.js +80 -0
  109. package/dist/src/config/config-validation-model.js +116 -0
  110. package/dist/src/config/config-validation.js +169 -222
  111. package/dist/src/config/config-views.js +136 -54
  112. package/dist/src/config/config-write-core.js +135 -0
  113. package/dist/src/config/config-write.js +164 -265
  114. package/dist/src/config/config.js +53 -38
  115. package/dist/src/config/model-selector.js +34 -34
  116. package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
  117. package/dist/src/doctor/doctor-backlog-plans.js +398 -0
  118. package/dist/src/doctor/doctor-backlog.js +249 -512
  119. package/dist/src/doctor/doctor-check-names.js +85 -0
  120. package/dist/src/doctor/doctor-checks.js +61 -593
  121. package/dist/src/doctor/doctor-claude-checks.js +142 -0
  122. package/dist/src/doctor/doctor-codex-checks.js +276 -0
  123. package/dist/src/doctor/doctor-contention-checks.js +506 -0
  124. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  125. package/dist/src/doctor/doctor-holds.js +80 -66
  126. package/dist/src/doctor/doctor-install.js +2 -1
  127. package/dist/src/doctor/doctor-model-checks.js +216 -537
  128. package/dist/src/doctor/doctor-notify.js +9 -5
  129. package/dist/src/doctor/doctor-opencode-checks.js +164 -0
  130. package/dist/src/doctor/doctor-orphans.js +182 -63
  131. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  132. package/dist/src/doctor/doctor-project-checks.js +397 -0
  133. package/dist/src/doctor/doctor.js +126 -77
  134. package/dist/src/doctor/image-probe.js +21 -8
  135. package/dist/src/doctor/outcome.js +3 -2
  136. package/dist/src/doctor/probe.js +21 -12
  137. package/dist/src/events/event-format.js +185 -89
  138. package/dist/src/events/event-labels.js +11 -5
  139. package/dist/src/events/event-legacy.js +39 -15
  140. package/dist/src/events/event-read.js +52 -39
  141. package/dist/src/events/event-tally.js +16 -0
  142. package/dist/src/events/event-window.js +80 -26
  143. package/dist/src/events/events.js +30 -13
  144. package/dist/src/events/notify.js +7 -7
  145. package/dist/src/failure/failure-cluster.js +12 -12
  146. package/dist/src/failure/failure-data.js +65 -60
  147. package/dist/src/failure/failure-render.js +29 -21
  148. package/dist/src/failure/failure-state-change.js +17 -12
  149. package/dist/src/failure/time-spend.js +35 -35
  150. package/dist/src/files/file-queue.js +17 -0
  151. package/dist/src/files/files.js +111 -35
  152. package/dist/src/files/json-files.js +3 -3
  153. package/dist/src/files/json-object.js +28 -7
  154. package/dist/src/files/stat-cache.js +2 -2
  155. package/dist/src/files/tail.js +1 -1
  156. package/dist/src/gates/bootstrap-gates.js +4 -5
  157. package/dist/src/gates/budget-gates.js +88 -225
  158. package/dist/src/gates/disk-gate.js +1 -1
  159. package/dist/src/gates/gate-polls.js +101 -143
  160. package/dist/src/gates/maintenance-quota.js +37 -30
  161. package/dist/src/gates/pause-gates.js +28 -10
  162. package/dist/src/gates/readiness.js +7 -3
  163. package/dist/src/gates/role-cap-gates.js +8 -8
  164. package/dist/src/gates/startup-gate.js +94 -32
  165. package/dist/src/gates/streak-gate.js +54 -30
  166. package/dist/src/git/commit-message.js +89 -46
  167. package/dist/src/git/git-diff.js +16 -10
  168. package/dist/src/git/git-run.js +79 -38
  169. package/dist/src/git/git.js +54 -34
  170. package/dist/src/git/slot-salvage.js +253 -0
  171. package/dist/src/git/slots-state.js +6 -6
  172. package/dist/src/git/worktree-pool.js +132 -38
  173. package/dist/src/git/worktree-use.js +4 -11
  174. package/dist/src/git/worktree.js +94 -37
  175. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  176. package/dist/src/gui/gui-args.js +79 -8
  177. package/dist/src/gui/gui-backlog-commands.js +43 -19
  178. package/dist/src/gui/gui-backlog-endpoints.js +156 -0
  179. package/dist/src/gui/gui-command.js +26 -13
  180. package/dist/src/gui/gui-config-commands.js +168 -80
  181. package/dist/src/gui/gui-endpoint-commands.js +5 -4
  182. package/dist/src/gui/gui-endpoint-common.js +4 -4
  183. package/dist/src/gui/gui-endpoints.js +11 -380
  184. package/dist/src/gui/gui-history-endpoints.js +301 -0
  185. package/dist/src/gui/gui-operator-commands.js +235 -13
  186. package/dist/src/gui/gui-prompt-commands.js +26 -20
  187. package/dist/src/gui/gui-report-endpoints.js +97 -0
  188. package/dist/src/gui/gui-server.js +26 -5
  189. package/dist/src/gui/gui-team-commands.js +7 -7
  190. package/dist/src/gui/http-body.js +1 -1
  191. package/dist/src/history/history-data.js +122 -234
  192. package/dist/src/history/history-queued.js +155 -0
  193. package/dist/src/history/history-render.js +42 -22
  194. package/dist/src/history/history.js +190 -49
  195. package/dist/src/inbox/inbox-attachments.js +29 -30
  196. package/dist/src/inbox/inbox-cancel.js +10 -11
  197. package/dist/src/inbox/inbox-edit.js +9 -9
  198. package/dist/src/inbox/inbox-submit.js +18 -18
  199. package/dist/src/inbox/inbox-top.js +28 -17
  200. package/dist/src/inbox/inbox.js +42 -43
  201. package/dist/src/inbox/pending-prompt.js +26 -26
  202. package/dist/src/inbox/prompt-commands.js +62 -49
  203. package/dist/src/inbox/prompt-not-before.js +2 -2
  204. package/dist/src/init/init-templates.js +1 -2
  205. package/dist/src/init/init.js +28 -23
  206. package/dist/src/merge/backlog-conflicts.js +215 -35
  207. package/dist/src/merge/merge-batch.js +14 -8
  208. package/dist/src/merge/merge-cancel.js +53 -0
  209. package/dist/src/merge/merge-check-failures.js +44 -22
  210. package/dist/src/merge/merge-core.js +58 -32
  211. package/dist/src/merge/merge-diff.js +5 -4
  212. package/dist/src/merge/merge-drain.js +45 -19
  213. package/dist/src/merge/merge-git.js +177 -38
  214. package/dist/src/merge/merge-pipeline.js +15 -3
  215. package/dist/src/merge/merge-post-review.js +214 -0
  216. package/dist/src/merge/merge-queue-render.js +48 -11
  217. package/dist/src/merge/merge-queue.js +52 -15
  218. package/dist/src/merge/merge-requeue.js +103 -0
  219. package/dist/src/merge/merge-slot.js +61 -16
  220. package/dist/src/merge/merge-stack.js +27 -11
  221. package/dist/src/merge/merge-to-main.js +77 -26
  222. package/dist/src/merge/merge-top.js +83 -0
  223. package/dist/src/merge/merge-verify.js +75 -18
  224. package/dist/src/notes-cli.js +35 -15
  225. package/dist/src/operator/operator-commands.js +192 -81
  226. package/dist/src/operator/operator-intent.js +69 -42
  227. package/dist/src/operator/operator-requests.js +35 -40
  228. package/dist/src/operator/retire.js +5 -6
  229. package/dist/src/operator/revert.js +142 -34
  230. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  231. package/dist/src/orchestrator/orchestrator-poll.js +352 -0
  232. package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
  233. package/dist/src/orchestrator/orchestrator.js +129 -369
  234. package/dist/src/orchestrator/retention.js +42 -8
  235. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  236. package/dist/src/orchestrator/scheduling-scope.js +245 -0
  237. package/dist/src/orchestrator/scheduling-skip.js +195 -0
  238. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  239. package/dist/src/orchestrator/startup-migrations.js +11 -7
  240. package/dist/src/paths.js +126 -49
  241. package/dist/src/pi/command-shape.js +12 -1
  242. package/dist/src/pi/pi-args.js +6 -4
  243. package/dist/src/pi/pi-backend.js +6 -6
  244. package/dist/src/pi/pi-bin.js +54 -63
  245. package/dist/src/pi/pi-listed-models.js +53 -0
  246. package/dist/src/pi/pi-models.js +63 -30
  247. package/dist/src/pi/pi-run-result.js +1 -1
  248. package/dist/src/pi/pi-settings.js +1 -1
  249. package/dist/src/pi/pi-stream.js +6 -6
  250. package/dist/src/pi/pi-watchdogs.js +18 -19
  251. package/dist/src/pi-extension/bounded-output.js +5 -3
  252. package/dist/src/pi-extension/context-budget.js +2 -2
  253. package/dist/src/pi-extension/context-shake.js +1 -1
  254. package/dist/src/pi-extension/context-window.js +0 -1
  255. package/dist/src/pi-extension/full-output.js +3 -10
  256. package/dist/src/pi-extension/strip-thinking.js +0 -1
  257. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  258. package/dist/src/pi-extension/write-guard.js +37 -0
  259. package/dist/src/process/launch-services.js +1 -2
  260. package/dist/src/process/node-bin.js +16 -0
  261. package/dist/src/process/process-group.js +8 -18
  262. package/dist/src/process/process.js +41 -2
  263. package/dist/src/process/run-marker.js +26 -6
  264. package/dist/src/process/supervisor.js +22 -21
  265. package/dist/src/prompt/dialect.js +17 -2
  266. package/dist/src/prompt/gate-notes.js +203 -0
  267. package/dist/src/prompt/gate-prompts.js +150 -217
  268. package/dist/src/prompt/notes-backlog.js +184 -0
  269. package/dist/src/prompt/principles.js +1 -2
  270. package/dist/src/prompt/prompt-followup.js +19 -14
  271. package/dist/src/prompt/prompt.js +131 -204
  272. package/dist/src/redeploy/redeploy-policy.js +24 -19
  273. package/dist/src/redeploy/redeploy-probes.js +1 -1
  274. package/dist/src/redeploy/redeploy.js +2 -3
  275. package/dist/src/redeploy/redeployer.js +20 -20
  276. package/dist/src/redeploy/self-reload.js +2 -1
  277. package/dist/src/report/report-data.js +188 -124
  278. package/dist/src/report/report-fold.js +130 -54
  279. package/dist/src/report/report-main-red.js +55 -0
  280. package/dist/src/report/report-render.js +136 -65
  281. package/dist/src/report/report-types.js +1 -0
  282. package/dist/src/report/report.js +23 -9
  283. package/dist/src/request-timeouts.js +2 -2
  284. package/dist/src/review/diff-rules.js +53 -0
  285. package/dist/src/review/known-flakes.js +112 -1
  286. package/dist/src/review/post-merge-review-accept.js +51 -0
  287. package/dist/src/review/post-merge-review-render.js +112 -0
  288. package/dist/src/review/post-merge-review.js +239 -0
  289. package/dist/src/review/protected-edits.js +24 -10
  290. package/dist/src/review/quarantine-render.js +65 -0
  291. package/dist/src/review/review-followup.js +8 -8
  292. package/dist/src/review/review-precheck.js +66 -23
  293. package/dist/src/review/review-run.js +395 -0
  294. package/dist/src/review/review-verdict.js +2 -2
  295. package/dist/src/review/review.js +180 -401
  296. package/dist/src/review/secret-leaks.js +36 -16
  297. package/dist/src/review/suite-rerun.js +0 -1
  298. package/dist/src/roles/legacy-role-migration.js +80 -7
  299. package/dist/src/roles/role-catalog.js +127 -12
  300. package/dist/src/roles/role-guidance.js +65 -20
  301. package/dist/src/roles/role-labels.js +7 -5
  302. package/dist/src/roles/role-render.js +108 -56
  303. package/dist/src/roles/role-view.js +95 -56
  304. package/dist/src/roles/roles.js +33 -33
  305. package/dist/src/roles/runner-ids.js +97 -0
  306. package/dist/src/run/acceptance-greps.js +119 -0
  307. package/dist/src/run/ci-parity.js +121 -0
  308. package/dist/src/run/feedback-filings.js +51 -0
  309. package/dist/src/run/qa-coverage.js +125 -0
  310. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  311. package/dist/src/run/run-detail-data.js +175 -0
  312. package/dist/src/run/run-detail-render.js +91 -0
  313. package/dist/src/run/run-detail.js +168 -0
  314. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
  315. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  316. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
  317. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  318. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
  319. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  320. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  321. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
  322. package/dist/src/run/selfcheck-digest.js +40 -0
  323. package/dist/src/run/stage-backlog-checks.js +249 -0
  324. package/dist/src/run/stage-check.js +178 -0
  325. package/dist/src/run/stage-checks-shared.js +65 -0
  326. package/dist/src/run/stage-name-checks.js +204 -0
  327. package/dist/src/run/stage-path-checks.js +231 -0
  328. package/dist/src/run/stage-prose-checks.js +261 -0
  329. package/dist/src/runner/leftover.js +216 -0
  330. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  331. package/dist/src/runner/revision.js +191 -0
  332. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  333. package/dist/src/runner/runner-outcome.js +109 -0
  334. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  335. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
  336. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
  337. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  338. package/dist/src/runner/runner-state.js +103 -0
  339. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  340. package/dist/src/scheduling/backoff.js +38 -38
  341. package/dist/src/scheduling/claim-entries.js +49 -0
  342. package/dist/src/scheduling/claims.js +35 -22
  343. package/dist/src/scheduling/once-round.js +22 -22
  344. package/dist/src/scheduling/quiet-hours.js +5 -6
  345. package/dist/src/scheduling/scheduling.js +74 -64
  346. package/dist/src/scheduling/work-merged-cache.js +2 -2
  347. package/dist/src/status/status-budget.js +55 -47
  348. package/dist/src/status/status-data.js +49 -17
  349. package/dist/src/status/status-inbox.js +2 -2
  350. package/dist/src/status/status-install.js +0 -1
  351. package/dist/src/status/status-polls.js +43 -28
  352. package/dist/src/status/status-roles.js +50 -13
  353. package/dist/src/team/error-storm.js +8 -8
  354. package/dist/src/team/failure-spread.js +2 -2
  355. package/dist/src/team/orchestrator-info.js +15 -9
  356. package/dist/src/team/reclaim.js +7 -8
  357. package/dist/src/team/team-hold.js +7 -7
  358. package/dist/src/team/team-polls.js +91 -50
  359. package/dist/src/team/team-state.js +8 -2
  360. package/dist/src/text/datetime.js +65 -18
  361. package/dist/src/text/format.js +48 -11
  362. package/dist/src/text/markdown.js +8 -5
  363. package/dist/src/text/phrases.js +60 -23
  364. package/dist/src/text/suggest.js +1 -1
  365. package/dist/src/text/text.js +78 -13
  366. package/dist/src/ui/badges.js +99 -38
  367. package/dist/src/ui/gui/gui-client-backlog.js +314 -0
  368. package/dist/src/ui/gui/gui-client-boot.js +89 -61
  369. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  370. package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
  371. package/dist/src/ui/gui/gui-client-drawer.js +155 -289
  372. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  373. package/dist/src/ui/gui/gui-client-history.js +437 -68
  374. package/dist/src/ui/gui/gui-client-labels.js +6 -3
  375. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  376. package/dist/src/ui/gui/gui-client-model.js +27 -36
  377. package/dist/src/ui/gui/gui-client-operator.js +60 -52
  378. package/dist/src/ui/gui/gui-client-pending.js +405 -24
  379. package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
  380. package/dist/src/ui/gui/gui-client-report.js +105 -430
  381. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  382. package/dist/src/ui/gui/gui-client-settings.js +163 -5
  383. package/dist/src/ui/gui/gui-client-team.js +36 -465
  384. package/dist/src/ui/gui/gui-client.js +38 -26
  385. package/dist/src/ui/gui/gui-icons.js +0 -2
  386. package/dist/src/ui/gui/gui-page.js +10 -7
  387. package/dist/src/ui/gui/gui-styles.js +38 -34
  388. package/dist/src/ui/progress-data.js +9 -11
  389. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
  390. package/dist/src/ui/status-model.js +133 -125
  391. package/dist/src/ui/status-payload.js +364 -49
  392. package/dist/src/ui/status-render.js +109 -82
  393. package/dist/src/ui/team-alerts.js +728 -62
  394. package/dist/src/ui/tone.js +12 -6
  395. package/dist/src/ui/transcript-run.js +58 -0
  396. package/dist/src/ui/transcript-tail.js +28 -20
  397. package/dist/src/ui/transcript.js +63 -23
  398. package/dist/src/ui/tui/tui-app.js +0 -1
  399. package/dist/src/ui/tui/tui-backlog.js +53 -14
  400. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  401. package/dist/src/ui/tui/tui-editor-attach.js +4 -12
  402. package/dist/src/ui/tui/tui-editor-backends.js +127 -0
  403. package/dist/src/ui/tui/tui-editor-brief.js +14 -36
  404. package/dist/src/ui/tui/tui-editor-budget.js +36 -39
  405. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  406. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  407. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  408. package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
  409. package/dist/src/ui/tui/tui-editor-config.js +99 -0
  410. package/dist/src/ui/tui/tui-editor-entry.js +39 -64
  411. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  412. package/dist/src/ui/tui/tui-editor-instances.js +46 -0
  413. package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
  414. package/dist/src/ui/tui/tui-editor-interval.js +29 -16
  415. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  416. package/dist/src/ui/tui/tui-editor-notes.js +9 -29
  417. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  418. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  419. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  420. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  421. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  422. package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
  423. package/dist/src/ui/tui/tui-editor-retire.js +5 -13
  424. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  425. package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
  426. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  427. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  428. package/dist/src/ui/tui/tui-editor-session.js +101 -34
  429. package/dist/src/ui/tui/tui-editor-slots.js +24 -0
  430. package/dist/src/ui/tui/tui-editors.js +44 -2
  431. package/dist/src/ui/tui/tui-frame.js +68 -11
  432. package/dist/src/ui/tui/tui-history.js +8 -9
  433. package/dist/src/ui/tui/tui-input.js +13 -11
  434. package/dist/src/ui/tui/tui-keymap.js +0 -1
  435. package/dist/src/ui/tui/tui-keys.js +33 -9
  436. package/dist/src/ui/tui/tui-pane-activity.js +35 -19
  437. package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
  438. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  439. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  440. package/dist/src/ui/tui/tui-pane-health.js +168 -8
  441. package/dist/src/ui/tui/tui-pane-history.js +446 -82
  442. package/dist/src/ui/tui/tui-pane-pending.js +539 -15
  443. package/dist/src/ui/tui/tui-pane-queued.js +78 -57
  444. package/dist/src/ui/tui/tui-pane-report.js +54 -16
  445. package/dist/src/ui/tui/tui-pane-session.js +34 -6
  446. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  447. package/dist/src/ui/tui/tui-pane.js +91 -28
  448. package/dist/src/ui/tui/tui-panes.js +153 -34
  449. package/dist/src/ui/tui/tui.js +18 -15
  450. package/dist/src/verdict/fix-claim.js +129 -33
  451. package/dist/src/verdict/no-change.js +4 -4
  452. package/dist/src/verdict/refusal.js +40 -16
  453. package/dist/src/verdict/reply-contract.js +77 -31
  454. package/dist/src/version.js +0 -1
  455. package/package.json +2 -1
  456. package/dist/src/loop/leftover.js +0 -130
  457. package/dist/src/loop/revision.js +0 -70
  458. package/dist/src/roles/loop-ids.js +0 -77
  459. package/dist/src/tick/qa-coverage.js +0 -108
  460. package/dist/src/tick/stage-check.js +0 -419
  461. package/dist/src/tick/telemetry-digest.js +0 -25
  462. package/dist/src/tick/tick-detail-data.js +0 -108
  463. package/dist/src/tick/tick-detail.js +0 -163
  464. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -0,0 +1,506 @@
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, deleteRefIfAt, listBranches, refSha, 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 { staleClaimDetail, staleClaimEntries } from "../scheduling/claim-entries.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 { artifactPhrase, retireRole } from "../operator/retire.js";
36
+ import { loadRunnerState } from "../runner/runner-state-load.js";
37
+ import { agree, plural, shortSpanPhrase } from "../text/phrases.js";
38
+ import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
39
+ /** Merge lock — read-only classification via classifyLock (the same three cases the breaker
40
+ * uses). A stale lock is a warning, not a failure: it self-heals on the next merge. */
41
+ export function checkMergeLock(root) {
42
+ const dir = mergeLockDir(root);
43
+ switch (classifyLock(dir)) {
44
+ case "absent":
45
+ return okOutcome("not held");
46
+ case "live": {
47
+ const pid = readLockPid(dir);
48
+ return okOutcome(pid !== null ? `held by running role (pid ${pid})` : "held (pid not yet written)");
49
+ }
50
+ case "stale":
51
+ return warnOutcome("stale — will be broken on next merge");
52
+ }
53
+ }
54
+ /** A pin older than a day is the doctor's stuck-pin signal: a paused or retired role is holding
55
+ * a pooled checkout nobody will reuse (plans/worktree-pool.md, "Observability"). */
56
+ const PIN_STALE_MS = DAY_MS;
57
+ /** The single selector of a stale workspace pin, shared by the doctor's `worktree pool` row
58
+ * (checkWorktreePool) and the always-on `workspace-pins` alert, so the two cannot drift on
59
+ * what a stale pin is: every pinned-for slot whose pin is older than PIN_STALE_MS. Empty when
60
+ * nothing is pinned old enough — an unpinned pool, a malformed or absent slots.json, or a
61
+ * timeouted (non-finite) pinnedAt all read as no stale pin, exactly the tolerance
62
+ * checkWorktreePool has today. `now` is a test seam. */
63
+ export function staleWorkspacePins(root, now = Date.now()) {
64
+ return readSlotsState(root).slots
65
+ .filter((slot) => slot.pinnedFor !== null && slot.pinnedAt != null && now - slot.pinnedAt > PIN_STALE_MS)
66
+ .map((slot) => ({
67
+ role: slot.pinnedFor,
68
+ dir: path.basename(slot.dir),
69
+ ageMs: now - slot.pinnedAt,
70
+ }));
71
+ }
72
+ /** The shared one-pin clause of the doctor's `worktree pool` row (checkWorktreePool) and the
73
+ * standing `workspace-pins` alert (team-alerts.ts): `<role> pinned on <dir> for <Nh>`. One
74
+ * home so the row and the alert cannot drift on how a pin's age is phrased (whole hours, off
75
+ * the pin's whole-ms age). `role` is a non-optional string on StaleWorkspacePin, but the
76
+ * clause renders it through textOr like the row always did, so a malformed pin can never
77
+ * print nothing where the phrase expects a role. */
78
+ export function stalePinPhrase(pin) {
79
+ return `${textOr(pin.role)} pinned on ${pin.dir} for ${Math.round(pin.ageMs / 3_600_000)}h`;
80
+ }
81
+ /** Directory names under the worktrees root that the pool no longer owns: a legacy per-role
82
+ * `<role>` checkout (the pre-pool layout) or a `_land-<role>` checkout from the merge queue. The
83
+ * dedicated `director`, `_slot-<n>`, `_merge`, `_main`, `_gate-main` and `_build` checkouts are
84
+ * expected and never reported. An unreadable worktrees dir means nothing to report. */
85
+ function leftoverWorktreeDirs(root) {
86
+ return subdirectoryNames(worktreesDir(root), (name) => name !== DIRECTOR_ROLE && !(name.startsWith("_") && !name.startsWith("_land-")));
87
+ }
88
+ /** The window of `slot_wait` events the pool row sums, and the total wait inside it that warns. */
89
+ export const SLOT_WAIT_CHECK_WINDOW_MS = DAY_MS;
90
+ const SLOT_WAIT_WARN_TOTAL_MS = 10 * 60_000;
91
+ export const PROVIDER_WAIT_CHECK_WINDOW_MS = DAY_MS;
92
+ const PROVIDER_WAIT_WARN_TOTAL_MS = 10 * 60_000;
93
+ export const CHECK_WAIT_CHECK_WINDOW_MS = DAY_MS;
94
+ const CHECK_WAIT_WARN_TOTAL_MS = 10 * 60_000;
95
+ /** Fold a window's `*_wait` events into the three numbers every contention check reads: how
96
+ * many waits there were (each event counts once), the total ms they waited, and the longest
97
+ * single wait — plus the longest event itself, for the check that names the role/purpose off
98
+ * it. The single home of that fold for the three contention checks (checkWorktreePool's
99
+ * `slot_wait` half, checkProviderContention, checkCheckContention), so the sum and the
100
+ * longest-wait selection cannot drift between them. A wait that omits or corrupts `waitedMs`
101
+ * reads as 0 for the sum, exactly as each inline `finiteNumber(ev.waitedMs, 0)` fold did;
102
+ * the longest-wait tie keeps the first event that reached it. */
103
+ function sumWaits(waits) {
104
+ let count = 0;
105
+ let totalMs = 0;
106
+ let longestMs = 0;
107
+ let longest = waits[0];
108
+ for (const ev of waits) {
109
+ count += 1;
110
+ const waited = finiteNumber(ev.waitedMs, 0);
111
+ totalMs += waited;
112
+ if (waited > longestMs) {
113
+ longestMs = waited;
114
+ longest = ev;
115
+ }
116
+ }
117
+ return { count, totalMs, longestMs, longest };
118
+ }
119
+ /** The `slot_wait` half of checkWorktreePool's rule: whether the window's waits cross
120
+ * SLOT_WAIT_WARN_TOTAL_MS and the pool row's exact detail clause — the wait count, total and
121
+ * longest spans (naming the role and whether it was a check or a run), and, on a warn, the
122
+ * knob to raise past `slotCount`. Shared by the doctor's `workspaces` row and the always-on
123
+ * contention alert, so the trigger and the advice cannot drift. */
124
+ export function slotWaitContention(waits, slotCount) {
125
+ const { count, totalMs, longestMs, longest } = sumWaits(waits);
126
+ const contended = totalMs >= SLOT_WAIT_WARN_TOTAL_MS;
127
+ const purpose = longest.purpose === "verify" ? "check" : "run";
128
+ const detail = `${plural(count, "wait")} to start in the last 24h, ${shortSpanPhrase(totalMs)} total, longest ${shortSpanPhrase(longestMs)} (${textOr(longest.role)} ${purpose})${contended ? ` — raise worktreeSlots above ${slotCount}` : ""}`;
129
+ return { warn: contended, detail };
130
+ }
131
+ /** Provider-slot contention's rule (the body of checkProviderContention from `sumWaits` on):
132
+ * sums the waits, ranks the per-key buckets busiest first (the key-less bucket, which also
133
+ * takes every team-cap wait whatever its key, reads "the run cap") and, on a warn past
134
+ * PROVIDER_WAIT_WARN_TOTAL_MS, names each key's maxConcurrentPerProvider cap to raise plus
135
+ * maxConcurrent for the run cap. Shared by the doctor's `provider contention` row and the
136
+ * always-on contention alert, so the trigger and the advice cannot drift. The shared rule
137
+ * leaves the count-zero and read-error arms to checkProviderContention. */
138
+ export function providerWaitContention(waits, config) {
139
+ const perKey = new Map();
140
+ const { count, totalMs, longestMs } = sumWaits(waits);
141
+ for (const ev of waits) {
142
+ const waited = finiteNumber(ev.waitedMs, 0);
143
+ const key = ev.cap !== "team" && typeof ev.key === "string" && ev.key.length > 0 ? ev.key : "";
144
+ perKey.set(key, (perKey.get(key) ?? 0) + waited);
145
+ }
146
+ // Rank the buckets by wait total, ties breaking on key name so the detail is deterministic.
147
+ // The key-less bucket reads "the run cap" — maxConcurrent, since no seam keys to a provider.
148
+ const ranked = rankCountEntries(perKey).map(([key, total]) => ({ key, total }));
149
+ const bucketLabel = (key) => (key === "" ? "the run cap" : `the ${key} slot`);
150
+ const cells = ranked.map((b) => `${bucketLabel(b.key)} ${shortSpanPhrase(b.total)}`);
151
+ // A single bucket keeps the pre-existing wording, a bare `(the X slot)` without the total
152
+ // (the total is already named by the `shortSpanPhrase(total)` in the clause before it);
153
+ // two or more name each bucket's total so the ranking reads at a glance.
154
+ const parenthetical = ranked.length === 1
155
+ ? `(${bucketLabel(ranked[0].key)})`
156
+ : `(${cells[0]}, also ${cells.slice(1).join(", ")})`;
157
+ let detail = `${plural(count, "wait")} for a provider slot in the last 24h, ${shortSpanPhrase(totalMs)} total, longest ${shortSpanPhrase(longestMs)} ${parenthetical}`;
158
+ const contended = totalMs >= PROVIDER_WAIT_WARN_TOTAL_MS;
159
+ if (contended) {
160
+ const advice = ranked
161
+ .filter((b) => b.key !== "" && config?.maxConcurrentPerProvider?.[b.key] !== undefined)
162
+ .map((b) => `raise maxConcurrentPerProvider.${b.key} above ${config?.maxConcurrentPerProvider?.[b.key]}`)
163
+ .join(" and ");
164
+ const teamAdvice = perKey.has("") && config !== undefined ? [`raise maxConcurrent above ${config.maxConcurrent}`] : [];
165
+ const all = [...(advice ? [advice] : []), ...teamAdvice].join(" and ");
166
+ if (all)
167
+ detail += ` — ${all}`;
168
+ }
169
+ return { warn: contended, detail };
170
+ }
171
+ /** Check-slot contention's rule (the body of checkCheckContention from `sumWaits` on): sums
172
+ * the waits and warns past CHECK_WAIT_WARN_TOTAL_MS naming the knob (maxConcurrentChecks),
173
+ * with the `(behind <kind> <span>, …)` clause when the waits carried `heldBy` labels, kinds
174
+ * busiest first. Shared by the doctor's `check contention` row and the always-on contention
175
+ * alert, so the trigger and the advice cannot drift. The shared rule leaves the count-zero
176
+ * and read-error arms to checkCheckContention. */
177
+ export function checkWaitContention(waits, config) {
178
+ const perKind = new Map();
179
+ const { count, totalMs, longestMs } = sumWaits(waits);
180
+ for (const ev of waits) {
181
+ const waited = finiteNumber(ev.waitedMs, 0);
182
+ for (const kind of new Set(stringList(ev.heldBy).map(checkHolderKind))) {
183
+ perKind.set(kind, (perKind.get(kind) ?? 0) + waited);
184
+ }
185
+ }
186
+ let detail = `${plural(count, "wait")} for a check slot in the last 24h, ${shortSpanPhrase(totalMs)} total, longest ${shortSpanPhrase(longestMs)}`;
187
+ if (perKind.size > 0) {
188
+ const ranked = rankCountEntries(perKind);
189
+ detail += ` (behind ${ranked.map(([kind, ms]) => `${kind} ${shortSpanPhrase(ms)}`).join(", ")})`;
190
+ }
191
+ if (totalMs < CHECK_WAIT_WARN_TOTAL_MS)
192
+ return { warn: false, detail };
193
+ detail += ` — raise maxConcurrentChecks above ${config?.maxConcurrentChecks ?? defaultConfig().maxConcurrentChecks}`;
194
+ return { warn: true, detail };
195
+ }
196
+ /** Worktree pool (plans/worktree-pool.md, part 5/5): report the pooled slots and warn on the
197
+ * states an operator should act on — a legacy `<role>` or `_land-*` checkout the pool migration
198
+ * left behind, a pin older than PIN_STALE_MS (a paused role holding a checkout), and the last
199
+ * 24 h of `slot_wait` events totalling SLOT_WAIT_WARN_TOTAL_MS (the pool is too small). The stale
200
+ * pin is also the standing `workspace-pins` alert's selection (staleWorkspacePins). Read-only,
201
+ * like every doctor check; `now` is a test seam for the pin-age arithmetic. */
202
+ export function checkWorktreePool(root, now = Date.now()) {
203
+ const slots = readSlotsState(root).slots;
204
+ const leased = slots.filter((slot) => slot.lease !== null);
205
+ const pins = slots.filter((slot) => slot.pinnedFor !== null);
206
+ const stale = staleWorkspacePins(root, now);
207
+ const leftover = leftoverWorktreeDirs(root);
208
+ const parts = [`${plural(slots.length, "workspace")}, ${leased.length} leased, ${pins.length} pinned`];
209
+ if (leftover.length > 0)
210
+ parts.push(`legacy worktrees still present: ${leftover.join(", ")}`);
211
+ if (stale.length > 0) {
212
+ // The stale-pin clause renders the shared staleWorkspacePins selection (the same list the
213
+ // standing `workspace-pins` alert carries), so the row's wording and the alert's selection
214
+ // cannot disagree on what a stale pin is or how old it is.
215
+ parts.push(stale.map(stalePinPhrase).join("; "));
216
+ }
217
+ let contended = false;
218
+ try {
219
+ const waits = eventsSince(root, SLOT_WAIT_CHECK_WINDOW_MS, now, (ev) => ev.type === "slot_wait");
220
+ if (waits.length > 0) {
221
+ const c = slotWaitContention(waits, slots.length);
222
+ contended = c.warn;
223
+ parts.push(c.detail);
224
+ }
225
+ }
226
+ catch (err) {
227
+ parts.push(`could not read the event log for waits to start: ${errorMessage(err)}`);
228
+ }
229
+ const warn = leftover.length > 0 || stale.length > 0 || contended;
230
+ return (warn ? warnOutcome : okOutcome)(parts.join(" — "));
231
+ }
232
+ /** The shared wrapper of the `checkProviderContention`/`checkCheckContention` twins: read the
233
+ * window's `*_wait` events (a read error leaves the level ok, saying so rather than crying
234
+ * wolf, exactly like the slot-wait branch's own read), sum them, and resolve the row's rule;
235
+ * a window with no waits reads "no <noun>". One home for the try/catch, count-zero and
236
+ * ok-fallback arms the two siblings repeated, so they cannot drift on which inputs read ok;
237
+ * the row-specific rule (`providerWaitContention` / `checkWaitContention`) is `rule`. `noun`
238
+ * names the waits in the ok message. */
239
+ function waitContentionRow(root, windowMs, type, noun, rule, config, now) {
240
+ let count = 0;
241
+ let verdict;
242
+ try {
243
+ const waits = eventsSince(root, windowMs, now, (ev) => ev.type === type);
244
+ count = sumWaits(waits).count;
245
+ verdict = rule(waits, config);
246
+ }
247
+ catch (err) {
248
+ // A read error leaves the level ok, exactly like the slot-wait branch's own read: the
249
+ // check cannot measure, so it says so rather than crying wolf.
250
+ return okOutcome(`could not read the event log for ${noun}: ${errorMessage(err)}`);
251
+ }
252
+ if (count === 0)
253
+ return okOutcome(`no ${noun}`);
254
+ return (verdict.warn ? warnOutcome : okOutcome)(verdict.detail);
255
+ }
256
+ /** Provider-slot contention: sums the last 24 h of `provider_wait` events (each a role run
257
+ * whose semaphore admission waited PROVIDER_WAIT_EVENT_MS or more, recorded in
258
+ * orchestrator-launch.ts) and warns when the total reaches PROVIDER_WAIT_WARN_TOTAL_MS — the
259
+ * provider cap is too small. Names every key that waited, busiest first (the key-less
260
+ * bucket, which also takes every team-cap wait whatever its key, reads "the run cap") and, on
261
+ * a warn, each key's maxConcurrentPerProvider cap to raise, plus maxConcurrent for the run cap.
262
+ * A counterpoint to checkWorktreePool's slot-wait half; read-only, like every doctor check.
263
+ * `now` is a test seam for the window. */
264
+ export function checkProviderContention(root, config, now = Date.now()) {
265
+ return waitContentionRow(root, PROVIDER_WAIT_CHECK_WINDOW_MS, "provider_wait", "provider waits", providerWaitContention, config, now);
266
+ }
267
+ /** Names the kind of check a `heldBy` label stands for, in checkCheckContention's tally. */
268
+ function checkHolderKind(label) {
269
+ if (label === "main baseline")
270
+ return "the main baseline";
271
+ const scope = label.slice(label.lastIndexOf(" ") + 1);
272
+ return scope === "gate" || scope === "merge" || scope === "batch" ? `${scope} checks` : "other checks";
273
+ }
274
+ /** Check-slot contention: sums the last 24 h of `check_wait` events (each a scoped check whose
275
+ * permit wait reached CHECK_WAIT_EVENT_MS, recorded in build-check-events.ts) and warns when
276
+ * the total reaches CHECK_WAIT_WARN_TOTAL_MS — maxConcurrentChecks is too small. The third
277
+ * sibling of checkWorktreePool's slot-wait half and checkProviderContention; read-only.
278
+ * When events carry `heldBy` (the labels of the permit holders the check parked behind), the
279
+ * detail gains `(behind <kind> <span>, …)` after the longest clause, kinds busiest first: a
280
+ * wait counts once toward each distinct kind that held a slot. `now` is a test seam for the
281
+ * window. */
282
+ export function checkCheckContention(root, config, now = Date.now()) {
283
+ return waitContentionRow(root, CHECK_WAIT_CHECK_WINDOW_MS, "check_wait", "check waits", checkWaitContention, config, now);
284
+ }
285
+ /** The ids `leftover role branches` calls retirable and the branches it leaves for manual
286
+ * deletion — the one walk the row and the reap share, so the two can never disagree on what a
287
+ * retirable leftover is. Retirable ids are the branches whose role is no longer enabled (disabled
288
+ * in tumwater.json, or an instance above the configured count) and that `tumwater retire --role
289
+ * <id>` accepts (knownRoleIds, or any well-formed custom-role name); a `tumwater/<id>` branch for
290
+ * any other id — a removed custom role or an id retire can't name — falls to `unknown` as its
291
+ * full branch name. The director and enabled roles are skipped. An unreadable tumwater.json
292
+ * (config null) sees no branches. */
293
+ export async function retirableLeftoverRoles(root, config) {
294
+ if (!config)
295
+ return { retirable: [], unknown: [] };
296
+ const prefix = branchName("");
297
+ const known = new Set(knownRoleIds(config));
298
+ const retirable = [];
299
+ const unknown = [];
300
+ for (const branch of await listBranches(root)) {
301
+ if (!branch.startsWith(prefix))
302
+ continue;
303
+ const id = branch.slice(prefix.length);
304
+ if (!id || id.includes("/") || id === DIRECTOR_ROLE || runnerEnabled(config, id))
305
+ continue;
306
+ if (known.has(id) || CUSTOM_NAME_RE.test(id))
307
+ retirable.push(id);
308
+ else
309
+ unknown.push(branch);
310
+ }
311
+ return { retirable, unknown };
312
+ }
313
+ /** The "leftover role branches" row: `tumwater/<id>` branches whose role is no longer enabled
314
+ * (disabled in tumwater.json, or an instance above the configured count), through the shared
315
+ * retirableLeftoverRoles walk. Any other id is listed by
316
+ * full branch name with a manual-delete hint. Read-only, like every check: it only lists refs;
317
+ * an unreadable tumwater.json skips it. */
318
+ export async function checkLeftoverRoleBranches(root, config) {
319
+ if (!config)
320
+ return okOutcome("no readable tumwater.json — nothing to verify");
321
+ const { retirable, unknown } = await retirableLeftoverRoles(root, config);
322
+ const parts = [];
323
+ if (retirable.length > 0) {
324
+ const n = retirable.length;
325
+ 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)`);
326
+ }
327
+ if (unknown.length > 0) {
328
+ parts.push(`${unknown.join(", ")} ${agree(unknown.length, "names", "name")} no role retire accepts — delete with \`git branch -D\` once its commits are not needed`);
329
+ }
330
+ return parts.length === 0 ? okOutcome("no branches left by disabled roles") : warnOutcome(parts.join("; "));
331
+ }
332
+ /** The `refs/tumwater/` pins and, among them, the broken ones (whose commit is gone) — the one
333
+ * walk the `tumwater pins` row (checkTumwaterPins) and its reap (dropBrokenPins) share, so the
334
+ * two can never disagree on what a broken pin is. Filtered from `refsUnder(root,
335
+ * "refs/tumwater/")` by `!commitExists(root, pin.sha)`. */
336
+ async function brokenPins(root) {
337
+ const pins = await refsUnder(root, "refs/tumwater/");
338
+ const broken = [];
339
+ for (const pin of pins)
340
+ if (!(await commitExists(root, pin.sha)))
341
+ broken.push(pin);
342
+ return { pins, broken };
343
+ }
344
+ /** The "tumwater pins" row: fails when a `refs/tumwater/` pin (merge, rejected or legacy landing)
345
+ * names a commit that is gone. It fails rather than warns because such a pin stops `git gc`
346
+ * (and the `gc --auto` commits trigger), and a slot reset or leftover recovery only drops one
347
+ * when it reaches it. Read-only: it lists refs and names the `git update-ref -d` fix (the
348
+ * `--drop-broken-pins` flag performs it). */
349
+ export async function checkTumwaterPins(root) {
350
+ const { pins, broken } = await brokenPins(root);
351
+ if (broken.length === 0) {
352
+ return okOutcome(pins.length === 0 ? "no tumwater pins" : `${plural(pins.length, "tumwater pin")}, each naming a commit that exists`);
353
+ }
354
+ const n = broken.length;
355
+ 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)`);
356
+ }
357
+ /** The lines `doctor --drop-broken-pins` prints (and the dashboard's Drop broken pins toast),
358
+ * one per dropped pin and, when any, one for the pins that could not be dropped. No lines for
359
+ * a run that dropped nothing and failed nothing. */
360
+ export function brokenPinDropLines(drop) {
361
+ const lines = [];
362
+ if (drop.dropped.length > 0)
363
+ lines.push(`${plural(drop.dropped.length, "dropped broken pin")}: ${drop.dropped.map((p) => `${p.ref} names ${shortSha(p.sha)}`).join(", ")}`);
364
+ if (drop.failed.length > 0)
365
+ lines.push(`${plural(drop.failed.length, "pin could not be dropped", "pins could not be dropped")}: ${drop.failed.map((f) => `${f.ref} — ${f.reason}`).join(", ")}`);
366
+ return lines;
367
+ }
368
+ /** The lines `doctor --retire-leftovers` prints, one per retired role naming its removed
369
+ * artifacts through the same wording `tumwater retire --role <id>` surfaces, and one naming
370
+ * each failed role and its reason. No lines for a run that retired and failed nothing. */
371
+ export function leftoverRetireLines(report) {
372
+ const lines = [];
373
+ for (const r of report.retired)
374
+ lines.push(`retired ${r.role}: ${r.removed.map((a) => artifactPhrase(a, r.role)).join("; ")}`);
375
+ for (const f of report.failed)
376
+ lines.push(`failed to retire ${f.role}: ${f.reason}`);
377
+ return lines;
378
+ }
379
+ /** Retire each disabled role `retirableLeftoverRoles` calls retirable — the reap behind `doctor
380
+ * --retire-leftovers` — through the same `retireRole` core `tumwater retire --role <id>` uses,
381
+ * never `--force` (exactly the CLI's own refusal policy). A thrown rail refusal is caught into
382
+ * `failed` with its reason and the loop continues, leaving that role exactly as it was.
383
+ * Read-only checks never call this. */
384
+ export async function retireLeftovers(root, config) {
385
+ const { retirable } = await retirableLeftoverRoles(root, config);
386
+ const retired = [];
387
+ const failed = [];
388
+ for (const role of retirable) {
389
+ try {
390
+ const { removed } = await retireRole(root, role, {});
391
+ retired.push({ role, removed });
392
+ }
393
+ catch (err) {
394
+ failed.push({ role, reason: errorMessage(err) });
395
+ }
396
+ }
397
+ return { retired, failed };
398
+ }
399
+ /** Drop each broken `refs/tumwater/` pin — the reap behind `doctor --drop-broken-pins` and the
400
+ * dashboard's Drop broken pins button. Each pin is deleted only while it still names the broken
401
+ * sha (deleteRefIfAt's guarded `git update-ref -d`), so a ref that moved under us is never
402
+ * dropped; a ref that still names a gone commit afterwards is reported failed. Read-only checks
403
+ * never call this. */
404
+ export async function dropBrokenPins(root) {
405
+ const { broken } = await brokenPins(root);
406
+ const dropped = [];
407
+ const failed = [];
408
+ for (const pin of broken) {
409
+ await deleteRefIfAt(root, pin.ref, pin.sha);
410
+ const still = await refSha(root, pin.ref);
411
+ if (still === null)
412
+ dropped.push(pin);
413
+ else if (!(await commitExists(root, still)))
414
+ failed.push({ ref: pin.ref, reason: `still names ${shortSha(still)}, which is gone` });
415
+ }
416
+ return { dropped, failed };
417
+ }
418
+ /** The ok detail of the instance check: non-zero counts, plans first, each pluralized. */
419
+ function claimCounts(claims) {
420
+ const bugs = claims.filter((c) => c.file === "BUGS.md").length;
421
+ const plans = claims.length - bugs;
422
+ const joined = joinParts([
423
+ plans > 0 ? plural(plans, "claimed plan") : "",
424
+ bugs > 0 ? plural(bugs, "claimed bug") : "",
425
+ ], ", ");
426
+ return joined || "no claimed plans or bugs";
427
+ }
428
+ /** Parallel work instances (plans/parallel-work-instances.md "Observability", part 6/7):
429
+ * each instance's claimed plan or bug and the two states an operator should act on — a claim
430
+ * whose key is no longer listed under its role's section (its instance is stuck on an entry
431
+ * that moved), and a claim idle for more than CLAIM_IDLE_MAX_MS (the scheduler releases
432
+ * these, so seeing one means the reader's view and the scheduler's disagree). An enabled
433
+ * multi-instance role configured with fewer pooled slots than maxConcurrent also warns:
434
+ * instances then contend for the same checkouts. A disabled role runs none of its
435
+ * configured instances, so it never contends and
436
+ * stays out of that warning.
437
+ * Read-only, like every check; `now` is the claim-age test seam, and an unreadable
438
+ * tumwater.json (config undefined) skips the walk — checkInit reports the config itself. */
439
+ export function checkWorkInstances(root, config, now = Date.now()) {
440
+ if (!config)
441
+ return okOutcome("no readable tumwater.json — nothing to verify");
442
+ const claims = runnerIds(config).map((id) => loadRunnerState(root, id).claim).filter((c) => c !== undefined);
443
+ const entries = staleClaimEntries(root, runnerIds(config), now);
444
+ const gone = entries.filter((e) => e.kind === "gone");
445
+ const stale = entries.filter((e) => e.kind === "idle");
446
+ const multi = Object.entries(config.roles)
447
+ .filter(([id, role]) => INSTANCE_ROLES.has(id) && role.enabled && (role.instances ?? 1) > 1)
448
+ .map(([id]) => id);
449
+ const slots = slotCount(config);
450
+ const smallPool = multi.length > 0 && slots < config.maxConcurrent;
451
+ if (gone.length === 0 && stale.length === 0 && !smallPool)
452
+ return okOutcome(claimCounts(claims));
453
+ const parts = [...gone.map(staleClaimDetail), ...stale.map(staleClaimDetail)];
454
+ if (smallPool)
455
+ parts.push(`${multi.join(", ")} run instances but worktreeSlots ${slots} is below maxConcurrent ${config.maxConcurrent}`);
456
+ return warnOutcome(parts.join("; "));
457
+ }
458
+ /** Provider concurrency caps (plans/provider-concurrency.md §8): audit the
459
+ * `maxConcurrentPerProvider` map against the seams the config can actually spawn — each role,
460
+ * the review gate, the resolver, the director, and each tier's fallback — so the operator
461
+ * sees whether every cap names a provider in use and whether any cap actually binds. Warns on
462
+ * a cap key no seam resolves to (a typo, or a provider not in use), a cap at or above
463
+ * `maxConcurrent` (which never binds — the team total is the real ceiling), and a capped
464
+ * team whose seams include one with no key (pi's default provider is unreadable, so that
465
+ * seam cannot be capped). A config with no per-provider map or no caps is fine, and so is a
466
+ * well-formed map; a broken config is reported by the init check. Takes `root` to read pi's
467
+ * own default model (piSettingsPath), the same seam the orchestrator's admission uses, so a
468
+ * bare no-model pi seam keys exactly as the team would key it. */
469
+ export function checkProviderConcurrency(config, root) {
470
+ if (!config)
471
+ return okOutcome("no config to audit");
472
+ const caps = config.maxConcurrentPerProvider;
473
+ const capEntries = enforcedKeyCaps(caps);
474
+ if (capEntries.length === 0)
475
+ return okOutcome("no per-provider caps configured");
476
+ // The pi default model the orchestrator's admission seam carries: a bare no-model pi seam
477
+ // resolves its key only through it, so a team whose default is readable has keyed seams and
478
+ // only an unreadable default leaves a key-less seam to warn about.
479
+ const piDefault = readPiDefaultModel(piSettingsPath(piModelsPath()), root);
480
+ const seams = seamsInUse(config);
481
+ const keyed = [];
482
+ for (const s of seams) {
483
+ const k = seamConcurrencyKey(s, piDefault);
484
+ if (k !== undefined && !keyed.includes(k))
485
+ keyed.push(k);
486
+ }
487
+ const keySet = new Set(keyed);
488
+ const capKeys = capEntries.map(([k]) => k);
489
+ const warnings = [];
490
+ for (const key of capKeys) {
491
+ if (!keySet.has(key))
492
+ warnings.push(`cap key "${key}" resolves to no model job (a typo, or a provider not in use)`);
493
+ }
494
+ for (const [key, cap] of capEntries) {
495
+ if (cap >= config.maxConcurrent)
496
+ warnings.push(`cap "${key}" ${cap} is at or above maxConcurrent ${config.maxConcurrent} — it never binds`);
497
+ }
498
+ const capSet = new Set(capKeys);
499
+ const keyless = seams.filter((s) => seamConcurrencyKey(s, piDefault) === undefined);
500
+ if (capSet.size > 0 && keyless.length > 0) {
501
+ 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(", ")}`);
502
+ }
503
+ if (warnings.length === 0)
504
+ return okOutcome(`${plural(capEntries.length, "provider cap")} — all model jobs resolve`);
505
+ return warnOutcome(warnings.join("; "));
506
+ }
@@ -0,0 +1,130 @@
1
+ import { loadConfigForCheck } from "./doctor-model-checks.js";
2
+ import { fallbackPair, tierChainSteps } from "../config/config-fallbacks.js";
3
+ import { fallbackModelPair, piModelsPath, piPairFree, piProviderBaseUrl, readPiProviders } from "../pi/pi-models.js";
4
+ import { readClaudeAccount } from "../agent/claude/claude-account.js";
5
+ import { formatClock } from "../text/datetime.js";
6
+ import { loadRunnerStates } from "./doctor-holds.js";
7
+ import { modelPairName, pairDailyCost } from "../budget/budget.js";
8
+ import { usd, usdCap } from "../text/format.js";
9
+ import { okOutcome, warnOutcome } from "./outcome.js";
10
+ /** The doctor's cap-fallback checks, split out of doctor-model-checks.ts (whose seam framework
11
+ * and declared-tier/image checks stay in a module whose per-backend provider checks live in
12
+ * doctor-{claude,opencode,codex,pi}-checks.ts): the daily-cost budget's third state — whether
13
+ * the configured fallback can really serve at the cap. checkFallbackModel prices the declared
14
+ * fallback pair in pi's definitions, and checkFallbackChain reports one fragment per step of
15
+ * every tier's chain saying whether that step can run now. Together they read config, pi's
16
+ * model definitions and the role states, and are the only doctor checks concerned with the
17
+ * budget's fallback — a distinct concern from the seam framework and declared-model checks,
18
+ * kept separate so a change to fallback pricing does not touch how doctor reads seams. The
19
+ * config guard (loadConfigForCheck) is imported from doctor-model-checks, exactly as the
20
+ * per-backend modules import that file's seam framework. The shared report contract
21
+ * (CheckOutcome) is imported from outcome.ts. */
22
+ /** Fallback model readiness — the daily-cost budget's third state (plans/fallback-model.md): with
23
+ * `fallbackModel` set, pi's definitions must price that pair at zero or the gate refuses it and
24
+ * roles pause at the cap exactly as if no fallback existed. That refusal is the right runtime
25
+ * behavior (spend must never climb past the cap), but a typo'd id would otherwise surface only
26
+ * after the day's budget is already spent — so doctor checks it up front, before the operator
27
+ * needs it. Read-only: it inspects pi's definitions file, never pi or the network. A price is not
28
+ * readiness, though: whether the backend can SERVE is known only from the fallback's own runs
29
+ * (src/budget/fallback-breaker.ts's breaker, BUGS.md 2026-09-20), so a free pair reads "serving not
30
+ * verified" — unless the running orchestrator has published that its breaker demoted the pair
31
+ * (`demoted`, from orchestrator.json; runDoctor passes it only while that orchestrator is alive),
32
+ * which warns with the failure count and the next probe time. No fallbackModel configured is
33
+ * informational, not a warning — plenty of teams intend to stop at the cap. `modelsPath` is
34
+ * injectable so tests need no real pi install. */
35
+ export function checkFallbackModel(root, modelsPath = piModelsPath(), demoted = null) {
36
+ const config = loadConfigForCheck(root);
37
+ if ("level" in config)
38
+ return config;
39
+ const declared = fallbackPair(config);
40
+ if (!declared)
41
+ return okOutcome("none configured — roles pause at the cap");
42
+ // A pair missing either half would fall through to pi's own (unverified) default, so it can
43
+ // never be free; naming that case beats rendering a bare "?/model". The free branch names
44
+ // the price-resolved pair (a map with no `default` entry borrows another tier's), while the
45
+ // refusal names the pair the operator declared.
46
+ const nameOf = (pair) => pair.provider && pair.model ? `${pair.provider}/${pair.model}` : "a half-resolved pair";
47
+ // The claude account answer (6a/7); the fallback price lookup reads it only for "claude"
48
+ // (part 2a/9).
49
+ const account = readClaudeAccount(root);
50
+ const accounts = (b) => (b === "claude" ? account : null);
51
+ const free = fallbackModelPair(config, modelsPath, accounts);
52
+ if (free) {
53
+ const name = nameOf(free);
54
+ if (demoted)
55
+ return warnOutcome(`${name} is priced at zero but not serving — the running team demoted it after ${demoted.failures} consecutive failed runs, so roles pause at the cap; one probe run retries it from ${formatClock(demoted.probeAt)}`);
56
+ return okOutcome(`${name} — priced at zero (cost n/a), serving not verified`);
57
+ }
58
+ return warnOutcome(`${nameOf(declared)} is not priced at zero in ${modelsPath} — at the cap roles pause instead of switching`);
59
+ }
60
+ /** Whether a local server answers `GET <url>`: node's built-in fetch with a short timeout, false
61
+ * on any failure — doctor is a pre-flight, not a place to wait out a hung server. */
62
+ async function probeModels(url) {
63
+ try {
64
+ const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
65
+ return res.ok;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ const isLocalUrl = (url) => {
72
+ try {
73
+ const host = new URL(url).hostname;
74
+ return host === "127.0.0.1" || host === "localhost";
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ };
80
+ /** The `fallback chain` check: one fragment per step of every tier's chain saying whether the
81
+ * step can run now — a local pi server that does not answer (warn), a step whose own daily cap is
82
+ * spent, a free pair, a claude step with its billing, or a priced pair. Reads config, pi's
83
+ * models.json and the role states; only a pi step on 127.0.0.1/localhost makes a network
84
+ * request. `probe` is injectable so tests need no real server. */
85
+ export async function checkFallbackChain(root, modelsPath = piModelsPath(), probe = probeModels) {
86
+ const config = loadConfigForCheck(root);
87
+ if ("level" in config)
88
+ return config;
89
+ const steps = tierChainSteps(config);
90
+ if (steps.length === 0)
91
+ return okOutcome("none configured");
92
+ const states = loadRunnerStates(root, config);
93
+ const providers = readPiProviders(modelsPath);
94
+ const account = readClaudeAccount(root);
95
+ // The free-ness lookup reads the one claude account held here (part 2a/9).
96
+ const accounts = (b) => (b === "claude" ? account : null);
97
+ const now = Date.now();
98
+ let down = false;
99
+ const fragments = [];
100
+ for (const { tier, step, pair } of steps) {
101
+ // A provider that names a backend other than pi (claude today; opencode from part 2) never
102
+ // asks pi for a base URL or runs pi's price probe — only a pi step may.
103
+ const nonPiBackend = pair.backend !== undefined && pair.backend !== "pi";
104
+ const base = !nonPiBackend && pair.provider ? piProviderBaseUrl(modelsPath, pair.provider) : null;
105
+ const local = base !== null && isLocalUrl(base);
106
+ const up = local ? await probe(`${base}/models`) : null;
107
+ const cap = pair.maxDailyCostUsd;
108
+ const spent = cap !== undefined ? pairDailyCost(states, modelPairName(pair), now) : 0;
109
+ let verdict;
110
+ if (up === false) {
111
+ down = true;
112
+ verdict = `not running (GET ${base}/models failed)`;
113
+ }
114
+ else if (cap !== undefined && spent >= cap) {
115
+ verdict = `cap reached (${usd(spent)}/${usdCap(cap)})`;
116
+ }
117
+ else if (nonPiBackend) {
118
+ verdict = `claude ${pair.extraUsage ? "extra usage" : (account?.billing ?? "billing unknown")}`;
119
+ }
120
+ else if (piPairFree(providers, pair, accounts)) {
121
+ verdict = up ? "free · server up" : "free";
122
+ }
123
+ else {
124
+ verdict = cap !== undefined ? `priced, ${usd(spent)}/${usdCap(cap)} today` : "priced, team cap only";
125
+ }
126
+ fragments.push(`${tier} ${step + 1}: ${pair.provider ?? "?"}/${pair.model ?? "?"} — ${verdict}`);
127
+ }
128
+ const detail = fragments.join("; ");
129
+ return down ? warnOutcome(detail) : okOutcome(detail);
130
+ }