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
@@ -80,35 +80,158 @@ function buildCandidate(root, current, entries, checkCommand) {
80
80
  const customRoles = entries === undefined ? current.customRoles : entries;
81
81
  const checkChanged = checkCommand !== undefined && (current.check?.command ?? "") !== checkCommand;
82
82
  const unchanged = !checkChanged && isDeepStrictEqual(customRoles, current.customRoles);
83
- // Strip roles.<id> entries for custom roles this request removes (see applyConfigRequest's
84
- // docstring): structurally valid names only; anything else stays for validateConfig to
85
- // reject — never dereference a request entry before validation.
83
+ const requestedNames = requestedRoleNames(customRoles);
84
+ const fileRoles = fileRolesAfterRemoval(root, current.customRoles, requestedNames);
85
+ const candidate = { ...current, customRoles };
86
+ withCheckCommand(candidate, current.check, checkCommand, checkChanged);
87
+ withRolesSection(candidate, fileRoles.raw, fileRoles.roles);
88
+ return { candidate: candidate, changed: !unchanged };
89
+ }
90
+ /** The structurally valid role names a request's customRoles array carries (see
91
+ * applyConfigRequest's docstring): only names on structurally valid entries feed the roles
92
+ * strip; anything else stays for validateConfig to reject — never dereference a request
93
+ * entry before validation. */
94
+ function requestedRoleNames(customRoles) {
86
95
  const requestedNames = new Set();
87
96
  for (const entry of customRoles) {
88
97
  if (isJsonObject(entry) && typeof entry.name === "string")
89
98
  requestedNames.add(entry.name);
90
99
  }
91
- // Base the roles rewrite on the FILE's own section, not the loaded (catalog-merged) map:
92
- // this whole-config write must not re-expand a partial section to every built-in
93
- // (preserveFileRoles' rule). Only the removed custom roles' entries are stripped, and only
94
- // from what the file actually holds.
95
- const rawRolesCandidate = rawConfigObject(root).roles;
96
- const roles = isJsonObject(rawRolesCandidate) ? { ...(rawRolesCandidate) } : {};
97
- for (const c of current.customRoles) {
100
+ return requestedNames;
101
+ }
102
+ /** The FILE's own roles section (not the loaded, catalog-merged map — this whole-config
103
+ * write must not re-expand a partial section to every built-in, preserveFileRoles' rule)
104
+ * with the removed custom roles' entries stripped: only the requested names the caller
105
+ * passes are removed, and only from what the file actually holds. Returns both the raw value
106
+ * (for when the file had no roles section) and the stripped map. */
107
+ function fileRolesAfterRemoval(root, currentRoles, requestedNames) {
108
+ const raw = rawConfigObject(root).roles;
109
+ const roles = isJsonObject(raw) ? { ...(raw) } : {};
110
+ for (const c of currentRoles) {
98
111
  if (!requestedNames.has(c.name))
99
112
  delete roles[c.name];
100
113
  }
101
- const candidate = { ...current, customRoles };
114
+ return { raw, roles };
115
+ }
116
+ /** Replace only `check.command` on the candidate when the request declares one, preserving
117
+ * the rest of the check section (gateCommand/mergeCommand/timeoutSeconds). */
118
+ function withCheckCommand(candidate, currentCheck, checkCommand, checkChanged) {
102
119
  if (checkChanged && checkCommand !== undefined) {
103
- // Preserve the rest of the check section (gateCommand/mergeCommand/timeoutSeconds);
104
- // only `command` is declared by the request.
105
- candidate.check = { ...current.check, command: checkCommand };
120
+ candidate.check = { ...currentCheck, command: checkCommand };
106
121
  }
122
+ }
123
+ /** Set the candidate's roles section to the stripped map, or drop it when the file had none. */
124
+ function withRolesSection(candidate, rawRolesCandidate, roles) {
107
125
  if (rawRolesCandidate === undefined)
108
126
  delete candidate.roles;
109
127
  else
110
128
  candidate.roles = roles;
111
- return { candidate: candidate, changed: !unchanged };
129
+ }
130
+ /** Parse the request JSON into its customRoles entries and raw `check` value: the isJsonObject
131
+ * throw, the old-key normalization and the top-level disallowed-key scan into `ignored`; the
132
+ * `check` value is returned untouched for checkCommandFrom. */
133
+ function parseConfigRequest(raw, ignored) {
134
+ const unnormalized = JSON.parse(raw);
135
+ if (!isJsonObject(unnormalized))
136
+ throw new Error(`config request must be a JSON object (got ${show(unnormalized)})`);
137
+ // Old key spellings read as the new ones; a request setting both fails with the pair named.
138
+ const normalized = normalizeConfigKeys(unnormalized);
139
+ if (normalized.problems.length > 0)
140
+ throw new Error(normalized.problems.join("; "));
141
+ const parsed = normalized.raw;
142
+ for (const key of Object.keys(parsed)) {
143
+ if (key !== "customRoles" && key !== "check")
144
+ ignored.push(key);
145
+ }
146
+ return { entries: parsed.customRoles, check: parsed.check };
147
+ }
148
+ /** Extract the declared check command from a request's `check` object: the isJsonObject throw,
149
+ * the sub-key scan that pushes the dropped `check.<key>` names into `ignored`, and the
150
+ * non-blank `command` (or its collected drop). */
151
+ function checkCommandFrom(check, ignored) {
152
+ // A check request is read only for its `command` (a non-blank string). Every other
153
+ // sub-key — and a blank `command` — is dropped into `ignored`, so it can never turn the
154
+ // safety gates off; a blank/absent command simply declares nothing.
155
+ if (check !== undefined) {
156
+ const checkReq = check;
157
+ if (!isJsonObject(checkReq))
158
+ throw new Error(`config request's check must be an object (got ${show(checkReq)})`);
159
+ for (const key of Object.keys(checkReq)) {
160
+ if (key !== "command")
161
+ ignored.push(`check.${key}`);
162
+ }
163
+ if (isNonBlankString(checkReq.command))
164
+ return checkReq.command;
165
+ else if (checkReq.command !== undefined)
166
+ ignored.push("check.command");
167
+ }
168
+ return undefined;
169
+ }
170
+ /** Verify a declared check.command before it is written (part 1/2): a declared check is
171
+ * fail-closed — the gate's pre-check rejects a red run and a failure that reproduces on main
172
+ * parks every queued pin behind a red main no role can repair by code — so the declared
173
+ * command must be observed green in the requesting worktree BEFORE it is written. Only a
174
+ * `passed` outcome writes; failed, timed-out, or skipped writes nothing and returns an error
175
+ * naming what was observed. Each candidate is run at `check.cwd` resolved against the
176
+ * worktree (default `.`) under the config-resolved timeout, and when a `check.gateCommand` is
177
+ * set and differs the gate command is verified first too — the declared command is the
178
+ * fail-closed promise, the gate command is what the gate scope will actually run — so a green
179
+ * here is a green at the gate for every command the write leaves live. */
180
+ async function verifyDeclaredCheck(root, wt, entries, checkCommand, runCheck) {
181
+ // Read the current config only for the guard — the write reloads fresh under the lock.
182
+ const currentForGuard = loadConfig(root);
183
+ // Pre-validate what the request would write BEFORE the declared check runs: a request
184
+ // that pairs a valid check.command with an invalid customRoles entry must be rejected
185
+ // without ever spawning the check (it would fail validation anyway). The locked write
186
+ // re-loads fresh and re-validates, so a config change between here and the write is
187
+ // still caught.
188
+ const { candidate: preCandidate } = buildCandidate(root, currentForGuard, entries, checkCommand);
189
+ validateConfig(collapseBackendAliases(preCandidate));
190
+ for (const check of guardChecks(currentForGuard, wt, checkCommand)) {
191
+ const outcome = await runCheck(wt, check, currentForGuard);
192
+ if (outcome.status !== "passed") {
193
+ throw new Error(`check.command not declared: \`${outcome.script}\` ${describeGuardFailure(outcome)} in the worktree`);
194
+ }
195
+ }
196
+ }
197
+ /** The locked whole-config write of a config request: the load-to-write sequence is one
198
+ * critical section under withConfigLock — a concurrent `config set`/dashboard write between
199
+ * the load and this write must not be dropped by the whole-file overwrite (see
200
+ * writeConfigMutation). Returns whether the request changed nothing and the now-live custom
201
+ * role names. */
202
+ function writeConfigRequest(root, entries, checkCommand) {
203
+ let applied = [];
204
+ let unchanged = false;
205
+ withConfigLock(root, () => {
206
+ const current = loadConfig(root); // fresh — bypasses the stat cache: a writer sees the latest file
207
+ const { candidate, changed } = buildCandidate(root, current, entries, checkCommand);
208
+ // Nothing the request would change: write nothing, like writeConfigMutation's no-op skip.
209
+ if (!changed) {
210
+ unchanged = true;
211
+ return;
212
+ }
213
+ // Collapse legacy/new pi spellings before writing so a merged config (which always
214
+ // carries the default piArgs) never lands both forms in the file — the next load's raw
215
+ // pass rejects a file holding both `piArgs` and `backends.pi.args`.
216
+ const collapsed = collapseBackendAliases(candidate);
217
+ validateConfig(collapsed); // throws listing every problem — nothing is written on failure
218
+ writeJsonAtomic(configPath(root), collapsed, true);
219
+ applied = collapsed.customRoles.map((c) => c.name);
220
+ });
221
+ return { unchanged, applied };
222
+ }
223
+ /** Delete the request on EVERY path — success, rejection, malformed JSON — so it can never be
224
+ * staged by commitAll, reach a review gate, or retry forever. A failed unlink is surfaced as
225
+ * an error so the caller logs it (the file would otherwise survive into the diff); a passed-in
226
+ * error outranks the failed-deletion message. Returns the error to surface. */
227
+ function discardConfigRequest(requestFile, error) {
228
+ try {
229
+ fs.unlinkSync(requestFile);
230
+ }
231
+ catch (err) {
232
+ return error ?? `config request file could not be deleted: ${errorMessage(err)}`;
233
+ }
234
+ return error;
112
235
  }
113
236
  /** Consume the role's config-write request file (plans/portability.md §3/7). After its pi
114
237
  * run the director may leave `.tumwater-config-request.json` at its worktree root:
@@ -154,7 +277,9 @@ function buildCandidate(root, current, entries, checkCommand) {
154
277
  * unless the write happened); `ignored` lists the discarded keys (top-level keys and, named
155
278
  * `check.<key>`, the check sub-keys that were dropped); `error`, when set, names why nothing
156
279
  * (or only part of the work) was done — the caller turns it and `ignored` into warning events.
157
- * `unchanged` is set when the request changes nothing; nothing is written. */
280
+ * `unchanged` is set when the request changes nothing; nothing is written. The body delegates
281
+ * each phase to a named helper — parseConfigRequest, checkCommandFrom, verifyDeclaredCheck,
282
+ * writeConfigRequest and discardConfigRequest. */
158
283
  export async function applyConfigRequest(root, wt, runCheck = defaultCheckGuard) {
159
284
  const requestFile = configRequestPath(wt);
160
285
  const raw = readTextOrNull(requestFile);
@@ -165,98 +290,24 @@ export async function applyConfigRequest(root, wt, runCheck = defaultCheckGuard)
165
290
  let error;
166
291
  let unchanged;
167
292
  try {
168
- const unnormalized = JSON.parse(raw);
169
- if (!isJsonObject(unnormalized))
170
- throw new Error(`config request must be a JSON object (got ${show(unnormalized)})`);
171
- // Old key spellings read as the new ones; a request setting both fails with the pair named.
172
- const normalized = normalizeConfigKeys(unnormalized);
173
- if (normalized.problems.length > 0)
174
- throw new Error(normalized.problems.join("; "));
175
- const parsed = normalized.raw;
176
- for (const key of Object.keys(parsed)) {
177
- if (key !== "customRoles" && key !== "check")
178
- ignored.push(key);
179
- }
180
- // A check request is read only for its `command` (a non-blank string). Every other
181
- // sub-key — and a blank `command` — is dropped into `ignored`, so it can never turn the
182
- // safety gates off; a blank/absent command simply declares nothing.
183
- let checkCommand;
184
- if (parsed.check !== undefined) {
185
- const checkReq = parsed.check;
186
- if (!isJsonObject(checkReq))
187
- throw new Error(`config request's check must be an object (got ${show(checkReq)})`);
188
- for (const key of Object.keys(checkReq)) {
189
- if (key !== "command")
190
- ignored.push(`check.${key}`);
191
- }
192
- if (isNonBlankString(checkReq.command))
193
- checkCommand = checkReq.command;
194
- else if (checkReq.command !== undefined)
195
- ignored.push("check.command");
196
- }
197
- const entriesRaw = parsed.customRoles;
198
- if (entriesRaw !== undefined && !Array.isArray(entriesRaw))
199
- throw new Error(`config request's customRoles must be an array (got ${show(entriesRaw)})`);
200
- const entries = entriesRaw;
201
- // Verified declaration (part 1/2): a declared check is fail-closed — the gate's pre-check
202
- // rejects a red run and a failure that reproduces on main parks every queued pin behind a
203
- // red main no role can repair by code — so the declared command must be observed green in
204
- // the requesting worktree BEFORE it is written. Only a `passed` outcome writes; failed,
205
- // timed-out, or skipped writes nothing and returns an error naming what was observed. Each
206
- // candidate is run at `check.cwd` resolved against the worktree (default `.`) under the
207
- // config-resolved timeout, and when a `check.gateCommand` is set and differs the gate
208
- // command is verified first too — the declared command is the fail-closed promise, the
209
- // gate command is what the gate scope will actually run — so a green here is a green at
210
- // the gate for every command the write leaves live.
293
+ const { entries, check } = parseConfigRequest(raw, ignored);
294
+ const checkCommand = checkCommandFrom(check, ignored);
295
+ // The array-shape check runs after the check-subkey scan in checkCommandFrom, preserving the
296
+ // original ordering so a doubly-malformed request still reports its dropped `check.<key>`
297
+ // warnings before the malformed-customRoles error.
298
+ if (entries !== undefined && !Array.isArray(entries))
299
+ throw new Error(`config request's customRoles must be an array (got ${show(entries)})`);
211
300
  if (checkCommand !== undefined) {
212
- // Read the current config only for the guard — the write reloads fresh under the lock.
213
- const currentForGuard = loadConfig(root);
214
- // Pre-validate what the request would write BEFORE the declared check runs: a request
215
- // that pairs a valid check.command with an invalid customRoles entry must be rejected
216
- // without ever spawning the check (it would fail validation anyway). The locked write
217
- // re-loads fresh and re-validates, so a config change between here and the write is
218
- // still caught.
219
- const { candidate: preCandidate } = buildCandidate(root, currentForGuard, entries, checkCommand);
220
- validateConfig(collapseBackendAliases(preCandidate));
221
- for (const check of guardChecks(currentForGuard, wt, checkCommand)) {
222
- const outcome = await runCheck(wt, check, currentForGuard);
223
- if (outcome.status !== "passed") {
224
- throw new Error(`check.command not declared: \`${outcome.script}\` ${describeGuardFailure(outcome)} in the worktree`);
225
- }
226
- }
301
+ await verifyDeclaredCheck(root, wt, entries, checkCommand, runCheck);
227
302
  }
228
- // The load-to-write sequence is one critical section under withConfigLock: a concurrent
229
- // `config set`/dashboard write between the load and this write must not be dropped by the
230
- // whole-file overwrite (see writeConfigMutation).
231
- withConfigLock(root, () => {
232
- const current = loadConfig(root); // fresh — bypasses the stat cache: a writer sees the latest file
233
- const { candidate, changed } = buildCandidate(root, current, entries, checkCommand);
234
- // Nothing the request would change: write nothing, like writeConfigMutation's no-op skip.
235
- if (!changed) {
236
- unchanged = true;
237
- return;
238
- }
239
- // Collapse legacy/new pi spellings before writing so a merged config (which always
240
- // carries the default piArgs) never lands both forms in the file — the next load's raw
241
- // pass rejects a file holding both `piArgs` and `backends.pi.args`.
242
- const collapsed = collapseBackendAliases(candidate);
243
- validateConfig(collapsed); // throws listing every problem — nothing is written on failure
244
- writeJsonAtomic(configPath(root), collapsed, true);
245
- applied = collapsed.customRoles.map((c) => c.name);
246
- });
303
+ const written = writeConfigRequest(root, entries, checkCommand);
304
+ applied = written.applied;
305
+ unchanged = written.unchanged ? true : undefined;
247
306
  }
248
307
  catch (err) {
249
308
  applied = []; // no write happened, or it must not be reported as applied
250
309
  error = errorMessage(err);
251
310
  }
252
- // Delete the request on EVERY path — success, rejection, malformed JSON — so it can never be
253
- // staged by commitAll, reach a review gate, or retry forever. A failed unlink is surfaced as
254
- // an error so the caller logs it (the file would otherwise survive into the diff).
255
- try {
256
- fs.unlinkSync(requestFile);
257
- }
258
- catch (err) {
259
- error ??= `config request file could not be deleted: ${errorMessage(err)}`;
260
- }
311
+ error = discardConfigRequest(requestFile, error);
261
312
  return unchanged ? { applied, ignored, error, unchanged } : { applied, ignored, error };
262
313
  }
@@ -5,7 +5,7 @@
5
5
  * its default (config.ts), and its allow-list entry sit beside each other. Split out of the former
6
6
  * types.ts grab-bag — the runtime types a run produces now live beside their consumers
7
7
  * (RunResult and RunOutcome in run-outcome.ts, RunnerState in runner-state.ts, HarnessEvent
8
- * in events.ts, MergeEntry in merge-queue.ts, and the pi-run types in run-agent.ts and
8
+ * in events.ts, MergeEntry in merge-queue.ts, and the pi-run types in agent-run.ts and
9
9
  * pi-run-result.ts) — so the schema the file on disk is validated against and the runtime
10
10
  * state the harness runs with
11
11
  * are two layers, changed for different reasons by different readers: a tumwater.json knob
@@ -59,8 +59,6 @@ export const TOP_LEVEL_KEYS = [
59
59
  "check",
60
60
  "piArgs",
61
61
  "maxConcurrent",
62
- "maxConcurrentPerProvider",
63
- "claudeWindowReserve",
64
62
  "mergeBatchMax",
65
63
  "maxConcurrentChecks",
66
64
  "worktreeSlots",
@@ -93,16 +91,16 @@ export const TOP_LEVEL_KEYS = [
93
91
  export const BACKOFF_KEYS = ["initialSeconds", "factor", "maxSeconds"];
94
92
  /** The keys an `backends.<id>` entry may hold; validation feeds checkKnownKeys so a typo'd
95
93
  * field (`backends.claude.binary`) is a load error rather than a silently inert entry.
96
- * `configDir`/`windowReserve` are claude/codex-only (CLAUDE_AGENT_KEYS / CODEX_BACKEND_KEYS),
97
- * so they are not silently inert on pi/opencode. */
94
+ * `configDir` is claude/codex-only (CLAUDE_AGENT_KEYS / CODEX_BACKEND_KEYS),
95
+ * so it is not silently inert on pi/opencode. */
98
96
  export const BACKEND_KEYS = ["bin", "args"];
99
- /** The keys a claude `backends.claude` entry may hold — the shared agent keys plus `configDir`
100
- * and `windowReserve`, the per-backend fields only the claude adapter reads. */
101
- export const CLAUDE_AGENT_KEYS = [...BACKEND_KEYS, "configDir", "windowReserve"];
97
+ /** The keys a claude `backends.claude` entry may hold — the shared agent keys plus `configDir`,
98
+ * the per-backend field only the claude adapter reads. */
99
+ export const CLAUDE_AGENT_KEYS = [...BACKEND_KEYS, "configDir"];
102
100
  /** The keys a codex `backends.codex` entry may hold — the same as claude's: the shared agent keys
103
- * plus `configDir` (which becomes `CODEX_HOME` for the codex processes, plans/codex-backend.md
104
- * §4) and `windowReserve` (the part 2 per-backend reserve). */
105
- export const CODEX_BACKEND_KEYS = [...BACKEND_KEYS, "configDir", "windowReserve"];
101
+ * plus `configDir`, which becomes `CODEX_HOME` for the codex processes (plans/codex-backend.md
102
+ * §4). */
103
+ export const CODEX_BACKEND_KEYS = [...BACKEND_KEYS, "configDir"];
106
104
  /** The keys tumwater.json's `bootstrap` section may hold. */
107
105
  export const BOOTSTRAP_KEYS = ["untilPlansDone"];
108
106
  export const ROLE_ENTRY_KEYS = [
@@ -3,7 +3,7 @@ import { checkKnownKeys, show } from "./config-field-checks.js";
3
3
  import { isJsonObject } from "../files/json-object.js";
4
4
  import { isNonBlankString } from "../text/text.js";
5
5
  import { typoSuffix } from "../text/suggest.js";
6
- import { isBackendId, parseModelSelector } from "./model-selector.js";
6
+ import { isBackendId, parseModelSelector } from "./config-model-selector.js";
7
7
  /** Model-selector and fallback-chain checks that validateConfig (config-validation.ts) runs
8
8
  * on each model-choosing section: empty selector halves, `fallback` lists and steps, and an
9
9
  * entry's `backend` against its model. Each takes the `problems` list to append to and reads
@@ -12,7 +12,7 @@
12
12
  import { BACKEND_IDS, BACKEND_KEYS, CLAUDE_AGENT_KEYS, CODEX_BACKEND_KEYS, HARNESS_PI_FLAGS, } from "./config-schema.js";
13
13
  import { isJsonObject } from "../files/json-object.js";
14
14
  import { typoSuffix } from "../text/suggest.js";
15
- import { isBackendId } from "./model-selector.js";
15
+ import { isBackendId } from "./config-model-selector.js";
16
16
  import { reservedPassthroughFlag } from "../agent/backend-traits.js";
17
17
  import { checkObjectSection, checkReservedArgs, show, } from "./config-field-checks.js";
18
18
  /** Validate the agent-execution block: the bare `backend`, `agentBin`, `baseBranch` and
@@ -27,7 +27,7 @@ export function validateAgentsBlock(r, problems, rawFile, helpers) {
27
27
  checkString(r, "", "agentBin", false);
28
28
  // The agent backend for bare/no-model selectors (plans/agent-backends.md "Config"): a
29
29
  // non-blank id from the schema's reserved set. An unknown id would fall back to pi at run
30
- // time (src/agent/run-agent.ts), silently running the wrong backend against a named one.
30
+ // time (src/agent/agent-run.ts), silently running the wrong backend against a named one.
31
31
  checkString(r, "", "backend", false);
32
32
  if (typeof r.backend === "string" && !isBackendId(r.backend))
33
33
  problems.push(`backend must name a backend (${BACKEND_IDS.join(", ")}; got ${show(r.backend)})${typoSuffix(r.backend, BACKEND_IDS)}`);
@@ -1,13 +1,12 @@
1
1
  /** The per-role and cross-field section rules of tumwater.json validation: the `customRoles`
2
2
  * collect-and-check block, the `roles.<id>` entry block, `review.afterMerge`'s name check,
3
- * the per-role daily cost caps and quiet hours, `maxConcurrentPerProvider`,
4
- * `claudeWindowReserve` and its mutually-exclusive spelling pair, and the `idleBackoff`
3
+ * the per-role daily cost caps and quiet hours, and the `idleBackoff`
5
4
  * cross-field pair. Extracted from the single validateConfig gate (config-validation.ts) so a
6
5
  * per-role or cross-field edit lands as a per-theme diff instead of a whole-gate one; this
7
6
  * module runs once, at the gate's end, after every other section has appended its problems,
8
7
  * so the overall problem order is unchanged. These rules sit on top of the generic machinery
9
8
  * in config-field-checks.ts; the helpers passed in are the closures the gate binds once (the
10
- * problems-bound checkers and the shared model-triple/window-reserve sequences), and the
9
+ * problems-bound checkers and the shared model-triple sequence), and the
11
10
  * cross-cutting `customNames` set — collected in the customRoles block, consumed by the
12
11
  * roles.<id>, afterMerge, cap and quiet-hour blocks — is owned here, so validateConfig
13
12
  * passes no set across the boundary. */
@@ -18,11 +17,7 @@ import { isJsonObject } from "../files/json-object.js";
18
17
  import { isNonBlankString } from "../text/text.js";
19
18
  import { parseQuietHours } from "../scheduling/quiet-hours.js";
20
19
  import { TUMWATER_SOURCE } from "../events/events.js";
21
- import { DOLLAR_CAP, MAX_DURATION_SECONDS, POSITIVE_INTEGER, checkKnownKeys, checkKnownRoleId, checkNumberField, show, } from "./config-field-checks.js";
22
- const DURATION_NON_NEGATIVE = {
23
- ok: (n) => n >= 0 && n <= MAX_DURATION_SECONDS,
24
- what: `a number of 0 or more, at most ${MAX_DURATION_SECONDS}`,
25
- };
20
+ import { DOLLAR_CAP, DURATION_NON_NEGATIVE, checkKnownKeys, checkKnownRoleId, checkNumberField, show, } from "./config-field-checks.js";
26
21
  /** A per-role instance count (`roles.feature.instances`): an integer from 1 to 8. Bounded so
27
22
  * a typo like `instances: 800` cannot spawn hundreds of runners, and integer so
28
23
  * `feature-1.5` — which no role id can name — is refused. */
@@ -52,7 +47,7 @@ function checkPerRoleMap(r, key, expectation, customNames, problems, checkValue)
52
47
  }
53
48
  /** Validate the per-role and cross-field sections that close the gate: `customRoles`, the
54
49
  * `roles.<id>` entries, `review.afterMerge`'s role names, the per-role caps and quiet hours,
55
- * `maxConcurrentPerProvider`, `claudeWindowReserve` and the `idleBackoff` cross-field pair,
50
+ * and the `idleBackoff` cross-field pair,
56
51
  * each appended to `problems` in the order the gate produced. The single call site in
57
52
  * validateConfig is the point where these problems were appended, so the order across the
58
53
  * whole pass is unchanged. */
@@ -62,8 +57,6 @@ export function validateRolesAndCrossField(r, problems, helpers) {
62
57
  validateReviewAfterMerge(r, problems, customNames);
63
58
  validatePerRoleCostCaps(r, problems, customNames);
64
59
  validatePerRoleQuietHours(r, problems, customNames);
65
- validateMaxConcurrentPerProvider(r, problems);
66
- validateClaudeWindowReserve(r, problems, helpers);
67
60
  validateIdleBackoff(r, problems);
68
61
  }
69
62
  /** Collect the `customRoles` names and refuse this block's bad shapes, appending to
@@ -215,47 +208,6 @@ function validatePerRoleQuietHours(r, problems, customNames) {
215
208
  problems.push(parsed.error);
216
209
  });
217
210
  }
218
- /** Validate per-provider concurrency caps (plans/provider-concurrency.md §1): a map from
219
- * provider key to a positive integer. Unlike the per-role maps, an unknown provider name is
220
- * NOT a validation error — providers live in pi's models.json, which validation does not
221
- * read, and a fallback's provider may only come into use later (doctor warns in part 5/5).
222
- * Each value must be a positive integer under a non-empty key. */
223
- function validateMaxConcurrentPerProvider(r, problems) {
224
- if (r.maxConcurrentPerProvider !== undefined) {
225
- const map = r.maxConcurrentPerProvider;
226
- if (!isJsonObject(map)) {
227
- problems.push(`maxConcurrentPerProvider must be an object mapping provider names to concurrency caps (got ${show(map)})`);
228
- }
229
- else {
230
- for (const [provider, value] of Object.entries(map)) {
231
- if (provider.trim() === "") {
232
- problems.push(`maxConcurrentPerProvider.<empty> — a provider key must not be empty`);
233
- continue;
234
- }
235
- if (typeof value !== "number" || !Number.isFinite(value) || !POSITIVE_INTEGER.ok(value)) {
236
- problems.push(`maxConcurrentPerProvider.${provider} must be ${POSITIVE_INTEGER.what} (got ${show(value)})`);
237
- }
238
- }
239
- }
240
- }
241
- }
242
- /** Validate `claudeWindowReserve` (the subscription-window reserve: a map from window type to
243
- * a fraction, 0 < f <= 1) and its mutually-exclusive spelling pair with
244
- * `backends.claude.windowReserve`. */
245
- function validateClaudeWindowReserve(r, problems, helpers) {
246
- const { checkWindowReserve } = helpers;
247
- // The subscription-window reserve: a map from window type to a fraction, 0 < f <= 1.
248
- if (r.claudeWindowReserve !== undefined)
249
- checkWindowReserve("claudeWindowReserve", r.claudeWindowReserve);
250
- // The reserve's two spellings are mutually exclusive (plans/codex-backend.md part 2b/9): the
251
- // nested key is the current one; `claudeWindowReserve` is kept as claude's older spelling. A
252
- // file holding both is ambiguous about which drives the window, so it is rejected naming both.
253
- if (r.claudeWindowReserve !== undefined &&
254
- isJsonObject(r.backends) &&
255
- isJsonObject(r.backends.claude) &&
256
- r.backends.claude.windowReserve !== undefined)
257
- problems.push("claudeWindowReserve and backends.claude.windowReserve set the same value — keep one (backends.claude.windowReserve is the current spelling; claudeWindowReserve is its old name)");
258
- }
259
211
  /** Validate the `idleBackoff` cross-field pair (judged on the merged config, where both sides
260
212
  * are always present; on a raw file only when the file itself names both): scheduleBackoff
261
213
  * clamps every idle wait — the first included — to min(initialSeconds, maxSeconds), so a
@@ -7,8 +7,8 @@ import path from "node:path";
7
7
  import { isNonBlankString, tooLongMessage } from "../text/text.js";
8
8
  import { PREFILL_REASON } from "../text/phrases.js";
9
9
  import { parseQuietHours } from "../scheduling/quiet-hours.js";
10
- import { isBackendId } from "./model-selector.js";
11
- import { AT_LEAST_ONE, checkBooleanField, checkKnownKeys, checkModelTripleField, checkNumberField, checkObjectSection, codexConfigKey, checkStringArrayField, checkStringField, NON_NEGATIVE, NON_NEGATIVE_OR_DISABLED, DOLLAR_CAP, MAX_DURATION_SECONDS, POSITIVE, POSITIVE_INTEGER, show, typeName, } from "./config-field-checks.js";
10
+ import { isBackendId } from "./config-model-selector.js";
11
+ import { AT_LEAST_ONE, checkBooleanField, checkKnownKeys, checkModelTripleField, checkNumberField, checkObjectSection, codexConfigKey, checkStringArrayField, checkStringField, NON_NEGATIVE, NON_NEGATIVE_OR_DISABLED, DOLLAR_CAP, DURATION_NON_NEGATIVE, DURATION_OR_DISABLED, DURATION_POSITIVE, POSITIVE, POSITIVE_INTEGER, show, typeName, } from "./config-field-checks.js";
12
12
  import { checkEntryBackend, checkRetiredSelector, checkSelectorHalves, } from "./config-selector-checks.js";
13
13
  import { validateAgentsBlock } from "./config-validation-agents.js";
14
14
  import { validateFallbackBlock, validateModelBlock, } from "./config-validation-model.js";
@@ -24,18 +24,6 @@ import { validateRolesAndCrossField } from "./config-validation-roles.js";
24
24
  * unknown-key and known-role guards, the NumberRule table, and the per-field type checkers —
25
25
  * lives in config-field-checks.ts; this module is the section-by-section rules plus the
26
26
  * cross-field checks, and the one export gate (validateConfig) for them. */
27
- const DURATION_POSITIVE = {
28
- ok: (n) => n > 0 && n <= MAX_DURATION_SECONDS,
29
- what: `a number greater than 0, at most ${MAX_DURATION_SECONDS}`,
30
- };
31
- const DURATION_NON_NEGATIVE = {
32
- ok: (n) => n >= 0 && n <= MAX_DURATION_SECONDS,
33
- what: `a number of 0 or more, at most ${MAX_DURATION_SECONDS}`,
34
- };
35
- const DURATION_OR_DISABLED = {
36
- ok: (n) => n >= 0 && n <= MAX_DURATION_SECONDS,
37
- what: `a number of 0 or more, at most ${MAX_DURATION_SECONDS} (0 disables)`,
38
- };
39
27
  /** Validate tumwater.json values, so a typo fails fast with an actionable message instead
40
28
  * of misbehaving at runtime — e.g. a non-numeric runTimeoutSeconds becomes NaN and kills
41
29
  * every pi run instantly, a non-numeric logMaxBytes rotates the event log on every write, an
@@ -72,37 +60,16 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
72
60
  if (typeof value === "string" && value.length > max)
73
61
  problems.push(tooLongMessage(label, value.length, max, PREFILL_REASON));
74
62
  };
75
- // The window-reserve map validation shared by a backend's nested `windowReserve` (via
76
- // checkBackendConfig) and the top-level `claudeWindowReserve` legacy spelling, so their
77
- // object/empty-type/fraction messages cannot drift. `label` is the message path — the
78
- // `.windowReserve`-suffixed prefix for a backend, `claudeWindowReserve` at top level.
79
- const checkWindowReserve = (label, value) => {
80
- if (!isJsonObject(value)) {
81
- problems.push(`${label} must be an object mapping window types to fractions (got ${show(value)})`);
82
- return;
83
- }
84
- for (const [type, v] of Object.entries(value)) {
85
- if (type.trim() === "")
86
- problems.push(`${label}.<empty> — a window type must not be empty`);
87
- else if (typeof v !== "number" || !Number.isFinite(v) || v <= 0 || v > 1)
88
- problems.push(`${label}.${type} must be a fraction above 0 and at most 1 (got ${show(v)})`);
89
- }
90
- };
91
63
  // claude's and codex's per-backend config dir (plans/notes-command.md "Keeping funes out
92
64
  // of team runs" and plans/codex-backend.md §4): a leading `~` expands; the value must be an
93
65
  // absolute path after expansion, or claude would resolve a relative CLAUDE_CONFIG_DIR (and
94
66
  // codex a relative CODEX_HOME) from the run's cwd and write transcripts somewhere
95
- // unexpected. Only claude and codex accept the key, so it is inert nowhere else. The nested
96
- // reserve spelling (part 2b/9): the same 0 < f ≤ 1 rule the top-level `claudeWindowReserve`
97
- // map gets, so a bad entry can never silently no-op a reserve.
67
+ // unexpected. Only claude and codex accept the key, so it is inert nowhere else.
98
68
  const checkBackendConfig = (entry, prefix) => {
99
69
  checkString(entry, `${prefix}.`, "configDir", false);
100
70
  if (typeof entry.configDir === "string" && entry.configDir !== "")
101
71
  if (!path.isAbsolute(expandHomeTilde(entry.configDir, os.homedir())))
102
72
  problems.push(`${prefix}.configDir must be an absolute path after ~ expansion (got ${show(entry.configDir)})`);
103
- if (entry.windowReserve === undefined)
104
- return;
105
- checkWindowReserve(`${prefix}.windowReserve`, entry.windowReserve);
106
73
  };
107
74
  // codex's `-c <key>=…` / `--config <key>=…` passthrough (plans/codex-backend.md §2/§3): a
108
75
  // key tumwater sets on every run (HARNESS_CODEX_CONFIG_KEYS) — or one the write guard owns,
@@ -227,7 +194,7 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
227
194
  if (!parsed.ok)
228
195
  problems.push(parsed.error);
229
196
  }
230
- // The operator notify hook (src/events/notify.ts): absent or empty string means off,
197
+ // The operator notify hook (src/events/event-notify.ts): absent or empty string means off,
231
198
  // otherwise the value is the shell command the orchestrator runs on notable events — so it
232
199
  // must be a string when present, and an empty string stays valid (it is how `config set
233
200
  // notify ""` clears the hook).
@@ -296,7 +263,6 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
296
263
  checkNumber,
297
264
  checkPromptLength,
298
265
  checkModelTripleEntry,
299
- checkWindowReserve,
300
266
  });
301
267
  if (problems.length > 0) {
302
268
  throw new Error(`invalid ${label}:\n - ${problems.join("\n - ")}`);
@@ -7,7 +7,7 @@
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";
@@ -31,20 +31,6 @@ export function resolvedBackendConfig(config, id) {
31
31
  }
32
32
  return { bin: entry?.bin ?? config.agentBin, args: (entry?.args ?? config.piArgs ?? []).slice() };
33
33
  }
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
34
  /** True when `s` names one of the three tiers — the form `roles.<id>.model` /
49
35
  * `review.model` may take to point at that tier of the top-level `model` map. */
50
36
  export function isTierName(s) {
@@ -109,7 +95,12 @@ export function tierModel(config, tier) {
109
95
  * key it overrides would.
110
96
  *
111
97
  * `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. */
98
+ * override — so `o.model` here is always a selector string, never a tier reference.
99
+ *
100
+ * Exported so imageRerouteTarget (src/agent/image-input.ts) can probe a peer tier's
101
+ * top-level model (`withModelOverrides(config, tier, {})`, the same shape resolverConfig
102
+ * builds strong with) and reshape a tier's fallback pair into a runnable config — the
103
+ * tier-config builder the plan names. */
113
104
  export function withModelOverrides(config, tier, o) {
114
105
  // The top-level model resolves at the seam's tier: a string form is the default tier's
115
106
  // selector; a map form takes the tier's entry, else `default`'s.
@@ -266,8 +257,8 @@ export function reviewRunConfig(config) {
266
257
  /** The optional provider/model/thinking fields of a resolved model seam, with each unset
267
258
  * (empty) field left out so the result carries only what was resolved — the one home of that
268
259
  * 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
260
+ * fallbackPair/tierOwnFallback) and role-view's RoleViewPayload (its model fields) all
261
+ * build a triple this way and follow the same rule — an absent or empty
271
262
  * value is
272
263
  * omitted rather than written as undefined/"" (an empty selector field would read as
273
264
  * "override present," so it must not appear). */
@@ -317,22 +308,6 @@ export function seamHoldKey(cfg) {
317
308
  export function seamPairName(cfg) {
318
309
  return modelPairName({ provider: seamHoldKey(cfg), model: cfg.model });
319
310
  }
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
311
  /** The team's model wiring as one human-readable label, for the live-reload `model_changed`
337
312
  * event: null when no tier resolves to a model (pi's own default), the single selector when
338
313
  * every set tier agrees (the string-form `model`), and the `small=…, default=…, strong=…`