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
@@ -4,7 +4,7 @@
4
4
  * utilities module. */
5
5
  /** Group items by a key, preserving first-seen key order and input order within each group.
6
6
  * The single home of the "append to a Map of arrays" step, shared by the event bucketers
7
- * (history-data.ts's bucketMergeEvents, failure/time-spend.ts's rejected-by-loop pass) and
7
+ * (history-queued.ts's bucketMergeEvents, failure/time-spend.ts's rejected-by-role pass) and
8
8
  * the team-hold poll's observations-by-provider grouping (team-polls.ts's pollTeamHold), so
9
9
  * those sites cannot drift on how a missing key is seeded. Returns a fresh Map; the input is
10
10
  * never mutated. */
@@ -35,8 +35,8 @@ export function getOrCreate(map, key, make) {
35
35
  * `map.set(k, (map.get(k) ?? 0) + 1)` step, shared by the failure-spread kind tally
36
36
  * (src/team/failure-spread.ts), the diff multiset's line tally (merge/merge-diff.ts), the
37
37
  * section-title tally (backlog/backlog-structure.ts), the once-summary outcome tally
38
- * (cli/cli-run.ts), the logs' per-type tally (cli/log-commands.ts), the flaky-check tally
39
- * (doctor/doctor-checks.ts), and the multi-runner base tally
38
+ * (cli/cli-start.ts), the logs' per-type tally (cli/log-commands.ts), the flaky-check tally
39
+ * (doctor/doctor-project-checks.ts), and the multi-runner base tally
40
40
  * (orchestrator/orchestrator-scheduling.ts), so those sites cannot drift on how a missing key is
41
41
  * seeded. */
42
42
  export function increment(map, key) {
@@ -46,13 +46,13 @@ export function increment(map, key) {
46
46
  * sibling of `increment`, the single home of the `record[key] = (record[key] ?? 0) + n`
47
47
  * step (the seed matters because `noUncheckedIndexedAccess` reads every entry as possibly
48
48
  * undefined), shared by the failure digest's outcome tally per role (failure-data.ts), the
49
- * usage report's window role totals (report-render.ts), and its per-day fold's tick and cost
49
+ * usage report's window role totals (report-render.ts), and its per-day fold's run and cost
50
50
  * tallies (report-fold.ts), so those sites cannot drift on how a missing key is seeded. The
51
51
  * test duration reporter (test/test-durations-reporter.ts) keeps its own copy: it imports no
52
52
  * tumwater module by design, since node --test loads it into its own parent process.
53
53
  *
54
54
  * The seed reads an OWN property (Object.hasOwn), not a bare `record[key] ?? 0`: the
55
- * custom-loop name charset admits "constructor", an Object.prototype key, whose inherited
55
+ * custom-role name charset admits "constructor", an Object.prototype key, whose inherited
56
56
  * function would otherwise be the seed — storing `function Object() …5` and turning every
57
57
  * later `roleSum`/report total into string concatenation (BUGS.md 2026-10-10). */
58
58
  export function addTo(record, key, amount) {
@@ -78,6 +78,20 @@ export function memoSetLru(cache, key, value, max) {
78
78
  }
79
79
  cache.set(key, value);
80
80
  }
81
+ /** Trim `arr` in place so it holds at most `max` elements, keeping only the newest (last) `max`
82
+ * and dropping the whole overflow from the front in one splice — the single home of the
83
+ * "keep the newest capped buffer" discipline that src/ui/transcript.ts's feedEntries and
84
+ * src/history/history.ts's `logs --follow` buffer both hand-rolled before, so those sites
85
+ * cannot drift on how an over-cap buffer is drained. Batch appends may overflow by more than
86
+ * one at a time, so the whole excess is dropped rather than a single element; an `arr` already
87
+ * at or under `max` is untouched. Mutates `arr` in place and returns nothing. (Deliberately
88
+ * unlike the per-element ring drains of src/scheduling/backoff.ts and src/orchestrator/
89
+ * orchestrator-launch.ts, which shift one entry at a time as they push — each is a different
90
+ * shape and keeps its own copy.) */
91
+ export function trimToLast(arr, max) {
92
+ if (arr.length > max)
93
+ arr.splice(0, arr.length - max);
94
+ }
81
95
  /** One deterministic ranking rule: count descending, then key ascending, so equal counts always
82
96
  * order the same way. These two helpers are the single home of that rule; every surface that
83
97
  * ranks counters by count calls one of them rather than re-implementing the comparator —
@@ -88,15 +102,16 @@ export function memoSetLru(cache, key, value, max) {
88
102
  * failure-data.ts's outcome table, and time-spend.ts's time-and-spend table). `sortByValueDesc`
89
103
  * is the deliberate sibling for the same value-descending shape that wants a code-unit tiebreak
90
104
  * instead of localeCompare, and unlike these two it sorts its input array in place. The one
91
- * deliberate exception to both orderings is ui/gui/gui-client-report.ts's reportRoleOrder, which
92
- * keeps its own copy by design: it runs in the browser, where harness modules cannot be imported.
105
+ * deliberate exception to both orderings is ui/gui/gui-client-report-chart.ts's reportRoleOrder,
106
+ * which keeps its own copy by design: it runs in the browser, where harness modules cannot be
107
+ * imported.
93
108
  * These two helpers return a fresh sorted array; their input is never mutated. */
94
109
  export function rankByCount(items, count, key) {
95
110
  return [...items].sort((a, b) => count(b) - count(a) || key(a).localeCompare(key(b)));
96
111
  }
97
112
  /** The lower median of a non-empty numeric list: the middle value, or the smaller of the
98
113
  * middle two for an even count. Returns a fresh sorted copy; the input is never mutated.
99
- * Shared by report-data.ts's per-day medians, doctor-checks.ts's and doctor-backlog.ts's
114
+ * Shared by report-data.ts's per-day medians, doctor-project-checks.ts's and doctor-backlog.ts's
100
115
  * 24 h phrase medians. (failure-data.ts's `median` is deliberately different: the mean of
101
116
  * the middle two for an even count.) */
102
117
  export function lowerMedian(values) {
@@ -105,7 +120,7 @@ export function lowerMedian(values) {
105
120
  }
106
121
  /** Ascending string comparator by UTF-16 code unit — the locale-independent order a bare `.sort()`
107
122
  * uses, unlike `localeCompare` (which `rankByCount` uses for its tiebreak). Used directly by
108
- * src/cli/cli-run.ts, src/cli/log-commands.ts and src/history/history-data.ts, and by
123
+ * src/cli/cli-start-summary.ts, src/cli/log-commands.ts and src/history/history-data.ts, and by
109
124
  * `sortByValueDesc` for its tiebreak. */
110
125
  export function compareCodeUnits(a, b) {
111
126
  return a < b ? -1 : a > b ? 1 : 0;
@@ -121,7 +136,8 @@ export function sortByValueDesc(entries) {
121
136
  return entries.sort((a, b) => b[1] - a[1] || compareCodeUnits(a[0], b[0]));
122
137
  }
123
138
  /** rankByCount for `[key, count]` entry pairs (count first): src/report/report-render.ts's
124
- * rankedRoleMap and src/team/failure-spread.ts's strongest-kind pick. */
139
+ * rankedRoleMap, src/team/failure-spread.ts's strongest-kind pick, and
140
+ * src/doctor/doctor-contention-checks.ts's check-wait and provider-wait rankings. */
125
141
  export function rankCountEntries(entries) {
126
142
  return rankByCount(entries, (e) => e[1], (e) => e[0]);
127
143
  }
@@ -9,7 +9,7 @@ import { Semaphore } from "./semaphore.js";
9
9
  * execution and classification (detect → spawn → classify) — because permit accounting is
10
10
  * scheduling policy, not check semantics: it changes when the concurrency or fairness policy
11
11
  * changes, not when the check's execution does. Separate from the orchestrator's maxConcurrent
12
- * semaphore: a role tick holds one of those while it waits here, but a check permit is held only
12
+ * semaphore: a role run holds one of those while it waits here, but a check permit is held only
13
13
  * around the check process itself — never across a pi run, the merge lock, or another check — so no
14
14
  * permit holder waits on anything a waiter holds. Sized at each acquire from the caller's live
15
15
  * config (checkCap), so a tumwater.json edit applies to the next check; Semaphore.setCapacity never
@@ -20,6 +20,9 @@ const checkPermits = new Semaphore(defaultConfig().maxConcurrentChecks);
20
20
  * second wait could never be granted (its own holder is the one blocking it). No call path
21
21
  * nests today; this keeps a future one from deadlocking the team's checks. */
22
22
  const holdingPermit = new AsyncLocalStorage();
23
+ /** Labels of the checks holding a permit now, keyed by a per-hold token; insertion order is
24
+ * acquisition order. A check_wait event names them (PermitWaitHooks.waited). */
25
+ const permitHolders = new Map();
23
26
  /** Waiting-queue tiers (Semaphore.acquire): a merge-scope check runs inside the merge lock, so it
24
27
  * is granted the next free permit ahead of queued gate and baseline checks — every check it waited
25
28
  * behind would be lock-hold time for every other merge. A running check is never preempted. */
@@ -32,23 +35,37 @@ function checkCap(config) {
32
35
  const n = config?.maxConcurrentChecks;
33
36
  return typeof n === "number" && Number.isInteger(n) && n >= 1 ? n : defaultConfig().maxConcurrentChecks;
34
37
  }
38
+ /** The check pool's live state for observers (the orchestrator's poll publishes it): permits held,
39
+ * the cap, and checks queued for one. `limit` is the cap set by the most recent withCheckPermit
40
+ * call, or the default before any check has run. */
41
+ export function checkPermitSnapshot() {
42
+ return { inUse: checkPermits.snapshot().inUse, limit: checkPermits.limit, waiting: checkPermits.waiting };
43
+ }
35
44
  /** Run `run` under one process-wide check permit (see checkPermits), resizing the cap from the
36
45
  * live config first and releasing in a finally — a check that fails, times out, or throws still
37
46
  * gives its permit back. Reentrant (holdingPermit): called again from inside `run`, it runs the
38
- * inner work under the permit already held. `hooks` hear about a wait, if there is one. */
39
- export async function withCheckPermit(config, tier, run, hooks) {
47
+ * inner work under the permit already held. `hooks` hear about a wait, if there is one; `label`
48
+ * names this check to the waiters it holds up ("a check" when omitted). */
49
+ export async function withCheckPermit(config, tier, run, hooks, label) {
40
50
  if (holdingPermit.getStore())
41
51
  return run();
42
52
  checkPermits.setCapacity(checkCap(config));
43
53
  if (!checkPermits.tryAcquire()) {
54
+ const waitStarted = Date.now();
55
+ // Snapshot at park time; not revised if holders change mid-wait.
56
+ const parked = { heldBy: [...permitHolders.values()], limit: checkPermits.limit };
44
57
  hooks?.waiting();
45
58
  await checkPermits.acquire(tier);
46
59
  hooks?.granted();
60
+ hooks?.waited?.(Date.now() - waitStarted, parked);
47
61
  }
62
+ const token = Symbol();
63
+ permitHolders.set(token, label ?? "a check");
48
64
  try {
49
65
  return await holdingPermit.run(true, run);
50
66
  }
51
67
  finally {
68
+ permitHolders.delete(token);
52
69
  checkPermits.release();
53
70
  }
54
71
  }
@@ -1,10 +1,10 @@
1
- // vocabulary: plain
2
1
  import fs from "node:fs";
3
2
  import path from "node:path";
4
3
  import { pidAlive } from "../process/process.js";
5
4
  import { errorMessage, parsePositiveInt } from "../text/text.js";
6
- import { ensureParentDir, removeTree } from "../files/files.js";
5
+ import { dirEntryNames, ensureParentDir, removeTree } from "../files/files.js";
7
6
  import { errCode } from "../errno.js";
7
+ import { primaryWorktreeLockPath } from "../paths.js";
8
8
  /** How old a lock dir must be before it is stale on age alone — regardless of whether its
9
9
  * recorded pid still looks alive, so a reused pid cannot latch a dead holder as live. */
10
10
  const STALE_MS = 10 * 60 * 1000;
@@ -70,7 +70,7 @@ export function classifyLock(dir) {
70
70
  return pidAlive(pid) ? "live" : "stale";
71
71
  }
72
72
  /** Name the lock's holder for the timeout message: the pid its pid file records, or a note
73
- * that none was readable. The message surfaces as a tick's lastError, and the pid is the one
73
+ * that none was readable. The message surfaces as a run's lastError, and the pid is the one
74
74
  * handle an operator needs to tell "waiting on a slow holder" from "an orphan dir nobody can
75
75
  * break" — without it every timeout reads the same and the holder has to be found by hand. */
76
76
  function lockHolderNote(dir) {
@@ -117,7 +117,7 @@ function acquireStep(dir, timeoutMs, waitedMs) {
117
117
  }
118
118
  /** The tail every wait-step in acquireStep shares once a lock is held (or was lost in the
119
119
  * publish race): break it if stale, then hand back "retry" for another pass — or throw the one
120
- * timeout error once the wait budget is spent. That error surfaces as a tick's lastError, and
120
+ * timeout error once the wait budget is spent. That error surfaces as a run's lastError, and
121
121
  * naming the holder is what distinguishes a slow holder from a wedged one (and says which
122
122
  * process to look at). Single-homed so the held path and the lost-race path cannot drift. */
123
123
  function retryOrTimeout(dir, timeoutMs, waitedMs) {
@@ -156,14 +156,8 @@ function publishLockDir(dir) {
156
156
  function sweepStaleTemps(dir) {
157
157
  const parent = path.dirname(dir);
158
158
  const prefix = `${path.basename(dir)}.acquiring-`;
159
- let entries;
160
- try {
161
- entries = fs.readdirSync(parent);
162
- }
163
- catch {
164
- return; // No parent to sweep; the acquire's own mkdir reports the real cause.
165
- }
166
- for (const name of entries) {
159
+ // No parent to sweep reads empty; the acquire's own mkdir reports the real cause.
160
+ for (const name of dirEntryNames(parent)) {
167
161
  if (!name.startsWith(prefix))
168
162
  continue;
169
163
  const candidate = path.join(parent, name);
@@ -176,7 +170,7 @@ function sweepStaleTemps(dir) {
176
170
  }
177
171
  }
178
172
  }
179
- /** Directory-and-pid mutex shared by all loops (and processes) of one project: the holder
173
+ /** Directory-and-pid mutex shared by all roles (and processes) of one project: the holder
180
174
  * publishes a dir with its pid in it by atomic rename. */
181
175
  export async function withLock(dir, fn, timeoutMs = 120_000) {
182
176
  const startedAt = Date.now();
@@ -235,3 +229,13 @@ export async function withStateLockAsync(lock, fn, timeoutMs = 10_000) {
235
229
  ensureParentDir(lock);
236
230
  return withLock(lock, fn, timeoutMs);
237
231
  }
232
+ /** withStateLock on the primary-worktree lock: the one lock every read-modify-write of a tracked
233
+ * file in the primary checkout (a backlog file, the brief) takes, which a merge's working-tree
234
+ * fast-forward takes too. */
235
+ export function withPrimaryWorktreeLock(root, fn) {
236
+ return withStateLock(primaryWorktreeLockPath(root), fn);
237
+ }
238
+ /** The async twin of withPrimaryWorktreeLock, for a section that awaits git. */
239
+ export function withPrimaryWorktreeLockAsync(root, fn) {
240
+ return withStateLockAsync(primaryWorktreeLockPath(root), fn);
241
+ }
@@ -1,8 +1,8 @@
1
- /** The AsyncLocalStorage store that lets a tick's lease follow its runs across providers
2
- * (plans/provider-concurrency.md §5): a lease rides here for the length of a tick, a verify task,
3
- * or a resolver run, and `LoopPi.runWithTransientRetry` re-keys it to the provider each run
4
- * actually uses. The one choke point every run a loop makes (authoring, gate, merge and resolver)
5
- * passes through, so a budget handback or a model-failure switch that moves a mid-tick run to
1
+ /** The AsyncLocalStorage store that lets a run's lease follow its runs across providers
2
+ * (plans/provider-concurrency.md §5): a lease rides here for the length of a run, a verify task,
3
+ * or a resolver run, and `RunnerPi.runWithTransientRetry` re-keys it to the provider each run
4
+ * actually uses. The one choke point every run a role makes (authoring, gate, merge and resolver)
5
+ * passes through, so a budget handback or a model-failure switch that moves a run in flight to
6
6
  * another provider moves the lease with it instead of leaving it parked on the key it was admitted
7
7
  * on. The check-permit.ts `holdingPermit` store is the pattern: nothing here is a separate permit,
8
8
  * only the single lease the run path already holds.
@@ -10,7 +10,7 @@
10
10
  * The store carries the `piDefault` (the caller's cached readPiDefaultModel result) beside the
11
11
  * lease so `ensureProviderLease` can resolve a bare no-model pi seam's key exactly as the
12
12
  * admission/review seam did — a run whose key were computed without it would re-key a default
13
- * team's tick off pi's own provider. It also carries the semaphore and, optionally, wait
13
+ * team's run off pi's own provider. It also carries the semaphore and, optionally, wait
14
14
  * hooks, so a re-key that has to park on a full target key reports its wait the way a parked
15
15
  * resolver does (merge-verify.ts's providerWaitStage). */
16
16
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -26,7 +26,7 @@ export function withProviderLease(lease, semaphore, run, opts = {}) {
26
26
  return providerLeaseStore.run({ lease, semaphore, piDefault: opts.piDefault, hooks: opts.hooks }, run);
27
27
  }
28
28
  /** Re-key the calling lease to the provider `cfg` runs on, if it is not there yet. No-op when
29
- * the current async context carries no lease (a direct LoopPi unit test, `tumwater tick`) or
29
+ * the current async context carries no lease (a direct RunnerPi unit test, `tumwater history`) or
30
30
  * when the run is already on the lease's key. When it does re-key and `hooks` are present and
31
31
  * the target key is at its cap, the hooks hear `waiting` before the lease parks and `granted`
32
32
  * once the key frees — a re-key never waits on the team total (it already holds a team
@@ -58,7 +58,7 @@ function keyAtCap(ctx, key) {
58
58
  }
59
59
  /** The pool-slot wait rule (plans/provider-concurrency.md §5 "No deadlock"): a lease never holds a
60
60
  * provider key while it waits for a worktree pool slot. Called by leaseSlot just before it parks on
61
- * a busy pool; it re-keys the calling lease to no key (which never waits), so a tick or verify
61
+ * a busy pool; it re-keys the calling lease to no key (which never waits), so a run or verify
62
62
  * waiting for a slot does not hold — and cannot deadlock — a provider slot whose in-use a re-key
63
63
  * waiter is waiting to drain. No-op without a lease in context. */
64
64
  export async function dropProviderLeaseKey() {
@@ -67,3 +67,39 @@ export async function dropProviderLeaseKey() {
67
67
  return;
68
68
  await ctx.lease.rekey(undefined);
69
69
  }
70
+ /** Run `wait`, an idle stretch inside the calling lease's work, with the lease's team permit and
71
+ * key slot handed back (SemaphoreLease.pause), so a parked run can use them meanwhile, then take
72
+ * them back (resume) before returning. A failed review's in-place retry pause is the caller: with
73
+ * one team permit, a reviewer that kept failing held it through two 30 s pauses and starved every
74
+ * other role. Once `signal` has fired, nothing is taken back and the caller's release() settles a
75
+ * still-paused lease. No-op without a lease in context. */
76
+ export async function idleWithoutProviderLease(wait, signal) {
77
+ const ctx = providerLeaseStore.getStore();
78
+ if (!ctx)
79
+ return wait();
80
+ ctx.lease.pause();
81
+ try {
82
+ await wait();
83
+ }
84
+ finally {
85
+ if (!signal?.aborted)
86
+ await untilAborted(ctx.lease.resume(), signal);
87
+ }
88
+ }
89
+ /** `p`, or an earlier return once `signal` fires: a resume parked on a full team must not keep an
90
+ * aborted verification waiting for a permit it no longer needs. */
91
+ async function untilAborted(p, signal) {
92
+ if (!signal)
93
+ return p;
94
+ let onAbort = () => { };
95
+ const aborted = new Promise((resolve) => {
96
+ onAbort = resolve;
97
+ signal.addEventListener("abort", onAbort, { once: true });
98
+ });
99
+ try {
100
+ await Promise.race([p, aborted]);
101
+ }
102
+ finally {
103
+ signal.removeEventListener("abort", onAbort);
104
+ }
105
+ }
@@ -6,6 +6,11 @@
6
6
  * provider never blocks a waiter on another (plans/provider-concurrency.md §3). The keyless
7
7
  * entry points — acquire/tryAcquire/release — are unchanged; only acquireLease, forceLease
8
8
  * and setKeyCaps touch the keyed path. */
9
+ /** The entries of a `maxConcurrentPerProvider` map the semaphore enforces: positive integers
10
+ * only, so a non-positive or non-integer cap is dropped rather than read as a limit. */
11
+ export function enforcedKeyCaps(caps) {
12
+ return Object.entries(caps ?? {}).filter(([, v]) => Number.isInteger(v) && v > 0);
13
+ }
9
14
  export class Semaphore {
10
15
  capacity;
11
16
  /** A parked WAITING (admission) waiter: its scheduling tier, optional provider key, and the
@@ -29,8 +34,8 @@ export class Semaphore {
29
34
  /** The live per-key caps, from `maxConcurrentPerProvider` (plans/provider-concurrency.md
30
35
  * §1). An absent key is uncapped — bounded by `capacity` alone. */
31
36
  keyCaps = new Map();
32
- /** Permits withheld from tier ≥ 1 acquirers so a work loop that has work to take can claim
33
- * one without waiting for a maintenance tick to release (plans/parallel-work-instances.md
37
+ /** Permits withheld from tier ≥ 1 acquirers so a work role that has work to take can claim
38
+ * one without waiting for a maintenance run to release (plans/parallel-work-instances.md
34
39
  * "Priority headroom", part 7/7). Tiers ≤ 0 (work, VERIFY_TIER, MERGE_TIER) ignore it. */
35
40
  reserve = 0;
36
41
  constructor(capacity) {
@@ -64,15 +69,7 @@ export class Semaphore {
64
69
  return;
65
70
  }
66
71
  await new Promise((resolve) => {
67
- const waiter = { tier, resolve };
68
- let i = this.waiters.length;
69
- while (i > 0) {
70
- const prev = this.waiters[i - 1];
71
- if (!prev || prev.tier <= tier)
72
- break; // insert before strictly-greater tiers only
73
- i -= 1;
74
- }
75
- this.waiters.splice(i, 0, waiter);
72
+ this.enqueue({ tier, resolve });
76
73
  });
77
74
  }
78
75
  /** Admit by both the team total and an optional provider key at once, waiting until both
@@ -92,10 +89,31 @@ export class Semaphore {
92
89
  // CANCEL the pending grant rather than undo a slot this lease never took. Null when no
93
90
  // rekey is parked.
94
91
  let parkedRekey = null;
92
+ // True while pause() has handed the team permit and the key's slot back: release() then owes
93
+ // nothing, and resume() re-admits at this lease's tier on its current key.
94
+ let paused = false;
95
+ // resume()'s admission waiter while it is queued. release() cancels it if drive has not
96
+ // granted it yet, and gives the grant back if it has.
97
+ let parkedResume = null;
95
98
  const release = () => {
96
99
  if (closed)
97
100
  return;
98
101
  closed = true;
102
+ if (parkedResume !== null) {
103
+ const w = parkedResume;
104
+ parkedResume = null;
105
+ const idx = this.waiters.indexOf(w);
106
+ if (idx >= 0) {
107
+ // Still queued: it took nothing, so cancel it and owe nothing.
108
+ this.waiters.splice(idx, 1);
109
+ w.resolve();
110
+ return;
111
+ }
112
+ // drive granted it this microtask: its permit and key slot are taken, so give them back.
113
+ paused = false;
114
+ }
115
+ if (paused)
116
+ return; // pause() already gave the permit and the key's slot back
99
117
  if (parkedRekey !== null) {
100
118
  // A re-key is parked waiting on its new key. Cancel it: pull it off the queue (so drive
101
119
  // can't grant it) and, if the grant already arrived this microtask, give the new key's slot
@@ -119,6 +137,12 @@ export class Semaphore {
119
137
  const rekey = async (to) => {
120
138
  if (to === currentKey || closed)
121
139
  return;
140
+ if (paused) {
141
+ // Nothing is held while paused: the move only changes the key resume() admits on.
142
+ currentKey = to;
143
+ lease.key = to;
144
+ return;
145
+ }
122
146
  // Give the old key back first, waking that key's waiters.
123
147
  if (currentKey !== undefined) {
124
148
  this.bumpKeyInUse(currentKey, -1);
@@ -143,7 +167,36 @@ export class Semaphore {
143
167
  this.bumpKeyInUse(to, 1);
144
168
  this.drive();
145
169
  };
146
- const lease = { key: currentKey, release, rekey };
170
+ const pause = () => {
171
+ if (closed || paused || parkedRekey !== null)
172
+ return;
173
+ paused = true;
174
+ if (currentKey !== undefined)
175
+ this.bumpKeyInUse(currentKey, -1);
176
+ this.inUse -= 1;
177
+ this.drive();
178
+ };
179
+ const resume = async () => {
180
+ if (closed || !paused || parkedResume !== null)
181
+ return;
182
+ if (this.canLeaseAtOnce(tier, currentKey)) {
183
+ this.inUse += 1;
184
+ if (currentKey !== undefined)
185
+ this.bumpKeyInUse(currentKey, 1);
186
+ paused = false;
187
+ return;
188
+ }
189
+ await new Promise((resolve) => {
190
+ const waiter = { tier, key: currentKey, resolve };
191
+ parkedResume = waiter;
192
+ this.enqueue(waiter);
193
+ });
194
+ parkedResume = null;
195
+ // drive granted it (the permit and key slot are taken), unless release() cancelled it.
196
+ if (!closed)
197
+ paused = false;
198
+ };
199
+ const lease = { key: currentKey, release, rekey, pause, resume };
147
200
  if (this.inUse < this.capacity - this.reserveFor(tier) && (key === undefined || !this.keyAtCap(key))) {
148
201
  this.inUse += 1;
149
202
  if (key !== undefined)
@@ -151,18 +204,23 @@ export class Semaphore {
151
204
  return lease;
152
205
  }
153
206
  await new Promise((resolve) => {
154
- const waiter = { tier, key, resolve };
155
- let i = this.waiters.length;
156
- while (i > 0) {
157
- const prev = this.waiters[i - 1];
158
- if (!prev || prev.tier <= tier)
159
- break;
160
- i -= 1;
161
- }
162
- this.waiters.splice(i, 0, waiter);
207
+ this.enqueue({ tier, key, resolve });
163
208
  });
164
209
  return lease;
165
210
  }
211
+ /** Queue an admission waiter behind every waiter of its tier or a lower one, so it inserts
212
+ * before strictly-greater tiers only (tier order, FIFO within a tier) — the one home of the
213
+ * queue's ordering, shared by acquire, acquireLease and a paused lease's resume. */
214
+ enqueue(waiter) {
215
+ let i = this.waiters.length;
216
+ while (i > 0) {
217
+ const prev = this.waiters[i - 1];
218
+ if (!prev || prev.tier <= waiter.tier)
219
+ break;
220
+ i -= 1;
221
+ }
222
+ this.waiters.splice(i, 0, waiter);
223
+ }
166
224
  /** Synchronously report whether acquireLease(tier, key) would return at once — both the
167
225
  * team total and the key (when set) have headroom right now, and the tier's reserve is
168
226
  * satisfied. Mirrors the exact fast-path condition acquireLease's first branch uses, so a
@@ -180,8 +238,10 @@ export class Semaphore {
180
238
  if (currentKey !== undefined) {
181
239
  this.bumpKeyInUse(currentKey, 1);
182
240
  }
241
+ // True while pause() has given the key's slot back (a forced lease holds no team permit).
242
+ let paused = false;
183
243
  const release = () => {
184
- if (currentKey !== undefined) {
244
+ if (currentKey !== undefined && !paused) {
185
245
  this.bumpKeyInUse(currentKey, -1);
186
246
  }
187
247
  this.drive();
@@ -189,16 +249,32 @@ export class Semaphore {
189
249
  const rekey = async (to) => {
190
250
  if (to === currentKey)
191
251
  return;
192
- if (currentKey !== undefined) {
252
+ if (currentKey !== undefined && !paused) {
193
253
  this.bumpKeyInUse(currentKey, -1);
194
254
  }
195
255
  currentKey = to;
196
256
  lease.key = to;
197
- if (to !== undefined)
257
+ if (to !== undefined && !paused)
198
258
  this.bumpKeyInUse(to, 1);
199
259
  this.drive();
200
260
  };
201
- const lease = { key: currentKey, release, rekey };
261
+ const pause = () => {
262
+ if (paused)
263
+ return;
264
+ paused = true;
265
+ if (currentKey !== undefined)
266
+ this.bumpKeyInUse(currentKey, -1);
267
+ this.drive();
268
+ };
269
+ // Like the forced lease itself, taking the slot back never waits.
270
+ const resume = async () => {
271
+ if (!paused)
272
+ return;
273
+ paused = false;
274
+ if (currentKey !== undefined)
275
+ this.bumpKeyInUse(currentKey, 1);
276
+ };
277
+ const lease = { key: currentKey, release, rekey, pause, resume };
202
278
  return lease;
203
279
  }
204
280
  /** Hand every parked waiter a permit it can take — both team and key headroom — in one
@@ -289,7 +365,7 @@ export class Semaphore {
289
365
  * holder — it only withholds future grants on that key until releases drain it under the new
290
366
  * cap, the same contract as setCapacity. Non-positive and non-integer entries are dropped. */
291
367
  setKeyCaps(caps) {
292
- this.keyCaps = new Map(Object.entries(caps).filter(([, v]) => Number.isInteger(v) && v > 0));
368
+ this.keyCaps = new Map(enforcedKeyCaps(caps));
293
369
  this.drive();
294
370
  }
295
371
  /** The pool's live state for observability (plans/provider-concurrency.md §8): the team
@@ -30,7 +30,6 @@ const CONFIG_KEY_RENAMES = [
30
30
  { from: "minTickIntervalSeconds", to: "minRunIntervalSeconds", scope: "top" },
31
31
  { from: "minTickIntervalSeconds", to: "minRunIntervalSeconds", scope: "role" },
32
32
  { from: "customLoops", to: "customRoles", scope: "top" },
33
- { from: "agent", to: "backend", scope: "top" },
34
33
  { from: "agents", to: "backends", scope: "top" },
35
34
  ];
36
35
  /** Move every old key of one object to its new name. Returns a copy when anything moved. */
@@ -8,7 +8,11 @@
8
8
  * This is config vocabulary, not HTTP vocabulary — it names which tumwater.json keys are
9
9
  * browser-editable — so it lives beside the other config-key lists (config-schema.ts's
10
10
  * TOP_LEVEL_KEYS, config-write.ts's per-key validators) rather than in the endpoint module
11
- * that first consumed it. The browser side mirrors the set for its labels
11
+ * that first consumed it. The four run-concurrency knobs (maxConcurrent,
12
+ * maxConcurrentPerProvider, maxConcurrentChecks, worktreeSlots) joined the set because
13
+ * tumwater doctor's own contention checks name them for the operator to raise
14
+ * (checkWorktreePool, checkProviderContention, checkCheckContention); the per-role maps and
15
+ * customRoles still stay CLI/director territory. The browser side mirrors the set for its labels
12
16
  * (ui/gui/gui-client-settings.ts's SETTINGS_KEYS), a manual copy; the GET /api/config test
13
17
  * pins the served key set to this list, and the settings test pins the browser copy's keys
14
18
  * and order to it.
@@ -18,6 +22,14 @@ export const EDITABLE_CONFIG_KEYS = [
18
22
  "model",
19
23
  "fallback",
20
24
  "maxDailyCostUsd",
25
+ // The run-concurrency knobs tumwater doctor's contention checks name for the operator to
26
+ // raise (checkWorktreePool, checkProviderContention) — grouped so the Settings card that
27
+ // shows the advice can apply it.
28
+ "maxConcurrent",
29
+ "maxConcurrentPerProvider",
30
+ "maxConcurrentChecks",
31
+ "worktreeSlots",
32
+ "claudeWindowReserve",
21
33
  "quietHours",
22
34
  "notify",
23
35
  // The per-backend section (plans/agent-backends.md, part 4a/7): the whole `agents` object is