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,24 +1,26 @@
1
- /** The config AS SEEN BY A RUN — pure derivations over a TumwaterConfig, split out of
2
- * config.ts: which provider/model/thinking a role, the reviewer, or the budget fallback
3
- * resolves to, and which timeout a reviewer run gets. No I/O and no caching — the
4
- * persistence side (defaults, loading, saving, the role-selection helpers) lives in
5
- * config.ts, the write side in config-write.ts. */
1
+ // vocabulary: plain
2
+ /** The config AS SEEN BY A RUN — the per-seam resolution half of the config views, split out
3
+ * of config.ts: which provider/model/thinking a role, the reviewer, or the conflict resolver
4
+ * resolves to, and which timeout a reviewer run gets. The budget/tier fallback side — which
5
+ * free pair the team runs on at the daily cap, and the per-role fallback config (fallbackPair,
6
+ * resolveTierFallbacks, applyFallbackModel, …) — lives in config-fallbacks.ts, which builds on
7
+ * this module one-way. No I/O and no caching — the persistence side (defaults, loading,
8
+ * saving, the role-selection helpers) lives in config.ts, the write side in config-write.ts. */
6
9
  import { MODEL_TIERS } from "./config-schema.js";
7
- import { formatModelSelector, parseModelSelector, routeModel } from "./model-selector.js";
8
- import { isJsonObject } from "../files/json-object.js";
10
+ import { formatModelSelector, isAgentId, parseModelSelector, routeModel } from "./model-selector.js";
9
11
  import { modelPairName } from "../budget/budget.js";
10
- import { roleById } from "../roles/roles.js";
12
+ import { isJsonObject } from "../files/json-object.js";
13
+ import { DIRECTOR_ROLE, roleById } from "../roles/roles.js";
11
14
  import { baseRoleOf } from "../roles/loop-ids.js";
12
- import { modelFallbackActive } from "../loop/model-fallback.js";
13
- /** The effective `agents.<id>` entry as a run sees it (plans/agent-backends.md "Back-compat"):
15
+ /** The effective `backends.<id>` entry as a run sees it (plans/agent-backends.md "Back-compat"):
14
16
  * the file's own entry, with the legacy top-level `agentBin`/`piArgs` read as the pi backend's
15
17
  * `bin`/`args` when the new form leaves a field absent. Pure — it never writes those legacy
16
- * values back into `config.agents`, so a config write that re-serializes the merged config
18
+ * values back into `config.backends`, so a config write that re-serializes the merged config
17
19
  * cannot leak a second spelling into the file (which validateConfig would then reject).
18
20
  * pi-backend.ts and claude-backend.ts read `.bin` through resolveAgentBin (pi-bin.ts);
19
21
  * claude-backend.ts reads `.args` for passthrough argv. */
20
22
  export function resolvedAgentConfig(config, id) {
21
- const entry = config.agents?.[id];
23
+ const entry = config.backends?.[id];
22
24
  // A view must own its array: returning the config's own `args` reference would let a caller
23
25
  // that appends passthrough argv silently mutate the loaded config, breaking the
24
26
  // purity promised above. `args` is the only mutable field, so a sliced copy suffices.
@@ -31,7 +33,7 @@ export function resolvedAgentConfig(config, id) {
31
33
  }
32
34
  /** True when `s` names one of the three tiers — the form `roles.<id>.model` /
33
35
  * `review.model` may take to point at that tier of the top-level `model` map. */
34
- function isTierName(s) {
36
+ export function isTierName(s) {
35
37
  return MODEL_TIERS.includes(s);
36
38
  }
37
39
  /** The top-level `model` resolved at one tier (plans/model-tiers.md "Which tier each seam
@@ -40,7 +42,7 @@ function isTierName(s) {
40
42
  * else `default`'s (a tier left out inherits `default`), as a pure selector. Undefined
41
43
  * leaves pi's own default in charge. Map-form + legacy provider is a validation error, so
42
44
  * no legacy provider is in scope for a map's entries. */
43
- function topTierSelector(config, tier) {
45
+ export function topTierSelector(config, tier) {
44
46
  if (typeof config.model === "string")
45
47
  return parseModelSelector(config.model, config.provider);
46
48
  if (isJsonObject(config.model)) {
@@ -50,7 +52,7 @@ function topTierSelector(config, tier) {
50
52
  return undefined;
51
53
  }
52
54
  /** The selector one seam's tier resolves to at the top level — the tier's own map entry,
53
- * else `default`'s, else none (pi's own default). The pricing view (fleetModelsFree) and
55
+ * else `default`'s, else none (pi's own default). The pricing view (teamModelsFree) and
54
56
  * the later tiered-fallback parts read this instead of re-deriving the resolution, so the
55
57
  * argv builder and the budget cannot disagree about which model a tier names. */
56
58
  export function tierModel(config, tier) {
@@ -66,8 +68,13 @@ export function tierModel(config, tier) {
66
68
  * key it overrides would.
67
69
  *
68
70
  * `tier` is the seam's effective tier, already resolved by the caller from any tier-name
69
- * override — so `o.model` here is always a selector string, never a tier reference. */
70
- function withModelOverrides(config, tier, o) {
71
+ * override — so `o.model` here is always a selector string, never a tier reference.
72
+ *
73
+ * Exported so imageRerouteTarget (src/agent/image-input.ts) can probe a peer tier's
74
+ * top-level model (`withModelOverrides(config, tier, {})`, the same shape resolverConfig
75
+ * builds strong with) and reshape a tier's fallback pair into a runnable config — the
76
+ * tier-config builder the plan names. */
77
+ export function withModelOverrides(config, tier, o) {
71
78
  // The top-level model resolves at the seam's tier: a string form is the default tier's
72
79
  // selector; a map form takes the tier's entry, else `default`'s.
73
80
  const topSel = topTierSelector(config, tier);
@@ -76,7 +83,7 @@ function withModelOverrides(config, tier, o) {
76
83
  const model = ownSel ? ownSel.model : topSel?.model;
77
84
  // The routed backend decides whether the provider half survives: a pi provider name is kept,
78
85
  // a backend namespace such as `claude` is dropped (that backend takes no provider).
79
- const routed = routeModel(provider, model, config.agent ?? "pi", tier);
86
+ const routed = routeModel(provider, model, config.backend ?? "pi", tier);
80
87
  return {
81
88
  ...config,
82
89
  provider: routed.agent === "pi" ? provider : undefined,
@@ -85,6 +92,16 @@ function withModelOverrides(config, tier, o) {
85
92
  agent: routed.agent,
86
93
  };
87
94
  }
95
+ /** The provider/model/thinking overrides a config section (a role entry or `review`) carries
96
+ * into withModelOverrides: a tier-name `model` already chose the seam's tier, so it is not a
97
+ * selector and drops out here. Shared by configForRole and reviewConfig. */
98
+ function seamOverrides(s) {
99
+ return {
100
+ provider: s.provider,
101
+ model: isTierName(s.model) ? undefined : s.model,
102
+ thinking: s.thinking,
103
+ };
104
+ }
88
105
  /** The tier a role's model resolves at: a tier-name `roles.<id>.model` names it directly, and
89
106
  * every other role rides the model catalog's assignment. Exported for the per-tier budget gate's
90
107
  * pause set (src/gates/gate-polls.ts, part 5c/8) and the dashboards' per-role budget pause
@@ -94,39 +111,43 @@ export function roleSeamTier(config, role) {
94
111
  const m = config.roles[baseRoleOf(role)]?.model;
95
112
  if (isTierName(m))
96
113
  return m;
114
+ if (role === DIRECTOR_ROLE)
115
+ return "strong";
97
116
  return roleById(role)?.tier ?? "default";
98
117
  }
99
118
  /** The config as seen by one role: role-level provider/model/thinking overrides applied
100
- * over the top-level values, plus the per-role minTickIntervalSeconds (a slow clock for
119
+ * over the top-level values, plus the per-role minRunIntervalSeconds (a slow clock for
101
120
  * roles that should act rarely) falling back to the global value when unset. The role's
102
121
  * model resolves at its effective tier (roleSeamTier): a `model` map serves the tier the
103
122
  * catalog assigns (or `roles.<id>.model` names), a string form is the default tier. */
104
123
  export function configForRole(config, role) {
105
124
  const rc = config.roles[baseRoleOf(role)];
106
- const resolved = withModelOverrides(config, roleSeamTier(config, role), rc
107
- ? {
108
- provider: rc.provider,
109
- model: isTierName(rc.model) ? undefined : rc.model,
110
- thinking: rc.thinking,
111
- }
112
- : {});
125
+ const resolved = withModelOverrides(config, roleSeamTier(config, role), rc ? seamOverrides(rc) : {});
113
126
  if (!rc)
114
127
  return resolved;
115
128
  return {
116
129
  ...resolved,
117
- minTickIntervalSeconds: rc.minTickIntervalSeconds ?? config.minTickIntervalSeconds,
130
+ minRunIntervalSeconds: rc.minRunIntervalSeconds ?? config.minRunIntervalSeconds,
118
131
  };
119
132
  }
120
133
  /** The config a role's ticks SCHEDULE from: configForRole, except that while the new-project
121
134
  * bootstrap is active (plans/work-ratio.md, part 2/2) the plan role schedules on the GLOBAL
122
- * minTickIntervalSeconds rather than its per-role 3600 s slow clock, so a young project gets a
123
- * steady stream of plans for feature. One home for the rule: isEligible enforces the same gap
124
- * when it decides whether plan may start, and loop-tick.ts hands this config to applyTickOutcome so
125
- * nextRunAt is scheduled from the same figure instead of the role default. */
126
- export function scheduleConfigForRole(config, role, bootstrapActive) {
135
+ * minRunIntervalSeconds rather than its per-role 3600 s slow clock, so a young project gets a
136
+ * steady stream of plans for feature; and while the brief still carries the placeholder status
137
+ * (descriptionIncomplete) the plan role schedules with a ZERO min gap, so a fresh project plans
138
+ * back-to-back until a run changes nothing — the zero gap is tighter than bootstrap. One home
139
+ * for the rule: isEligible enforces the same gap when it decides whether plan may start, and
140
+ * loop-tick.ts hands this config to applyTickOutcome so nextRunAt is scheduled from the same
141
+ * figure instead of the role default. `descriptionIncomplete` means the brief's status section
142
+ * is still the placeholder; the initial prompt half is always present (init rejects an empty
143
+ * one), so the placeholder status is the machine-detectable "description not yet complete". */
144
+ export function scheduleConfigForRole(config, role, bootstrapActive, descriptionIncomplete = false) {
127
145
  const resolved = configForRole(config, role);
146
+ if (descriptionIncomplete && baseRoleOf(role) === "plan") {
147
+ return { ...resolved, minRunIntervalSeconds: 0 };
148
+ }
128
149
  if (bootstrapActive && baseRoleOf(role) === "plan") {
129
- return { ...resolved, minTickIntervalSeconds: config.minTickIntervalSeconds };
150
+ return { ...resolved, minRunIntervalSeconds: config.minRunIntervalSeconds };
130
151
  }
131
152
  return resolved;
132
153
  }
@@ -139,16 +160,12 @@ export function scheduleConfigForRole(config, role, bootstrapActive) {
139
160
  * `review.model` names another tier. */
140
161
  export function reviewConfig(config) {
141
162
  const rm = config.review.model;
142
- return withModelOverrides(config, isTierName(rm) ? rm : "strong", {
143
- provider: config.review.provider,
144
- model: isTierName(rm) ? undefined : rm,
145
- thinking: config.review.thinking,
146
- });
163
+ return withModelOverrides(config, isTierName(rm) ? rm : "strong", seamOverrides(config.review));
147
164
  }
148
- /** The config the landing conflict resolver runs on (plans/model-tiers.md, part 4/8): the
149
- * strong tier's model installed over the config the landing was handed — resolution is rare,
150
- * tolerant of latency, and edits code inside landing, so it rides the seam where model quality
151
- * matters most. It reads whatever config the landing carries — the role's per-tier budget
165
+ /** The config the merge conflict resolver runs on (plans/model-tiers.md, part 4/8): the
166
+ * strong tier's model installed over the config the merge was handed — resolution is rare,
167
+ * tolerant of latency, and edits code inside the merge, so it rides the seam where model quality
168
+ * matters most. It reads whatever config the merge carries — the role's per-tier budget
152
169
  * fallback view — so it follows the budget fallback's config (part 5a/8). No other overrides:
153
170
  * the session dir, raw log,
154
171
  * transient retry, and usage folding stay the authoring run's. */
@@ -156,38 +173,38 @@ export function resolverConfig(config) {
156
173
  return withModelOverrides(config, "strong", {});
157
174
  }
158
175
  /** Default wall-clock budget for one reviewer run (`review.timeoutSeconds`). The reviewer holds
159
- * the landing slot the whole queue waits on, and its only other limits were the tick's budget
176
+ * the merge slot the whole queue waits on, and its only other limits were the tick's budget
160
177
  * and the quiet watchdog: reviews measured 2026-09-22/23 ran 2.3 min median, 9.2 min p90 and
161
178
  * 24.6 min max, while earlier days had 1.5–3.5 h reviews. 15 min clears the ordinary review and
162
179
  * kills a wedged one in minutes instead of the tick's hours. */
163
180
  export const REVIEW_TIMEOUT_S = 900;
164
- /** The reviewer's wall-clock floor while the budget fallback carries the fleet. The free model
181
+ /** The reviewer's wall-clock floor while the budget fallback carries the team. The free model
165
182
  * is typically local and far slower per turn than the budgeted one it replaces, but a review
166
183
  * still needs the same number of turns: measured 2026-09-30, 1,081 reviews on the budgeted
167
184
  * model took 7 turns at p50 and 15 at p90, while the local fallback managed one turn every
168
185
  * ~150–220 s — so REVIEW_TIMEOUT_S fit 4–7 turns, and none of the 21 reviews started in the
169
186
  * 09-28..09-30 fallback windows ever returned a verdict (14 timed out; BUGS.md 2026-09-30).
170
- * Every code change then looped review → timeout → re-land until midnight. An hour fits ~20
187
+ * Every code change then looped review → timeout → re-merge until midnight. An hour fits ~20
171
188
  * turns, past the p90 review. */
172
189
  export const FALLBACK_REVIEW_TIMEOUT_S = 3600;
173
190
  /** The config as seen by the gate's reviewer run: the reviewer's model wiring (reviewConfig)
174
191
  * with its own time budget — `review.timeoutSeconds`, default REVIEW_TIMEOUT_S — overriding
175
- * the tick's. A smaller tickTimeoutSeconds still wins. A still-progressing gate run gets one
192
+ * the tick's. A smaller runTimeoutSeconds still wins. A still-progressing gate run gets one
176
193
  * extension of the same length (src/pi/pi-watchdogs.ts), so the effective ceiling on a large
177
194
  * diff's review is twice this budget. */
178
195
  export function reviewRunConfig(config) {
179
196
  const cfg = reviewConfig(config);
180
197
  return {
181
198
  ...cfg,
182
- tickTimeoutSeconds: Math.min(cfg.tickTimeoutSeconds, config.review.timeoutSeconds ?? REVIEW_TIMEOUT_S),
199
+ runTimeoutSeconds: Math.min(cfg.runTimeoutSeconds, config.review.timeoutSeconds ?? REVIEW_TIMEOUT_S),
183
200
  };
184
201
  }
185
202
  /** The optional provider/model/thinking fields of a resolved model seam, with each unset
186
203
  * (empty) field left out so the result carries only what was resolved — the one home of that
187
- * assembly. Four call sites build a triple this way and follow the same rule:
188
- * fallbackSelectorFields (shared by fallbackPair's map and string branches and
189
- * tierOwnFallback), fallbackPair's legacy-object branch, role-view's RoleViewPayload (its
190
- * model fields), and modelFallbackView (the episode pair) — an absent or empty value is
204
+ * assembly. The fallback layer (config-fallbacks.ts's fallbackSelectorFields and
205
+ * fallbackPair/tierOwnFallback/modelFallbackView) and role-view's RoleViewPayload (its
206
+ * model fields) all build a triple this way and follow the same rule — an absent or empty
207
+ * value is
191
208
  * omitted rather than written as undefined/"" (an empty selector field would read as
192
209
  * "override present," so it must not appear). */
193
210
  export function resolvedModelFields(provider, model, thinking) {
@@ -200,19 +217,11 @@ export function resolvedModelFields(provider, model, thinking) {
200
217
  fields.thinking = thinking;
201
218
  return fields;
202
219
  }
203
- /** The resolved model fields of a pure `fallback` selector string: parsed as a pure selector
204
- * (no legacy provider in scope), with the ambient top-level thinking when the selector
205
- * carries none. fallbackPair's map and string branches and tierOwnFallback share this
206
- * assembly, so all three read one rule for how a `fallback` selector resolves. */
207
- function fallbackSelectorFields(selector, config) {
208
- const parsed = parseModelSelector(selector);
209
- return resolvedModelFields(parsed.provider, parsed.model, parsed.thinking ?? config.thinking);
210
- }
211
220
  /** The `model` selector a run actually starts on, as an event/payload field: the resolved
212
221
  * provider/model/thinking triple formatted as one selector string, or an empty object when
213
222
  * no model id is resolved (pi's own default) — the key stays absent rather than empty, the
214
223
  * same empty-means-unset rule resolvedModelFields applies to the raw halves. One home of
215
- * that assembly and guard, shared by the two run-start event writers (loop-tick.ts's tick_start,
224
+ * that assembly and guard, shared by the two run-start event writers (loop-tick.ts's run_start,
216
225
  * review.ts's review_start) and the per-loop status snapshot (status-data.ts) so the three
217
226
  * observability surfaces cannot disagree about which model a run starts on. A non-pi agent is
218
227
  * named as the selector's provider namespace (agentQualifiedSelector), so a claude run reads
@@ -230,22 +239,52 @@ export function agentQualifiedSelector(cfg) {
230
239
  const base = formatModelSelector({ provider: cfg.provider, model: cfg.model, thinking: cfg.thinking });
231
240
  return cfg.agent && cfg.agent !== "pi" ? `${cfg.agent}/${base}` : base;
232
241
  }
233
- /** The key a resolved seam's fleet hold is filed under and the scheduler checks a role
242
+ /** The key a resolved seam's team hold is filed under and the scheduler checks a role
234
243
  * against: a non-pi backend's own id (claude seams carry no pi provider), else the pi
235
244
  * provider (undefined for pi's own default). The failure hold and the subscription-window
236
245
  * hold both key on this, so a hold and the roles it gates cannot drift two ways. */
237
246
  export function seamHoldKey(cfg) {
238
247
  return cfg.agent !== undefined && cfg.agent !== "pi" ? cfg.agent : cfg.provider;
239
248
  }
240
- /** The fleet's model wiring as one human-readable label, for the live-reload `model_changed`
249
+ /** The `modelPairName` a resolved seam's run is charged under (LoopState.dayCostByPair): the
250
+ * routed backend's id stands in for the provider on a non-pi seam, exactly as the fallback
251
+ * pairs carry it (config-fallbacks.ts's seamKeyedFallbackProvider), so a run and the chain
252
+ * step it ran on name one key. */
253
+ export function seamPairName(cfg) {
254
+ return modelPairName({ provider: seamHoldKey(cfg), model: cfg.model });
255
+ }
256
+ /** The key a resolved seam's run counts against the per-provider cap
257
+ * (plans/provider-concurrency.md §2): its hold key, else — for a pi seam that names no model
258
+ * at all and so runs pi's saved default — the default provider from pi's settings
259
+ * (`piDefault`, the caller's cached readPiDefaultModel result, so this view stays free of
260
+ * I/O). A bare id with no provider, or a seam whose pi settings cannot name a default, yields
261
+ * undefined: only the team total bounds it. HF sub-provider pins (`huggingface/…:deepinfra`)
262
+ * share the base provider, and billing does not split claude, because this reuses the hold key
263
+ * — "omlx is held" and "omlx is full" name the same thing on every surface. */
264
+ export function seamConcurrencyKey(cfg, piDefault) {
265
+ const hold = seamHoldKey(cfg);
266
+ if (hold !== undefined)
267
+ return hold;
268
+ if (cfg.model === undefined)
269
+ return piDefault?.provider;
270
+ return undefined;
271
+ }
272
+ /** The team's model wiring as one human-readable label, for the live-reload `model_changed`
241
273
  * event: null when no tier resolves to a model (pi's own default), the single selector when
242
274
  * every set tier agrees (the string-form `model`), and the `small=…, default=…, strong=…`
243
275
  * list when the tiers differ. Reading EVERY tier makes an edit to any tier's entry change the
244
- * label. */
245
- export function fleetModelLabel(config) {
276
+ * label. Each tier's label is agent-qualified (agentQualifiedSelector), so flipping the
277
+ * top-level `agent` over a bare-id model relabels every tier and the reload now emits
278
+ * `model_changed` — a team's backend is part of what it runs (PLANS.md "Mixed claude + pi
279
+ * fleets, part 3/4"), and an edit that moves a bare id to another backend must not read as
280
+ * "nothing changed". */
281
+ export function teamModelLabel(config) {
246
282
  const entries = MODEL_TIERS.map((tier) => {
247
283
  const sel = tierModel(config, tier);
248
- return sel ? { tier, label: formatModelSelector(sel) } : null;
284
+ if (!sel)
285
+ return null;
286
+ const { agent } = routeModel(sel.provider, sel.model, config.backend ?? "pi", tier);
287
+ return { tier, label: agentQualifiedSelector({ ...sel, agent }) };
249
288
  }).filter((e) => e !== null);
250
289
  if (entries.length === 0)
251
290
  return null;
@@ -253,276 +292,19 @@ export function fleetModelLabel(config) {
253
292
  return entries[0].label;
254
293
  return entries.map((e) => `${e.tier}=${e.label}`).join(", ");
255
294
  }
256
- /** The fleet-level fallback pair: the `default` tier's resolved pair under
257
- * resolveTierFallbacks' borrow order, or null when the tier resolves to pause. The
258
- * selector-string `fallback` (plans/model-tiers.md), the legacy `fallbackModel` object, and
259
- * the map form all feed one resolution — a map with no `default` entry (or a `"pause"` one)
260
- * borrows another tier's pair exactly as the budget gate does, so this view and the gate
261
- * cannot disagree about which model the fleet would engage. `usable` filters the pairs that
262
- * may supply the answer: the default predicate admits every declared pair (the config-only
263
- * view), while a pricing predicate (src/pi/pi-models.ts's fallbackModelPair) yields the pair
264
- * the gate would actually engage when a declared `default` pair is priced and a free pair
265
- * exists at another tier. One definition so the freeness check, the dashboards' badge, the
266
- * doctor, and applyFallbackModel below cannot disagree. */
267
- export function fallbackPair(config, usable = () => true) {
268
- return resolveTierFallbacks(config, usable).default.pair;
269
- }
270
- /** The legacy `fallbackModel` object resolved to a pair: its own fields over the top-level
271
- * values, the same precedence every other override section uses. Private — reached only
272
- * through tierOwnFallback (the default tier's own fallback) and so fallbackPair's resolution,
273
- * which keeps the object and the string/map forms on one rule without recursing. */
274
- function legacyFallbackPair(config) {
275
- const fbo = config.fallbackModel;
276
- if (!fbo)
277
- return null;
278
- // The top-level model is a selector too (parsed the same way withModelOverrides does), so a
279
- // fallback field that omits model borrows the parsed id, never the raw selector string.
280
- const topSel = topTierSelector(config, "default");
281
- const provider = fbo.provider ?? topSel?.provider ?? config.provider;
282
- const model = fbo.model ?? topSel?.model;
283
- const thinking = fbo.thinking ?? topSel?.thinking ?? config.thinking;
284
- return resolvedModelFields(provider, model, thinking);
285
- }
286
- /** The order a tier borrows other tiers' own fallbacks in, after trying its own: small
287
- * climbs (small → default → strong), default keeps near itself (default → strong → small),
288
- * and strong never drops to a small model (strong → default → pause) — a weak reviewer or
289
- * planner does more harm than a paused one. */
290
- const BORROW_ORDER = {
291
- small: ["small", "default", "strong"],
292
- default: ["default", "strong", "small"],
293
- strong: ["strong", "default"],
294
- };
295
- /** A tier's OWN fallback pair — what the `fallback` key declares at that tier's map entry,
296
- * or (default only) the string form of `fallback` or the legacy `fallbackModel` object — or
297
- * undefined when the tier declares nothing or declares `"pause"` (an explicit opt-out, which
298
- * also makes it unborrowable: a tier with no own fallback offers nothing to borrow). Parsed
299
- * like fallbackPair: a pure selector, with the ambient top-level thinking when the selector
300
- * carries none. */
301
- function tierOwnFallback(config, tier) {
302
- const fb = config.fallback;
303
- let selector;
304
- if (fb !== undefined) {
305
- // The top-level string form names the default tier's own fallback; `"pause"` is rejected
306
- // by validation but defensively names no model (matching the map entries below).
307
- if (typeof fb === "string")
308
- selector = tier === "default" && fb !== "pause" ? fb : undefined;
309
- else {
310
- const raw = fb[tier];
311
- if (raw !== undefined && raw !== "pause")
312
- selector = raw;
313
- }
314
- }
315
- else if (tier === "default") {
316
- // The legacy `fallbackModel` object is the default tier's own fallback, with its
317
- // provider/thinking fields and the top-level borrow precedence.
318
- return legacyFallbackPair(config) ?? undefined;
319
- }
320
- if (selector === undefined)
321
- return undefined;
322
- return fallbackSelectorFields(selector, config);
323
- }
324
- /** Which free pair each tier runs on at the daily cap (plans/model-tiers.md "Budget fallback
325
- * by tier", part 5a/8). A tier runs its own declared fallback when `usable(pair)`; otherwise
326
- * it borrows another tier's OWN fallback — never one that tier itself borrowed — in
327
- * BORROW_ORDER's order; otherwise it pauses (`pair: null`). The engine is pure: the caller
328
- * supplies `usable` (part 5b/8 wires it to pair pricing and the fallback breaker), so the
329
- * same rules are testable with a stub and the gates cannot disagree with each other. A
330
- * config with no fallback at all resolves every tier to pause, and a single `fallback: F`
331
- * reads as default's own pair, which small and strong then borrow — today's behavior. */
332
- export function resolveTierFallbacks(config, usable) {
333
- const own = (tier) => {
334
- const pair = tierOwnFallback(config, tier);
335
- return pair !== undefined && usable(pair) ? pair : undefined;
336
- };
337
- const resolve = (tier) => {
338
- const mine = own(tier);
339
- if (mine)
340
- return { pair: mine, from: null };
341
- for (const t of BORROW_ORDER[tier]) {
342
- if (t === tier)
343
- continue;
344
- const theirs = own(t);
345
- if (theirs)
346
- return { pair: theirs, from: t };
347
- }
348
- return { pair: null, from: null };
349
- };
350
- return { small: resolve("small"), default: resolve("default"), strong: resolve("strong") };
351
- }
352
- /** The config a role's ticks run on while a model-fallback episode is active (PLANS.md "Model
353
- * failure fallback, part 1/2"): the role's tier pair from resolveTierFallbacks (the tier's own
354
- * pair, else BORROW_ORDER's borrow), shaped like configForRole — provider/model/thinking over
355
- * the role's own and top-level values, plus the role's minTickIntervalSeconds. Null when the
356
- * tier resolves to pause (no fallback configured), so the feature stays off. Pure. */
357
- export function fallbackRoleConfig(config, role) {
358
- const tier = roleSeamTier(config, role);
359
- const pair = resolveTierFallbacks(config, () => true)[tier].pair;
360
- if (pair === null)
361
- return null;
362
- const rc = config.roles[baseRoleOf(role)];
363
- return {
364
- ...withModelOverrides(config, tier, {
365
- provider: pair.provider,
366
- model: pair.model,
367
- thinking: pair.thinking,
368
- }),
369
- minTickIntervalSeconds: rc?.minTickIntervalSeconds ?? config.minTickIntervalSeconds,
370
- };
371
- }
372
- /** Build a ModelFallbackView for an episode, or null while the episode is not running the
373
- * fallback (no episode, or a due probe on the primary). role-view.ts's
374
- * RoleViewPayload.modelFallback and status/status-data.ts's per-loop row both build this
375
- * shape, so one function keeps the two surfaces from disagreeing about the pair, its start,
376
- * and its reason. `now` is the caller's clock (the status snapshot passes its own poll
377
- * clock), so a row and its poll agree on what "active" means. */
378
- export function modelFallbackView(episode, config, role, now = Date.now()) {
379
- if (episode === undefined || !modelFallbackActive(episode, now))
380
- return null;
381
- const episodeConfig = fallbackRoleConfig(config, role);
382
- if (episodeConfig === null)
383
- return null;
384
- return {
385
- ...resolvedModelFields(episodeConfig.provider, episodeConfig.model, episodeConfig.thinking),
386
- since: episode.since,
387
- reason: episode.reason,
388
- };
389
- }
390
- /** The per-tier fallback labels the observability surfaces render (part 7b/8): tier →
391
- * `provider/model[ (from <tier>)]` for every tier the resolution gives a pair, in tier order.
392
- * The `(from …)` suffix marks a borrowed pair — a strong-tier borrow is the fact an operator
393
- * reading the feed or the badge can act on. Tiers resolved to pause are left out: the badge
394
- * and the event name the pairs that RUN, the pause story lives on the pause events and the
395
- * per-role rows. */
396
- export function tierFallbackLabels(resolved) {
397
- const out = {};
398
- for (const tier of MODEL_TIERS) {
399
- const r = resolved[tier];
400
- if (r.pair !== null)
401
- out[tier] = modelPairName(r.pair) + (r.from !== null ? ` (from ${r.from})` : "");
402
- }
403
- return out;
404
- }
405
- /** True when the resolution hands out two or more DISTINCT pairs — the condition under which
406
- * the tiered surfaces (the budget_fallback event, the header badge) switch from today's
407
- * single-pair text to the tier list: a single fallback whose pair the other tiers borrow
408
- * resolves every tier to the same pair, and that stays on the single-pair text, byte
409
- * identical to the pre-tier surfaces. */
410
- export function tiersResolveDistinctPairs(resolved) {
411
- const pairs = MODEL_TIERS.map((t) => resolved[t].pair)
412
- .filter((p) => p !== null)
413
- .map((p) => modelPairName(p));
414
- return new Set(pairs).size >= 2;
415
- }
416
- /** Drop a model-override seam's provider/model/thinking fields — the one rule the fallback
417
- * view's role and reviewer strips share, so a field on that triple cannot be dropped from one
418
- * and left on the other (where it would reach a priced model after the cap). */
419
- function stripModel(o) {
420
- const { provider: _p, model: _m, thinking: _t, ...rest } = o;
421
- return rest;
422
- }
423
- /** The reviewer config as the fallback view sees it: model seam stripped, and its time budget
424
- * raised to at least FALLBACK_REVIEW_TIMEOUT_S (a budget sized for the budgeted model's turns
425
- * times out every review on a slower free one; a configured value above the floor stands). The
426
- * one home of that rule, shared by both fallback branches. */
427
- function fallbackReview(review) {
428
- return {
429
- ...stripModel(review),
430
- timeoutSeconds: Math.max(review.timeoutSeconds ?? REVIEW_TIMEOUT_S, FALLBACK_REVIEW_TIMEOUT_S),
431
- };
432
- }
433
- /** Every `roles.<id>` entry with its model seam stripped. With `keepTierNames`, an entry whose
434
- * model is a tier name is left untouched: the resolved tier map now serves that tier itself, so
435
- * the reference stays valid (stripping it would lose the role's tier). */
436
- function stripRoleModels(roles, keepTierNames) {
437
- return Object.fromEntries(Object.entries(roles).map(([id, rc]) => [
438
- id,
439
- keepTierNames && isTierName(rc?.model) ? rc : stripModel(rc),
440
- ]));
441
- }
442
- /** Every `roles.<id>` entry with its model seam stripped, except a seam `free` calls free — a
443
- * subscription claude role keeps its own model through the cap rather than moving to the
444
- * fallback (plans/agent-backends.md "Free-ness"). With `keepTierNames`, an entry whose model is
445
- * a tier name is left untouched: the resolved tier map now serves that tier itself, so the
446
- * reference stays valid (stripping it would lose the role's tier). */
447
- function freeRoleModels(config, free) {
448
- return Object.fromEntries(Object.entries(config.roles).map(([id, rc]) => {
449
- if (rc && free?.(withModelOverrides(config, roleSeamTier(config, id), rc)))
450
- return [id, rc];
451
- return [id, isTierName(rc?.model) ? rc : stripModel(rc)];
452
- }));
453
- }
454
- /** The config as seen by a role loop running on the free fallback model
455
- * (plans/fallback-model.md): the fallback's provider/model/thinking installed as the top-level
456
- * values AND every per-role and reviewer model override dropped, so that EVERY seam that could
457
- * otherwise reach a priced model — an author run, its reviewer, a conflict resolver — resolves
458
- * to the one free pair. Dropping the overrides is the point: a role pinned to a paid model in
459
- * tumwater.json must not keep spending after the cap is reached. The one other change is the
460
- * reviewer's time budget, raised to at least FALLBACK_REVIEW_TIMEOUT_S: a budget sized for the
461
- * budgeted model's turns times out every review on a slower free one (a configured
462
- * `review.timeoutSeconds` above the floor stands, and reviewRunConfig still caps it at
463
- * tickTimeoutSeconds). Everything else (intervals, thresholds, exempt paths, the cap itself) is
464
- * unchanged when no fallback is configured. With a per-tier `resolved` map (resolveTierFallbacks,
465
- * part 5a/8) the model map is rewritten to the resolved pairs instead — each seam keeps its
466
- * tier: `model[tier]` becomes the tier's pair as a selector string, a tier resolved to pause
467
- * is left out (it inherits default's entry, harmless because the gate holds its roles), raw
468
- * per-role selector overrides are dropped so a paid pin cannot keep spending, and tier-name
469
- * role overrides (`roles.<id>.model: "strong"`) are kept since the map now serves the tier
470
- * itself. The director's exemption lives in the gates (roleForConfig), not here. A
471
- * subscription claude seam is never moved (`free`): the gate passes one predicate over the
472
- * resolved seam, so a free tier keeps its own primary in the map — with the ambient thinking
473
- * level it resolved with — a free role keeps its own override, and a free reviewer keeps its own
474
- * model, while every priced seam still lands on its tier's fallback pair. */
475
- export function applyFallbackModel(config, resolved, free) {
476
- if (resolved) {
477
- const selectorOf = (f) => f.pair
478
- ? formatModelSelector({
479
- provider: f.pair.provider,
480
- model: f.pair.model ?? "",
481
- thinking: f.pair.thinking,
482
- })
483
- : undefined;
484
- const model = {};
485
- for (const tier of MODEL_TIERS) {
486
- // A free seam keeps the tier's own primary selector — the backend namespace a resolved
487
- // seam has cleared, plus the ambient thinking level `topTierSelector` alone omits. A moved
488
- // seam gets its pair selector, which already carries that ambient level.
489
- const seam = withModelOverrides(config, tier, {});
490
- if (free?.(seam)) {
491
- const own = topTierSelector(config, tier);
492
- if (own !== undefined)
493
- model[tier] = formatModelSelector({ ...own, thinking: own.thinking ?? config.thinking });
494
- // A tier with no top-level selector — `agent: "claude"` riding its agent default —
495
- // has no own selector to carry the level, so name the routed default plus it.
496
- else if (seam.model !== undefined)
497
- model[tier] = formatModelSelector({
498
- provider: seam.provider,
499
- model: seam.model,
500
- thinking: seam.thinking,
501
- });
502
- continue;
503
- }
504
- const sel = selectorOf(resolved[tier]);
505
- if (sel !== undefined)
506
- model[tier] = sel;
507
- }
508
- return {
509
- ...config,
510
- provider: undefined,
511
- model,
512
- thinking: undefined,
513
- review: free?.(reviewConfig(config)) ? config.review : fallbackReview(config.review),
514
- roles: freeRoleModels(config, free),
515
- };
516
- }
517
- const pair = fallbackPair(config);
518
- if (!pair)
519
- return config;
520
- return {
521
- ...config,
522
- provider: pair.provider,
523
- model: pair.model,
524
- thinking: pair.thinking,
525
- review: fallbackReview(config.review),
526
- roles: stripRoleModels(config.roles, false),
527
- };
295
+ /** One fallback pair rendered for display, agent-qualified (PLANS.md "Mixed claude + pi
296
+ * fleets, part 3/4" gap 7): a pair routed to a non-pi backend — its `provider` is that
297
+ * backend's id, per seamKeyedFallbackProvider, so `claude` — renders as `claude/claude-sonnet-5-5`,
298
+ * the same agent-qualified form every other surface carries; a pi pair keeps the bare model
299
+ * byte-identical to before (the badge, the team alert and `tumwater role` all name the
300
+ * model a pi fallback runs, never its provider). Falls back through the provider to the
301
+ * caller's `<agent> default` label when both halves are absent. One home so the three display
302
+ * surfaces cannot render one fallback two ways. */
303
+ export function fallbackDisplayPair(pair, agentDefault) {
304
+ const p = pair ?? {};
305
+ if (p.model !== undefined)
306
+ return p.provider !== undefined && isAgentId(p.provider) ? `${p.provider}/${p.model}` : p.model;
307
+ if (p.provider !== undefined)
308
+ return p.provider;
309
+ return agentDefault;
528
310
  }