tumwater 0.4.2 → 0.4.4

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 (354) 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} +27 -13
  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 +6 -27
  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 +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -7,11 +7,13 @@
7
7
  * saving, the role-selection helpers) lives in config.ts, the write side in config-write.ts
8
8
  * (editor verbs) and config-request.ts (the director's staged request). */
9
9
  import { MODEL_TIERS } from "./config-schema.js";
10
- import { formatModelSelector, parseModelSelector, routeModel } from "./model-selector.js";
10
+ import { formatModelSelector, parseModelSelector, routeModel } from "./config-model-selector.js";
11
11
  import { modelPairName } from "../budget/budget.js";
12
12
  import { isJsonObject } from "../files/json-object.js";
13
13
  import { DIRECTOR_ROLE, roleById } from "../roles/roles.js";
14
14
  import { baseRoleOf } from "../roles/runner-ids.js";
15
+ import { sampleHostLoad } from "../build/host-sleep.js";
16
+ import { starvedDeadlineMs } from "../build/build-check-events.js";
15
17
  /** The effective `backends.<id>` entry as a run sees it (plans/agent-backends.md "Back-compat"):
16
18
  * the file's own entry, with the legacy top-level `agentBin`/`piArgs` read as the pi backend's
17
19
  * `bin`/`args` when the new form leaves a field absent. Pure — it never writes those legacy
@@ -31,20 +33,6 @@ export function resolvedBackendConfig(config, id) {
31
33
  }
32
34
  return { bin: entry?.bin ?? config.agentBin, args: (entry?.args ?? config.piArgs ?? []).slice() };
33
35
  }
34
- /** A backend's subscription-window reserve (plans/codex-backend.md part 2b/9): the nested
35
- * `backends[backend].windowReserve` when the backend has one, else the top-level
36
- * `claudeWindowReserve` for claude (its older spelling), else undefined. The two spellings are
37
- * mutually exclusive in a loaded file (validateConfig rejects one holding both), so the
38
- * fallback only ever answers for a config written before the rename. Pure: it never writes a
39
- * spelling back into the file. */
40
- export function windowReserveFor(config, backend) {
41
- const nested = config.backends?.[backend]?.windowReserve;
42
- if (nested !== undefined)
43
- return nested;
44
- if (backend === "claude")
45
- return config.claudeWindowReserve;
46
- return undefined;
47
- }
48
36
  /** True when `s` names one of the three tiers — the form `roles.<id>.model` /
49
37
  * `review.model` may take to point at that tier of the top-level `model` map. */
50
38
  export function isTierName(s) {
@@ -109,7 +97,12 @@ export function tierModel(config, tier) {
109
97
  * key it overrides would.
110
98
  *
111
99
  * `tier` is the seam's effective tier, already resolved by the caller from any tier-name
112
- * override — so `o.model` here is always a selector string, never a tier reference. */
100
+ * override — so `o.model` here is always a selector string, never a tier reference.
101
+ *
102
+ * Exported so imageRerouteTarget (src/agent/image-input.ts) can probe a peer tier's
103
+ * top-level model (`withModelOverrides(config, tier, {})`, the same shape resolverConfig
104
+ * builds strong with) and reshape a tier's fallback pair into a runnable config — the
105
+ * tier-config builder the plan names. */
113
106
  export function withModelOverrides(config, tier, o) {
114
107
  // The top-level model resolves at the seam's tier: a string form is the default tier's
115
108
  // selector; a map form takes the tier's entry, else `default`'s.
@@ -174,14 +167,6 @@ export function roleSeamTier(config, role) {
174
167
  return "strong";
175
168
  return roleById(role)?.tier ?? "default";
176
169
  }
177
- /** Whether the review gate reviews this role's commits AFTER they merge (review.afterMerge
178
- * lists the role's base id). One home for the membership test the review gate (review.ts's
179
- * reviewAttempt), the runner's `already_queued` recovery arm (runner-run.ts) and the role
180
- * view (`tumwater role <id>` / the dashboard role drawer) all share, so an operator reading
181
- * one surface sees the same answer every consumer enforces. Pure, no I/O. */
182
- export function reviewedAfterMerge(config, role) {
183
- return (config.review.afterMerge ?? []).includes(baseRoleOf(role));
184
- }
185
170
  /** The config as seen by one role: role-level provider/model/thinking overrides applied
186
171
  * over the top-level values, plus the per-role minRunIntervalSeconds (a slow clock for
187
172
  * roles that should act rarely) falling back to the global value when unset. The role's
@@ -240,7 +225,9 @@ export function resolverConfig(config) {
240
225
  * the merge slot the whole queue waits on, and its only other limits were the run's budget
241
226
  * and the quiet watchdog: reviews measured 2026-09-22/23 ran 2.3 min median, 9.2 min p90 and
242
227
  * 24.6 min max, while earlier days had 1.5–3.5 h reviews. 15 min clears the ordinary review and
243
- * kills a wedged one in minutes instead of the run's hours. */
228
+ * kills a wedged one in minutes instead of the run's hours. Under a starved host the budget
229
+ * stretches by the load-to-cores ratio (see reviewRunConfig), so a progressing review on a
230
+ * loaded box is not killed at the wall by load it does not control. */
244
231
  export const REVIEW_TIMEOUT_S = 900;
245
232
  /** The reviewer's wall-clock floor while the budget fallback carries the team. The free model
246
233
  * is typically local and far slower per turn than the budgeted one it replaces, but a review
@@ -253,21 +240,26 @@ export const REVIEW_TIMEOUT_S = 900;
253
240
  export const FALLBACK_REVIEW_TIMEOUT_S = 3600;
254
241
  /** The config as seen by the gate's reviewer run: the reviewer's model wiring (reviewConfig)
255
242
  * with its own time budget — `review.timeoutSeconds`, default REVIEW_TIMEOUT_S — overriding
256
- * the run's. A smaller runTimeoutSeconds still wins. A still-progressing gate run gets one
257
- * extension of the same length (src/pi/pi-watchdogs.ts), so the effective ceiling on a large
258
- * diff's review is twice this budget. */
259
- export function reviewRunConfig(config) {
243
+ * the run's. A smaller runTimeoutSeconds still wins. Under a starved host the budget stretches
244
+ * by `starvedDeadlineMs` (the merge-scope precedent in build-check-events.ts): the load-to-cores
245
+ * ratio caps it at double the base, so on an ordinary quiet host the value is exactly today's
246
+ * budget while a progressing review on a loaded box is not killed at the wall by load it does
247
+ * not control. A still-progressing gate run gets one extension of the same length
248
+ * (src/pi/pi-watchdogs.ts), so the effective ceiling on a large diff's review is twice this
249
+ * stretched budget. `load` defaults to `sampleHostLoad` and is injectable for offline tests. */
250
+ export function reviewRunConfig(config, load = sampleHostLoad) {
260
251
  const cfg = reviewConfig(config);
252
+ const baseMs = (config.review.timeoutSeconds ?? REVIEW_TIMEOUT_S) * 1000;
261
253
  return {
262
254
  ...cfg,
263
- runTimeoutSeconds: Math.min(cfg.runTimeoutSeconds, config.review.timeoutSeconds ?? REVIEW_TIMEOUT_S),
255
+ runTimeoutSeconds: Math.min(cfg.runTimeoutSeconds, starvedDeadlineMs(baseMs, load()) / 1000),
264
256
  };
265
257
  }
266
258
  /** The optional provider/model/thinking fields of a resolved model seam, with each unset
267
259
  * (empty) field left out so the result carries only what was resolved — the one home of that
268
260
  * assembly. The fallback layer (config-fallbacks.ts's fallbackSelectorFields and
269
- * fallbackPair/tierOwnFallback/modelFallbackView) and role-view's RoleViewPayload (its
270
- * model fields) all build a triple this way and follow the same rule — an absent or empty
261
+ * fallbackPair/tierOwnFallback) and role-view's RoleViewPayload (its model fields) all
262
+ * build a triple this way and follow the same rule — an absent or empty
271
263
  * value is
272
264
  * omitted rather than written as undefined/"" (an empty selector field would read as
273
265
  * "override present," so it must not appear). */
@@ -285,8 +277,8 @@ export function resolvedModelFields(provider, model, thinking) {
285
277
  * provider/model/thinking triple formatted as one selector string, or an empty object when
286
278
  * no model id is resolved (pi's own default) — the key stays absent rather than empty, the
287
279
  * same empty-means-unset rule resolvedModelFields applies to the raw halves. One home of
288
- * that assembly and guard, shared by the two run-start event writers (runner-run.ts's run_start,
289
- * review-run.ts's review_start) and the per-role status snapshot (status-data.ts) so the three
280
+ * that assembly and guard, shared by the run-start event writer (runner-run.ts's run_start)
281
+ * and the per-role status snapshot (status-data.ts) so the two
290
282
  * observability surfaces cannot disagree about which model a run starts on. A non-pi backend is
291
283
  * named as the selector's provider namespace (backendQualifiedSelector), so a claude run reads
292
284
  * `claude/claude-sonnet-5-5` in every surface instead of a bare model id. */
@@ -317,22 +309,6 @@ export function seamHoldKey(cfg) {
317
309
  export function seamPairName(cfg) {
318
310
  return modelPairName({ provider: seamHoldKey(cfg), model: cfg.model });
319
311
  }
320
- /** The key a resolved seam's run counts against the per-provider cap
321
- * (plans/provider-concurrency.md §2): its hold key, else — for a pi seam that names no model
322
- * at all and so runs pi's saved default — the default provider from pi's settings
323
- * (`piDefault`, the caller's cached readPiDefaultModel result, so this view stays free of
324
- * I/O). A bare id with no provider, or a seam whose pi settings cannot name a default, yields
325
- * undefined: only the team total bounds it. HF sub-provider pins (`huggingface/…:deepinfra`)
326
- * share the base provider, and billing does not split claude, because this reuses the hold key
327
- * — "omlx is held" and "omlx is full" name the same thing on every surface. */
328
- export function seamConcurrencyKey(cfg, piDefault) {
329
- const hold = seamHoldKey(cfg);
330
- if (hold !== undefined)
331
- return hold;
332
- if (cfg.model === undefined)
333
- return piDefault?.provider;
334
- return undefined;
335
- }
336
312
  /** The team's model wiring as one human-readable label, for the live-reload `model_changed`
337
313
  * event: null when no tier resolves to a model (pi's own default), the single selector when
338
314
  * every set tier agrees (the string-form `model`), and the `small=…, default=…, strong=…`
@@ -18,12 +18,11 @@ import { show } from "./config-field-checks.js";
18
18
  import { canonicalConfigKey } from "./config-aliases.js";
19
19
  import { unknownRoleMessage } from "../roles/roles.js";
20
20
  import { loadConfig, collapseBackendAliases, knownRoleIdsCached, } from "./config.js";
21
- import { checkDailyBudgetUsd, checkProviderConcurrencyCap, checkQuietHours, parseConfigKey, PER_KEY_VALIDATORS, unknownConfigKeyError, unknownRoleFieldError, } from "./config-keys.js";
21
+ import { checkDailyBudgetUsd, checkQuietHours, parseConfigKey, PER_KEY_VALIDATORS, unknownConfigKeyError, unknownRoleFieldError, } from "./config-keys.js";
22
22
  import { writeConfigMutation, fail, rawConfigObject, without, preservingFileRoles, preserveFileRoles, withSectionOrDropped, } from "./config-write-core.js";
23
23
  /** Drop `field` from the `backends.<backend>` entry of the config `next`, dropping the whole
24
24
  * backend entry when it empties. The one home of the "remove a nested backend field, drop the
25
- * backend when empty" step the legacy `agentBin`/`piArgs` collapse and the `claudeWindowReserve`
26
- * collapse share, so the empty-entry rule cannot drift between the two. */
25
+ * backend when empty" step the legacy `agentBin`/`piArgs` collapse uses. */
27
26
  function dropNestedBackendField(next, backend, field) {
28
27
  const record = next;
29
28
  const backends = { ...(record.backends ?? {}) };
@@ -62,8 +61,7 @@ export function setDailyBudgetUsd(root, value) {
62
61
  * that setConfigKey's JSON.parse would read past validateConfig's integer check and silently
63
62
  * write — the same silent misread the per-role instances/interval guards, the TUI team-key
64
63
  * editors, and the GUI Settings rows refuse — so setConfigKey refuses it, naming the key and
65
- * writing nothing. The per-provider map (maxConcurrentPerProvider.<provider>) has its own
66
- * checkProviderConcurrencyCap screen and is not in this set. */
64
+ * writing nothing. */
67
65
  const PLAIN_INTEGER_TOP_LEVEL_KEYS = new Set([
68
66
  "maxConcurrent",
69
67
  "mergeBatchMax",
@@ -93,7 +91,6 @@ export function setConfigKey(root, key, rawValue) {
93
91
  // and the per-kind merge groups close over that call's `value`/`unchanged`/`oldValue`.
94
92
  const exponentRefusal = (parsed, key, trimmed) => {
95
93
  const isPlainIntegerKey = (parsed.kind === "top" && PLAIN_INTEGER_TOP_LEVEL_KEYS.has(key)) ||
96
- (parsed.kind === "map" && parsed.map === "maxConcurrentPerProvider") ||
97
94
  (parsed.kind === "role" && parsed.field === "instances") ||
98
95
  (parsed.kind === "section" && parsed.section === "bootstrap" && parsed.field === "untilPlansDone");
99
96
  if (isPlainIntegerKey && trimmed !== "" && isExponentSpelling(trimmed))
@@ -112,9 +109,7 @@ export function setConfigKey(root, key, rawValue) {
112
109
  const label = `${parsed.map}.${parsed.role}`;
113
110
  const validator = parsed.map === "maxDailyCostUsdPerRole"
114
111
  ? checkDailyBudgetUsd
115
- : parsed.map === "maxConcurrentPerProvider"
116
- ? checkProviderConcurrencyCap
117
- : checkQuietHours;
112
+ : checkQuietHours;
118
113
  const problem = validator(value, label);
119
114
  if (problem)
120
115
  return fail(problem);
@@ -185,12 +180,6 @@ export function setConfigKey(root, key, rawValue) {
185
180
  else
186
181
  delete nextRecord.piArgs;
187
182
  }
188
- // Setting the nested claude reserve drops the top-level spelling, so the file can never
189
- // hold both (validateConfig rejects a file that does, mirroring the agentBin/piArgs pair).
190
- if (parsed.id === "claude" && parsed.field === "windowReserve") {
191
- const nextRecord = next;
192
- delete nextRecord.claudeWindowReserve;
193
- }
194
183
  return preservingFileRoles(root, next);
195
184
  };
196
185
  const mergeRoleEntry = (record, parsed) => {
@@ -281,10 +270,6 @@ export function setConfigKey(root, key, rawValue) {
281
270
  // Writers keep one spelling: a legacy top-level set drops the matching backends.pi field, so
282
271
  // the file can never hold both (validateConfig's raw pass rejects a file that does).
283
272
  const dropLegacyPiAlias = (next, key) => dropNestedBackendField(next, "pi", key === "agentBin" ? "bin" : "args");
284
- // Writers keep one spelling: setting the top-level `claudeWindowReserve` drops the nested
285
- // `backends.claude.windowReserve`, so a supported `config set` never writes the rejected pair
286
- // (validateConfig still refuses a hand-edited file holding both).
287
- const dropNestedReserve = (next) => dropNestedBackendField(next, "claude", "windowReserve");
288
273
  const result = writeConfigMutation(root, (cfg) => {
289
274
  oldValue = cfg[key];
290
275
  if (isDeepStrictEqual(oldValue, value)) {
@@ -306,8 +291,6 @@ export function setConfigKey(root, key, rawValue) {
306
291
  return collapseBackendAliases(next);
307
292
  if (key === "agentBin" || key === "piArgs")
308
293
  return dropLegacyPiAlias(next, key);
309
- if (key === "claudeWindowReserve")
310
- return dropNestedReserve(next);
311
294
  return next;
312
295
  });
313
296
  if (!result.ok)
@@ -322,8 +305,8 @@ export function setConfigKey(root, key, rawValue) {
322
305
  return fail(parsed.error);
323
306
  // Shape, before the JSON.parse, for every plain-positive-integer key this verb can write —
324
307
  // the top-level run-concurrency knobs and legacy merge batch cap, plus the integer-bound
325
- // team-scope keys that arrive as a dotted key (`maxConcurrentPerProvider.<provider>`,
326
- // `roles.<id>.instances`, the `bootstrap.untilPlansDone` section field): refuse an exponent
308
+ // team-scope keys that arrive as a dotted key (`roles.<id>.instances`, the
309
+ // `bootstrap.untilPlansDone` section field): refuse an exponent
327
310
  // spelling ("4e1" → 40) that JSON.parse would read past validateConfig's integer check and
328
311
  // silently write, naming the key and writing nothing — the twin of the per-role guards, the
329
312
  // TUI team-key editors, and the GUI Settings rows. A non-exponent invalid falls through for
@@ -409,6 +392,106 @@ export function setRoleModelFields(root, base, fields) {
409
392
  return { ok: true, unchanged: true };
410
393
  return result;
411
394
  }
395
+ /** Unset the top-level key `parsed.key` from the raw file, reverting it to its default or to
396
+ * absence (a whole-config write). Absent from the file (only a default carries it): nothing to
397
+ * remove — return the config unchanged so writeConfigMutation skips the write. */
398
+ function unsetTopKey(root, cfg, raw, parsed) {
399
+ // Absent from the file (only a default carries it): nothing to remove.
400
+ if (!Object.hasOwn(raw, parsed.key))
401
+ return { cfg, oldValue: undefined };
402
+ const oldValue = raw[parsed.key];
403
+ const record = cfg;
404
+ const next = without(record, parsed.key);
405
+ // An unrelated unset is a whole-config write too: keep the file's own roles section.
406
+ if (parsed.key !== "roles")
407
+ preserveFileRoles(root, next);
408
+ return { cfg: next, oldValue };
409
+ }
410
+ /** Unset ONE dotted-map entry (`maxDailyCostUsdPerRole.<role>` / `quietHoursPerRole.<role>`),
411
+ * preserving its siblings; the `parsed.map` section is dropped when it empties. An absent file
412
+ * entry is a no-op success that leaves the config unchanged. */
413
+ function unsetMapEntry(root, cfg, raw, parsed) {
414
+ const rawMap = objectOrEmpty(raw[parsed.map]);
415
+ if (!Object.hasOwn(rawMap, parsed.role))
416
+ return { cfg, oldValue: undefined }; // absent file entry: a no-op success
417
+ const oldValue = rawMap[parsed.role];
418
+ const record = cfg;
419
+ const existing = record[parsed.map] ?? {};
420
+ const next = without(existing, parsed.role);
421
+ return { cfg: withSectionOrDropped(root, cfg, parsed.map, next), oldValue };
422
+ }
423
+ /** Unset one `model.<tier>`; a string `model` resolves to { default } (modelTierMap), so unsetting
424
+ * a tier the FILE does not hold is a no-op that leaves the string untouched. The `model` section
425
+ * is dropped when it empties. */
426
+ function unsetModelTier(root, cfg, raw, parsed) {
427
+ // A string `model` resolves to { default } (modelTierMap); unsetting a tier the FILE
428
+ // does not hold is a no-op that leaves the string untouched.
429
+ const rawTiers = modelTierMap(raw.model);
430
+ if (!Object.hasOwn(rawTiers, parsed.tier))
431
+ return { cfg, oldValue: undefined };
432
+ const oldValue = rawTiers[parsed.tier];
433
+ const existing = modelTierMap(cfg.model);
434
+ const next = without(existing, parsed.tier);
435
+ return { cfg: withSectionOrDropped(root, cfg, "model", next), oldValue };
436
+ }
437
+ /** Unset ONE dotted section field (`backoff.<field>` / `bootstrap.<field>` …), preserving its
438
+ * siblings; the section is dropped when it empties. Presence is judged against the FILE: a field
439
+ * only the default carries is a no-op success. */
440
+ function unsetSectionField(root, cfg, raw, parsed) {
441
+ // Presence is judged against the FILE: a field only the default carries is a no-op
442
+ // success, and an emptied section leaves the file.
443
+ const rawSection = objectOrEmpty(raw[parsed.section]);
444
+ if (!Object.hasOwn(rawSection, parsed.field))
445
+ return { cfg, oldValue: undefined };
446
+ const oldValue = rawSection[parsed.field];
447
+ const record = cfg;
448
+ const existing = record[parsed.section] ?? {};
449
+ return { cfg: withSectionOrDropped(root, cfg, parsed.section, without(existing, parsed.field)), oldValue };
450
+ }
451
+ /** Unset ONE `backends.<id>` field (`bin`/`args`/`configDir`); the entry and the `backends`
452
+ * section are dropped when they empty, so the file never keeps an empty object. Presence is
453
+ * judged against the FILE, so a field only the default (or the legacy alias's resolved view)
454
+ * carries is a no-op success. */
455
+ function unsetBackendField(root, cfg, raw, parsed) {
456
+ // Presence is judged against the FILE, so unsetting a field only the default (or the
457
+ // legacy alias's resolved view) carries is a no-op success. The entry and the `backends`
458
+ // section are dropped when they empty, so the file never keeps an empty object.
459
+ const rawBackends = objectOrEmpty(raw.backends);
460
+ const rawEntry = objectOrEmpty(rawBackends[parsed.id]);
461
+ if (!Object.hasOwn(rawEntry, parsed.field))
462
+ return { cfg, oldValue: undefined }; // absent file field: a no-op success
463
+ const oldValue = rawEntry[parsed.field];
464
+ const record = cfg;
465
+ const backends = { ...record.backends };
466
+ const entry = { ...(backends[parsed.id] ?? {}) };
467
+ delete entry[parsed.field];
468
+ if (Object.keys(entry).length === 0)
469
+ delete backends[parsed.id];
470
+ else
471
+ backends[parsed.id] = entry;
472
+ return { cfg: withSectionOrDropped(root, cfg, "backends", backends), oldValue };
473
+ }
474
+ /** Unset ONE `roles.<id>` field, based on the FILE's own roles — not the loaded (catalog-merged)
475
+ * map — so the write must not re-expand the operator's partial section to every built-in. The
476
+ * entry is dropped when it empties, and the whole `roles` section when it was the last one. */
477
+ function unsetRoleField(cfg, raw, parsed) {
478
+ const rawRoles = objectOrEmpty(raw.roles);
479
+ const rawEntry = objectOrEmpty(rawRoles[parsed.id]);
480
+ if (!Object.hasOwn(rawEntry, parsed.field))
481
+ return { cfg, oldValue: undefined }; // absent file field: a no-op success
482
+ const oldValue = rawEntry[parsed.field];
483
+ // Base the removal on the FILE's own roles, not the loaded (catalog-merged) map: the
484
+ // write must not re-expand the operator's partial section to every built-in.
485
+ const record = cfg;
486
+ const nextEntry = without(rawEntry, parsed.field); // the file's own entry (its field was present, checked above)
487
+ if (Object.keys(nextEntry).length > 0)
488
+ return { cfg: { ...cfg, roles: { ...rawRoles, [parsed.id]: nextEntry } }, oldValue };
489
+ // The entry emptied: drop it, and the whole `roles` section when it was the last one.
490
+ const nextRoles = without(rawRoles, parsed.id);
491
+ if (Object.keys(nextRoles).length === 0)
492
+ return { cfg: without(record, "roles"), oldValue };
493
+ return { cfg: { ...cfg, roles: nextRoles }, oldValue };
494
+ }
412
495
  /** Remove ONE value of tumwater.json from its raw CLI text (`tumwater config unset <key>`):
413
496
  * the inverse of setConfigKey. A bare top-level key is deleted (reverting to its default or
414
497
  * to absence); a dotted key (`maxDailyCostUsdPerRole.<role>`, `quietHoursPerRole.<role>`,
@@ -444,86 +527,29 @@ export function unsetConfigKey(root, key) {
444
527
  let oldValue;
445
528
  const result = writeConfigMutation(root, (cfg) => {
446
529
  const raw = rawConfigObject(root); // the file's own keys, before defaults fill them in
447
- const record = cfg;
448
- if (parsed.kind === "top") {
449
- // Absent from the file (only a default carries it): nothing to remove.
450
- if (!Object.hasOwn(raw, parsed.key))
451
- return cfg;
452
- oldValue = raw[parsed.key];
453
- const next = without(record, parsed.key);
454
- // An unrelated unset is a whole-config write too: keep the file's own roles section.
455
- if (parsed.key !== "roles")
456
- preserveFileRoles(root, next);
457
- return next;
530
+ let moved;
531
+ switch (parsed.kind) {
532
+ case "top":
533
+ moved = unsetTopKey(root, cfg, raw, parsed);
534
+ break;
535
+ case "map":
536
+ moved = unsetMapEntry(root, cfg, raw, parsed);
537
+ break;
538
+ case "tier":
539
+ moved = unsetModelTier(root, cfg, raw, parsed);
540
+ break;
541
+ case "section":
542
+ moved = unsetSectionField(root, cfg, raw, parsed);
543
+ break;
544
+ case "agent":
545
+ moved = unsetBackendField(root, cfg, raw, parsed);
546
+ break;
547
+ case "role":
548
+ moved = unsetRoleField(cfg, raw, parsed);
549
+ break;
458
550
  }
459
- if (parsed.kind === "map") {
460
- const rawMapCandidate = raw[parsed.map];
461
- const rawMap = objectOrEmpty(rawMapCandidate);
462
- if (!Object.hasOwn(rawMap, parsed.role))
463
- return cfg; // absent file entry: a no-op success
464
- oldValue = rawMap[parsed.role];
465
- const existing = record[parsed.map] ?? {};
466
- const next = without(existing, parsed.role);
467
- return withSectionOrDropped(root, cfg, parsed.map, next);
468
- }
469
- if (parsed.kind === "tier") {
470
- // A string `model` resolves to { default } (modelTierMap); unsetting a tier the FILE
471
- // does not hold is a no-op that leaves the string untouched.
472
- const rawTiers = modelTierMap(raw.model);
473
- if (!Object.hasOwn(rawTiers, parsed.tier))
474
- return cfg;
475
- oldValue = rawTiers[parsed.tier];
476
- const existing = modelTierMap(record.model);
477
- const next = without(existing, parsed.tier);
478
- return withSectionOrDropped(root, cfg, "model", next);
479
- }
480
- if (parsed.kind === "section") {
481
- // Presence is judged against the FILE: a field only the default carries is a no-op
482
- // success, and an emptied section leaves the file.
483
- const rawSection = objectOrEmpty(raw[parsed.section]);
484
- if (!Object.hasOwn(rawSection, parsed.field))
485
- return cfg;
486
- oldValue = rawSection[parsed.field];
487
- const existing = record[parsed.section] ?? {};
488
- return withSectionOrDropped(root, cfg, parsed.section, without(existing, parsed.field));
489
- }
490
- if (parsed.kind === "agent") {
491
- // Presence is judged against the FILE, so unsetting a field only the default (or the
492
- // legacy alias's resolved view) carries is a no-op success. The entry and the `backends`
493
- // section are dropped when they empty, so the file never keeps an empty object.
494
- const rawBackends = objectOrEmpty(raw.backends);
495
- const rawEntryCandidate = rawBackends[parsed.id];
496
- const rawEntry = objectOrEmpty(rawEntryCandidate);
497
- if (!Object.hasOwn(rawEntry, parsed.field))
498
- return cfg; // absent file field: a no-op success
499
- oldValue = rawEntry[parsed.field];
500
- const backends = { ...record.backends };
501
- const entry = { ...(backends[parsed.id] ?? {}) };
502
- delete entry[parsed.field];
503
- if (Object.keys(entry).length === 0)
504
- delete backends[parsed.id];
505
- else
506
- backends[parsed.id] = entry;
507
- return withSectionOrDropped(root, cfg, "backends", backends);
508
- }
509
- const rawRolesCandidate = raw.roles;
510
- const rawRoles = objectOrEmpty(rawRolesCandidate);
511
- const rawEntryCandidate = rawRoles[parsed.id];
512
- const rawEntry = objectOrEmpty(rawEntryCandidate);
513
- if (!Object.hasOwn(rawEntry, parsed.field))
514
- return cfg; // absent file field: a no-op success
515
- oldValue = rawEntry[parsed.field];
516
- // Base the removal on the FILE's own roles, not the loaded (catalog-merged) map: the
517
- // write must not re-expand the operator's partial section to every built-in.
518
- const existing = rawEntry; // the file's own entry (its field was present, checked above)
519
- const nextEntry = without(existing, parsed.field);
520
- if (Object.keys(nextEntry).length > 0)
521
- return { ...cfg, roles: { ...rawRoles, [parsed.id]: nextEntry } };
522
- // The entry emptied: drop it, and the whole `roles` section when it was the last one.
523
- const nextRoles = without(rawRoles, parsed.id);
524
- if (Object.keys(nextRoles).length === 0)
525
- return without(record, "roles");
526
- return { ...cfg, roles: nextRoles };
551
+ oldValue = moved.oldValue;
552
+ return moved.cfg;
527
553
  });
528
554
  if (!result.ok)
529
555
  return result;
@@ -34,7 +34,7 @@ export function parseJsonConfig(file, basename) {
34
34
  * defaults for concurrency, timeouts, log size, retention, thrash detection, idle backoff,
35
35
  * self-redeploy, and review settings. The return type is the resolved view (config-views.ts): a
36
36
  * freshly defaulted config has no model map to resolve — only a loaded file can carry one — so
37
- * every call site that hands a default into a pi run (run-agent.ts's PiRunOptions, tests)
37
+ * every call site that hands a default into a pi run (agent-run.ts's PiRunOptions, tests)
38
38
  * satisfies ResolvedModelConfig directly. */
39
39
  export function defaultConfig() {
40
40
  const roles = {};
@@ -143,15 +143,7 @@ export function defaultConfig() {
143
143
  // explicit user instruction from being discarded by a rejected review — has nothing left
144
144
  // to exempt. Removing it also closes the path where a director run could merge a mixed
145
145
  // doc-plus-config diff unreviewed.
146
- // Reviewer as a regular role (part 1/4): the high-volume, low-blast-radius roles — bugfix,
147
- // readme, organize, plus clean, dedupe, coverage — are reviewed AFTER they merge, on their
148
- // merged commit (src/review/post-merge-review.ts), instead of holding the merge queue for a
149
- // reviewer run on their (usually trivial) diff; a rejection keeps the commit on main
150
- // with the reviewer's reasons. feature, director, perf, improve and custom roles stay
151
- // pre-merge. A
152
- // config that omits `review.afterMerge` inherits this, and `"afterMerge": []` opts out (back
153
- // to reviewing before merging).
154
- review: { enabled: true, exemptPaths: ["*.md", "docs/**"], afterMerge: ["bugfix", "clean", "coverage", "dedupe", "organize", "readme"] },
146
+ review: { enabled: true, exemptPaths: ["*.md", "docs/**"] },
155
147
  customRoles: [],
156
148
  roles,
157
149
  };
@@ -285,7 +277,7 @@ function cloneConfig(c) {
285
277
  * reconfiguration (BUGS.md 2026-09-23: a merge's fast-forward deleted it and the team ran
286
278
  * 8.6 h on defaults), so callers keep their last-known-good exactly as for a broken file, or
287
279
  * fall back to defaults only when they never saw one. Startup's "missing ⇒ not initialized"
288
- * gate (startup-gate.ts) and loadConfig's first-run defaults are unaffected. */
280
+ * gate (startup-gates.ts) and loadConfig's first-run defaults are unaffected. */
289
281
  export function loadConfigCached(root) {
290
282
  const file = configPath(root);
291
283
  try {
@@ -353,13 +345,12 @@ function stableJson(value) {
353
345
  }
354
346
  /** Sorted names of the config settings whose value differs between two loaded configs, for the
355
347
  * orchestrator's live-reload event. `maxConcurrent` and `sessionRetentionDays` are skipped: each
356
- * already has its own, more informative event. `maxConcurrentPerProvider` is skipped too — each
357
- * changed provider fires its own `provider_concurrency_changed` event. The `roles` map is never
348
+ * already has its own, more informative event. The `roles` map is never
358
349
  * reported whole — only
359
350
  * each differing `roles.<id>` — so a `customRoles` add/remove names both the section and the
360
351
  * affected role. */
361
352
  export function changedConfigKeys(prev, next) {
362
- const skip = new Set(["maxConcurrent", "maxConcurrentPerProvider", "sessionRetentionDays", "roles"]);
353
+ const skip = new Set(["maxConcurrent", "sessionRetentionDays", "roles"]);
363
354
  const keys = [];
364
355
  const prevRecord = prev;
365
356
  const nextRecord = next;
@@ -2,14 +2,14 @@ import { andMore, quoteHeading, readBacklogChecked, FIX_CLAIMS_CHECKED, REFUSAL_
2
2
  import { truncate } from "../text/text.js";
3
3
  import { plural, agree } from "../text/phrases.js";
4
4
  import { ageInDays, filingDate, fixedBugEntries, openBugEntries, openQuestionEntries, plannedPlanEntries } from "../backlog/backlog.js";
5
- import { questionFilingDate } from "../backlog/question-answer.js";
5
+ import { questionFilingDate } from "../backlog/backlog-question-answer.js";
6
6
  import { needsReviewNote, refusalNote } from "../backlog/backlog-eligibility.js";
7
7
  import { plannedPartKeys, plansWaitingOn, prerequisitesMerged } from "../backlog/backlog-parts.js";
8
8
  import { lastDate } from "../backlog/backlog-md.js";
9
9
  import { formatTimestamp } from "../text/datetime.js";
10
10
  import { stringList } from "../files/json-object.js";
11
11
  import { readEventsSince } from "../events/event-window.js";
12
- import { okOutcome, warnOutcome } from "./outcome.js";
12
+ import { okOutcome, warnOutcome } from "./doctor-outcome.js";
13
13
  /** The doctor's open-bug and question checks, split out of doctor-backlog.ts: questions that
14
14
  * have waited too long for an answer (checkStaleQuestions), bugs left unfixed (checkStaleBugs),
15
15
  * fixed records whose quoted test regressed (checkRegressedFixes), and
@@ -17,7 +17,8 @@ import { okOutcome, warnOutcome } from "./outcome.js";
17
17
  * (readBacklogChecked, andMore) from doctor-backlog.ts, plus the age/trim bounds, and
18
18
  * all warn instead of failing: a stale or
19
19
  * parked entry is operator signal, not a broken environment. doctor.ts composes them beside the
20
- * plan-structure and queue checks; the shared CheckOutcome shape is imported from outcome.ts. */
20
+ * plan-structure and queue checks; the shared CheckOutcome shape is imported from
21
+ * doctor-outcome.ts. */
21
22
  /** Stale questions — open QUESTIONS.md questions that have waited more than 7 days for an
22
23
  * answer. A team can stay parked on a question for days while `tumwater doctor` reports
23
24
  * nothing; the `--list` age (`questionAgeSuffix` on `tumwater questions`) is the only other
@@ -143,7 +144,7 @@ function sinceWindowMs(sinces) {
143
144
  * cannot drift on the selection. `since` is the day-after the fix date — the recurrence scan's
144
145
  * boundary (the heading carries only a date, so a same-day failure cannot be told from the one
145
146
  * the fix answered). Returns `[]` when no candidate has both a quoted test and a date. */
146
- export function regressedFixCandidates(root) {
147
+ function regressedFixCandidates(root) {
147
148
  const candidates = fixedBugEntries(root).slice(0, FIX_CLAIMS_CHECKED).flatMap((entry) => {
148
149
  const name = quotedTestName(entry.title);
149
150
  const date = lastDate(entry.title, /(\d{4}-\d{2}-\d{2})/);
@@ -159,14 +160,14 @@ export function regressedFixCandidates(root) {
159
160
  * `failureMentions` per candidate over `events`, keeping the last mention (the doctor's exact
160
161
  * match). The caller supplies the event window — the doctor reads back to the oldest candidate's
161
162
  * day-after, the always-on alert the trailing day. */
162
- export function matchedRegressedFixes(candidates, events) {
163
+ function matchedRegressedFixes(candidates, events) {
163
164
  return candidates.flatMap((c) => {
164
165
  const newest = lastFailureMention(events, c.name, c.since);
165
166
  return newest ? [{ ...c, newest }] : [];
166
167
  });
167
168
  }
168
169
  /** The doctor's `regressed fixes` warn sentence, verbatim, or null when nothing regressed. */
169
- export function regressedFixesPhrase(regressed) {
170
+ function regressedFixesPhrase(regressed) {
170
171
  const [first, ...rest] = regressed;
171
172
  if (!first)
172
173
  return null;