tumwater 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -6,7 +6,7 @@ import { formatClock } from "../text/datetime.js";
6
6
  import { loadRunnerStates } from "./doctor-holds.js";
7
7
  import { modelPairName, pairDailyCost } from "../budget/budget.js";
8
8
  import { usd, usdCap } from "../text/format.js";
9
- import { okOutcome, warnOutcome } from "./outcome.js";
9
+ import { okOutcome, warnOutcome } from "./doctor-outcome.js";
10
10
  /** The doctor's cap-fallback checks, split out of doctor-model-checks.ts (whose seam framework
11
11
  * and declared-tier/image checks stay in a module whose per-backend provider checks live in
12
12
  * doctor-{claude,opencode,codex,pi}-checks.ts): the daily-cost budget's third state — whether
@@ -18,7 +18,7 @@ import { okOutcome, warnOutcome } from "./outcome.js";
18
18
  * kept separate so a change to fallback pricing does not touch how doctor reads seams. The
19
19
  * config guard (loadConfigForCheck) is imported from doctor-model-checks, exactly as the
20
20
  * per-backend modules import that file's seam framework. The shared report contract
21
- * (CheckOutcome) is imported from outcome.ts. */
21
+ * (CheckOutcome) is imported from doctor-outcome.ts. */
22
22
  /** Fallback model readiness — the daily-cost budget's third state (plans/fallback-model.md): with
23
23
  * `fallbackModel` set, pi's definitions must price that pair at zero or the gate refuses it and
24
24
  * roles pause at the cap exactly as if no fallback existed. That refusal is the right runtime
@@ -77,6 +77,48 @@ const isLocalUrl = (url) => {
77
77
  return false;
78
78
  }
79
79
  };
80
+ /** Which URL this step probes, and did it answer. A provider that names a backend other than
81
+ * pi (claude today; opencode from part 2) never asks pi for a base URL or runs pi's price
82
+ * probe — only a pi step may. Returns the step's backend-ness, the resolved base URL and
83
+ * whether a local pi step answered; a non-local or non-pi step never probes (up stays null). */
84
+ async function resolveStepProbe(modelsPath, pair, probe) {
85
+ const nonPiBackend = pair.backend !== undefined && pair.backend !== "pi";
86
+ const base = !nonPiBackend && pair.provider ? piProviderBaseUrl(modelsPath, pair.provider) : null;
87
+ const local = base !== null && isLocalUrl(base);
88
+ const up = local ? await probe(`${base}/models`) : null;
89
+ return { nonPiBackend, base, up };
90
+ }
91
+ /** The claude step's billing display: extra usage when the step follows an earlier claude
92
+ * model, else the persisted account's billing answer (or unknowns when the account is absent). */
93
+ function claudeVerdict(extra, account) {
94
+ return extra ? "extra usage" : (account?.billing ?? "billing unknown");
95
+ }
96
+ /** Why this step is not runnable now (or is, when no arm fires): the five-way verdict, and
97
+ * whether the chain is down. The `up === false` arm is the only one that marks the chain down.
98
+ * `accounts` prices a free/claude pair for the one claude account held by the caller. */
99
+ function stepVerdict(pair, ctx) {
100
+ const { up, base, cap, spent, providers, accounts } = ctx;
101
+ if (up === false) {
102
+ return { verdict: `not running (GET ${base}/models failed)`, down: true };
103
+ }
104
+ else if (cap !== undefined && spent >= cap) {
105
+ return { verdict: `cap reached (${usd(spent)}/${usdCap(cap)})`, down: false };
106
+ }
107
+ else if (pair.backend !== undefined && pair.backend !== "pi") {
108
+ return { verdict: `claude ${claudeVerdict(pair.extraUsage, accounts("claude"))}`, down: false };
109
+ }
110
+ else if (piPairFree(providers, pair, accounts)) {
111
+ return { verdict: up ? "free · server up" : "free", down: false };
112
+ }
113
+ return {
114
+ verdict: cap !== undefined ? `priced, ${usd(spent)}/${usdCap(cap)} today` : "priced, team cap only",
115
+ down: false,
116
+ };
117
+ }
118
+ /** One chain fragment's line: the tier, the 1-based step number and the step's pair and verdict. */
119
+ function chainFragment(tier, step, pair, verdict) {
120
+ return `${tier} ${step + 1}: ${pair.provider ?? "?"}/${pair.model ?? "?"} — ${verdict}`;
121
+ }
80
122
  /** The `fallback chain` check: one fragment per step of every tier's chain saying whether the
81
123
  * step can run now — a local pi server that does not answer (warn), a step whose own daily cap is
82
124
  * spent, a free pair, a claude step with its billing, or a priced pair. Reads config, pi's
@@ -98,32 +140,12 @@ export async function checkFallbackChain(root, modelsPath = piModelsPath(), prob
98
140
  let down = false;
99
141
  const fragments = [];
100
142
  for (const { tier, step, pair } of steps) {
101
- // A provider that names a backend other than pi (claude today; opencode from part 2) never
102
- // asks pi for a base URL or runs pi's price probe — only a pi step may.
103
- const nonPiBackend = pair.backend !== undefined && pair.backend !== "pi";
104
- const base = !nonPiBackend && pair.provider ? piProviderBaseUrl(modelsPath, pair.provider) : null;
105
- const local = base !== null && isLocalUrl(base);
106
- const up = local ? await probe(`${base}/models`) : null;
143
+ const { base, up } = await resolveStepProbe(modelsPath, pair, probe);
107
144
  const cap = pair.maxDailyCostUsd;
108
145
  const spent = cap !== undefined ? pairDailyCost(states, modelPairName(pair), now) : 0;
109
- let verdict;
110
- if (up === false) {
111
- down = true;
112
- verdict = `not running (GET ${base}/models failed)`;
113
- }
114
- else if (cap !== undefined && spent >= cap) {
115
- verdict = `cap reached (${usd(spent)}/${usdCap(cap)})`;
116
- }
117
- else if (nonPiBackend) {
118
- verdict = `claude ${pair.extraUsage ? "extra usage" : (account?.billing ?? "billing unknown")}`;
119
- }
120
- else if (piPairFree(providers, pair, accounts)) {
121
- verdict = up ? "free · server up" : "free";
122
- }
123
- else {
124
- verdict = cap !== undefined ? `priced, ${usd(spent)}/${usdCap(cap)} today` : "priced, team cap only";
125
- }
126
- fragments.push(`${tier} ${step + 1}: ${pair.provider ?? "?"}/${pair.model ?? "?"} — ${verdict}`);
146
+ const { verdict, down: isDown } = stepVerdict(pair, { up, base, cap, spent, providers, accounts });
147
+ down ||= isDown;
148
+ fragments.push(chainFragment(tier, step, pair, verdict));
127
149
  }
128
150
  const detail = fragments.join("; ");
129
151
  return down ? warnOutcome(detail) : okOutcome(detail);
@@ -1,8 +1,6 @@
1
1
  import { roleLabel } from "../roles/role-labels.js";
2
2
  import { sortEntriesByKey } from "../collections.js";
3
3
  import { pausedRoles, pausedRolesUntil, standingTeamPause } from "../team/team-state.js";
4
- import { MODEL_TIERS } from "../config/config-schema.js";
5
- import { withModelOverrides } from "../config/config-views.js";
6
4
  import { commitTier, DIRECTOR_ROLE } from "../roles/roles.js";
7
5
  import { budgetReached, budgetWarning, teamDailyCost } from "../budget/budget.js";
8
6
  import { roleCapVerdicts } from "../gates/role-cap-gates.js";
@@ -17,17 +15,13 @@ import { usd } from "../text/format.js";
17
15
  import { parseQuietHours, quietHoursStatus, quietWindowEnd, roleQuietHold } from "../scheduling/quiet-hours.js";
18
16
  import { DAY_MS, HOUR_MS, humanUntil } from "../text/datetime.js";
19
17
  import { budgetPhrase, pauseReasonSuffix, plural } from "../text/phrases.js";
20
- import { readAccount, utilizationPct, windowEntries, windowLabel } from "../agent/subscription-account.js";
21
- import { backendDisplayName } from "../agent/backend-traits.js";
22
- import { windowObservation } from "../team/team-polls.js";
23
18
  import { readEventsSince } from "../events/event-window.js";
24
19
  import { errorMessage } from "../text/text.js";
25
- import { okOutcome, warnOutcome } from "./outcome.js";
20
+ import { okOutcome, warnOutcome } from "./doctor-outcome.js";
26
21
  import { redMainWarning } from "../baseline/main-guardrails.js";
27
- /** The standing-hold checks: `pause` (checkPause), `quiet hours` (checkQuietHours), and `budget`
28
- * (checkBudget), the `claude window`/`codex window` rows (checkSubscriptionWindow),
29
- * `maintenance quota` (checkMaintenanceQuota), and `error streaks` (checkErrorStreaks), all
30
- * read-only reports of holds on running. */
22
+ /** The standing-hold checks: `pause` (checkPause), `quiet hours` (checkQuietHours), `budget`
23
+ * (checkBudget), `maintenance quota` (checkMaintenanceQuota), and `error streaks`
24
+ * (checkErrorStreaks), all read-only reports of holds on running. */
31
25
  export function loadRunnerStates(root, config) {
32
26
  return runnerIds(config)
33
27
  .map((id) => loadRunnerState(root, id))
@@ -139,39 +133,6 @@ export function checkMaintenanceQuota(root, config, aliveTeam, now = Date.now())
139
133
  const anyMaintenance = runners.some((l) => commitTier(l.role) === "maintenance");
140
134
  return okOutcome(anyMaintenance ? base : `${base}; no maintenance role enabled`);
141
135
  }
142
- /** The `<backend> window` check, one row per subscription backend in use (claude and codex):
143
- * whether a rejected subscription window holds the subscription `<backend>` seams. Read-only and
144
- * built on windowObservation (the backend's own), the verdict the scheduler's team hold uses, so
145
- * it cannot disagree with the hold. A hold is a warn because it stops every subscription seam on
146
- * that backend until the window resets; otherwise the most constrained window's state is
147
- * reported as ok (a rejected window spending overage reads `rejected` here: overage spends, it
148
- * does not hold). Returns null (omitting the row from doctor) when the backend has no
149
- * subscription account — the same omit-a-row pattern as `pi extensions` — because an absent or
150
- * API-billed account never holds. */
151
- export function checkSubscriptionWindow(backend, root, now = Date.now(), chain, config) {
152
- const account = readAccount(root, backend, now);
153
- if (account?.billing !== "subscription")
154
- return null;
155
- const held = windowObservation(backend, account, now);
156
- if (held?.kind === "subscription-window" && held.windowType !== undefined && held.resetsAt !== undefined) {
157
- const label = windowLabel(held.windowType);
158
- const resets = humanUntil(held.resetsAt, now);
159
- const moved = MODEL_TIERS.filter((t) => (chain?.[t]?.reason === "window" || chain?.[t]?.reason === "reserve") &&
160
- config !== undefined &&
161
- withModelOverrides(config, t, {}).agent === backend);
162
- if (moved.length > 0) {
163
- const waiting = MODEL_TIERS.filter((t) => !chain?.[t] && config !== undefined && withModelOverrides(config, t, {}).agent === backend);
164
- return warnOutcome(`${label} window rejected — ${moved.join(", ")} moved to fallbacks until it resets in ${resets}${waiting.length > 0 ? `; ${waiting.join(", ")} wait` : ""}`);
165
- }
166
- return warnOutcome(`${label} window rejected — subscription ${backendDisplayName(backend)} model jobs wait until it resets in ${resets}`);
167
- }
168
- const top = windowEntries(account)[0];
169
- if (top === undefined)
170
- return okOutcome("subscription; no window reported yet");
171
- const pct = typeof top.utilization === "number" ? ` (${utilizationPct(top.utilization)}% used)` : "";
172
- const label = windowLabel(top.type);
173
- return okOutcome(top.status === "allowed_warning" ? `${label} window nearing its limit${pct}` : `${label} window ${top.status}${pct}`);
174
- }
175
136
  /** How far back the `provider hold` check reads for hold/resume crossings. The longest a
176
137
  * hold can stay is HOLD_CAP_MS (team-hold's 15-minute cap that escalation doubles against),
177
138
  * so a window a comfortable multiple of that covers any standing hold's crossing event plus
@@ -182,8 +143,8 @@ const HOLD_CHECK_WINDOW_MS = 2 * HOUR_MS;
182
143
  * that stops the roles on that provider from starting new runs until it re-opens. Read-only:
183
144
  * it infers the standing set from the event log's own `rate_limit_hold`/`rate_limit_resumed`
184
145
  * crossings, the events pollTeamHold writes, so it cannot disagree with the hold. A standing
185
- * hold is a warn (as with the `claude window` row) because it stops every role on that
186
- * provider; `now` is injected so a test can pin a hold standing before its deadline and
146
+ * hold is a warn because it stops every role on that provider; `now` is injected so a test
147
+ * can pin a hold standing before its deadline and
187
148
  * re-opened after. The ended hold's kind rides the resumed event (see pollTeamHold), and any
188
149
  * resumed event ends that provider's current hold whatever its kind. */
189
150
  export function checkProviderHolds(root, now = Date.now()) {
@@ -246,8 +207,8 @@ export function checkErrorStreaks(root, config) {
246
207
  return warnOutcome(`failing run after run: ${phrases.join("; ")}`);
247
208
  }
248
209
  /** The `main red` check: the row lists the runs the merge-side `main_red` pin ended — a red
249
- * main is still recorded as the `main_red` outcome that ended those runs until parts 3d/3–3f/3
250
- * land — and warns when main has been red over RED_MAIN_WARN_MS even with no role held (the
210
+ * main is still recorded as the `main_red` outcome that ended those runs until part 3f/3
211
+ * lands — and warns when main has been red over RED_MAIN_WARN_MS even with no role held (the
251
212
  * guardrail, G1). Read-only: it asks heldByMainRed, the predicate the status phase's `main red`
252
213
  * label reads, so the two cannot disagree on the held runs, and redMainWarning over the event
253
214
  * trail, so both phrases can appear in one row. A warn, not a fail: a red main does not stop
@@ -8,7 +8,7 @@ import { readEventsSince } from "../events/event-window.js";
8
8
  import { DAY_MS } from "../text/datetime.js";
9
9
  import { plural, shortSpanPhrase } from "../text/phrases.js";
10
10
  import { errorMessage, textOr } from "../text/text.js";
11
- import { okOutcome, warnOutcome } from "./outcome.js";
11
+ import { okOutcome, warnOutcome } from "./doctor-outcome.js";
12
12
  /** How far back a failed `dep_install` event may sit and still explain the drift. */
13
13
  const DEP_INSTALL_CHECK_WINDOW_MS = DAY_MS;
14
14
  export function checkRootInstall(root, now = Date.now()) {
@@ -6,7 +6,7 @@
6
6
  * launchservicesd facts this reads — the thresholds and the warning text — stay in
7
7
  * src/process/launch-services.ts, shared with the running team's watch. `platform` is a
8
8
  * parameter so the macOS branches are testable on any host. */
9
- import { okOutcome, warnOutcome } from "./outcome.js";
9
+ import { okOutcome, warnOutcome } from "./doctor-outcome.js";
10
10
  import { systemProcessProbe } from "../process/process-table.js";
11
11
  import { LAUNCH_SERVICES_KILL_PORTS, LAUNCH_SERVICES_WARN_PORTS, launchServicesWarning, } from "../process/launch-services.js";
12
12
  import { compactTokens } from "../text/format.js";
@@ -1,13 +1,16 @@
1
1
  import { childEnv } from "../process/process.js";
2
- import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
2
+ import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./doctor-probe.js";
3
3
  import { defaultConfig, loadConfigSafe } from "../config/config.js";
4
4
  import { seamsInUse } from "../config/config-fallbacks.js";
5
5
  import { cacheReadUnpriced, piModelsPath, readPiProviders } from "../pi/pi-models.js";
6
+ import { roleImageInfo } from "../agent/image-input.js";
7
+ import { readPiListedModels } from "../pi/pi-listed-models.js";
8
+ import { runnerIds } from "../roles/runner-ids.js";
6
9
  import { envPath } from "../files/files.js";
7
10
  import { piListedModels, piProviderAuth } from "./doctor-pi-checks.js";
8
11
  import { agree } from "../text/phrases.js";
9
12
  import { joinParts } from "../text/text.js";
10
- import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
13
+ import { failOutcome, okOutcome, warnOutcome } from "./doctor-outcome.js";
11
14
  import { belowNodeFloor } from "../version.js";
12
15
  /** The one phrase a configured-models check uses when the config declares no models at all:
13
16
  * informational (capability falls to pi's own defaults), not a warning. Shared by
@@ -30,8 +33,9 @@ const PHRASE_CANNOT_READ_MODELS = "cannot check — could not read pi's model de
30
33
  * modules do. The claude, opencode, codex and pi provider checks live in their own per-backend
31
34
  * modules (doctor-claude-checks.ts, doctor-opencode-checks.ts, doctor-codex-checks.ts,
32
35
  * doctor-pi-checks.ts), each importing this file's shared seam framework and the AUTH_CHECK_*
33
- * probe budgets from probe.ts rather than duplicating them. The shared report contract
34
- * (CheckOutcome) is imported from outcome.ts, exactly as the other doctor-* modules do. Together
36
+ * probe budgets from doctor-probe.ts rather than duplicating them. The shared report contract
37
+ * (CheckOutcome) is imported from doctor-outcome.ts, exactly as the other doctor-*
38
+ * modules do. Together
35
39
  * these are the doctor's only checks that read pi's model definitions and, for the probes, spawn
36
40
  * an agent binary — a distinct concern from the filesystem/git checks, kept together so a change
37
41
  * to how doctor reads models does not touch the environment checks. */
@@ -131,30 +135,8 @@ export function codexSeamsInUse(config) {
131
135
  export function piSeamsInUse(config) {
132
136
  return seamsInUse(config).some((s) => s.agent === "pi");
133
137
  }
134
- /** Declared model readiness — plans/model-tiers.md "Doctor" (part 7c/8): every model the config can
135
- * put on a seam must resolve in pi's definitions, and its provider must report `ready` from the
136
- * agent binary's own auth check. A strong tier pi cannot resolve would fail every review, and so
137
- * every merge — the check exists so the operator finds out before the team does, mid-review. Pairs
138
- * collected in the same sweep teamModelsFree prices: each tier's top-level selector (a string is
139
- * default's), every enabled role's resolved pair (per-role overrides included; tier-name overrides
140
- * resolve through configForRole), and the reviewer's while review is on. A pair with no model is
141
- * pi's own default and out of scope; fallback pairs are the sibling check's (checkFallbackModel
142
- * prices the cap pair). Levels: an unresolvable pair fails, a provider that is not ready warns
143
- * (credentials are fixable without a config edit), a probe that cannot run warns rather than
144
- * guesses, and a priced model whose cache reads declare $0 warns — the daily budget would
145
- * undercount it (BUGS.md 2026-10-06). When one of PI_SMOL_MODEL / PI_SLOW_MODEL / PI_PLAN_MODEL is
146
- * set the check also reports that tumwater does not read it — the variables are oh-my-pi's, pi
147
- * ignores them, and with omp as `agentBin` they still reach omp through the inherited environment
148
- * (src/pi/pi-backend.ts, built on src/process/run-marker.ts's backendBaseEnv) — pointing at the
149
- * tier keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests
150
- * need no pi on PATH; `env` so the PI_* branches stay hermetic. */
151
- export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env, listModels = () => piListedModels(loadConfigSafe(root).config ?? defaultConfig(), envPath(), AUTH_CHECK_TIMEOUT_MS, root)) {
152
- const config = loadConfigForCheck(root);
153
- if ("level" in config)
154
- return config;
155
- // Asked lazily and at most once: only a pair models.json does not declare needs the listing.
156
- let listing;
157
- const listed = () => (listing ??= listModels());
138
+ /** One job: sweep the config's seam pairs into the pair map and the routed-models map. */
139
+ function collectTierPairs(config) {
158
140
  const pairs = new Map();
159
141
  // Routed (non-pi) models keyed by the backend they resolved to, so the routing note names
160
142
  // each backend correctly; opencode is not claude.
@@ -175,75 +157,100 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
175
157
  };
176
158
  for (const pair of seamPairs(config))
177
159
  add(pair.agent, pair.provider, pair.model);
160
+ return { pairs, routedModels };
161
+ }
162
+ /** One job: compute the oh-my-pi variables note, and whether any is set. */
163
+ function ompTierNote(env) {
178
164
  const ompVars = ["PI_SMOL_MODEL", "PI_SLOW_MODEL", "PI_PLAN_MODEL"].filter((v) => env[v] !== undefined && env[v] !== "");
179
- const ompNote = ompVars.length === 0
180
- ? ""
181
- : ` — ${ompVars.join(" and ")} ${agree(ompVars.length, "is", "are")} set: tumwater does not read ${agree(ompVars.length, "it", "them")} (oh-my-pi's variables; pi ignores them; with omp as agentBin they still reach omp through the inherited environment) — set model.small / model.strong instead`;
182
- if (pairs.size === 0 && routedModels.size === 0)
183
- return (ompVars.length === 0 ? okOutcome : warnOutcome)(`${PHRASE_NO_MODELS_DECLARED}${ompNote}`);
165
+ return {
166
+ set: ompVars.length > 0,
167
+ note: ompVars.length === 0
168
+ ? ""
169
+ : ` — ${ompVars.join(" and ")} ${agree(ompVars.length, "is", "are")} set: tumwater does not read ${agree(ompVars.length, "it", "them")} (oh-my-pi's variables; pi ignores them; with omp as agentBin they still reach omp through the inherited environment) — set model.small / model.strong instead`,
170
+ };
171
+ }
172
+ /** One job: read pi's provider definitions, or the empty map when the file is unreadable but
173
+ * every pair names a provider the listing can back (the unreadable case the caller warns on). */
174
+ async function readTierProviders(modelsPath, pairs, listed) {
184
175
  let providers = readPiProviders(modelsPath);
185
176
  if (!providers && [...pairs.values()].some((p) => p.provider !== undefined) && (await listed()))
186
177
  providers = new Map();
187
- const fails = [];
178
+ return providers;
179
+ }
180
+ /** One job: the fail string for a pair neither pi's definitions nor (when reachable) its
181
+ * listing resolve, or undefined when the listing does contain it. */
182
+ function unresolvedModelFail(provider, model, defs, listedModels, modelsPath) {
183
+ if (listedModels?.has(`${provider}/${model}`))
184
+ return undefined;
185
+ const providerListed = listedModels != null && [...listedModels].some((k) => k.startsWith(`${provider}/`));
186
+ if (!defs && !providerListed) {
187
+ return listedModels === null
188
+ ? `provider ${provider} is not in pi's definitions (${modelsPath})`
189
+ : `provider ${provider} is not in pi's definitions (${modelsPath}) and \`pi --list-models\` lists none of its models — pi lists only providers it has credentials for`;
190
+ }
191
+ if (listedModels === null) {
192
+ // No exact entry: pi clones the provider's default model for the id, inheriting that
193
+ // default's price and context window (BUGS.md 2026-10-06) — name that consequence
194
+ // instead of only the failed lookup.
195
+ return `${provider}/${model} does not resolve in pi's definitions (${modelsPath}) — pi would price it at the provider default's rates and context window`;
196
+ }
197
+ return `${provider}/${model} is neither in pi's definitions (${modelsPath}) nor listed by \`pi --list-models\``;
198
+ }
199
+ /** One job: the warn string for a priced model whose cache reads declare $0, or undefined. */
200
+ function cacheReadWarning(providers, provider, model, def) {
201
+ if (def && cacheReadUnpriced(providers, provider, model)) {
202
+ return `${provider}/${model} prices cache reads at $0 — the daily budget will undercount its spend`;
203
+ }
204
+ return undefined;
205
+ }
206
+ /** One job: probe one provider's auth once, reporting ready or the warn when it is not. */
207
+ async function probeTierProvider(provider, auth, checkedProviders) {
208
+ if (checkedProviders.has(provider))
209
+ return {};
210
+ checkedProviders.add(provider);
211
+ const verdict = await auth(provider);
212
+ if (verdict === "ready")
213
+ return { ready: true };
214
+ if (verdict === "not-ready")
215
+ return { warn: `provider ${provider} reports not ready — check its credentials` };
216
+ return { warn: `could not verify provider ${provider} — the auth check did not run or its output was unreadable` };
217
+ }
218
+ /** One job: resolve one pi pair — the per-token claude warn, the definitions lookup and its
219
+ * unresolved fail, the free-cache-reads warn, and the once-per-provider auth probe. */
220
+ async function resolveTierPair(provider, model, providers, listed, auth, checkedProviders, modelsPath) {
188
221
  const warns = [];
189
- if (pairs.size > 0 && !providers)
190
- return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}${ompNote}`);
191
- const readyProviders = new Set();
192
- const checkedProviders = new Set();
193
- for (const { provider, model } of pairs.values()) {
194
- if (provider === undefined) {
195
- warns.push(`${model} names no provider — pi resolves it with its own default, tumwater cannot verify it`);
196
- continue;
197
- }
198
- // A Claude model routed to pi (plans/provider-concurrency.md "Routing a bare Claude model id
199
- // to claude"): a named provider keeps it on pi, but since Anthropic's 2026-04-04
200
- // third-party-harness change a Claude plan's limits cover only first-party tools, so it is
201
- // billed per token even under a Claude subscription login — the bare `claude-…` form on the
202
- // claude CLI uses the plan's included usage instead.
203
- if (model?.startsWith("claude-")) {
204
- warns.push(`${provider}/${model} runs on pi and is billed per token, not the Claude plan's included usage — use a bare claude-… id to route it to the claude CLI`);
205
- }
206
- const defs = providers?.get(provider);
207
- const def = defs?.find((d) => d.id === model);
208
- if (!def) {
209
- const listedModels = await listed();
210
- if (!listedModels?.has(`${provider}/${model}`)) {
211
- const providerListed = listedModels != null && [...listedModels].some((k) => k.startsWith(`${provider}/`));
212
- if (!defs && !providerListed) {
213
- fails.push(listedModels === null
214
- ? `provider ${provider} is not in pi's definitions (${modelsPath})`
215
- : `provider ${provider} is not in pi's definitions (${modelsPath}) and \`pi --list-models\` lists none of its models — pi lists only providers it has credentials for`);
216
- }
217
- else if (listedModels === null) {
218
- // No exact entry: pi clones the provider's default model for the id, inheriting that
219
- // default's price and context window (BUGS.md 2026-10-06) — name that consequence
220
- // instead of only the failed lookup.
221
- fails.push(`${provider}/${model} does not resolve in pi's definitions (${modelsPath}) — pi would price it at the provider default's rates and context window`);
222
- }
223
- else {
224
- fails.push(`${provider}/${model} is neither in pi's definitions (${modelsPath}) nor listed by \`pi --list-models\``);
225
- }
226
- continue;
227
- }
228
- }
229
- if (def && providers && cacheReadUnpriced(providers, provider, model)) {
230
- // A priced model whose cache reads declare $0: pi bills cache-read tokens at zero and
231
- // the daily budget undercounts the real spend (BUGS.md 2026-10-06).
232
- warns.push(`${provider}/${model} prices cache reads at $0 — the daily budget will undercount its spend`);
233
- }
234
- if (!checkedProviders.has(provider)) {
235
- checkedProviders.add(provider);
236
- const verdict = await auth(provider);
237
- if (verdict === "ready")
238
- readyProviders.add(provider);
239
- else if (verdict === "not-ready")
240
- warns.push(`provider ${provider} reports not ready — check its credentials`);
241
- else
242
- warns.push(`could not verify provider ${provider} — the auth check did not run or its output was unreadable`);
243
- }
222
+ if (provider === undefined) {
223
+ warns.push(`${model} names no provider — pi resolves it with its own default, tumwater cannot verify it`);
224
+ return { warns };
244
225
  }
245
- const found = [...fails, ...warns].join("; ");
246
- const resolvedPi = `${pairs.size} declared ${agree(pairs.size, "model", "models")} resolve${readyProviders.size > 0 ? ` and ${agree(readyProviders.size, "provider", "all providers")} ${[...readyProviders].sort().join(", ")} report ready` : ""}`;
226
+ // A Claude model routed to pi (plans/provider-concurrency.md "Routing a bare Claude model id
227
+ // to claude"): a named provider keeps it on pi, but since Anthropic's 2026-04-04
228
+ // third-party-harness change a Claude plan's limits cover only first-party tools, so it is
229
+ // billed per token even under a Claude subscription login — the bare `claude-…` form on the
230
+ // claude CLI uses the plan's included usage instead.
231
+ if (model?.startsWith("claude-")) {
232
+ warns.push(`${provider}/${model} runs on pi and is billed per token, not the Claude plan's included usage — use a bare claude-… id to route it to the claude CLI`);
233
+ }
234
+ const defs = providers?.get(provider);
235
+ const def = defs?.find((d) => d.id === model);
236
+ if (!def) {
237
+ const listedModels = await listed();
238
+ const fail = unresolvedModelFail(provider, model, defs, listedModels, modelsPath);
239
+ if (fail !== undefined)
240
+ return { warns, fail };
241
+ }
242
+ if (providers) {
243
+ const warning = cacheReadWarning(providers, provider, model, def);
244
+ if (warning !== undefined)
245
+ warns.push(warning);
246
+ }
247
+ const probe = await probeTierProvider(provider, auth, checkedProviders);
248
+ if (probe.warn !== undefined)
249
+ warns.push(probe.warn);
250
+ return { warns, ready: probe.ready };
251
+ }
252
+ /** One job: one routing note per non-pi backend in use, joined. */
253
+ function routedTierNotes(routedModels) {
247
254
  // One routing note per non-pi backend in use, so an opencode-routed model is never labeled
248
255
  // as claude's. The opencode note also carries the design §2 caveat that a bare `opencode/…`
249
256
  // id always means the OpenCode CLI, never pi's built-in `opencode` gateway. Every note opens
@@ -260,13 +267,67 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
260
267
  ? routedNote(agent, n, "resolved by the codex backend, not pi's definitions")
261
268
  : routedNote(agent, n, "resolved by the claude backend, not pi's definitions"));
262
269
  }
263
- const routedText = routedNotes.length > 0 ? routedNotes.join("; ") : "";
270
+ return routedNotes.length > 0 ? routedNotes.join("; ") : "";
271
+ }
272
+ /** One job: assemble the final fail/warn/ok outcome from the collected verdicts. */
273
+ function tierOutcome(fails, warns, pairsCount, readyProviders, routedText, ompNote, ompSet) {
274
+ const found = [...fails, ...warns].join("; ");
275
+ const resolvedPi = `${pairsCount} declared ${agree(pairsCount, "model", "models")} resolve${readyProviders.size > 0 ? ` and ${agree(readyProviders.size, "provider", "all providers")} ${[...readyProviders].sort().join(", ")} report ready` : ""}`;
264
276
  const base = fails.length === 0 && warns.length === 0
265
- ? joinParts([pairs.size > 0 ? resolvedPi : "", routedText])
277
+ ? joinParts([pairsCount > 0 ? resolvedPi : "", routedText])
266
278
  : found;
267
279
  if (fails.length > 0)
268
280
  return failOutcome(`${base}${ompNote}`);
269
- return (warns.length > 0 || ompVars.length > 0 ? warnOutcome : okOutcome)(`${base}${ompNote}`);
281
+ return (warns.length > 0 || ompSet ? warnOutcome : okOutcome)(`${base}${ompNote}`);
282
+ }
283
+ /** Declared model readiness — plans/model-tiers.md "Doctor" (part 7c/8): every model the config can
284
+ * put on a seam must resolve in pi's definitions, and its provider must report `ready` from the
285
+ * agent binary's own auth check. A strong tier pi cannot resolve would fail every review, and so
286
+ * every merge — the check exists so the operator finds out before the team does, mid-review. Pairs
287
+ * collected in the same sweep teamModelsFree prices: each tier's top-level selector (a string is
288
+ * default's), every enabled role's resolved pair (per-role overrides included; tier-name overrides
289
+ * resolve through configForRole), and the reviewer's while review is on. A pair with no model is
290
+ * pi's own default and out of scope; fallback pairs are the sibling check's (checkFallbackModel
291
+ * prices the cap pair). Levels: an unresolvable pair fails, a provider that is not ready warns
292
+ * (credentials are fixable without a config edit), a probe that cannot run warns rather than
293
+ * guesses, and a priced model whose cache reads declare $0 warns — the daily budget would
294
+ * undercount it (BUGS.md 2026-10-06). When one of PI_SMOL_MODEL / PI_SLOW_MODEL / PI_PLAN_MODEL is
295
+ * set the check also reports that tumwater does not read it — the variables are oh-my-pi's, pi
296
+ * ignores them, and with omp as `agentBin` they still reach omp through the inherited environment
297
+ * (src/pi/pi-backend.ts, built on src/process/run-marker.ts's backendBaseEnv) — pointing at the
298
+ * tier keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests
299
+ * need no pi on PATH; `env` so the PI_* branches stay hermetic. The inline jobs are split into
300
+ * the module-private helpers `collectTierPairs`, `ompTierNote`, `readTierProviders`,
301
+ * `resolveTierPair`, `unresolvedModelFail`, `cacheReadWarning`, `probeTierProvider`,
302
+ * `routedTierNotes` and `tierOutcome` so each is found by name. */
303
+ export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env, listModels = () => piListedModels(loadConfigSafe(root).config ?? defaultConfig(), envPath(), AUTH_CHECK_TIMEOUT_MS, root)) {
304
+ const config = loadConfigForCheck(root);
305
+ if ("level" in config)
306
+ return config;
307
+ // Asked lazily and at most once: only a pair models.json does not declare needs the listing.
308
+ let listing;
309
+ const listed = () => (listing ??= listModels());
310
+ const { pairs, routedModels } = collectTierPairs(config);
311
+ const { set: ompSet, note: ompNote } = ompTierNote(env);
312
+ if (pairs.size === 0 && routedModels.size === 0)
313
+ return (ompSet ? warnOutcome : okOutcome)(`${PHRASE_NO_MODELS_DECLARED}${ompNote}`);
314
+ const providers = await readTierProviders(modelsPath, pairs, listed);
315
+ const fails = [];
316
+ const warns = [];
317
+ if (pairs.size > 0 && !providers)
318
+ return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}${ompNote}`);
319
+ const readyProviders = new Set();
320
+ const checkedProviders = new Set();
321
+ for (const { provider, model } of pairs.values()) {
322
+ const result = await resolveTierPair(provider, model, providers, listed, auth, checkedProviders, modelsPath);
323
+ warns.push(...result.warns);
324
+ if (result.fail !== undefined)
325
+ fails.push(result.fail);
326
+ if (result.ready === true)
327
+ readyProviders.add(provider);
328
+ }
329
+ const routedText = routedTierNotes(routedModels);
330
+ return tierOutcome(fails, warns, pairs.size, readyProviders, routedText, ompNote, ompSet);
270
331
  }
271
332
  /** `gh` readiness for the opt-in `feedback` observer (plans/feedback-role.md "Shape" → "Part 1"):
272
333
  * the role files issues as the operator's GitHub login through `gh`, so doctor confirms `gh`
@@ -289,3 +350,26 @@ export async function checkFeedbackFiling(root, pathEnv = envPath(), probe = (ar
289
350
  return failOutcome("gh is not authenticated — run `gh auth login` — the feedback role files issues as your GitHub login");
290
351
  return okOutcome("gh present and authenticated — the feedback role can file issues on znation/tumwater");
291
352
  }
353
+ /** The `image input` row (plans/image-input.md "Surfaces", part 5/5): which enabled roles run
354
+ * on a model that cannot read images, and where each reroutes an image prompt — or that no
355
+ * usable reroute exists. Re-added probe-free by Subtract part 1d/6 after part 5f/6 deleted the
356
+ * probe-backed row: it reads roleImageInfo (the same resolver the status rows, role page, and
357
+ * submit gate share) rather than spawning a backend, so doctor and the surfaces cannot drift on
358
+ * who is text-only. No alert when none is (imageInput true or null — an unknown pair is not
359
+ * counted as text-only, mirroring the submit gate and the `models for each tier` stance). */
360
+ export function checkImageInput(root, modelsPath = piModelsPath(), listed = readPiListedModels(root)) {
361
+ const config = loadConfigForCheck(root);
362
+ if ("level" in config)
363
+ return config;
364
+ const textOnly = runnerIds(config).filter((role) => roleImageInfo(config, role, modelsPath, listed).imageInput === false);
365
+ if (textOnly.length === 0)
366
+ return okOutcome("every enabled role reads images");
367
+ const noTarget = textOnly.filter((role) => {
368
+ const rr = roleImageInfo(config, role, modelsPath, listed).imageReroute;
369
+ return rr === null || rr === "";
370
+ });
371
+ if (noTarget.length > 0)
372
+ return warnOutcome(`${noTarget.join(", ")} ${agree(noTarget.length, "runs", "run")} on models that can't read images and ${agree(noTarget.length, "has", "have")} no reroute target — a run that reads an image fails, and image attachments to these roles are refused. Declare image input on a vision model in pi's models.json, or point a tier at one.`);
373
+ const targets = new Set(textOnly.map((role) => roleImageInfo(config, role, modelsPath, listed).imageReroute ?? ""));
374
+ return okOutcome(`${textOnly.join(", ")} ${agree(textOnly.length, "runs", "run")} on models that can't read images — image prompts rerun on ${[...targets].join(", ")}.`);
375
+ }
@@ -1,9 +1,9 @@
1
1
  import { eventsSince } from "../events/event-window.js";
2
- import { isNotifyFailure } from "../events/notify.js";
2
+ import { isNotifyFailure } from "../events/event-notify.js";
3
3
  import { DAY_MS, humanAgo } from "../text/datetime.js";
4
4
  import { plural } from "../text/phrases.js";
5
5
  import { errorMessage, squash } from "../text/text.js";
6
- import { okOutcome, warnOutcome } from "./outcome.js";
6
+ import { okOutcome, warnOutcome } from "./doctor-outcome.js";
7
7
  /** How far back the `notify` check looks for failed pages: a day, fixed. An opinionated
8
8
  * default with no knob, like STALE_BUG_DAYS: long enough that a transient hook failure shows
9
9
  * up, short enough that a page that did not go through is still the operator's business. */
@@ -1,7 +1,7 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import { childEnv } from "../process/process.js";
4
- import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
4
+ import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./doctor-probe.js";
5
5
  import { opencodeBackend, opencodeSpillDir } from "../agent/opencode/opencode-backend.js";
6
6
  import { opencodeModelFree, opencodeModelKey, readOpencodeCache } from "../agent/opencode/opencode-catalog.js";
7
7
  import { formatDate } from "../text/datetime.js";
@@ -9,7 +9,7 @@ import { opencodeDbPath } from "../paths.js";
9
9
  import { envPath, isDirectory, readTextOrNull, statOrNull, treeBytes } from "../files/files.js";
10
10
  import { formatBytes, tokenWindowLabel } from "../text/format.js";
11
11
  import { escapeRegExp } from "../text/text.js";
12
- import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
12
+ import { failOutcome, okOutcome, warnOutcome } from "./doctor-outcome.js";
13
13
  import { loadConfigForCheck, opencodeSeamsInUse, seamedModels, } from "./doctor-model-checks.js";
14
14
  /** The doctor's opencode credential and leak readiness check, split out of doctor-model-checks.ts
15
15
  * (whose shared seam/model framework and the pi and tier-models checks stay there):
@@ -19,8 +19,9 @@ import { loadConfigForCheck, opencodeSeamsInUse, seamedModels, } from "./doctor-
19
19
  * filesystem/git checks beside it, kept together so a change to how doctor reads opencode does
20
20
  * not touch the other model checks. The shared report contract (CheckOutcome) and the
21
21
  * seam/model framework (loadConfigForCheck, opencodeSeamsInUse, seamPairs) come from
22
- * doctor-model-checks.ts, the AUTH_CHECK_* probe budgets from probe.ts, and the shared report
23
- * contract (CheckOutcome) from outcome.ts, as the other doctor-* modules do. */
22
+ * doctor-model-checks.ts, the AUTH_CHECK_* probe budgets from doctor-probe.ts, and the
23
+ * shared report contract (CheckOutcome) from doctor-outcome.ts, as the other doctor-*
24
+ * modules do. */
24
25
  /** The provider of a non-gateway opencode model id (`opencode/<provider>/<model>`), or
25
26
  * undefined for a gateway id (a bare `opencode/<model>` or `<model>`, served by OpenCode's
26
27
  * own gateway with no credential). A model id with more than the `opencode/` namespace prefix