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
@@ -0,0 +1,379 @@
1
+ // vocabulary: plain
2
+ /** The budget/tier FALLBACK side of the config views, split out of config-views.ts: how the
3
+ * team falls back at the daily spend cap — which free pair each tier runs on
4
+ * (resolveTierFallbacks and its BORROW_ORDER), the per-role fallback config a model-failure
5
+ * or cap episode hands a loop (fallbackRoleConfig, applyFallbackModel), and the surfaces
6
+ * that name those pairs (seamsInUse, modelFallbackView, tierFallbackLabels). The per-seam
7
+ * resolution these build on — how one role/reviewer/resolver provider/model/thinking resolves
8
+ * (configForRole, reviewConfig, withModelOverrides, …) — stays in config-views.ts, so the
9
+ * one-way dependency runs fallback → seam and never back. Pure, like its sibling: no I/O and
10
+ * no caching. */
11
+ import { MODEL_TIERS, } from "./config-schema.js";
12
+ import { formatModelSelector, isAgentId, parseModelSelector } from "./model-selector.js";
13
+ import { modelPairName } from "../budget/budget.js";
14
+ import { DIRECTOR_ROLE } from "../roles/roles.js";
15
+ import { baseRoleOf } from "../roles/loop-ids.js";
16
+ import { enabledRoleIds } from "./config.js";
17
+ import { modelFallbackActive } from "../loop/model-fallback.js";
18
+ import { FALLBACK_REVIEW_TIMEOUT_S, REVIEW_TIMEOUT_S, configForRole, isTierName, resolvedModelFields, resolverConfig, reviewConfig, reviewRunConfig, roleSeamTier, topTierSelector, withModelOverrides, } from "./config-views.js";
19
+ /** The resolved model fields of a pure `fallback` selector string: parsed as a pure selector
20
+ * (no legacy provider in scope), with the ambient top-level thinking when the selector
21
+ * carries none. fallbackPair's map and string branches and tierOwnChain share this
22
+ * assembly, so all three read one rule for how a `fallback` selector resolves. */
23
+ function fallbackSelectorFields(selector, config) {
24
+ const parsed = parseModelSelector(selector);
25
+ return resolvedModelFields(seamKeyedFallbackProvider(config, parsed.provider), parsed.model, parsed.thinking ?? config.thinking);
26
+ }
27
+ /** A fallback pair's provider keyed by the seam it routes to (PLANS.md "Mixed claude + pi
28
+ * fleets, part 2/4"): a pair that routes to a non-pi backend carries that backend's name as
29
+ * its provider — `claude/claude-sonnet-5-5` and a bare `claude-sonnet-5-5` on a claude
30
+ * team both read `claude` — so the budget gate's handback (gate-polls.ts's tickOnPair
31
+ * against the tick's seamHoldKey capture, loop-tick.ts's setTickPair) matches a tick on
32
+ * either selector form. The same routing rule as routeModel: a provider naming an agent is
33
+ * that agent, no provider runs on the config's `agent`, anything else is pi, which keeps
34
+ * its own provider (a bare pi selector keeps undefined, exactly as before). */
35
+ function seamKeyedFallbackProvider(config, provider) {
36
+ const agent = provider === undefined ? config.backend : isAgentId(provider) ? provider : "pi";
37
+ return agent !== "pi" ? agent : provider;
38
+ }
39
+ /** The team-level fallback pair: the `default` tier's resolved pair under
40
+ * resolveTierFallbacks' borrow order, or null when the tier resolves to pause. The
41
+ * selector-string `fallback` (plans/model-tiers.md), the legacy `fallbackModel` object, and
42
+ * the map form all feed one resolution — a map with no `default` entry (or a `"pause"` one)
43
+ * borrows another tier's pair exactly as the budget gate does, so this view and the gate
44
+ * cannot disagree about which model the team would engage. `usable` filters the pairs that
45
+ * may supply the answer: the default predicate admits every declared pair (the config-only
46
+ * view), while a pricing predicate (src/pi/pi-models.ts's fallbackModelPair) yields the pair
47
+ * the gate would actually engage when a declared `default` pair is priced and a free pair
48
+ * exists at another tier. One definition so the freeness check, the dashboards' badge, the
49
+ * doctor, and applyFallbackModel below cannot disagree. */
50
+ export function fallbackPair(config, usable = () => true) {
51
+ return resolveTierFallbacks(config, usable).default.pair;
52
+ }
53
+ /** The legacy `fallbackModel` object resolved to a pair: its own fields over the top-level
54
+ * values, the same precedence every other override section uses. Private — reached only
55
+ * through tierOwnChain (the default tier's own fallback) and so fallbackPair's resolution,
56
+ * which keeps the object and the string/map forms on one rule without recursing. */
57
+ function legacyFallbackPair(config) {
58
+ const fbo = config.fallbackModel;
59
+ if (!fbo)
60
+ return null;
61
+ // The top-level model is a selector too (parsed the same way withModelOverrides does), so a
62
+ // fallback field that omits model borrows the parsed id, never the raw selector string.
63
+ const topSel = topTierSelector(config, "default");
64
+ const provider = seamKeyedFallbackProvider(config, fbo.provider ?? topSel?.provider ?? config.provider);
65
+ const model = fbo.model ?? topSel?.model;
66
+ const thinking = fbo.thinking ?? topSel?.thinking ?? config.thinking;
67
+ return resolvedModelFields(provider, model, thinking);
68
+ }
69
+ /** The order a tier borrows other tiers' own fallbacks in, after trying its own: small
70
+ * climbs (small → default → strong), default keeps near itself (default → strong → small),
71
+ * and strong never drops to a small model (strong → default → pause) — a weak reviewer or
72
+ * planner does more harm than a paused one. */
73
+ const BORROW_ORDER = {
74
+ small: ["small", "default", "strong"],
75
+ default: ["default", "strong", "small"],
76
+ strong: ["strong", "default"],
77
+ };
78
+ /** Exported for imageRerouteTarget (src/agent/image-input.ts), which walks a rerouting role's
79
+ * tier through the same borrow order the budget fallback uses (skipping the tier itself), and
80
+ * for the doctor's `image input` check naming the tier a text-only loop reroutes to. */
81
+ export { BORROW_ORDER };
82
+ /** A tier's OWN fallback chain, first step first — what the `fallback` key declares at that
83
+ * tier's map entry (a selector or a list of them), or (default only) the string/list form of
84
+ * `fallback` or the legacy `fallbackModel` object (a one-element chain). Empty when the tier
85
+ * declares nothing or declares `"pause"` (an explicit opt-out, which also makes it
86
+ * unborrowable: a tier with no own fallback offers nothing to borrow). Each step is parsed
87
+ * like fallbackPair: a pure selector, with the ambient top-level thinking when the selector
88
+ * carries none. */
89
+ function tierOwnChain(config, tier) {
90
+ const fb = config.fallback;
91
+ let raw;
92
+ if (fb !== undefined) {
93
+ // The top-level string/list form names the default tier's own chain; `"pause"` is rejected
94
+ // by validation but defensively names no model (matching the map entries below).
95
+ if (typeof fb === "string" || Array.isArray(fb))
96
+ raw = tier === "default" ? fb : undefined;
97
+ else
98
+ raw = fb[tier];
99
+ }
100
+ else if (tier === "default") {
101
+ // The legacy `fallbackModel` object is the default tier's own fallback, with its
102
+ // provider/thinking fields and the top-level borrow precedence.
103
+ const legacy = legacyFallbackPair(config);
104
+ if (legacy === null)
105
+ return [];
106
+ const primaryClaude = withModelOverrides(config, tier, {}).agent === "claude";
107
+ return [legacy.provider === "claude" && primaryClaude ? { ...legacy, extraUsage: true } : legacy];
108
+ }
109
+ if (raw === undefined || raw === "pause")
110
+ return [];
111
+ // A Claude step after an earlier Claude model — the tier's primary counts — may spend extra
112
+ // usage; the first Claude model stays on the subscription. Position alone decides.
113
+ let claudeSeen = withModelOverrides(config, tier, {}).agent === "claude";
114
+ return (Array.isArray(raw) ? raw : [raw])
115
+ .filter((s) => s !== "pause")
116
+ .map((s) => {
117
+ const step = typeof s === "string" ? { model: s } : s;
118
+ const fields = fallbackSelectorFields(step.model, config);
119
+ const extraUsage = fields.provider === "claude" && claudeSeen;
120
+ if (fields.provider === "claude")
121
+ claudeSeen = true;
122
+ return {
123
+ ...fields,
124
+ ...(step.maxDailyCostUsd !== undefined ? { maxDailyCostUsd: step.maxDailyCostUsd } : {}),
125
+ ...(extraUsage ? { extraUsage } : {}),
126
+ };
127
+ });
128
+ }
129
+ /** Every step of every tier's own chain, as the pair and its 0-based index in that chain — the
130
+ * enumeration the budget gate keys its breakers by and seamsInUse checks binaries over. */
131
+ export function tierChainSteps(config) {
132
+ return MODEL_TIERS.flatMap((tier) => tierOwnChain(config, tier).map((pair, step) => ({ tier, step, pair })));
133
+ }
134
+ const stepField = (step) => (step > 0 ? { step } : {});
135
+ /** Which free pair each tier runs on at the daily cap (plans/model-tiers.md "Budget fallback
136
+ * by tier", part 5a/8). A tier runs the first `usable(pair)` step of its own declared chain;
137
+ * otherwise it borrows the first usable step of another tier's OWN chain — never one that
138
+ * tier itself borrowed — in BORROW_ORDER's order; otherwise it pauses (`pair: null`). The
139
+ * engine is pure: the caller supplies `usable` (part 5b/8 wires it to pair pricing and the
140
+ * fallback breaker), so the same rules are testable with a stub and the gates cannot disagree
141
+ * with each other. A config with no fallback at all resolves every tier to pause, and a single
142
+ * `fallback: F` reads as default's own pair, which small and strong then borrow — today's
143
+ * behavior. */
144
+ export function resolveTierFallbacks(config, usable) {
145
+ const firstUsable = (tier) => {
146
+ const chain = tierOwnChain(config, tier);
147
+ const step = chain.findIndex((p) => usable(p));
148
+ return step < 0 ? undefined : { pair: chain[step], step };
149
+ };
150
+ const resolve = (tier) => {
151
+ const mine = firstUsable(tier);
152
+ if (mine)
153
+ return { pair: mine.pair, from: null, ...stepField(mine.step) };
154
+ for (const t of BORROW_ORDER[tier]) {
155
+ if (t === tier)
156
+ continue;
157
+ const theirs = firstUsable(t);
158
+ if (theirs)
159
+ return { pair: theirs.pair, from: t, ...stepField(theirs.step) };
160
+ }
161
+ return { pair: null, from: null };
162
+ };
163
+ return { small: resolve("small"), default: resolve("default"), strong: resolve("strong") };
164
+ }
165
+ /** The config a role's ticks run on while a model-fallback episode is active (PLANS.md "Model
166
+ * failure fallback, part 1/2"): the role's tier pair from resolveTierFallbacks (the tier's own
167
+ * pair, else BORROW_ORDER's borrow), shaped like configForRole — provider/model/thinking over
168
+ * the role's own and top-level values, plus the role's minRunIntervalSeconds. Null when the
169
+ * tier resolves to pause (no fallback configured), so the feature stays off. Pure. */
170
+ export function fallbackRoleConfig(config, role) {
171
+ const tier = roleSeamTier(config, role);
172
+ const pair = resolveTierFallbacks(config, () => true)[tier].pair;
173
+ if (pair === null)
174
+ return null;
175
+ const rc = config.roles[baseRoleOf(role)];
176
+ return {
177
+ ...withModelOverrides(config, tier, {
178
+ provider: pair.provider,
179
+ model: pair.model,
180
+ thinking: pair.thinking,
181
+ }),
182
+ minRunIntervalSeconds: rc?.minRunIntervalSeconds ?? config.minRunIntervalSeconds,
183
+ };
184
+ }
185
+ /** Every seam the team can spawn, each with its label: every enabled role's resolved config
186
+ * (configForRole), the review gate when it is on (reviewRunConfig), the conflict resolver
187
+ * (resolverConfig), the director (a catalog role, named explicitly so it is never missed), and
188
+ * each tier's budget fallback (resolveTierFallbacks) — the model-failure fallback
189
+ * (fallbackRoleConfig) runs the same tier pair, so one enumeration covers it. Deduplicated by
190
+ * label + routed pair so a seam reached through several surfaces is listed once. Read by the
191
+ * startup gate's agent list, doctor's pair sweep (and so its binary/credential/version checks
192
+ * and routing table), and the live-edit reload's newly-routed-backend check — no surface can
193
+ * miss a seam that exists only as a fallback. */
194
+ export function seamsInUse(config) {
195
+ const seams = new Map();
196
+ const add = (label, cfg) => {
197
+ const key = `${label}\0${cfg.agent ?? ""}\0${cfg.provider ?? ""}\0${cfg.model ?? ""}\0${cfg.thinking ?? ""}`;
198
+ seams.set(key, {
199
+ label,
200
+ agent: cfg.agent,
201
+ provider: cfg.provider,
202
+ model: cfg.model,
203
+ thinking: cfg.thinking,
204
+ });
205
+ };
206
+ for (const role of enabledRoleIds(config))
207
+ add(role, configForRole(config, role));
208
+ if (config.review.enabled)
209
+ add("review", reviewRunConfig(config));
210
+ add("resolver", resolverConfig(config));
211
+ add("director", configForRole(config, DIRECTOR_ROLE));
212
+ for (const { tier, step, pair } of tierChainSteps(config)) {
213
+ add(step === 0 ? `fallback:${tier}` : `fallback:${tier}#${step}`, withModelOverrides(config, tier, { provider: pair.provider, model: pair.model, thinking: pair.thinking }));
214
+ }
215
+ return [...seams.values()];
216
+ }
217
+ /** Build a ModelFallbackView for an episode, or null while the episode is not running the
218
+ * fallback (no episode, or a due probe on the primary). role-view.ts's
219
+ * RoleViewPayload.modelFallback and status/status-data.ts's per-loop row both build this
220
+ * shape, so one function keeps the two surfaces from disagreeing about the pair, its start,
221
+ * and its reason. `now` is the caller's clock (the status snapshot passes its own poll
222
+ * clock), so a row and its poll agree on what "active" means. */
223
+ export function modelFallbackView(episode, config, role, now = Date.now()) {
224
+ if (episode === undefined || !modelFallbackActive(episode, now))
225
+ return null;
226
+ const episodeConfig = fallbackRoleConfig(config, role);
227
+ if (episodeConfig === null)
228
+ return null;
229
+ return {
230
+ ...resolvedModelFields(episodeConfig.provider, episodeConfig.model, episodeConfig.thinking),
231
+ ...(episodeConfig.agent !== undefined && episodeConfig.agent !== "pi" ? { backend: episodeConfig.agent } : {}),
232
+ since: episode.since,
233
+ reason: episode.reason,
234
+ };
235
+ }
236
+ /** The per-tier fallback labels the observability surfaces render (part 7b/8): tier →
237
+ * `provider/model[ (from <tier>)]` for every tier the resolution gives a pair, in tier order.
238
+ * The `(from …)` suffix marks a borrowed pair — a strong-tier borrow is the fact an operator
239
+ * reading the feed or the badge can act on. Tiers resolved to pause are left out: the badge
240
+ * and the event name the pairs that RUN, the pause story lives on the pause events and the
241
+ * per-role rows. */
242
+ export function tierFallbackLabels(resolved) {
243
+ const out = {};
244
+ for (const tier of MODEL_TIERS) {
245
+ const r = resolved[tier];
246
+ if (r.pair !== null)
247
+ out[tier] = modelPairName(r.pair) + (r.from !== null ? ` (from ${r.from})` : "");
248
+ }
249
+ return out;
250
+ }
251
+ /** True when the resolution hands out two or more DISTINCT pairs — the condition under which
252
+ * the tiered surfaces (the budget_fallback event, the header badge) switch from today's
253
+ * single-pair text to the tier list: a single fallback whose pair the other tiers borrow
254
+ * resolves every tier to the same pair, and that stays on the single-pair text, byte
255
+ * identical to the pre-tier surfaces. */
256
+ export function tiersResolveDistinctPairs(resolved) {
257
+ const pairs = MODEL_TIERS.map((t) => resolved[t].pair)
258
+ .filter((p) => p !== null)
259
+ .map((p) => modelPairName(p));
260
+ return new Set(pairs).size >= 2;
261
+ }
262
+ /** Drop a model-override seam's provider/model/thinking fields — the one rule the fallback
263
+ * view's role and reviewer strips share, so a field on that triple cannot be dropped from one
264
+ * and left on the other (where it would reach a priced model after the cap). */
265
+ function stripModel(o) {
266
+ const { provider: _p, model: _m, thinking: _t, ...rest } = o;
267
+ return rest;
268
+ }
269
+ /** The reviewer config as the fallback view sees it: model seam stripped, and its time budget
270
+ * raised to at least FALLBACK_REVIEW_TIMEOUT_S (a budget sized for the budgeted model's turns
271
+ * times out every review on a slower free one; a configured value above the floor stands). The
272
+ * one home of that rule, shared by both fallback branches. */
273
+ function fallbackReview(review) {
274
+ return {
275
+ ...stripModel(review),
276
+ timeoutSeconds: Math.max(review.timeoutSeconds ?? REVIEW_TIMEOUT_S, FALLBACK_REVIEW_TIMEOUT_S),
277
+ };
278
+ }
279
+ /** Every `roles.<id>` entry with its model seam stripped. With `keepTierNames`, an entry whose
280
+ * model is a tier name is left untouched: the resolved tier map now serves that tier itself, so
281
+ * the reference stays valid (stripping it would lose the role's tier). */
282
+ function stripRoleModels(roles, keepTierNames) {
283
+ return Object.fromEntries(Object.entries(roles).map(([id, rc]) => [
284
+ id,
285
+ keepTierNames && isTierName(rc?.model) ? rc : stripModel(rc),
286
+ ]));
287
+ }
288
+ /** Every `roles.<id>` entry with its model seam stripped, except a seam `free` calls free — a
289
+ * subscription claude role keeps its own model through the cap rather than moving to the
290
+ * fallback (plans/agent-backends.md "Free-ness"). With `keepTierNames`, an entry whose model is
291
+ * a tier name is left untouched: the resolved tier map now serves that tier itself, so the
292
+ * reference stays valid (stripping it would lose the role's tier). */
293
+ function freeRoleModels(config, free) {
294
+ return Object.fromEntries(Object.entries(config.roles).map(([id, rc]) => {
295
+ if (rc && free?.(withModelOverrides(config, roleSeamTier(config, id), rc)))
296
+ return [id, rc];
297
+ return [id, isTierName(rc?.model) ? rc : stripModel(rc)];
298
+ }));
299
+ }
300
+ /** The config as seen by a role loop running on the free fallback model
301
+ * (plans/fallback-model.md): the fallback's provider/model/thinking installed as the top-level
302
+ * values AND every per-role and reviewer model override dropped, so that EVERY seam that could
303
+ * otherwise reach a priced model — an author run, its reviewer, a conflict resolver — resolves
304
+ * to the one free pair. Dropping the overrides is the point: a role pinned to a paid model in
305
+ * tumwater.json must not keep spending after the cap is reached. The one other change is the
306
+ * reviewer's time budget, raised to at least FALLBACK_REVIEW_TIMEOUT_S: a budget sized for the
307
+ * budgeted model's turns times out every review on a slower free one (a configured
308
+ * `review.timeoutSeconds` above the floor stands, and reviewRunConfig still caps it at
309
+ * runTimeoutSeconds). Everything else (intervals, thresholds, exempt paths, the cap itself) is
310
+ * unchanged when no fallback is configured. With a per-tier `resolved` map (resolveTierFallbacks,
311
+ * part 5a/8) the model map is rewritten to the resolved pairs instead — each seam keeps its
312
+ * tier: `model[tier]` becomes the tier's pair as a selector string, a tier resolved to pause
313
+ * is left out (it inherits default's entry, harmless because the gate holds its roles), raw
314
+ * per-role selector overrides are dropped so a paid pin cannot keep spending, and tier-name
315
+ * role overrides (`roles.<id>.model: "strong"`) are kept since the map now serves the tier
316
+ * itself. The director's exemption lives in the gates (roleForConfig), not here. A
317
+ * subscription claude seam is never moved (`free`): the gate passes one predicate over the
318
+ * resolved seam, so a free tier keeps its own primary in the map — with the ambient thinking
319
+ * level it resolved with — a free role keeps its own override, and a free reviewer keeps its own
320
+ * model, while every priced seam still lands on its tier's fallback pair. */
321
+ export function applyFallbackModel(config, resolved, free) {
322
+ if (resolved) {
323
+ const selectorOf = (f) => f.pair
324
+ ? formatModelSelector({
325
+ provider: f.pair.provider,
326
+ model: f.pair.model ?? "",
327
+ thinking: f.pair.thinking,
328
+ })
329
+ : undefined;
330
+ const model = {};
331
+ const stepCaps = {};
332
+ for (const tier of MODEL_TIERS) {
333
+ // A free seam keeps the tier's own primary selector — the backend namespace a resolved
334
+ // seam has cleared, plus the ambient thinking level `topTierSelector` alone omits. A moved
335
+ // seam gets its pair selector, which already carries that ambient level.
336
+ const seam = withModelOverrides(config, tier, {});
337
+ if (free?.(seam)) {
338
+ const own = topTierSelector(config, tier);
339
+ if (own !== undefined)
340
+ model[tier] = formatModelSelector({ ...own, thinking: own.thinking ?? config.thinking });
341
+ // A tier with no top-level selector — `agent: "claude"` riding its agent default —
342
+ // has no own selector to carry the level, so name the routed default plus it.
343
+ else if (seam.model !== undefined)
344
+ model[tier] = formatModelSelector({
345
+ provider: seam.provider,
346
+ model: seam.model,
347
+ thinking: seam.thinking,
348
+ });
349
+ continue;
350
+ }
351
+ const sel = selectorOf(resolved[tier]);
352
+ if (sel !== undefined)
353
+ model[tier] = sel;
354
+ const cap = resolved[tier].pair?.maxDailyCostUsd;
355
+ if (cap !== undefined)
356
+ stepCaps[modelPairName(resolved[tier].pair)] = cap;
357
+ }
358
+ return {
359
+ ...config,
360
+ ...(Object.keys(stepCaps).length > 0 ? { fallbackStepCaps: stepCaps } : {}),
361
+ provider: undefined,
362
+ model,
363
+ thinking: undefined,
364
+ review: free?.(reviewConfig(config)) ? config.review : fallbackReview(config.review),
365
+ roles: freeRoleModels(config, free),
366
+ };
367
+ }
368
+ const pair = fallbackPair(config);
369
+ if (!pair)
370
+ return config;
371
+ return {
372
+ ...config,
373
+ provider: pair.provider,
374
+ model: pair.model,
375
+ thinking: pair.thinking,
376
+ review: fallbackReview(config.review),
377
+ roles: stripRoleModels(config.roles, false),
378
+ };
379
+ }
@@ -1,3 +1,4 @@
1
+ // vocabulary: plain
1
2
  import { MODEL_TRIPLE_KEYS, MODEL_TIERS, TIER_MAP_KEYS, THINKING_LEVELS, } from "./config-schema.js";
2
3
  import { allRoleIds } from "../roles/roles.js";
3
4
  import { isJsonObject } from "../files/json-object.js";
@@ -88,8 +89,16 @@ export const POSITIVE_INTEGER = {
88
89
  what: "an integer of at least 1",
89
90
  };
90
91
  export const AT_LEAST_ONE = { ok: (n) => n >= 1, what: "a number of at least 1" };
92
+ /** The upper bound every seconds field that feeds a ×1000 duration shares: 2147483 s
93
+ * × 1000 stays inside node's signed-32-bit setTimeout/setInterval range (2^31−1 ms,
94
+ * about 24.8 days). Above it node clamps the delay to 1ms — a one-zero typo like
95
+ * `"runTimeoutSeconds": 1e300` became Infinity ms, and every pi run died the moment it
96
+ * started (BUGS.md 2026-10-03). One home for the bound, shared by the config schema's
97
+ * duration rules (config-validation.ts) and checkMinRunInterval (config-keys.ts), so the
98
+ * dashboard's interval editor cannot drift from the schema's bound. */
99
+ export const MAX_DURATION_SECONDS = 2147483;
91
100
  /** Shared known-role guard for the per-role config sections (`roles.<id>` entries and
92
- * `maxDailyCostUsdPerRole.<id>` keys): an id outside the role catalog and customLoops cannot
101
+ * `maxDailyCostUsdPerRole.<id>` keys): an id outside the role catalog and customRoles cannot
93
102
  * work — a `roles` typo spawns a phantom loop that errors every tick forever; a caps typo
94
103
  * silently no-ops the cap — so both reject it here with the valid ids, the same message shape
95
104
  * `tumwater logs --role` uses for a bad flag value. Returns true when the id is known. */
@@ -102,7 +111,7 @@ export function checkKnownRoleId(section, id, customNames, problems) {
102
111
  }
103
112
  /** Validate one string field when present. `allowEmpty` defaults true; the model-triple fields
104
113
  * pass false — pi-args.ts skips an empty value when it builds its flags, so `"provider": ""`
105
- * would be silently ignored and the fleet would quietly fall back to pi's default — and an
114
+ * would be silently ignored and the team would quietly fall back to pi's default — and an
106
115
  * empty `fallbackModel` field makes fallbackPair drop it, so the fallback never engages. */
107
116
  export function checkStringField(problems, obj, prefix, key, allowEmpty = true) {
108
117
  whenPresent(obj, key, (v) => {
@@ -133,24 +142,24 @@ export function checkModelTripleField(problems, obj, prefix, modelShape = "selec
133
142
  checkStringField(problems, obj, prefix, key, false);
134
143
  const t = obj.thinking;
135
144
  if (isNonBlankString(t) && !THINKING_LEVELS.has(t))
136
- problems.push(`${prefix}thinking must be one of ${[...THINKING_LEVELS].join(", ")} (got ${show(t)})`);
145
+ problems.push(`${prefix}thinking must be one of ${[...THINKING_LEVELS].join(", ")} (got ${show(t)})${typoSuffix(t, [...THINKING_LEVELS])}`);
137
146
  const m = obj.model;
138
147
  if (m === undefined)
139
148
  return;
140
149
  if (modelShape === "tier-map" && isJsonObject(m)) {
141
150
  checkKnownKeys(m, TIER_MAP_KEYS, `${prefix}model`, problems);
142
151
  if (obj.provider !== undefined)
143
- problems.push(`${prefix}model as a map by tier cannot coexist with ${prefix ? prefix : "the legacy top-level "}provider — move the provider into each tier's selector (got provider ${show(obj.provider)})`);
152
+ problems.push(`${prefix}model as a map by tier cannot coexist with ${prefix ? prefix : "the legacy top-level "}provider — move the provider into each tier's model id (got provider ${show(obj.provider)})`);
144
153
  for (const [tier, v] of Object.entries(m))
145
154
  if (!isNonBlankString(v))
146
- problems.push(`${prefix}model.${tier} must be a non-empty selector string (got ${show(v)})`);
155
+ problems.push(`${prefix}model.${tier} must be a non-empty model id (got ${show(v)})`);
147
156
  return;
148
157
  }
149
158
  if (typeof m !== "string") {
150
159
  const what = modelShape === "tier-map"
151
- ? "a selector string or a map by tier (small, default, strong)"
160
+ ? "a model id or a map by tier (small, default, strong)"
152
161
  : modelShape === "tier-name"
153
- ? "a selector string or a tier name (small, default, strong)"
162
+ ? "a model id or a tier name (small, default, strong)"
154
163
  : "a string";
155
164
  problems.push(`${prefix}model must be ${what} (got ${show(m)})`);
156
165
  return;
@@ -162,7 +171,7 @@ export function checkModelTripleField(problems, obj, prefix, modelShape = "selec
162
171
  if (MODEL_TIERS.includes(m)) {
163
172
  if (modelShape === "tier-name")
164
173
  return; // A valid tier reference.
165
- problems.push(`${prefix}model must name a selector — tier names are valid only as roles.<id>.model or review.model values (got ${show(m)})`);
174
+ problems.push(`${prefix}model must name a model id — tier names are valid only as roles.<id>.model or review.model values (got ${show(m)})`);
166
175
  }
167
176
  }
168
177
  /** Validate one numeric field against a NumberRule when present. */
@@ -199,7 +208,7 @@ export function checkStringArrayField(problems, obj, prefix, key) {
199
208
  }
200
209
  /** Report every passthrough arg in `args` whose flag the harness sets itself — the one screen
201
210
  * behind both of validateConfig's reserved-flag checks (top-level `piArgs` and each
202
- * `agents.<id>.args`). `argsPath` names the array in the message (`piArgs`, `agents.pi.args`),
211
+ * `backends.<id>.args`). `argsPath` names the array in the message (`piArgs`, `backends.pi.args`),
203
212
  * and `whyFor` resolves the flag's reason against the reserved set it is screened against, or
204
213
  * undefined when the flag is not reserved. The flag is matched even when the arg carries its
205
214
  * value as `--flag=value` (split on the first `=`), so that joined spelling cannot slip past
@@ -211,6 +220,6 @@ export function checkReservedArgs(problems, args, argsPath, whyFor) {
211
220
  return;
212
221
  const why = whyFor(arg.split("=", 1)[0]);
213
222
  if (why)
214
- problems.push(`${argsPath}[${i}] ${show(arg)} duplicates a flag the harness sets — ${why}`);
223
+ problems.push(`${argsPath}[${i}] ${show(arg)} duplicates a flag tumwater sets — ${why}`);
215
224
  });
216
225
  }
@@ -0,0 +1,160 @@
1
+ // vocabulary: plain
2
+ /** The config KEY language — how `tumwater config get/set/unset` and the dashboard editors
3
+ * name a config value and what makes one legitimate, apart from the writes that change it.
4
+ * One family, in its own module because it serves both verbs AND the read side: `config get`,
5
+ * shell completion, doctor's holds and the dashboard controllers all address keys and screen
6
+ * per-key values without performing a write, so burying this in the config-write module made
7
+ * read-side callers import from the "config WRITES" layer. The mutators (the load →
8
+ * validate → atomic-write idiom, the cross-process lock) live in src/config/config-write.ts;
9
+ * the per-key validators and key-shape parsing here are the one home of the wording both the
10
+ * writers and the read side show. */
11
+ import { AGENT_KEYS, CLAUDE_AGENT_KEYS, ROLE_ENTRY_KEYS, TIER_MAP_KEYS, TOP_LEVEL_KEYS } from "./config-schema.js";
12
+ import { typoSuffix } from "../text/suggest.js";
13
+ import { MAX_DURATION_SECONDS, POSITIVE_INTEGER, show } from "./config-field-checks.js";
14
+ import { parseQuietHours } from "../scheduling/quiet-hours.js";
15
+ /** One definition of "a valid daily budget cap" (the TUI's Ctrl+B editor and the GUI's
16
+ * /api/budget endpoint both run their input through it): a finite number of 0 or more —
17
+ * 0 disables the gate, fractional dollars allowed (the badge renders cents). Returns an
18
+ * actionable error message for anything else so both surfaces can flash it without
19
+ * try/catch plumbing. A value past Number.MAX_SAFE_INTEGER is rejected too: it stays
20
+ * finite ("9"×25 → 1e24), so only this screen stands between a one-zero typo in the
21
+ * browser's budget editor and an effectively uncapped budget (BUGS.md 2026-10-02, the
22
+ * same rule the TUI's parseBudgetInput applies). `key` names the config key in the error,
23
+ * defaulting to the team-wide `maxDailyCostUsd`; the per-role map passes
24
+ * `maxDailyCostUsdPerRole.<id>` so a rejected entry names the key actually set. */
25
+ export function checkDailyBudgetUsd(value, key = "maxDailyCostUsd") {
26
+ if (typeof value !== "number" ||
27
+ !Number.isFinite(value) ||
28
+ value < 0 ||
29
+ value > Number.MAX_SAFE_INTEGER)
30
+ return `${key} must be a number of 0 or more, at most ${Number.MAX_SAFE_INTEGER}, 0 disables (got ${show(value)})`;
31
+ return null;
32
+ }
33
+ /** One definition of "a valid per-provider concurrency cap" (plans/provider-concurrency.md
34
+ * §1): a positive integer, accepted for any (possibly unknown) provider name — a fallback's
35
+ * provider may only come into use later, so the key is not checked against a catalog. The one
36
+ * validation both `config set maxConcurrentPerProvider.<provider>` and validateConfig show,
37
+ * so they cannot drift apart on what a valid cap is. `key` names the config key in the error,
38
+ * defaulting to the map name; the dotted map passes `maxConcurrentPerProvider.<provider>`
39
+ * so a rejected entry names the key actually set. */
40
+ export function checkProviderConcurrencyCap(value, key = "maxConcurrentPerProvider") {
41
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 1)
42
+ return `${key} must be ${POSITIVE_INTEGER.what} (got ${show(value)})`;
43
+ return null;
44
+ }
45
+ /** One definition of "a valid quietHours window" (src/scheduling/quiet-hours.ts owns the format):
46
+ * "HH:MM-HH:MM" in local time, wrapping permitted, empty means off. parseQuietHours's
47
+ * message is the one wording every surface (validateConfig, `config set`, the TUI/GUI
48
+ * editors) shows, so they cannot drift apart on what a valid window is. `key` names the
49
+ * config key in the error, defaulting to the team-wide `quietHours`; the per-role map passes
50
+ * `quietHoursPerRole.<id>` so a rejected entry names the key actually set. */
51
+ export function checkQuietHours(value, key = "quietHours") {
52
+ const parsed = parseQuietHours(value, key);
53
+ return parsed.ok ? null : parsed.error;
54
+ }
55
+ /** One definition of "a valid min run interval" — the seconds value the team-wide
56
+ * `minRunIntervalSeconds` and a per-role `roles.<id>.minRunIntervalSeconds` override hold:
57
+ * a finite number of 0 or more, at most MAX_DURATION_SECONDS (the ×1000 setTimeout bound the
58
+ * config schema's duration rules share). The dashboard's interval editor runs this before
59
+ * writing; `config set roles.<id>.minRunIntervalSeconds` leans on validateConfig's
60
+ * DURATION_NON_NEGATIVE for the CLI path instead, but the two cannot drift on the upper bound
61
+ * because both read the same MAX_DURATION_SECONDS constant. `key` names the config key in the
62
+ * error, defaulting to the team-wide `minRunIntervalSeconds`; the per-role path passes
63
+ * `roles.<id>.minRunIntervalSeconds` so a rejected entry names the key actually set. */
64
+ export function checkMinRunInterval(value, key = "minRunIntervalSeconds") {
65
+ if (typeof value !== "number" ||
66
+ !Number.isFinite(value) ||
67
+ value < 0 ||
68
+ value > MAX_DURATION_SECONDS)
69
+ return `${key} must be a number of 0 or more, at most ${MAX_DURATION_SECONDS} (got ${show(value)})`;
70
+ return null;
71
+ }
72
+ /** Per-key value validators `config set` (and the dashboard editors that call it) run
73
+ * BEFORE the write, so a malformed value fails with its own actionable message instead of
74
+ * waiting for validateConfig's whole-file report. */
75
+ export const PER_KEY_VALIDATORS = {
76
+ quietHours: checkQuietHours,
77
+ };
78
+ /** The per-role maps `config get/set` reaches with dotted keys: each names ONE entry
79
+ * (`maxDailyCostUsdPerRole.<role>`, `quietHoursPerRole.<role>`) that merges into the
80
+ * existing map, so raising one role's cap never requires re-typing another's. The
81
+ * `maxConcurrentPerProvider` map is dotted the same way (`maxConcurrentPerProvider.<provider>`);
82
+ * its keys are provider names, not role ids, so its per-key validation skips the role-catalog
83
+ * check the per-role maps apply. The per-key value validator runs before the write, the same
84
+ * screening a bare top-level key gets. */
85
+ export const DOTTED_MAP_KEYS = ["maxDailyCostUsdPerRole", "quietHoursPerRole", "maxConcurrentPerProvider"];
86
+ /** The valid first segments of a dotted config key — the per-role maps plus the `roles.` and
87
+ * `model.` sections. The generic dotted-shape error suggests against these so a typo'd head
88
+ * (`modle.small`, `maxDailyCostUsdPerRoll.feature`) gets the same did-you-mean the sibling
89
+ * unknown-key errors attach. */
90
+ const DOTTED_KEY_HEADS = [...DOTTED_MAP_KEYS, "roles", "model", "backends"];
91
+ /** Split a config key into its write/get shape: `foo` is top-level,
92
+ * `maxDailyCostUsdPerRole.feature` / `quietHoursPerRole.qa` name one map entry,
93
+ * `roles.qa.model` names one role-entry field, and anything else dotted is an error
94
+ * naming the supported dotted forms. */
95
+ export function parseConfigKey(key) {
96
+ if (!key.includes("."))
97
+ return { kind: "top", key };
98
+ const parts = key.split(".");
99
+ const first = parts[0] ?? "";
100
+ const second = parts.length > 1 ? parts[1] ?? "" : undefined;
101
+ const third = parts.length > 2 ? parts[2] ?? "" : undefined;
102
+ if (third === undefined && second !== undefined && second !== "" && DOTTED_MAP_KEYS.includes(first))
103
+ return { kind: "map", map: first, role: second };
104
+ if (parts.length === 3 && first === "roles" && second !== undefined && second !== "" && third !== undefined && third !== "")
105
+ return { kind: "role", id: second, field: third };
106
+ // `backends.<id>.<field>` names one field of one agent backend's entry (the way
107
+ // `roles.<id>.<field>` names one role field). The id is shape-checked only — routing owns
108
+ // which agent ids exist (part 4b); a typo'd field is refused here with the valid ones named.
109
+ if (parts.length === 3 && first === "backends" && second !== undefined && second !== "" && third !== undefined) {
110
+ // configDir is claude-only (CLAUDE_AGENT_KEYS): a config set backends.pi.configDir would
111
+ // write a key nothing reads, so it is refused everywhere but claude.
112
+ const validKeys = second === "claude" ? CLAUDE_AGENT_KEYS : AGENT_KEYS;
113
+ if (validKeys.includes(third))
114
+ return { kind: "agent", id: second, field: third };
115
+ return {
116
+ kind: "error",
117
+ error: `unknown backend field "${third}" for backends.${second} (valid fields: ${validKeys.join(", ")})${typoSuffix(third, validKeys)}`,
118
+ };
119
+ }
120
+ // `model.<tier>` names one entry of the tier model map — the way `roles.<id>.model`
121
+ // names one role field, `maxDailyCostUsdPerRole.<role>` names one map entry. The tier
122
+ // must be a real tier: `config set model.turbo x` would otherwise write a dead map key
123
+ // the runtime (and doctor's checkKnownKeys) would silently never resolve.
124
+ if (parts.length === 2 && first === "model") {
125
+ if ((TIER_MAP_KEYS).includes(second ?? ""))
126
+ return { kind: "tier", map: "model", tier: second };
127
+ return {
128
+ kind: "error",
129
+ 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)}`,
130
+ };
131
+ }
132
+ // A typo'd first segment is the common miss (`maxDailyCostUsdPerRoll.feature`,
133
+ // `modle.small`); when the head is one of the valid ones the shape itself is wrong, so
134
+ // suggesting that head back would mislead — the generic wording is the whole story there.
135
+ const headHint = DOTTED_KEY_HEADS.includes(first)
136
+ ? ""
137
+ : typoSuffix(first, DOTTED_KEY_HEADS);
138
+ return {
139
+ kind: "error",
140
+ 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}`,
141
+ };
142
+ }
143
+ /** The error `tumwater config get <key>` and `config set <key> <value>` both surface for a
144
+ * key outside TOP_LEVEL_KEYS — the valid-keys list plus text.ts's did-you-mean suggestion —
145
+ * or null when the key is known. One home for the phrasing so whichever verb misspells a
146
+ * key, the typo reads the same. */
147
+ export function unknownConfigKeyError(key) {
148
+ if (TOP_LEVEL_KEYS.includes(key))
149
+ return null;
150
+ return `unknown config key "${key}" (valid top-level keys: ${TOP_LEVEL_KEYS.join(", ")})${typoSuffix(key, TOP_LEVEL_KEYS)}`;
151
+ }
152
+ /** The error for a dotted role key naming a field ROLE_ENTRY_KEYS does not hold — the one
153
+ * home of the wording `config set` and `config get` both surface, so a typo'd `roles.qa.colour`
154
+ * reads the same whichever verb names it (both refuse it; a silent `null` from get would look
155
+ * like a real unset value). Null when the field is a known role-entry key. */
156
+ export function unknownRoleFieldError(id, field) {
157
+ if (ROLE_ENTRY_KEYS.includes(field))
158
+ return null;
159
+ return `unknown role field "${field}" for roles.${id} (valid fields: ${ROLE_ENTRY_KEYS.join(", ")})${typoSuffix(field, ROLE_ENTRY_KEYS)}`;
160
+ }