tumwater 0.4.2 → 0.4.3

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 (305) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +25 -12
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +1 -8
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -166,11 +166,9 @@ function applyHistoryModelFilter(rows, rest) {
166
166
  }
167
167
  return { rows, models };
168
168
  }
169
- /** Render the collected-and-filtered rows: the `--by`/`--summary` summary grouping, the `--json`
170
- * document, the empty-state wording (blaming the filter that emptied the set, else the log or
171
- * window), the aligned table, and the sparse-window note. A bad `--by` value fails here, after
172
- * row collection and filtering and before `--json`. */
173
- function sayHistory(rows, q, covered, filtered) {
169
+ /** Validate `--by`'s value and its `--summary` requirement; return whether the grouping is by
170
+ * model (false for `role`). One home for "can this `--by` proceed, and does it group by model?" */
171
+ function historySummaryGroup(q) {
174
172
  // `--by role|model` picks how --summary groups its lines (role by default).
175
173
  const byRaw = flagValue(q.rest, "--by");
176
174
  let byModel = false;
@@ -184,6 +182,52 @@ function sayHistory(rows, q, covered, filtered) {
184
182
  fail("history --by needs --summary (it picks how the summary groups runs)");
185
183
  byModel = byRaw === "model";
186
184
  }
185
+ return byModel;
186
+ }
187
+ /** The empty-set prose: blame the filter that emptied the set (model, then result, then grep),
188
+ * else the log or window. Never the `no runs yet`/`no runs in <window>` prose when rows were
189
+ * scanned and filtered out — the runs are there, none match. */
190
+ function sayHistoryEmpty(q, filtered) {
191
+ // A grep that matched nothing must not borrow the empty-log prose: `no runs yet` and
192
+ // `no runs in 2h` are statements about the LOG and the WINDOW, and both are false when
193
+ // rows were scanned and filtered out — the operator would read a quiet team where the
194
+ // truth is `the runs are there, none match`. The logs --grep rule (its `no events
195
+ // matching "<pattern>"`, src/cli/log-commands.ts) names the pattern instead; --json stays
196
+ // a bare {"rows":[]} document above, never prose in either form.
197
+ //
198
+ // The result class is named only when it emptied a non-empty set (`afterGrep > 0`): an
199
+ // empty log or window scanned no rows, so the result filter removed nothing and the
200
+ // empty-log/window prose is the true one — the dashboard's History view draws the same
201
+ // line. A grep that admitted nothing keeps its own wording regardless of --result.
202
+ say(filtered.models !== null && filtered.afterResult > 0
203
+ ? `no runs on model "${filtered.models.join(",")}"`
204
+ : filtered.classes !== null && filtered.afterGrep > 0
205
+ ? `no runs with result "${filtered.classes.join(",")}"`
206
+ : q.grepPattern !== null
207
+ ? `no runs matching "${q.grepPattern}"`
208
+ : q.sinceMs === null
209
+ ? "no runs yet"
210
+ : q.untilMs === null
211
+ ? `no runs in ${durationLabel(q.sinceMs)}`
212
+ : `no runs ${betweenWindow(q.sinceMs, q.untilMs)}`);
213
+ }
214
+ /** Render the non-empty rows: the summary (model or role) when `--summary`, else the aligned
215
+ * table. Called only with `rows.length > 0`.
216
+ */
217
+ function sayHistoryRows(rows, summary, byModel) {
218
+ if (summary)
219
+ say(byModel ? renderModelSummary(summarizeRunRowsByModel(rows)) : renderSummary(summarizeRunRows(rows)));
220
+ else {
221
+ const widths = widestWidths(rows);
222
+ say(rows.map((r) => renderRow(r, widths)).join("\n"));
223
+ }
224
+ }
225
+ /** Render the collected-and-filtered rows: the `--by`/`--summary` summary grouping, the `--json`
226
+ * document, the empty-state wording (blaming the filter that emptied the set, else the log or
227
+ * window), the aligned table, and the sparse-window note. A bad `--by` value fails here, after
228
+ * row collection and filtering and before `--json`. */
229
+ function sayHistory(rows, q, covered, filtered) {
230
+ const byModel = historySummaryGroup(q);
187
231
  const summary = q.rest.includes("--summary") && rows.length > 0;
188
232
  // --json swaps the renderer for the collector's own payload, exactly as status --json and
189
233
  // report --json: the same rows the table prints, each with ts/tokens/costUsd kept raw. A
@@ -194,36 +238,10 @@ function sayHistory(rows, q, covered, filtered) {
194
238
  return;
195
239
  }
196
240
  if (rows.length === 0) {
197
- // A grep that matched nothing must not borrow the empty-log prose: `no runs yet` and
198
- // `no runs in 2h` are statements about the LOG and the WINDOW, and both are false when
199
- // rows were scanned and filtered out — the operator would read a quiet team where the
200
- // truth is `the runs are there, none match`. The logs --grep rule (its `no events
201
- // matching "<pattern>"`, src/cli/log-commands.ts) names the pattern instead; --json stays
202
- // a bare {"rows":[]} document above, never prose in either form.
203
- //
204
- // The result class is named only when it emptied a non-empty set (`afterGrep > 0`): an
205
- // empty log or window scanned no rows, so the result filter removed nothing and the
206
- // empty-log/window prose is the true one — the dashboard's History view draws the same
207
- // line. A grep that admitted nothing keeps its own wording regardless of --result.
208
- say(filtered.models !== null && filtered.afterResult > 0
209
- ? `no runs on model "${filtered.models.join(",")}"`
210
- : filtered.classes !== null && filtered.afterGrep > 0
211
- ? `no runs with result "${filtered.classes.join(",")}"`
212
- : q.grepPattern !== null
213
- ? `no runs matching "${q.grepPattern}"`
214
- : q.sinceMs === null
215
- ? "no runs yet"
216
- : q.untilMs === null
217
- ? `no runs in ${durationLabel(q.sinceMs)}`
218
- : `no runs ${betweenWindow(q.sinceMs, q.untilMs)}`);
241
+ sayHistoryEmpty(q, filtered);
219
242
  return;
220
243
  }
221
- if (summary)
222
- say(byModel ? renderModelSummary(summarizeRunRowsByModel(rows)) : renderSummary(summarizeRunRows(rows)));
223
- else {
224
- const widths = widestWidths(rows);
225
- say(rows.map((r) => renderRow(r, widths)).join("\n"));
226
- }
244
+ sayHistoryRows(rows, summary, byModel);
227
245
  // A sparse window is never mistaken for a quiet team — but the note only ever rides rows
228
246
  // (an empty window returned above), it never touches --json output, and it is the shared
229
247
  // SPARSE_WINDOW_NOTE wording: the oldest retained event being inside the window is exactly
@@ -179,10 +179,10 @@ function runPromptImageGate(root, target) {
179
179
  if (guidance.notice)
180
180
  say(guidance.notice);
181
181
  }
182
- /** `--edit <n>` mode — replace a queued prompt's body by position: the attachment gate, then
183
- * the editor for a bare position or the typed/`--file` text, list-wide or `--role`-scoped. One
184
- * helper so `--edit`'s rules are found by name. */
185
- function runPromptEdit(root, position, text, attachPaths, role) {
182
+ /** The `--edit` attachment phase: read the `--attach` image list (undefined when there is
183
+ * none) and run the image-input gate under the role the edit targets — the position's
184
+ * resolved role when no `--role` narrows it. */
185
+ function readEditAttachments(root, position, attachPaths, role) {
186
186
  // The same broken-config policy and list-wide resolution as cancel, one verb over: the
187
187
  // file's new content rides the same queue file (position, enqueue stamp, and a pending
188
188
  // --at deferral untouched), and a concurrent dequeue reports gone and exits clean. An
@@ -200,48 +200,66 @@ function runPromptEdit(root, position, text, attachPaths, role) {
200
200
  const targetRole = role ?? resolveListedRole(root, position);
201
201
  runPromptImageGate(root, targetRole);
202
202
  }
203
- const typed = text;
204
- if (typed === null) {
205
- // Bare `--edit <n>`: open the prompt's body in the editor. The role is resolved first
206
- // and the write goes by file name, so a dequeue while the editor is open reports gone
207
- // instead of editing the neighbour that shifted into the position.
208
- const targetRole = role ?? resolveListedRole(root, position);
209
- let name;
210
- let body;
211
- try {
212
- name = path.basename(queuedFileAtPosition(root, targetRole, position));
213
- body = readQueuedPromptBody(root, targetRole, name);
214
- }
215
- catch (err) {
216
- return fail(errorMessage(err));
217
- }
218
- if (body === null) {
219
- sayPromptOutcome(position, targetRole, { status: "gone" }, role === null);
220
- return;
221
- }
222
- const after = editInEditor(body, "prompt.md");
223
- // An emptied buffer is an abort, not a blank prompt: the queue would otherwise hold a
224
- // prompt with no text, which the enqueue forms refuse.
225
- if (after.trim() === "" && body.trim() !== "")
226
- fail("the edited prompt is empty; nothing changed (use --cancel <n> to drop it)");
227
- if (after.trim() === body.trim() && images === undefined) {
228
- say(`prompt ${position} unchanged`);
229
- return;
230
- }
231
- try {
232
- sayPromptOutcome(position, targetRole, editQueuedFile(root, targetRole, name, after, images), role === null);
233
- }
234
- catch (err) {
235
- fail(errorMessage(err));
236
- }
203
+ return images;
204
+ }
205
+ /** The bare `--edit <n>` arm: resolve the role, read the queued body by file name (so a
206
+ * concurrent dequeue reports gone), open it in the editor and write the result back —
207
+ * unchanged, emptied or edited — with the carried `images`. */
208
+ function editBareQueuedPrompt(root, position, role, images) {
209
+ // Bare `--edit <n>`: open the prompt's body in the editor. The role is resolved first
210
+ // and the write goes by file name, so a dequeue while the editor is open reports gone
211
+ // instead of editing the neighbour that shifted into the position.
212
+ const targetRole = role ?? resolveListedRole(root, position);
213
+ let name;
214
+ let body;
215
+ try {
216
+ name = path.basename(queuedFileAtPosition(root, targetRole, position));
217
+ body = readQueuedPromptBody(root, targetRole, name);
218
+ }
219
+ catch (err) {
220
+ return fail(errorMessage(err));
221
+ }
222
+ if (body === null) {
223
+ sayPromptOutcome(position, targetRole, { status: "gone" }, role === null);
237
224
  return;
238
225
  }
226
+ const after = editInEditor(body, "prompt.md");
227
+ // An emptied buffer is an abort, not a blank prompt: the queue would otherwise hold a
228
+ // prompt with no text, which the enqueue forms refuse.
229
+ if (after.trim() === "" && body.trim() !== "")
230
+ fail("the edited prompt is empty; nothing changed (use --cancel <n> to drop it)");
231
+ if (after.trim() === body.trim() && images === undefined) {
232
+ say(`prompt ${position} unchanged`);
233
+ return;
234
+ }
235
+ try {
236
+ sayPromptOutcome(position, targetRole, editQueuedFile(root, targetRole, name, after, images), role === null);
237
+ }
238
+ catch (err) {
239
+ fail(errorMessage(err));
240
+ }
241
+ }
242
+ /** The typed `--edit <n> <text>` / `--file` arm: the list-wide `editListedPrompt` path when
243
+ * no `--role`, else the role-scoped `editRolePrompt` command, with the carried `images`. */
244
+ function editTypedQueuedPrompt(root, position, text, role, images) {
239
245
  if (role === null) {
240
- sayListedOutcome(editListedPrompt(root, listWideScope(root), position, typed, images), position);
246
+ sayListedOutcome(editListedPrompt(root, listWideScope(root), position, text, images), position);
241
247
  return;
242
248
  }
243
249
  const target = role ?? DIRECTOR_ROLE;
244
- sayPromptOutcome(position, target, runRolePromptCommand(root, target, position, (r, role, position) => editRolePrompt(r, role, position, typed, images)), false);
250
+ sayPromptOutcome(position, target, runRolePromptCommand(root, target, position, (r, role, position) => editRolePrompt(r, role, position, text, images)), false);
251
+ }
252
+ /** `--edit <n>` mode — replace a queued prompt's body by position: read the `--attach` image
253
+ * list through the gate, then the editor for a bare position or the typed/`--file` text,
254
+ * list-wide or `--role`-scoped. The three phases live in `readEditAttachments`,
255
+ * `editBareQueuedPrompt` and `editTypedQueuedPrompt`, so each `--edit` job is found by name. */
256
+ function runPromptEdit(root, position, text, attachPaths, role) {
257
+ const images = readEditAttachments(root, position, attachPaths, role);
258
+ if (text === null) {
259
+ editBareQueuedPrompt(root, position, role, images);
260
+ return;
261
+ }
262
+ editTypedQueuedPrompt(root, position, text, role, images);
245
263
  }
246
264
  /** `--top <n>` mode — move a queued prompt to the head of its queue by position, list-wide or
247
265
  * `--role`-scoped. One helper so `--top`'s rules are found by name. */
@@ -283,8 +301,7 @@ function runPromptSubmit(root, text, atDelayMs, attachPaths, role) {
283
301
  // `--attach <path>`: read and validate through the shared helper (a bad path fails by name
284
302
  // before anything is queued). An attachment the target role cannot read is refused here by
285
303
  // name — the same gate submitRolePrompt enforces, surfaced early so the CLI exits non-zero
286
- // with the fix before anything is written or queued; a role that reroutes a blind read is
287
- // not refused, but the operator is told where the prompt will actually run.
304
+ // with the fix before anything is written or queued.
288
305
  const images = attachPaths.length > 0 ? readPromptImages(attachPaths) : undefined;
289
306
  if (images && images.length > 0) {
290
307
  runPromptImageGate(root, target);
@@ -234,26 +234,8 @@ async function resolveInitPlan(root, briefText, branch, opts) {
234
234
  const preferredBranch = await resolveInitPreferredBranch(root, branch, wasRepo);
235
235
  return { dryRun, tpl, combinedPrompt, brief, readmeExists, adopted, wasRepo, preferredBranch };
236
236
  }
237
- /** Seed `root` from a resolved `InitPlan`: git init (a new repo only), every absent project
238
- * file and starter directory, the config, and the commit. Holds the `created`/`leftAlone`
239
- * bookkeeping locally; performs no validation and never refuses. Returns everything the
240
- * composed `InitResult` needs except `adopted`, `dryRun` and `template`, which `initProject`
241
- * reads off `plan`. */
242
- async function seedProject(root, plan) {
243
- const { dryRun, tpl, combinedPrompt, brief, readmeExists, adopted, wasRepo, preferredBranch } = plan;
244
- let repoInitialized = false;
245
- let createdBranch;
246
- if (!wasRepo) {
247
- if (!dryRun)
248
- await git(root, "init", "-b", preferredBranch);
249
- repoInitialized = true;
250
- createdBranch = preferredBranch;
251
- }
252
- // Decided before any project file is written (init only seeds bootstrap on a fresh project).
253
- // A repo the `git init` above just created (or a dry run would) has no commits, and init
254
- // itself commits nothing before the end, so this one answer also picks the commit message.
255
- const withCommits = wasRepo && (await hasCommits(root));
256
- const fresh = await isFreshProject(root, withCommits);
237
+ /** The `created`/`leftAlone` arrays and the `claim`/`write` closures above them. */
238
+ function seedWriters(root, dryRun) {
257
239
  const created = [];
258
240
  const leftAlone = [];
259
241
  /** Record a template path this run either created (via `make`, under a real run only) or
@@ -275,33 +257,64 @@ async function seedProject(root, plan) {
275
257
  // because claim's existsSync would then treat the torn file as complete on a re-run and
276
258
  // never repair it.
277
259
  const write = (name, content) => claim(name, path.join(root, name), () => writeTextAtomic(path.join(root, name), content));
260
+ return { created, leftAlone, claim, write };
261
+ }
262
+ /** The new-repo `git init` job: seed the preferred branch unless the run is a dry run. */
263
+ async function initSeedRepo(root, plan) {
264
+ let repoInitialized = false;
265
+ let createdBranch;
266
+ if (!plan.wasRepo) {
267
+ if (!plan.dryRun)
268
+ await git(root, "init", "-b", plan.preferredBranch);
269
+ repoInitialized = true;
270
+ createdBranch = plan.preferredBranch;
271
+ }
272
+ return { repoInitialized, createdBranch };
273
+ }
274
+ /** Whether `root` is a fresh project and the repo has commits — decided before any project
275
+ * file is written (init only seeds bootstrap on a fresh project). A repo the `git init`
276
+ * above just created (or a dry run would) has no commits, and init itself commits nothing
277
+ * before the end, so this one answer also picks the commit message. */
278
+ async function resolveSeedFreshness(root, plan) {
279
+ const withCommits = plan.wasRepo && (await hasCommits(root));
280
+ const fresh = await isFreshProject(root, withCommits);
281
+ return { withCommits, fresh };
282
+ }
283
+ /** Where the brief goes: the existing brief file, TUMWATER.md (adopted), or README.md. */
284
+ function seedBriefFile(root, plan, writers) {
278
285
  // README.md is the brief only when none exists yet and there is no README to adopt (a fresh
279
286
  // repo): with TUMWATER.md owning the managed sections, a created README.md would carry a
280
287
  // duplicate prompt + status block that readBriefText never reaches and the readme role
281
288
  // never maintains.
282
289
  const name = projectName(root);
283
- if (brief !== null)
284
- leftAlone.push(brief);
285
- else if (adopted) {
286
- if (readmeExists)
287
- leftAlone.push("README.md");
288
- write("TUMWATER.md", briefTemplate(name, combinedPrompt));
290
+ if (plan.brief !== null)
291
+ writers.leftAlone.push(plan.brief);
292
+ else if (plan.adopted) {
293
+ if (plan.readmeExists)
294
+ writers.leftAlone.push("README.md");
295
+ writers.write("TUMWATER.md", briefTemplate(name, plan.combinedPrompt));
289
296
  }
290
297
  else
291
- write("README.md", readmeTemplate(name, combinedPrompt));
298
+ writers.write("README.md", readmeTemplate(name, plan.combinedPrompt));
299
+ }
300
+ /** The template backlog files: PLANS/BUGS/QUESTIONS/PRINCIPLES, with starter-plans subst. */
301
+ function seedBacklogFiles(tpl, writers) {
292
302
  // A template with starter plans seeds the backlog with them, rendered as `### ` entries —
293
303
  // the primary entry format every backlog reader parses — so the team's first runs land on
294
304
  // real work. blank keeps the `_None yet._` placeholder byte-identical to today.
295
305
  const plansContent = tpl.starterPlans.length > 0
296
306
  ? PLANS_TEMPLATE.replace("_None yet._", tpl.starterPlans.map((p) => `### ${p}`).join("\n\n"))
297
307
  : PLANS_TEMPLATE;
298
- write("PLANS.md", plansContent);
299
- write("BUGS.md", BUGS_TEMPLATE);
300
- write("QUESTIONS.md", QUESTIONS_TEMPLATE);
301
- write("PRINCIPLES.md", PRINCIPLES_TEMPLATE);
308
+ writers.write("PLANS.md", plansContent);
309
+ writers.write("BUGS.md", BUGS_TEMPLATE);
310
+ writers.write("QUESTIONS.md", QUESTIONS_TEMPLATE);
311
+ writers.write("PRINCIPLES.md", PRINCIPLES_TEMPLATE);
312
+ }
313
+ /** The config seed and its fresh-project bootstrap; returns the seeded bootstrap count. */
314
+ function seedConfigFile(root, fresh, dryRun, writers) {
302
315
  let bootstrap = null;
303
316
  if (fs.existsSync(configPath(root)))
304
- leftAlone.push(CONFIG_BASENAME);
317
+ writers.leftAlone.push(CONFIG_BASENAME);
305
318
  else {
306
319
  const seed = seedConfig(root);
307
320
  if (fresh) {
@@ -310,26 +323,36 @@ async function seedProject(root, plan) {
310
323
  }
311
324
  if (!dryRun)
312
325
  saveConfig(root, seed);
313
- created.push(CONFIG_BASENAME);
326
+ writers.created.push(CONFIG_BASENAME);
314
327
  }
328
+ return bootstrap;
329
+ }
330
+ /** The .gitignore edit: gain the missing lines, or record it as left alone. */
331
+ function seedGitignore(root, dryRun, writers) {
315
332
  if (ensureGitignore(root, dryRun))
316
- created.push(".gitignore");
333
+ writers.created.push(".gitignore");
317
334
  else if (fs.existsSync(path.join(root, ".gitignore")))
318
- leftAlone.push(".gitignore");
335
+ writers.leftAlone.push(".gitignore");
336
+ }
337
+ /** The template's starter directories — empty ones only, before the first run. */
338
+ function seedStarterDirs(root, tpl, writers) {
319
339
  // The template's starter directories: empty ones only, before the first run — the team's
320
340
  // own runs write any code. Existing directories are left alone, like existing files above.
321
341
  for (const dir of tpl.starterDirs) {
322
- claim(dir, path.join(root, dir), () => ensureDir(path.join(root, dir)));
342
+ writers.claim(dir, path.join(root, dir), () => ensureDir(path.join(root, dir)));
323
343
  }
344
+ }
345
+ /** What gets committed: the committable filter and the `git add` / staged-check / commit. */
346
+ async function commitSeeded(root, plan, withCommits, writers) {
324
347
  // The config stays out of the commit pathspec: `git add -- tumwater.json` fails on a path
325
348
  // the just-written .gitignore ignores (plans/portability.md §4a/7). It still heads the
326
349
  // `created …` line the CLI prints — the file WAS created, it just must not be tracked. When
327
350
  // that leaves the pathspec empty (`git add --` with no pathspec exits 1), there is nothing
328
351
  // to commit: a repo that only gained a config reports it and stays uncommitted.
329
- const dirSet = new Set(tpl.starterDirs);
330
- const committable = created.filter((name) => name !== CONFIG_BASENAME && !dirSet.has(name));
352
+ const dirSet = new Set(plan.tpl.starterDirs);
353
+ const committable = writers.created.filter((name) => name !== CONFIG_BASENAME && !dirSet.has(name));
331
354
  let committed = false;
332
- if (!dryRun && committable.length > 0) {
355
+ if (!plan.dryRun && committable.length > 0) {
333
356
  await git(root, "add", "--", ...committable);
334
357
  const staged = await gitTry(root, "diff", "--cached", "--quiet");
335
358
  if (staged === null) {
@@ -339,7 +362,32 @@ async function seedProject(root, plan) {
339
362
  committed = true;
340
363
  }
341
364
  }
342
- return { created, leftAlone, committed, repoInitialized, branch: createdBranch, bootstrap };
365
+ return committed;
366
+ }
367
+ /** Seed `root` from a resolved `InitPlan`: git init (a new repo only), every absent project
368
+ * file and starter directory, the config, and the commit. Holds the `created`/`leftAlone`
369
+ * bookkeeping locally; performs no validation and never refuses. Returns everything the
370
+ * composed `InitResult` needs except `adopted`, `dryRun` and `template`, which `initProject`
371
+ * reads off `plan`. */
372
+ async function seedProject(root, plan) {
373
+ const { dryRun, tpl } = plan;
374
+ const { repoInitialized, createdBranch } = await initSeedRepo(root, plan);
375
+ const { withCommits, fresh } = await resolveSeedFreshness(root, plan);
376
+ const writers = seedWriters(root, dryRun);
377
+ seedBriefFile(root, plan, writers);
378
+ seedBacklogFiles(tpl, writers);
379
+ const bootstrap = seedConfigFile(root, fresh, dryRun, writers);
380
+ seedGitignore(root, dryRun, writers);
381
+ seedStarterDirs(root, tpl, writers);
382
+ const committed = await commitSeeded(root, plan, withCommits, writers);
383
+ return {
384
+ created: writers.created,
385
+ leftAlone: writers.leftAlone,
386
+ committed,
387
+ repoInitialized,
388
+ branch: createdBranch,
389
+ bootstrap,
390
+ };
343
391
  }
344
392
  /** Initialize a repo for tumwater: README (with prompt + status) when no project brief exists
345
393
  * yet, PLANS, BUGS, QUESTIONS, PRINCIPLES, tumwater.json, .gitignore — then commit whatever was
@@ -314,7 +314,7 @@ export async function resolveBacklogInsertConflicts(wt, files) {
314
314
  }
315
315
  // Atomic (tmp + rename): a merge process killed mid-write (SIGKILL, the timeout
316
316
  // group-kill) must leave either the conflicted file or the fully resolved one, never a
317
- // truncated backlog — the CLI's backlog writers (backlog-write.ts, question-answer.ts)
317
+ // truncated backlog — the CLI's backlog writers (backlog-write.ts, backlog-question-answer.ts)
318
318
  // already write these unbounded-history files through this helper.
319
319
  writeTextAtomic(p, resolved);
320
320
  await git(wt, "add", "--", file);
@@ -11,7 +11,7 @@ import { reviewAheadOfMain } from "../review/review.js";
11
11
  import { reviewRunConfig, modelSelectorField } from "../config/config-views.js";
12
12
  import { warnEventBestEffort, logEventBestEffort } from "../events/events.js";
13
13
  import { saveRunnerStateBestEffort } from "../runner/runner-state.js";
14
- import { REVISION_FINAL_ON_REPEAT, REVISION_BACKSTOP, holdEntryAfterExhaustion } from "../runner/revision.js";
14
+ import { REVISION_FINAL_ON_REPEAT, REVISION_BACKSTOP, holdEntryAfterExhaustion } from "../runner/runner-revision.js";
15
15
  import { priorObjectionNumber } from "../review/review-verdict.js";
16
16
  import { DIRECTOR_ROLE } from "../roles/roles.js";
17
17
  import { shortSha } from "../text/format.js";
@@ -1,7 +1,6 @@
1
1
  import { mergeApproved } from "./merge-batch.js";
2
2
  import { removeMergeChange, resolveWaitStage, setMergeChangeStatus } from "./merge-slot.js";
3
- import { seamConcurrencyKey } from "../config/config-views.js";
4
- import { withProviderLease } from "../concurrency/provider-lease.js";
3
+ import { withRunPermit } from "../concurrency/run-permit.js";
5
4
  import { dropMerge, entryRevisionFields, queuedMergeFiles } from "./merge-queue.js";
6
5
  import { errorMessage } from "../text/text.js";
7
6
  import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
@@ -123,22 +122,20 @@ function startMerge(ctx, p, picks) {
123
122
  runPi: async (w, prompt, s, cfg) => {
124
123
  // A parked resolver names its wait (resolve-wait, then resolving once granted) the way
125
124
  // a parked check does (withCheckPermit): on 2026-10-12 one waited 2.5 min for a permit
126
- // with the merge queue behind it, reading as "merging". The lease is keyed on the
127
- // resolver config's provider (the `cfg` it is handed), so the conflict resolution
128
- // counts against the strong tier's provider cap like any other strong-tier run
125
+ // with the merge queue behind it, reading as "merging". The resolution waits on the
126
+ // one team total (maxConcurrent) like any other MERGE_TIER run
129
127
  // (plans/provider-concurrency.md §4). canLeaseAtOnce is acquireLease's own fast path
130
128
  // decided synchronously, so a free permit shows no wait; MERGE_TIER ignores the
131
129
  // maintenance reserve.
132
- const key = cfg ? seamConcurrencyKey(cfg, ctx.piDefault) : undefined;
133
130
  const hooks = resolveWaitStage(root, v.entry.role);
134
- if (!semaphore.canLeaseAtOnce(MERGE_TIER, key))
131
+ if (!semaphore.canLeaseAtOnce(MERGE_TIER))
135
132
  hooks.waiting();
136
- const lease = await semaphore.acquireLease(MERGE_TIER, key);
133
+ const lease = await semaphore.acquireLease(MERGE_TIER);
137
134
  hooks.granted();
138
135
  try {
139
- // The resolver's lease rides in the provider-lease store for its run, so the run
140
- // re-keys it to the provider it actually uses (plans/provider-concurrency.md §5).
141
- return await withProviderLease(lease, semaphore, () => v.author.runMergePi(w, prompt, s, cfg), { piDefault: ctx.piDefault });
136
+ // The resolver's lease rides in the run-permit store for its run, so an idle
137
+ // pause inside it (a failed review's in-place retry) re-takes the same permit.
138
+ return await withRunPermit(lease, () => v.author.runMergePi(w, prompt, s, cfg));
142
139
  }
143
140
  finally {
144
141
  lease.release();
@@ -43,15 +43,14 @@ export function guardMergeTask(root, role, promise) {
43
43
  warnEventBestEffort(root, TUMWATER_SOURCE, `merge task for ${role} could not settle: ${errorMessage(err)}`);
44
44
  });
45
45
  }
46
- /** Take a permit at `tier` (on `key` when given) unless `signal` fires first: resolves to its
47
- * release, or to null when the signal won — a grant that arrives after that is handed straight
48
- * back (a hop, never a leak), so a parked verification that a shutdown settles holds nothing. When
49
- * `key` is set the permit is a keyed lease, so the verification also counts against its provider's
50
- * cap (plans/provider-concurrency.md §4). */
51
- export async function acquireUnlessAborted(semaphore, tier, signal, key) {
46
+ /** Take a permit at `tier` unless `signal` fires first: resolves to its release, or to null
47
+ * when the signal won — a grant that arrives after that is handed straight back (a hop, never a
48
+ * leak), so a parked verification that a shutdown settles holds nothing. A verification's
49
+ * permit is the one team total (`maxConcurrent`). */
50
+ export async function acquireUnlessAborted(semaphore, tier, signal) {
52
51
  if (signal.aborted)
53
52
  return null;
54
- const granted = semaphore.acquireLease(tier, key);
53
+ const granted = semaphore.acquireLease(tier);
55
54
  const stopped = new Promise((resolve) => signal.addEventListener("abort", () => resolve(false), { once: true }));
56
55
  if (await Promise.race([granted, stopped])) {
57
56
  return granted;
@@ -1,5 +1,4 @@
1
- import { withProviderLease } from "../concurrency/provider-lease.js";
2
- import { reviewRunConfig, seamConcurrencyKey } from "../config/config-views.js";
1
+ import { withRunPermit } from "../concurrency/run-permit.js";
3
2
  import { subscribeEvents, warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
4
3
  import { deleteRef, headOf, subjectOfCommit } from "../git/git.js";
5
4
  import { ensureDetachedWorktree } from "../git/worktree.js";
@@ -35,12 +34,11 @@ function startPostReview(ctx, p, entry) {
35
34
  const task = { promise: Promise.resolve(), controller: new AbortController(), roles: [], userAborted: false, parked: true };
36
35
  const detach = abortOnShutdown(ctx.signal, task.controller);
37
36
  const author = resolveAuthor(ctx, entry.role);
38
- const key = seamConcurrencyKey(reviewRunConfig(ctx.liveConfig), ctx.piDefault);
39
37
  const run = (async () => {
40
38
  try {
41
- if (!semaphore.canLeaseAtOnce(VERIFY_TIER, key))
39
+ if (!semaphore.canLeaseAtOnce(VERIFY_TIER))
42
40
  return; // permits are busy; try again next poll
43
- const lease = await acquireUnlessAborted(semaphore, VERIFY_TIER, task.controller.signal, key);
41
+ const lease = await acquireUnlessAborted(semaphore, VERIFY_TIER, task.controller.signal);
44
42
  task.parked = false;
45
43
  if (lease === null)
46
44
  return;
@@ -125,19 +123,18 @@ async function fileDroppedReview(ctx, entry, failure) {
125
123
  * is never a throw. */
126
124
  async function mergeReviewerFix(ctx, entry, fix) {
127
125
  const author = resolveAuthor(ctx, entry.role);
128
- // The fix merge's conflict resolver rides the same provider lease the merge slot's resolver
129
- // does (merge-drain.ts startMerge): the MERGE_TIER lease is keyed on the resolver config's
130
- // provider and rides in the provider-lease store, so a conflict resolution counts against the
131
- // strong tier's cap like any other strong-tier run instead of acquiring a provider unchecked.
126
+ // The fix merge's conflict resolver rides the same team permit the merge slot's resolver
127
+ // does (merge-drain.ts startMerge): the MERGE_TIER lease is acquired on the one team total
128
+ // and rides in the run-permit store, so a conflict resolution counts against maxConcurrent
129
+ // like any other strong-tier run.
132
130
  const runPi = async (wt, prompt, sessionName, config) => {
133
- const key = config ? seamConcurrencyKey(config, ctx.piDefault) : undefined;
134
131
  const hooks = resolveWaitStage(ctx.root, entry.role);
135
- if (!ctx.semaphore.canLeaseAtOnce(MERGE_TIER, key))
132
+ if (!ctx.semaphore.canLeaseAtOnce(MERGE_TIER))
136
133
  hooks.waiting();
137
- const lease = await ctx.semaphore.acquireLease(MERGE_TIER, key);
134
+ const lease = await ctx.semaphore.acquireLease(MERGE_TIER);
138
135
  hooks.granted();
139
136
  try {
140
- return await withProviderLease(lease, ctx.semaphore, () => author.runMergePi(wt, prompt, sessionName, config), { piDefault: ctx.piDefault });
137
+ return await withRunPermit(lease, () => author.runMergePi(wt, prompt, sessionName, config));
141
138
  }
142
139
  finally {
143
140
  lease.release();
@@ -6,7 +6,7 @@
6
6
  * retriable result) or once main's head stops being red (a `main_red` pin). */
7
7
  import { enqueueMerge, queuedMerges } from "./merge-queue.js";
8
8
  import { RETRIABLE_MERGE_RESULTS } from "./merge-core.js";
9
- import { pinnedRetrySha } from "../runner/leftover.js";
9
+ import { pinnedRetrySha } from "../runner/runner-leftover.js";
10
10
  import { branchHead } from "../git/git.js";
11
11
  import { cachedBaselineVerdict } from "../baseline/main-baseline.js";
12
12
  import { saveRunnerStateBestEffort } from "../runner/runner-state.js";
@@ -192,14 +192,6 @@ export function checkWaitStage(root, roles) {
192
192
  export function resolveWaitStage(root, role) {
193
193
  return waitStageHooks(root, [role], "resolve-wait", "resolving");
194
194
  }
195
- /** The verification's hooks around a re-key onto a full provider (plans/provider-concurrency.md §5,
196
- * "A re-key wait is visible"): `provider-wait` while the verification's lease parks on the target
197
- * provider's full cap, `reviewing` once the key frees and the review proceeds. resolveWaitStage's
198
- * shape for the re-key path — a verification that normally shares its review seam never
199
- * re-keys, so the stage fires only when a run actually moves to a different, full provider. */
200
- export function providerWaitStage(root, role) {
201
- return waitStageHooks(root, [role], "provider-wait", "reviewing");
202
- }
203
195
  /** The marker's per-change records, whichever shape it has: `changes` as written, or — for a
204
196
  * marker an older generation wrote with one top-level change — that change, `merging` since the
205
197
  * marker's start at the marker's stage. Reads both shapes into records, so addMergeChange's