tumwater 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -1,57 +1,27 @@
1
+ // vocabulary: plain
1
2
  /** Harness-mediated config WRITES — the mutators behind the operator surfaces (the TUI's
2
3
  * Ctrl+B editor, the GUI's /api/budget endpoint) and the director's config requests. Each
3
4
  * one loads fresh (bypassing config.ts's stat cache), validates before writing, and writes
4
5
  * atomically, because readers poll tumwater.json every ~2 s. The read side — defaults,
5
- * loading, saving, and the role-selection helpers — lives in config.ts, and the per-run
6
- * model derivations in config-views.ts. */
6
+ * loading, saving, and the role-selection helpers — lives in config.ts, the per-run
7
+ * model derivations in config-views.ts, and the config KEY language — how keys are parsed,
8
+ * addressed and given per-key value screeners — lives in config-keys.ts (both the write
9
+ * verbs here and the read side / doctor share it). */
7
10
  import fs from "node:fs";
8
- import { AGENT_KEYS, modelTierMap, ROLE_ENTRY_KEYS, TIER_MAP_KEYS, TOP_LEVEL_KEYS, } from "./config-schema.js";
11
+ import { modelTierMap } from "./config-schema.js";
9
12
  import { configPath, configLockPath, configRequestPath, CONFIG_BASENAME } from "../paths.js";
10
13
  import { errorMessage } from "../text/text.js";
11
- import { typoSuffix } from "../text/suggest.js";
12
14
  import { writeJsonAtomic } from "../files/json-files.js";
13
15
  import { isJsonObject, objectOrEmpty } from "../files/json-object.js";
14
16
  import { withStateLock } from "../concurrency/lock.js";
15
17
  import { show } from "./config-field-checks.js";
16
18
  import { validateConfig } from "./config-validation.js";
17
- import { loadConfig, parseJsonConfig, collapseAgentAliases } from "./config.js";
18
- import { parseQuietHours } from "../scheduling/quiet-hours.js";
19
- /** One definition of "a valid daily budget cap" (the TUI's Ctrl+B editor and the GUI's
20
- * /api/budget endpoint both run their input through it): a finite number of 0 or more —
21
- * 0 disables the gate, fractional dollars allowed (the badge renders cents). Returns an
22
- * actionable error message for anything else so both surfaces can flash it without
23
- * try/catch plumbing. A value past Number.MAX_SAFE_INTEGER is rejected too: it stays
24
- * finite ("9"×25 → 1e24), so only this screen stands between a one-zero typo in the
25
- * browser's budget editor and an effectively uncapped budget (BUGS.md 2026-10-02, the
26
- * same rule the TUI's parseBudgetInput applies). `key` names the config key in the error,
27
- * defaulting to the fleet-wide `maxDailyCostUsd`; the per-role map passes
28
- * `maxDailyCostUsdPerRole.<id>` so a rejected entry names the key actually set. */
29
- export function checkDailyBudgetUsd(value, key = "maxDailyCostUsd") {
30
- if (typeof value !== "number" ||
31
- !Number.isFinite(value) ||
32
- value < 0 ||
33
- value > Number.MAX_SAFE_INTEGER)
34
- return `${key} must be a number of 0 or more, at most ${Number.MAX_SAFE_INTEGER}, 0 disables (got ${show(value)})`;
35
- return null;
36
- }
37
- /** One definition of "a valid quietHours window" (src/scheduling/quiet-hours.ts owns the format):
38
- * "HH:MM-HH:MM" in local time, wrapping permitted, empty means off. parseQuietHours's
39
- * message is the one wording every surface (validateConfig, `config set`, the TUI/GUI
40
- * editors) shows, so they cannot drift apart on what a valid window is. `key` names the
41
- * config key in the error, defaulting to the fleet-wide `quietHours`; the per-role map passes
42
- * `quietHoursPerRole.<id>` so a rejected entry names the key actually set. */
43
- export function checkQuietHours(value, key = "quietHours") {
44
- const parsed = parseQuietHours(value, key);
45
- return parsed.ok ? null : parsed.error;
46
- }
47
- /** Per-key value validators `config set` (and the dashboard editors that call it) run
48
- * BEFORE the write, so a malformed value fails with its own actionable message instead of
49
- * waiting for validateConfig's whole-file report. */
50
- const PER_KEY_VALIDATORS = {
51
- quietHours: checkQuietHours,
52
- };
19
+ import { canonicalConfigKey, normalizeConfigKeys } from "./config-aliases.js";
20
+ import { unknownRoleMessage } from "../roles/roles.js";
21
+ import { loadConfig, parseJsonConfig, collapseAgentAliases, knownRoleIdsCached } from "./config.js";
22
+ import { checkDailyBudgetUsd, checkProviderConcurrencyCap, checkQuietHours, parseConfigKey, PER_KEY_VALIDATORS, unknownConfigKeyError, unknownRoleFieldError, } from "./config-keys.js";
53
23
  /** Serialize a config write through the cross-process lock (paths.ts's configLockPath): the
54
- * CLI, a standalone dashboard's server, and the running fleet are separate processes, so an
24
+ * CLI, a standalone dashboard's server, and the running team are separate processes, so an
55
25
  * unguarded read-modify-write lets one writer's whole-file write silently drop another's change
56
26
  * (the same lost update paused-roles.json already guards against). A lock that cannot be
57
27
  * acquired within the budget throws rather than writing unlocked — a silently lost config edit
@@ -62,6 +32,12 @@ function withConfigLock(root, fn) {
62
32
  // file).
63
33
  return withStateLock(configLockPath(root), fn, CONFIG_LOCK_TIMEOUT_MS);
64
34
  }
35
+ /** The error arm of the shared write-result union — one home for the `{ ok: false, error }`
36
+ * constructor every writer's failure path returns (a broken on-disk file, a validation
37
+ * problem, a failed write, a lost lock), so the shape cannot drift between callers. */
38
+ function fail(error) {
39
+ return { ok: false, error };
40
+ }
65
41
  /** The one load → mutate → validate → atomic-write idiom every top-level config writer
66
42
  * shares (setDailyBudgetUsd, setConfigKey): a fresh loadConfig that bypasses config.ts's
67
43
  * stat cache (a writer must see the latest file), the caller's mutation applied in memory,
@@ -80,7 +56,7 @@ function writeConfigMutation(root, mutate) {
80
56
  cfg = loadConfig(root); // fresh — bypasses the stat cache on purpose
81
57
  }
82
58
  catch (err) {
83
- return { ok: false, error: errorMessage(err) }; // broken file: never overwrite it with defaults
59
+ return fail(errorMessage(err)); // broken file: never overwrite it with defaults
84
60
  }
85
61
  const mutated = mutate(cfg);
86
62
  // An unset that finds nothing to remove returns the loaded config unchanged. Write nothing:
@@ -96,20 +72,20 @@ function writeConfigMutation(root, mutate) {
96
72
  validateConfig(candidate); // throws listing every problem — nothing is written on failure
97
73
  }
98
74
  catch (err) {
99
- return { ok: false, error: errorMessage(err) };
75
+ return fail(errorMessage(err));
100
76
  }
101
77
  try {
102
78
  // The trailing newline is tumwater.json's convention (POSIX text file).
103
79
  writeJsonAtomic(configPath(root), candidate, true);
104
80
  }
105
81
  catch (err) {
106
- return { ok: false, error: errorMessage(err) };
82
+ return fail(errorMessage(err));
107
83
  }
108
84
  return { ok: true };
109
85
  });
110
86
  }
111
87
  catch (err) {
112
- return { ok: false, error: errorMessage(err) }; // lock acquire timeout/failure — nothing written
88
+ return fail(errorMessage(err)); // lock acquire timeout/failure — nothing written
113
89
  }
114
90
  }
115
91
  /** Set the daily cost budget cap in tumwater.json through the shared writer idiom (see
@@ -118,7 +94,7 @@ function writeConfigMutation(root, mutate) {
118
94
  export function setDailyBudgetUsd(root, value) {
119
95
  const problem = checkDailyBudgetUsd(value);
120
96
  if (problem)
121
- return { ok: false, error: problem };
97
+ return fail(problem);
122
98
  return writeConfigMutation(root, (cfg) => {
123
99
  const next = { ...cfg, maxDailyCostUsd: value };
124
100
  // A whole-config write: keep the file's own roles section (see preserveFileRoles).
@@ -126,83 +102,6 @@ export function setDailyBudgetUsd(root, value) {
126
102
  return next;
127
103
  });
128
104
  }
129
- /** The per-role maps `config get/set` reaches with dotted keys: each names ONE entry
130
- * (`maxDailyCostUsdPerRole.<role>`, `quietHoursPerRole.<role>`) that merges into the
131
- * existing map, so raising one role's cap never requires re-typing another's. The per-key
132
- * value validator runs before the write, the same screening a bare top-level key gets. */
133
- const DOTTED_MAP_KEYS = ["maxDailyCostUsdPerRole", "quietHoursPerRole"];
134
- /** The valid first segments of a dotted config key — the per-role maps plus the `roles.` and
135
- * `model.` sections. The generic dotted-shape error suggests against these so a typo'd head
136
- * (`modle.small`, `maxDailyCostUsdPerRoll.feature`) gets the same did-you-mean the sibling
137
- * unknown-key errors attach. */
138
- const DOTTED_KEY_HEADS = [...DOTTED_MAP_KEYS, "roles", "model", "agents"];
139
- /** Split a config key into its write/get shape: `foo` is top-level,
140
- * `maxDailyCostUsdPerRole.feature` / `quietHoursPerRole.qa` name one map entry,
141
- * `roles.qa.model` names one role-entry field, and anything else dotted is an error
142
- * naming the supported dotted forms. */
143
- export function parseConfigKey(key) {
144
- if (!key.includes("."))
145
- return { kind: "top", key };
146
- const parts = key.split(".");
147
- const first = parts[0] ?? "";
148
- const second = parts.length > 1 ? parts[1] ?? "" : undefined;
149
- const third = parts.length > 2 ? parts[2] ?? "" : undefined;
150
- if (third === undefined && second !== undefined && second !== "" && DOTTED_MAP_KEYS.includes(first))
151
- return { kind: "map", map: first, role: second };
152
- if (parts.length === 3 && first === "roles" && second !== undefined && second !== "" && third !== undefined && third !== "")
153
- return { kind: "role", id: second, field: third };
154
- // `agents.<id>.<field>` names one field of one agent backend's entry (the way
155
- // `roles.<id>.<field>` names one role field). The id is shape-checked only — routing owns
156
- // which agent ids exist (part 4b); a typo'd field is refused here with the valid ones named.
157
- if (parts.length === 3 && first === "agents" && second !== undefined && second !== "" && third !== undefined) {
158
- if (AGENT_KEYS.includes(third))
159
- return { kind: "agent", id: second, field: third };
160
- return {
161
- kind: "error",
162
- error: `unknown agent field "${third}" for agents.${second} (valid fields: ${AGENT_KEYS.join(", ")})${typoSuffix(third, AGENT_KEYS)}`,
163
- };
164
- }
165
- // `model.<tier>` names one entry of the tier model map — the way `roles.<id>.model`
166
- // names one role field, `maxDailyCostUsdPerRole.<role>` names one map entry. The tier
167
- // must be a real tier: `config set model.turbo x` would otherwise write a dead map key
168
- // the runtime (and doctor's checkKnownKeys) would silently never resolve.
169
- if (parts.length === 2 && first === "model") {
170
- if (TIER_MAP_KEYS.includes(second ?? ""))
171
- return { kind: "tier", map: "model", tier: second };
172
- return {
173
- kind: "error",
174
- error: `unknown model tier "${second ?? ""}" — a dotted \`model.<tier>\` names one of ${TIER_MAP_KEYS.join(", ")} (e.g. \`config set model.strong huggingface/zai-org/GLM-5.3-Flash:together:low\`)${typoSuffix(second ?? "", TIER_MAP_KEYS)}`,
175
- };
176
- }
177
- // A typo'd first segment is the common miss (`maxDailyCostUsdPerRoll.feature`,
178
- // `modle.small`); when the head is one of the valid ones the shape itself is wrong, so
179
- // suggesting that head back would mislead — the generic wording is the whole story there.
180
- const headHint = DOTTED_KEY_HEADS.includes(first)
181
- ? ""
182
- : typoSuffix(first, DOTTED_KEY_HEADS);
183
- return {
184
- kind: "error",
185
- error: `unknown config key "${key}" — dotted keys name one entry of a per-role map or one field of a role entry (e.g. \`maxDailyCostUsdPerRole.feature 1.5\`, \`roles.qa.model x\`); a bare key names a whole top-level value${headHint}`,
186
- };
187
- }
188
- /** The error `tumwater config get <key>` and `config set <key> <value>` both surface for a
189
- * key outside TOP_LEVEL_KEYS — the valid-keys list plus text.ts's did-you-mean suggestion —
190
- * or null when the key is known. One home for the phrasing so whichever verb misspells a
191
- * key, the typo reads the same. */
192
- export function unknownConfigKeyError(key) {
193
- if (TOP_LEVEL_KEYS.includes(key))
194
- return null;
195
- return `unknown config key "${key}" (valid top-level keys: ${TOP_LEVEL_KEYS.join(", ")})${typoSuffix(key, TOP_LEVEL_KEYS)}`;
196
- }
197
- /** The error for a dotted role key naming a field ROLE_ENTRY_KEYS does not hold — the one
198
- * home of the wording `config set` and `config get` both surface, so a typo'd `roles.qa.colour`
199
- * reads the same whichever verb names it (both refuse it; a silent `null` from get would look
200
- * like a real unset value). Null when the field is a known role-entry key. */
201
- export function unknownRoleFieldError(id, field) {
202
- if (ROLE_ENTRY_KEYS.includes(field))
203
- return null;
204
- return `unknown role field "${field}" for roles.${id} (valid fields: ${ROLE_ENTRY_KEYS.join(", ")})${typoSuffix(field, ROLE_ENTRY_KEYS)}`;
205
- }
206
105
  /** Set ONE top-level key of tumwater.json from its raw CLI text (`tumwater config set`'s
207
106
  * engine): the key must be a member of TOP_LEVEL_KEYS — the same protection checkKnownKeys
208
107
  * gives the file itself, so `config set modle x` cannot write a dead key the runtime would
@@ -219,9 +118,10 @@ export function unknownRoleFieldError(id, field) {
219
118
  * actionable message. Returns the parsed value and the previous one so the caller can
220
119
  * confirm the change; on any failure the file is untouched (see writeConfigMutation). */
221
120
  export function setConfigKey(root, key, rawValue) {
121
+ key = canonicalConfigKey(key);
222
122
  const parsed = parseConfigKey(key);
223
123
  if (parsed.kind === "error")
224
- return { ok: false, error: parsed.error };
124
+ return fail(parsed.error);
225
125
  let value;
226
126
  try {
227
127
  value = JSON.parse(rawValue);
@@ -232,21 +132,22 @@ export function setConfigKey(root, key, rawValue) {
232
132
  if (parsed.kind !== "top") {
233
133
  const fieldError = parsed.kind === "role" ? unknownRoleFieldError(parsed.id, parsed.field) : null;
234
134
  if (fieldError)
235
- return { ok: false, error: fieldError };
135
+ return fail(fieldError);
236
136
  if (parsed.kind === "map") {
237
137
  // Name the key the operator actually typed (`maxDailyCostUsdPerRole.qa`), not the
238
- // fleet-wide field the shared validator's message would otherwise default to.
138
+ // team-wide field the shared validator's message would otherwise default to.
239
139
  const label = `${parsed.map}.${parsed.role}`;
240
- const validator = parsed.map === "maxDailyCostUsdPerRole" ? checkDailyBudgetUsd : checkQuietHours;
140
+ const validator = parsed.map === "maxDailyCostUsdPerRole"
141
+ ? checkDailyBudgetUsd
142
+ : parsed.map === "maxConcurrentPerProvider"
143
+ ? checkProviderConcurrencyCap
144
+ : checkQuietHours;
241
145
  const problem = validator(value, label);
242
146
  if (problem)
243
- return { ok: false, error: problem };
147
+ return fail(problem);
244
148
  }
245
149
  if (parsed.kind === "tier" && (typeof value !== "string" || value === "")) {
246
- return {
247
- ok: false,
248
- error: `model.${parsed.tier} must be a model selector string (e.g. \`config set model.strong huggingface/zai-org/GLM-5.3-Flash:together:low\`) — got ${show(value)}`,
249
- };
150
+ return fail(`model.${parsed.tier} must be a model id (e.g. \`config set model.strong huggingface/zai-org/GLM-5.3-Flash:together:low\`) — got ${show(value)}`);
250
151
  }
251
152
  let oldValue;
252
153
  const result = writeConfigMutation(root, (cfg) => {
@@ -272,12 +173,12 @@ export function setConfigKey(root, key, rawValue) {
272
173
  // Merge one field of one backend's entry, preserving the other field and the other
273
174
  // backends. validateConfig in writeConfigMutation owns the value type, so a bad
274
175
  // value fails there and nothing is written.
275
- const agents = { ...record.agents };
176
+ const agents = { ...record.backends };
276
177
  const entry = { ...(agents[parsed.id] ?? {}) };
277
178
  oldValue = entry[parsed.field];
278
179
  entry[parsed.field] = value;
279
180
  agents[parsed.id] = entry;
280
- const next = { ...cfg, agents };
181
+ const next = { ...cfg, backends: agents };
281
182
  // Writers keep one spelling: the new pi form drops its legacy alias so the file can
282
183
  // never hold both (validateConfig's raw pass rejects a file that does).
283
184
  if (parsed.id === "pi") {
@@ -313,7 +214,7 @@ export function setConfigKey(root, key, rawValue) {
313
214
  }
314
215
  const unknown = unknownConfigKeyError(key);
315
216
  if (unknown)
316
- return { ok: false, error: unknown };
217
+ return fail(unknown);
317
218
  // Legacy `provider` / `fallbackModel` are accepted to FIX an existing value (a config
318
219
  // that already has one keeps the old meaning), but `config set` never adds them to a
319
220
  // config that lacks them — the new-form writers emit `model` and `fallback` only.
@@ -328,16 +229,13 @@ export function setConfigKey(root, key, rawValue) {
328
229
  }
329
230
  })();
330
231
  if (absent)
331
- return {
332
- ok: false,
333
- error: `config set ${key}: this config has no ${key}, and a writer never adds legacy ${key} — set \`${key === "provider" ? "model" : "fallback"}\` instead`,
334
- };
232
+ return fail(`config set ${key}: this config has no ${key}, and a writer never adds legacy ${key} — set \`${key === "provider" ? "model" : "fallback"}\` instead`);
335
233
  }
336
234
  const perKeyValidator = PER_KEY_VALIDATORS[key];
337
235
  if (perKeyValidator) {
338
236
  const problem = perKeyValidator(value);
339
237
  if (problem)
340
- return { ok: false, error: problem };
238
+ return fail(problem);
341
239
  }
342
240
  let oldValue;
343
241
  const result = writeConfigMutation(root, (cfg) => {
@@ -349,29 +247,26 @@ export function setConfigKey(root, key, rawValue) {
349
247
  // When the key IS `roles` the value is the replacement, so it is left alone.
350
248
  if (key !== "roles")
351
249
  preserveFileRoles(root, next);
352
- // Writers keep one spelling: a whole-section `agents` set (the GUI Save path) drops the
250
+ // Writers keep one spelling: a whole-section `backends` set (the GUI Save path) drops the
353
251
  // legacy aliases the new pi form now carries, so the file can never hold both.
354
- if (key === "agents" && isJsonObject(value)) {
355
- const pi = isJsonObject(value.pi) ? value.pi : undefined;
356
- const record = next;
357
- if (pi?.bin !== undefined)
358
- delete record.agentBin;
359
- if (pi?.args !== undefined)
360
- delete record.piArgs;
361
- return next;
362
- }
363
- // Writers keep one spelling: a legacy top-level set drops the matching agents.pi field, so
252
+ // Writers keep one spelling: a whole-section `backends` set (the GUI Save path) drops the
253
+ // legacy agentBin/piArgs aliases the new pi form now carries, so the file can never hold
254
+ // both — the same collapseAgentAliases rule the whole-config writers (above and in
255
+ // config.ts) use, so the alias policy cannot drift between the per-key arm and the others.
256
+ if (key === "backends")
257
+ return collapseAgentAliases(next);
258
+ // Writers keep one spelling: a legacy top-level set drops the matching backends.pi field, so
364
259
  // the file can never hold both (validateConfig's raw pass rejects a file that does).
365
260
  if (key === "agentBin" || key === "piArgs") {
366
261
  const record = next;
367
- const agents = { ...(record.agents ?? {}) };
262
+ const agents = { ...(record.backends ?? {}) };
368
263
  const entry = { ...(agents.pi ?? {}) };
369
264
  delete entry[key === "agentBin" ? "bin" : "args"];
370
265
  if (Object.keys(entry).length === 0)
371
266
  delete agents.pi;
372
267
  else
373
268
  agents.pi = entry;
374
- return { ...next, agents };
269
+ return { ...next, backends: agents };
375
270
  }
376
271
  return next;
377
272
  });
@@ -379,6 +274,66 @@ export function setConfigKey(root, key, rawValue) {
379
274
  return result;
380
275
  return { ok: true, value, oldValue };
381
276
  }
277
+ /** Set a role's provider/model/thinking override triple in tumwater.json (`roles.<base>`),
278
+ * the write behind the dashboard role drawer's Model overrides editor: one writeConfigMutation
279
+ * rebuilds `roles.<base>` from the FILE's own `roles` section (the `rawConfigObject(root).roles` /
280
+ * `objectOrEmpty` idiom setConfigKey's role branch uses, so the write never re-expands the
281
+ * operator's partial section), and validateConfig's `roles.<id>` check (checkModelTriple,
282
+ * modelShape "tier-name") owns the whole candidate, so a bad `thinking` level writes nothing.
283
+ * An unknown base id writes nothing too — refused up front, because validateConfig is skipped
284
+ * on the no-op path (a clear of fields a role the file never held changes nothing) and the
285
+ * writer must not answer a phantom role "updated". `undefined` leaves that field as the file
286
+ * has it; `""` deletes it (never stores an empty string, which checkStringField rejects), and
287
+ * clearing the entry's last override removes `roles.<base>` entirely rather than leaving an
288
+ * empty object (the file-wide no-empty-object invariant unsetConfigKey's `without` keeps).
289
+ * An all-no-op call (every named field already absent-or-equal) returns the loaded config
290
+ * unchanged and writeConfigMutation skips the write, so clearing keys the file never held
291
+ * leaves its bytes identical. */
292
+ export function setRoleModelFields(root, base, fields) {
293
+ // An unknown base id writes nothing. validateConfig owns the refusal when the write changes
294
+ // the candidate, but it is skipped on the no-op path (clearing fields a role the file never
295
+ // held changes nothing), so refuse the phantom role up front — the same knownRoleIds guard
296
+ // the CLI's unsetConfigKey clear paths and the GUI's rejectBadRole use — rather than answer
297
+ // a bogus id "updated".
298
+ if (!knownRoleIdsCached(root).includes(base))
299
+ return fail(unknownRoleMessage(base, knownRoleIdsCached(root)));
300
+ return writeConfigMutation(root, (cfg) => {
301
+ const rawRolesCandidate = rawConfigObject(root).roles;
302
+ const rawRoles = objectOrEmpty(rawRolesCandidate);
303
+ const rawEntryCandidate = rawRoles[base];
304
+ const rawEntry = objectOrEmpty(rawEntryCandidate);
305
+ const nextEntry = { ...rawEntry };
306
+ let changed = false;
307
+ for (const [key, value] of Object.entries(fields)) {
308
+ if (value === undefined)
309
+ continue; // leave that field as the file has it
310
+ if (value === "") {
311
+ if (Object.prototype.hasOwnProperty.call(nextEntry, key)) {
312
+ delete nextEntry[key];
313
+ changed = true;
314
+ }
315
+ }
316
+ else if (nextEntry[key] !== value) {
317
+ nextEntry[key] = value;
318
+ changed = true;
319
+ }
320
+ }
321
+ if (!changed)
322
+ return cfg; // writeConfigMutation skips the write — file bytes identical
323
+ const nextRoles = { ...rawRoles };
324
+ // Clearing the entry's last override empties it — never leave an empty `roles.<base>: {}`
325
+ // (the file-wide no-empty-object invariant unsetConfigKey's `without` keeps); an emptied
326
+ // entry falls back to the catalog's defaults at load. When the section empties entirely,
327
+ // drop the `roles` key too, exactly as unsetConfigKey's role branch does.
328
+ if (Object.keys(nextEntry).length === 0)
329
+ delete nextRoles[base];
330
+ else
331
+ nextRoles[base] = nextEntry;
332
+ if (Object.keys(nextRoles).length === 0)
333
+ return without(cfg, "roles");
334
+ return { ...cfg, roles: nextRoles };
335
+ });
336
+ }
382
337
  /** A shallow copy of `obj` without `key` — the copy each `unsetConfigKey` branch edits
383
338
  * before it decides whether the removal emptied its container, and the shape the branch then
384
339
  * returns when it did (an emptied map, the last field of a `roles.<id>` entry, an emptied
@@ -390,7 +345,7 @@ function without(obj, key) {
390
345
  }
391
346
  /** The raw parsed tumwater.json as a plain object — the pre-defaults view `unsetConfigKey`'s
392
347
  * presence check needs, because the merged config always carries defaults a top-level key or a
393
- * `roles.<id>.<field>` (e.g. the seeded `minTickIntervalSeconds`) may never have been set to.
348
+ * `roles.<id>.<field>` (e.g. the seeded `minRunIntervalSeconds`) may never have been set to.
394
349
  * Read inside writeConfigMutation's lock; a missing or unparsable file is `{}` (loadConfig has
395
350
  * already rejected a broken file before a mutation runs, and an absent file is the bare
396
351
  * defaults — there is nothing to retract). */
@@ -418,7 +373,7 @@ function preservingFileRoles(root, candidate) {
418
373
  return candidate;
419
374
  }
420
375
  /** The config with top-level `key` set to `section`, or removed when `section` is empty — the
421
- * unset shape for the per-role maps, `model` tiers and `agents`, which never leave an empty
376
+ * unset shape for the per-role maps, `model` tiers and `backends`, which never leave an empty
422
377
  * object in the file. A whole-config write, so the file's own roles section is kept. */
423
378
  function withSectionOrDropped(root, cfg, key, section) {
424
379
  const next = Object.keys(section).length === 0 ? without(cfg, key) : { ...cfg, [key]: section };
@@ -431,9 +386,9 @@ function withSectionOrDropped(root, cfg, key, section) {
431
386
  * preserving its siblings, so an operator retracts one role's override without re-typing the
432
387
  * rest. The containing map/`model` key is dropped when it empties, so the file never keeps an
433
388
  * empty object (a `roles.<id>` entry cannot empty through the CLI: loadConfig always seeds
434
- * `enabled`/`minTickIntervalSeconds`). Presence is judged against the raw FILE, not the merged
389
+ * `enabled`/`minRunIntervalSeconds`). Presence is judged against the raw FILE, not the merged
435
390
  * config: a key the file never set but the defaults fill in (a top-level `quietHours`/
436
- * `maxConcurrent`, a seeded `roles.<id>.minTickIntervalSeconds`) is nothing to remove, so the
391
+ * `maxConcurrent`, a seeded `roles.<id>.minRunIntervalSeconds`) is nothing to remove, so the
437
392
  * mutation returns the loaded config unchanged and writeConfigMutation skips the write — the
438
393
  * file's bytes stay identical rather than being re-serialized with the defaults expanded.
439
394
  * Legacy `provider`/`fallbackModel` unset normally — only
@@ -442,18 +397,19 @@ function withSectionOrDropped(root, cfg, key, section) {
442
397
  * still own truth, so a rejected candidate leaves the file byte-identical. oldValue is the
443
398
  * file's own value removed, or `undefined` when the file never held it. */
444
399
  export function unsetConfigKey(root, key) {
400
+ key = canonicalConfigKey(key);
445
401
  const parsed = parseConfigKey(key);
446
402
  if (parsed.kind === "error")
447
- return { ok: false, error: parsed.error };
403
+ return fail(parsed.error);
448
404
  if (parsed.kind === "role") {
449
405
  const fieldError = unknownRoleFieldError(parsed.id, parsed.field);
450
406
  if (fieldError)
451
- return { ok: false, error: fieldError };
407
+ return fail(fieldError);
452
408
  }
453
409
  if (parsed.kind === "top") {
454
410
  const unknown = unknownConfigKeyError(key);
455
411
  if (unknown)
456
- return { ok: false, error: unknown };
412
+ return fail(unknown);
457
413
  }
458
414
  let oldValue;
459
415
  const result = writeConfigMutation(root, (cfg) => {
@@ -493,22 +449,22 @@ export function unsetConfigKey(root, key) {
493
449
  }
494
450
  if (parsed.kind === "agent") {
495
451
  // Presence is judged against the FILE, so unsetting a field only the default (or the
496
- // legacy alias's resolved view) carries is a no-op success. The entry and the `agents`
452
+ // legacy alias's resolved view) carries is a no-op success. The entry and the `backends`
497
453
  // section are dropped when they empty, so the file never keeps an empty object.
498
- const rawAgents = objectOrEmpty(raw.agents);
454
+ const rawAgents = objectOrEmpty(raw.backends);
499
455
  const rawEntryCandidate = rawAgents[parsed.id];
500
456
  const rawEntry = objectOrEmpty(rawEntryCandidate);
501
457
  if (!Object.hasOwn(rawEntry, parsed.field))
502
458
  return cfg; // absent file field: a no-op success
503
459
  oldValue = rawEntry[parsed.field];
504
- const agents = { ...record.agents };
460
+ const agents = { ...record.backends };
505
461
  const entry = { ...(agents[parsed.id] ?? {}) };
506
462
  delete entry[parsed.field];
507
463
  if (Object.keys(entry).length === 0)
508
464
  delete agents[parsed.id];
509
465
  else
510
466
  agents[parsed.id] = entry;
511
- return withSectionOrDropped(root, cfg, "agents", agents);
467
+ return withSectionOrDropped(root, cfg, "backends", agents);
512
468
  }
513
469
  const rawRolesCandidate = raw.roles;
514
470
  const rawRoles = objectOrEmpty(rawRolesCandidate);
@@ -535,13 +491,13 @@ export function unsetConfigKey(root, key) {
535
491
  }
536
492
  /** Consume the director's config-write request file (plans/portability.md §3/7). After its pi
537
493
  * run the director may leave `.tumwater-config-request.json` at its worktree root:
538
- * `{ "customLoops": [ { "name", "task" }, … ] }` — the whole array, replacing the current one.
494
+ * `{ "customRoles": [ { "name", "task" }, … ] }` — the whole array, replacing the current one.
539
495
  * Applied atomically to the live config with setDailyBudgetUsd's idiom (fresh loadConfig that
540
496
  * bypasses the stat cache, validateConfig, then writeJsonAtomic — readers poll every ~2 s), so
541
497
  * the orchestrator's live reload starts the new loop with no commit, no review gate, and no
542
498
  * merge, and the request file never enters a diff.
543
499
  *
544
- * The permitted key set is enforced here, not in prose: `customLoops` is accepted and every
500
+ * The permitted key set is enforced here, not in prose: `customRoles` is accepted and every
545
501
  * other top-level key is collected into `ignored` and dropped (the caller logs the warning that
546
502
  * names them). Validation runs BEFORE any write and nothing here dereferences a request entry
547
503
  * first — a structurally malformed entry (`[null]`, a non-string name) is left for
@@ -574,16 +530,21 @@ export function applyConfigRequest(root, wt) {
574
530
  let applied = [];
575
531
  let error;
576
532
  try {
577
- const parsed = JSON.parse(raw);
578
- if (!isJsonObject(parsed))
579
- throw new Error(`config request must be a JSON object (got ${show(parsed)})`);
533
+ const unnormalized = JSON.parse(raw);
534
+ if (!isJsonObject(unnormalized))
535
+ throw new Error(`config request must be a JSON object (got ${show(unnormalized)})`);
536
+ // Old key spellings read as the new ones; a request setting both fails with the pair named.
537
+ const normalized = normalizeConfigKeys(unnormalized);
538
+ if (normalized.problems.length > 0)
539
+ throw new Error(normalized.problems.join("; "));
540
+ const parsed = normalized.raw;
580
541
  for (const key of Object.keys(parsed)) {
581
- if (key !== "customLoops")
542
+ if (key !== "customRoles")
582
543
  ignored.push(key);
583
544
  }
584
- const loops = parsed.customLoops;
545
+ const loops = parsed.customRoles;
585
546
  if (!Array.isArray(loops))
586
- throw new Error(`config request's customLoops must be an array (got ${show(loops)})`);
547
+ throw new Error(`config request's customRoles must be an array (got ${show(loops)})`);
587
548
  // The load-to-write sequence is one critical section under withConfigLock: a concurrent
588
549
  // `config set`/dashboard write between the load and this write must not be dropped by the
589
550
  // whole-file overwrite (see writeConfigMutation).
@@ -603,13 +564,13 @@ export function applyConfigRequest(root, wt) {
603
564
  // only from what the file actually holds.
604
565
  const rawRolesCandidate = rawConfigObject(root).roles;
605
566
  const roles = isJsonObject(rawRolesCandidate)
606
- ? { ...rawRolesCandidate }
567
+ ? { ...(rawRolesCandidate) }
607
568
  : {};
608
- for (const c of current.customLoops) {
569
+ for (const c of current.customRoles) {
609
570
  if (!requestedNames.has(c.name))
610
571
  delete roles[c.name];
611
572
  }
612
- const candidate = { ...current, customLoops: loops };
573
+ const candidate = { ...current, customRoles: loops };
613
574
  const candidateRecord = candidate;
614
575
  if (rawRolesCandidate === undefined)
615
576
  delete candidateRecord.roles;
@@ -617,11 +578,11 @@ export function applyConfigRequest(root, wt) {
617
578
  candidateRecord.roles = roles;
618
579
  // Collapse legacy/new pi spellings before writing so a merged config (which always
619
580
  // carries the default piArgs) never lands both forms in the file — the next load's raw
620
- // pass rejects a file holding both `piArgs` and `agents.pi.args`.
581
+ // pass rejects a file holding both `piArgs` and `backends.pi.args`.
621
582
  const collapsed = collapseAgentAliases(candidate);
622
583
  validateConfig(collapsed); // throws listing every problem — nothing is written on failure
623
584
  writeJsonAtomic(configPath(root), collapsed, true);
624
- applied = collapsed.customLoops.map((c) => c.name);
585
+ applied = collapsed.customRoles.map((c) => c.name);
625
586
  });
626
587
  }
627
588
  catch (err) {