tumwater 0.2.0 → 0.4.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 (464) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +55 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +6 -4
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +404 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +281 -55
  41. package/dist/src/backlog/backlog-cli-view.js +255 -0
  42. package/dist/src/backlog/backlog-cli.js +91 -335
  43. package/dist/src/backlog/backlog-commit.js +20 -13
  44. package/dist/src/backlog/backlog-eligibility.js +193 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +264 -0
  48. package/dist/src/backlog/backlog-release.js +134 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-guardrails.js +136 -0
  56. package/dist/src/baseline/main-red.js +153 -36
  57. package/dist/src/baseline/main-watch.js +232 -0
  58. package/dist/src/brief/brief-edit.js +12 -12
  59. package/dist/src/brief/brief.js +31 -51
  60. package/dist/src/budget/budget-fallback-view.js +288 -0
  61. package/dist/src/budget/budget-wiring.js +158 -0
  62. package/dist/src/budget/budget.js +31 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +65 -3
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +181 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +183 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +291 -598
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-review-commands.js +113 -0
  84. package/dist/src/cli/cli-role-command.js +525 -0
  85. package/dist/src/cli/cli-start-summary.js +167 -0
  86. package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
  87. package/dist/src/cli/completion.js +169 -22
  88. package/dist/src/cli/config-commands.js +58 -26
  89. package/dist/src/cli/follow-view.js +75 -0
  90. package/dist/src/cli/help.js +258 -82
  91. package/dist/src/cli/log-commands.js +164 -85
  92. package/dist/src/cli/question-commands.js +211 -10
  93. package/dist/src/cli.js +173 -45
  94. package/dist/src/collections.js +56 -13
  95. package/dist/src/concurrency/check-permit.js +20 -3
  96. package/dist/src/concurrency/lock.js +17 -13
  97. package/dist/src/concurrency/provider-lease.js +44 -8
  98. package/dist/src/concurrency/semaphore.js +102 -26
  99. package/dist/src/config/config-aliases.js +0 -1
  100. package/dist/src/config/config-editable-keys.js +16 -1
  101. package/dist/src/config/config-fallbacks.js +94 -56
  102. package/dist/src/config/config-field-checks.js +40 -10
  103. package/dist/src/config/config-keys.js +30 -8
  104. package/dist/src/config/config-live.js +63 -24
  105. package/dist/src/config/config-request.js +137 -0
  106. package/dist/src/config/config-schema.js +31 -24
  107. package/dist/src/config/config-selector-checks.js +123 -0
  108. package/dist/src/config/config-validation-agents.js +80 -0
  109. package/dist/src/config/config-validation-model.js +116 -0
  110. package/dist/src/config/config-validation.js +169 -222
  111. package/dist/src/config/config-views.js +136 -54
  112. package/dist/src/config/config-write-core.js +135 -0
  113. package/dist/src/config/config-write.js +164 -265
  114. package/dist/src/config/config.js +53 -38
  115. package/dist/src/config/model-selector.js +34 -34
  116. package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
  117. package/dist/src/doctor/doctor-backlog-plans.js +398 -0
  118. package/dist/src/doctor/doctor-backlog.js +249 -512
  119. package/dist/src/doctor/doctor-check-names.js +85 -0
  120. package/dist/src/doctor/doctor-checks.js +61 -593
  121. package/dist/src/doctor/doctor-claude-checks.js +142 -0
  122. package/dist/src/doctor/doctor-codex-checks.js +276 -0
  123. package/dist/src/doctor/doctor-contention-checks.js +506 -0
  124. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  125. package/dist/src/doctor/doctor-holds.js +80 -66
  126. package/dist/src/doctor/doctor-install.js +2 -1
  127. package/dist/src/doctor/doctor-model-checks.js +216 -537
  128. package/dist/src/doctor/doctor-notify.js +9 -5
  129. package/dist/src/doctor/doctor-opencode-checks.js +164 -0
  130. package/dist/src/doctor/doctor-orphans.js +182 -63
  131. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  132. package/dist/src/doctor/doctor-project-checks.js +397 -0
  133. package/dist/src/doctor/doctor.js +126 -77
  134. package/dist/src/doctor/image-probe.js +21 -8
  135. package/dist/src/doctor/outcome.js +3 -2
  136. package/dist/src/doctor/probe.js +21 -12
  137. package/dist/src/events/event-format.js +185 -89
  138. package/dist/src/events/event-labels.js +11 -5
  139. package/dist/src/events/event-legacy.js +39 -15
  140. package/dist/src/events/event-read.js +52 -39
  141. package/dist/src/events/event-tally.js +16 -0
  142. package/dist/src/events/event-window.js +80 -26
  143. package/dist/src/events/events.js +30 -13
  144. package/dist/src/events/notify.js +7 -7
  145. package/dist/src/failure/failure-cluster.js +12 -12
  146. package/dist/src/failure/failure-data.js +65 -60
  147. package/dist/src/failure/failure-render.js +29 -21
  148. package/dist/src/failure/failure-state-change.js +17 -12
  149. package/dist/src/failure/time-spend.js +35 -35
  150. package/dist/src/files/file-queue.js +17 -0
  151. package/dist/src/files/files.js +111 -35
  152. package/dist/src/files/json-files.js +3 -3
  153. package/dist/src/files/json-object.js +28 -7
  154. package/dist/src/files/stat-cache.js +2 -2
  155. package/dist/src/files/tail.js +1 -1
  156. package/dist/src/gates/bootstrap-gates.js +4 -5
  157. package/dist/src/gates/budget-gates.js +88 -225
  158. package/dist/src/gates/disk-gate.js +1 -1
  159. package/dist/src/gates/gate-polls.js +101 -143
  160. package/dist/src/gates/maintenance-quota.js +37 -30
  161. package/dist/src/gates/pause-gates.js +28 -10
  162. package/dist/src/gates/readiness.js +7 -3
  163. package/dist/src/gates/role-cap-gates.js +8 -8
  164. package/dist/src/gates/startup-gate.js +94 -32
  165. package/dist/src/gates/streak-gate.js +54 -30
  166. package/dist/src/git/commit-message.js +89 -46
  167. package/dist/src/git/git-diff.js +16 -10
  168. package/dist/src/git/git-run.js +79 -38
  169. package/dist/src/git/git.js +54 -34
  170. package/dist/src/git/slot-salvage.js +253 -0
  171. package/dist/src/git/slots-state.js +6 -6
  172. package/dist/src/git/worktree-pool.js +132 -38
  173. package/dist/src/git/worktree-use.js +4 -11
  174. package/dist/src/git/worktree.js +94 -37
  175. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  176. package/dist/src/gui/gui-args.js +79 -8
  177. package/dist/src/gui/gui-backlog-commands.js +43 -19
  178. package/dist/src/gui/gui-backlog-endpoints.js +156 -0
  179. package/dist/src/gui/gui-command.js +26 -13
  180. package/dist/src/gui/gui-config-commands.js +168 -80
  181. package/dist/src/gui/gui-endpoint-commands.js +5 -4
  182. package/dist/src/gui/gui-endpoint-common.js +4 -4
  183. package/dist/src/gui/gui-endpoints.js +11 -380
  184. package/dist/src/gui/gui-history-endpoints.js +301 -0
  185. package/dist/src/gui/gui-operator-commands.js +235 -13
  186. package/dist/src/gui/gui-prompt-commands.js +26 -20
  187. package/dist/src/gui/gui-report-endpoints.js +97 -0
  188. package/dist/src/gui/gui-server.js +26 -5
  189. package/dist/src/gui/gui-team-commands.js +7 -7
  190. package/dist/src/gui/http-body.js +1 -1
  191. package/dist/src/history/history-data.js +122 -234
  192. package/dist/src/history/history-queued.js +155 -0
  193. package/dist/src/history/history-render.js +42 -22
  194. package/dist/src/history/history.js +190 -49
  195. package/dist/src/inbox/inbox-attachments.js +29 -30
  196. package/dist/src/inbox/inbox-cancel.js +10 -11
  197. package/dist/src/inbox/inbox-edit.js +9 -9
  198. package/dist/src/inbox/inbox-submit.js +18 -18
  199. package/dist/src/inbox/inbox-top.js +28 -17
  200. package/dist/src/inbox/inbox.js +42 -43
  201. package/dist/src/inbox/pending-prompt.js +26 -26
  202. package/dist/src/inbox/prompt-commands.js +62 -49
  203. package/dist/src/inbox/prompt-not-before.js +2 -2
  204. package/dist/src/init/init-templates.js +1 -2
  205. package/dist/src/init/init.js +28 -23
  206. package/dist/src/merge/backlog-conflicts.js +215 -35
  207. package/dist/src/merge/merge-batch.js +14 -8
  208. package/dist/src/merge/merge-cancel.js +53 -0
  209. package/dist/src/merge/merge-check-failures.js +44 -22
  210. package/dist/src/merge/merge-core.js +58 -32
  211. package/dist/src/merge/merge-diff.js +5 -4
  212. package/dist/src/merge/merge-drain.js +45 -19
  213. package/dist/src/merge/merge-git.js +177 -38
  214. package/dist/src/merge/merge-pipeline.js +15 -3
  215. package/dist/src/merge/merge-post-review.js +214 -0
  216. package/dist/src/merge/merge-queue-render.js +48 -11
  217. package/dist/src/merge/merge-queue.js +52 -15
  218. package/dist/src/merge/merge-requeue.js +103 -0
  219. package/dist/src/merge/merge-slot.js +61 -16
  220. package/dist/src/merge/merge-stack.js +27 -11
  221. package/dist/src/merge/merge-to-main.js +77 -26
  222. package/dist/src/merge/merge-top.js +83 -0
  223. package/dist/src/merge/merge-verify.js +75 -18
  224. package/dist/src/notes-cli.js +35 -15
  225. package/dist/src/operator/operator-commands.js +192 -81
  226. package/dist/src/operator/operator-intent.js +69 -42
  227. package/dist/src/operator/operator-requests.js +35 -40
  228. package/dist/src/operator/retire.js +5 -6
  229. package/dist/src/operator/revert.js +142 -34
  230. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  231. package/dist/src/orchestrator/orchestrator-poll.js +352 -0
  232. package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
  233. package/dist/src/orchestrator/orchestrator.js +129 -369
  234. package/dist/src/orchestrator/retention.js +42 -8
  235. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  236. package/dist/src/orchestrator/scheduling-scope.js +245 -0
  237. package/dist/src/orchestrator/scheduling-skip.js +195 -0
  238. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  239. package/dist/src/orchestrator/startup-migrations.js +11 -7
  240. package/dist/src/paths.js +126 -49
  241. package/dist/src/pi/command-shape.js +12 -1
  242. package/dist/src/pi/pi-args.js +6 -4
  243. package/dist/src/pi/pi-backend.js +6 -6
  244. package/dist/src/pi/pi-bin.js +54 -63
  245. package/dist/src/pi/pi-listed-models.js +53 -0
  246. package/dist/src/pi/pi-models.js +63 -30
  247. package/dist/src/pi/pi-run-result.js +1 -1
  248. package/dist/src/pi/pi-settings.js +1 -1
  249. package/dist/src/pi/pi-stream.js +6 -6
  250. package/dist/src/pi/pi-watchdogs.js +18 -19
  251. package/dist/src/pi-extension/bounded-output.js +5 -3
  252. package/dist/src/pi-extension/context-budget.js +2 -2
  253. package/dist/src/pi-extension/context-shake.js +1 -1
  254. package/dist/src/pi-extension/context-window.js +0 -1
  255. package/dist/src/pi-extension/full-output.js +3 -10
  256. package/dist/src/pi-extension/strip-thinking.js +0 -1
  257. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  258. package/dist/src/pi-extension/write-guard.js +37 -0
  259. package/dist/src/process/launch-services.js +1 -2
  260. package/dist/src/process/node-bin.js +16 -0
  261. package/dist/src/process/process-group.js +8 -18
  262. package/dist/src/process/process.js +41 -2
  263. package/dist/src/process/run-marker.js +26 -6
  264. package/dist/src/process/supervisor.js +22 -21
  265. package/dist/src/prompt/dialect.js +17 -2
  266. package/dist/src/prompt/gate-notes.js +203 -0
  267. package/dist/src/prompt/gate-prompts.js +150 -217
  268. package/dist/src/prompt/notes-backlog.js +184 -0
  269. package/dist/src/prompt/principles.js +1 -2
  270. package/dist/src/prompt/prompt-followup.js +19 -14
  271. package/dist/src/prompt/prompt.js +131 -204
  272. package/dist/src/redeploy/redeploy-policy.js +24 -19
  273. package/dist/src/redeploy/redeploy-probes.js +1 -1
  274. package/dist/src/redeploy/redeploy.js +2 -3
  275. package/dist/src/redeploy/redeployer.js +20 -20
  276. package/dist/src/redeploy/self-reload.js +2 -1
  277. package/dist/src/report/report-data.js +188 -124
  278. package/dist/src/report/report-fold.js +130 -54
  279. package/dist/src/report/report-main-red.js +55 -0
  280. package/dist/src/report/report-render.js +136 -65
  281. package/dist/src/report/report-types.js +1 -0
  282. package/dist/src/report/report.js +23 -9
  283. package/dist/src/request-timeouts.js +2 -2
  284. package/dist/src/review/diff-rules.js +53 -0
  285. package/dist/src/review/known-flakes.js +112 -1
  286. package/dist/src/review/post-merge-review-accept.js +51 -0
  287. package/dist/src/review/post-merge-review-render.js +112 -0
  288. package/dist/src/review/post-merge-review.js +239 -0
  289. package/dist/src/review/protected-edits.js +24 -10
  290. package/dist/src/review/quarantine-render.js +65 -0
  291. package/dist/src/review/review-followup.js +8 -8
  292. package/dist/src/review/review-precheck.js +66 -23
  293. package/dist/src/review/review-run.js +395 -0
  294. package/dist/src/review/review-verdict.js +2 -2
  295. package/dist/src/review/review.js +180 -401
  296. package/dist/src/review/secret-leaks.js +36 -16
  297. package/dist/src/review/suite-rerun.js +0 -1
  298. package/dist/src/roles/legacy-role-migration.js +80 -7
  299. package/dist/src/roles/role-catalog.js +127 -12
  300. package/dist/src/roles/role-guidance.js +65 -20
  301. package/dist/src/roles/role-labels.js +7 -5
  302. package/dist/src/roles/role-render.js +108 -56
  303. package/dist/src/roles/role-view.js +95 -56
  304. package/dist/src/roles/roles.js +33 -33
  305. package/dist/src/roles/runner-ids.js +97 -0
  306. package/dist/src/run/acceptance-greps.js +119 -0
  307. package/dist/src/run/ci-parity.js +121 -0
  308. package/dist/src/run/feedback-filings.js +51 -0
  309. package/dist/src/run/qa-coverage.js +125 -0
  310. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  311. package/dist/src/run/run-detail-data.js +175 -0
  312. package/dist/src/run/run-detail-render.js +91 -0
  313. package/dist/src/run/run-detail.js +168 -0
  314. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
  315. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  316. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
  317. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  318. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
  319. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  320. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  321. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
  322. package/dist/src/run/selfcheck-digest.js +40 -0
  323. package/dist/src/run/stage-backlog-checks.js +249 -0
  324. package/dist/src/run/stage-check.js +178 -0
  325. package/dist/src/run/stage-checks-shared.js +65 -0
  326. package/dist/src/run/stage-name-checks.js +204 -0
  327. package/dist/src/run/stage-path-checks.js +231 -0
  328. package/dist/src/run/stage-prose-checks.js +261 -0
  329. package/dist/src/runner/leftover.js +216 -0
  330. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  331. package/dist/src/runner/revision.js +191 -0
  332. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  333. package/dist/src/runner/runner-outcome.js +109 -0
  334. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  335. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
  336. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
  337. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  338. package/dist/src/runner/runner-state.js +103 -0
  339. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  340. package/dist/src/scheduling/backoff.js +38 -38
  341. package/dist/src/scheduling/claim-entries.js +49 -0
  342. package/dist/src/scheduling/claims.js +35 -22
  343. package/dist/src/scheduling/once-round.js +22 -22
  344. package/dist/src/scheduling/quiet-hours.js +5 -6
  345. package/dist/src/scheduling/scheduling.js +74 -64
  346. package/dist/src/scheduling/work-merged-cache.js +2 -2
  347. package/dist/src/status/status-budget.js +55 -47
  348. package/dist/src/status/status-data.js +49 -17
  349. package/dist/src/status/status-inbox.js +2 -2
  350. package/dist/src/status/status-install.js +0 -1
  351. package/dist/src/status/status-polls.js +43 -28
  352. package/dist/src/status/status-roles.js +50 -13
  353. package/dist/src/team/error-storm.js +8 -8
  354. package/dist/src/team/failure-spread.js +2 -2
  355. package/dist/src/team/orchestrator-info.js +15 -9
  356. package/dist/src/team/reclaim.js +7 -8
  357. package/dist/src/team/team-hold.js +7 -7
  358. package/dist/src/team/team-polls.js +91 -50
  359. package/dist/src/team/team-state.js +8 -2
  360. package/dist/src/text/datetime.js +65 -18
  361. package/dist/src/text/format.js +48 -11
  362. package/dist/src/text/markdown.js +8 -5
  363. package/dist/src/text/phrases.js +60 -23
  364. package/dist/src/text/suggest.js +1 -1
  365. package/dist/src/text/text.js +78 -13
  366. package/dist/src/ui/badges.js +99 -38
  367. package/dist/src/ui/gui/gui-client-backlog.js +314 -0
  368. package/dist/src/ui/gui/gui-client-boot.js +89 -61
  369. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  370. package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
  371. package/dist/src/ui/gui/gui-client-drawer.js +155 -289
  372. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  373. package/dist/src/ui/gui/gui-client-history.js +437 -68
  374. package/dist/src/ui/gui/gui-client-labels.js +6 -3
  375. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  376. package/dist/src/ui/gui/gui-client-model.js +27 -36
  377. package/dist/src/ui/gui/gui-client-operator.js +60 -52
  378. package/dist/src/ui/gui/gui-client-pending.js +405 -24
  379. package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
  380. package/dist/src/ui/gui/gui-client-report.js +105 -430
  381. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  382. package/dist/src/ui/gui/gui-client-settings.js +163 -5
  383. package/dist/src/ui/gui/gui-client-team.js +36 -465
  384. package/dist/src/ui/gui/gui-client.js +38 -26
  385. package/dist/src/ui/gui/gui-icons.js +0 -2
  386. package/dist/src/ui/gui/gui-page.js +10 -7
  387. package/dist/src/ui/gui/gui-styles.js +38 -34
  388. package/dist/src/ui/progress-data.js +9 -11
  389. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
  390. package/dist/src/ui/status-model.js +133 -125
  391. package/dist/src/ui/status-payload.js +364 -49
  392. package/dist/src/ui/status-render.js +109 -82
  393. package/dist/src/ui/team-alerts.js +728 -62
  394. package/dist/src/ui/tone.js +12 -6
  395. package/dist/src/ui/transcript-run.js +58 -0
  396. package/dist/src/ui/transcript-tail.js +28 -20
  397. package/dist/src/ui/transcript.js +63 -23
  398. package/dist/src/ui/tui/tui-app.js +0 -1
  399. package/dist/src/ui/tui/tui-backlog.js +53 -14
  400. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  401. package/dist/src/ui/tui/tui-editor-attach.js +4 -12
  402. package/dist/src/ui/tui/tui-editor-backends.js +127 -0
  403. package/dist/src/ui/tui/tui-editor-brief.js +14 -36
  404. package/dist/src/ui/tui/tui-editor-budget.js +36 -39
  405. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  406. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  407. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  408. package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
  409. package/dist/src/ui/tui/tui-editor-config.js +99 -0
  410. package/dist/src/ui/tui/tui-editor-entry.js +39 -64
  411. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  412. package/dist/src/ui/tui/tui-editor-instances.js +46 -0
  413. package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
  414. package/dist/src/ui/tui/tui-editor-interval.js +29 -16
  415. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  416. package/dist/src/ui/tui/tui-editor-notes.js +9 -29
  417. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  418. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  419. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  420. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  421. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  422. package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
  423. package/dist/src/ui/tui/tui-editor-retire.js +5 -13
  424. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  425. package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
  426. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  427. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  428. package/dist/src/ui/tui/tui-editor-session.js +101 -34
  429. package/dist/src/ui/tui/tui-editor-slots.js +24 -0
  430. package/dist/src/ui/tui/tui-editors.js +44 -2
  431. package/dist/src/ui/tui/tui-frame.js +68 -11
  432. package/dist/src/ui/tui/tui-history.js +8 -9
  433. package/dist/src/ui/tui/tui-input.js +13 -11
  434. package/dist/src/ui/tui/tui-keymap.js +0 -1
  435. package/dist/src/ui/tui/tui-keys.js +33 -9
  436. package/dist/src/ui/tui/tui-pane-activity.js +35 -19
  437. package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
  438. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  439. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  440. package/dist/src/ui/tui/tui-pane-health.js +168 -8
  441. package/dist/src/ui/tui/tui-pane-history.js +446 -82
  442. package/dist/src/ui/tui/tui-pane-pending.js +539 -15
  443. package/dist/src/ui/tui/tui-pane-queued.js +78 -57
  444. package/dist/src/ui/tui/tui-pane-report.js +54 -16
  445. package/dist/src/ui/tui/tui-pane-session.js +34 -6
  446. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  447. package/dist/src/ui/tui/tui-pane.js +91 -28
  448. package/dist/src/ui/tui/tui-panes.js +153 -34
  449. package/dist/src/ui/tui/tui.js +18 -15
  450. package/dist/src/verdict/fix-claim.js +129 -33
  451. package/dist/src/verdict/no-change.js +4 -4
  452. package/dist/src/verdict/refusal.js +40 -16
  453. package/dist/src/verdict/reply-contract.js +77 -31
  454. package/dist/src/version.js +0 -1
  455. package/package.json +2 -1
  456. package/dist/src/loop/leftover.js +0 -130
  457. package/dist/src/loop/revision.js +0 -70
  458. package/dist/src/roles/loop-ids.js +0 -77
  459. package/dist/src/tick/qa-coverage.js +0 -108
  460. package/dist/src/tick/stage-check.js +0 -419
  461. package/dist/src/tick/telemetry-digest.js +0 -25
  462. package/dist/src/tick/tick-detail-data.js +0 -108
  463. package/dist/src/tick/tick-detail.js +0 -163
  464. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,175 +1,69 @@
1
- // vocabulary: plain
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
1
  import { childEnv } from "../process/process.js";
6
- import { boundedProbe } from "./probe.js";
7
- import { opencodeBackend, opencodeSpillDir } from "../agent/opencode/opencode-backend.js";
8
- import { opencodeModelKey, readOpencodeCache } from "../agent/opencode/opencode-catalog.js";
9
- import { opencodeDbPath } from "../paths.js";
10
- import { readTextOrNull, statOrNull } from "../files/files.js";
2
+ import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
11
3
  import { enabledRoleIds, defaultConfig, loadConfigSafe } from "../config/config.js";
12
- import { configForRole, resolvedAgentConfig } from "../config/config-views.js";
13
- import { fallbackPair, resolveTierFallbacks, seamsInUse, tierChainSteps } from "../config/config-fallbacks.js";
14
- import { cacheReadUnpriced, fallbackModelPair, piModelsPath, piPairFree, piProviderBaseUrl, readPiProviders } from "../pi/pi-models.js";
4
+ import { configForRole } from "../config/config-views.js";
5
+ import { resolveTierFallbacks, seamsInUse } from "../config/config-fallbacks.js";
6
+ import { cacheReadUnpriced, piModelsPath, piPairFree, readPiProviders } from "../pi/pi-models.js";
15
7
  import { MODEL_TIERS } from "../config/config-schema.js";
16
- import { isAgentId } from "../config/model-selector.js";
17
8
  import { imageReroute, seamImageInput } from "../agent/image-input.js";
18
9
  import { modelLabel, usesPiModels } from "../agent/backend-traits.js";
19
- import { agentBackend } from "../agent/agent-backend.js";
10
+ import { backendById } from "../agent/agent-backend.js";
20
11
  import { imageProbeEntryFresh, imageProbeKey, readImageProbeCache, runImageProbe, writeImageProbeCache, } from "./image-probe.js";
21
- import { claudeBackend, claudeConfigDir } from "../agent/claude/claude-backend.js";
22
- import { claudeAuthUsesSubscription, parseClaudeAuthStatus, readClaudeAccount } from "../agent/claude/claude-account.js";
23
- import { claudeEnv } from "../agent/claude/claude-env.js";
24
- import { parseJsonObject } from "../files/json-object.js";
12
+ import { readClaudeAccount } from "../agent/claude/claude-account.js";
25
13
  import { envPath } from "../files/files.js";
26
- import { modelPairName, pairDailyCost } from "../budget/budget.js";
27
- import { usd, usdCap } from "../text/format.js";
28
- import { loadLoopStates } from "./doctor-holds.js";
29
- import { resolveAgentBin } from "../pi/pi-bin.js";
14
+ import { piListedModels, piProviderAuth } from "./doctor-pi-checks.js";
15
+ import { readPiListedModels } from "../pi/pi-listed-models.js";
30
16
  import { agree } from "../text/phrases.js";
31
- import { formatDate, formatTime } from "../text/datetime.js";
32
- import { belowNodeFloor } from "../version.js";
17
+ import { formatDate } from "../text/datetime.js";
18
+ import { errorMessage, joinParts } from "../text/text.js";
33
19
  import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
20
+ import { belowNodeFloor } from "../version.js";
21
+ /** The one phrase a configured-models check uses when the config declares no models at all:
22
+ * informational (capability falls to pi's own defaults), not a warning. Shared by
23
+ * checkTierModels and checkImageInput so the two checks word the same "nothing configured"
24
+ * stance identically. */
25
+ const PHRASE_NO_MODELS_DECLARED = "no models declared — every model job uses pi's own default";
26
+ /** The one phrase a configured-models check uses when pi's definitions file could not be read
27
+ * at all: the check cannot answer what it was asked, so it warns instead of guessing. Shared by
28
+ * checkTierModels and checkImageInput so the two checks word the "cannot check" stance
29
+ * identically; each appends its own modelsPath after the shared stem, and checkTierModels
30
+ * also appends its ompNote. */
31
+ const PHRASE_CANNOT_READ_MODELS = "cannot check — could not read pi's model definitions at ";
34
32
  /** The doctor's model-readiness checks, split out of doctor-checks.ts (whose environment and
35
- * repo checks stay there): the cap fallback's price check (checkFallbackModel), the agent
36
- * binary's own credential probe (piProviderAuth), and the declared-tier resolution check
37
- * (checkTierModels). These three are the doctor's only checks that read pi's model definitions
38
- * and, for the credential probe, spawn the agent binary — a distinct concern from the
39
- * filesystem/git checks beside them, kept together so a change to how doctor reads models does
40
- * not touch the environment checks. The shared report contract (CheckOutcome) is imported from
41
- * outcome.ts, exactly as doctor-orphans.ts and doctor-backlog.ts do. */
33
+ * repo-setup checks stay there, and whose state/disk/build checks live in
34
+ * doctor-project-checks.ts), and split apart again so one theme owns one module: this file holds
35
+ * the shared config guard, the seam framework (seamPairs and the per-backend seam predicates),
36
+ * and the declared-tier and image-input checks (checkTierModels, checkImageInput). The cap
37
+ * fallback price and fallback-chain checks (checkFallbackModel, checkFallbackChain) live in
38
+ * doctor-fallback-checks.ts, importing this file's loadConfigForCheck just as the per-backend
39
+ * modules do. The claude, opencode, codex and pi provider checks live in their own per-backend
40
+ * modules (doctor-claude-checks.ts, doctor-opencode-checks.ts, doctor-codex-checks.ts,
41
+ * doctor-pi-checks.ts), each importing this file's shared seam framework and the AUTH_CHECK_*
42
+ * probe budgets from probe.ts rather than duplicating them. The shared report contract
43
+ * (CheckOutcome) is imported from outcome.ts, exactly as the other doctor-* modules do. Together
44
+ * these are the doctor's only checks that read pi's model definitions and, for the probes, spawn
45
+ * an agent binary — a distinct concern from the filesystem/git checks, kept together so a change
46
+ * to how doctor reads models does not touch the environment checks. */
42
47
  /** The config a readiness check reads, or the warn outcome a broken tumwater.json earns:
43
48
  * checkInit already fails on a broken config, so a model check only reports that it could not
44
- * run. One home for the guard and its wording, shared by checkFallbackModel and
45
- * checkTierModels (the same shape doctor-backlog.ts's readDocChecked uses for unreadable
46
- * files). The config or the warn outcome, so each check's one-line guard — `const config =
47
- * loadConfigForCheck(root); if ("level" in config) return config;` — is all a check needs to
48
- * read its config instead of repeating a load-and-unwrap prologue. */
49
- function loadConfigForCheck(root) {
49
+ * run. One home for the guard and its wording, shared by checkTierModels here and by
50
+ * doctor-fallback-checks.ts and the per-backend modules (the same shape doctor-backlog.ts's
51
+ * readDocChecked uses for unreadable files). The config or the warn outcome, so each check's
52
+ * one-line guard — `const config = loadConfigForCheck(root); if ("level" in config) return
53
+ * config;` — is all a check needs to read its config instead of repeating a
54
+ * load-and-unwrap prologue. */
55
+ export function loadConfigForCheck(root) {
50
56
  const { config, error } = loadConfigSafe(root);
51
57
  if (config === undefined)
52
58
  return warnOutcome(`cannot check — ${error}`);
53
59
  return config;
54
60
  }
55
- /** Fallback model readiness — the daily-cost budget's third state (plans/fallback-model.md): with
56
- * `fallbackModel` set, pi's definitions must price that pair at zero or the gate refuses it and
57
- * roles pause at the cap exactly as if no fallback existed. That refusal is the right runtime
58
- * behavior (spend must never climb past the cap), but a typo'd id would otherwise surface only
59
- * after the day's budget is already spent — so doctor checks it up front, before the operator
60
- * needs it. Read-only: it inspects pi's definitions file, never pi or the network. A price is not
61
- * readiness, though: whether the backend can SERVE is known only from the fallback's own ticks
62
- * (src/budget/fallback-breaker.ts's breaker, BUGS.md 2026-09-20), so a free pair reads "serving not
63
- * verified" — unless the running orchestrator has published that its breaker demoted the pair
64
- * (`demoted`, from orchestrator.json; runDoctor passes it only while that orchestrator is alive),
65
- * which warns with the failure count and the next probe time. No fallbackModel configured is
66
- * informational, not a warning — plenty of teams intend to stop at the cap. `modelsPath` is
67
- * injectable so tests need no real pi install. */
68
- export function checkFallbackModel(root, modelsPath = piModelsPath(), demoted = null) {
69
- const config = loadConfigForCheck(root);
70
- if ("level" in config)
71
- return config;
72
- const declared = fallbackPair(config);
73
- if (!declared)
74
- return okOutcome("none configured — roles pause at the cap");
75
- // A pair missing either half would fall through to pi's own (unverified) default, so it can
76
- // never be free; naming that case beats rendering a bare "?/model". The free branch names
77
- // the price-resolved pair (a map with no `default` entry borrows another tier's), while the
78
- // refusal names the pair the operator declared.
79
- const nameOf = (pair) => pair.provider && pair.model ? `${pair.provider}/${pair.model}` : "a half-resolved pair";
80
- const free = fallbackModelPair(config, modelsPath, readClaudeAccount(root));
81
- if (free) {
82
- const name = nameOf(free);
83
- if (demoted)
84
- return warnOutcome(`${name} is priced at zero but not serving — the running team demoted it after ${demoted.failures} consecutive failed runs, so roles pause at the cap; one probe run retries it from ${formatTime(new Date(demoted.probeAt))}`);
85
- return okOutcome(`${name} — priced at zero (cost n/a), serving not verified`);
86
- }
87
- return warnOutcome(`${nameOf(declared)} is not priced at zero in ${modelsPath} — at the cap roles pause instead of switching`);
88
- }
89
- /** Whether a local server answers `GET <url>`: node's built-in fetch with a short timeout, false
90
- * on any failure — doctor is a pre-flight, not a place to wait out a hung server. */
91
- async function probeModels(url) {
92
- try {
93
- const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
94
- return res.ok;
95
- }
96
- catch {
97
- return false;
98
- }
99
- }
100
- const isLocalUrl = (url) => {
101
- try {
102
- const host = new URL(url).hostname;
103
- return host === "127.0.0.1" || host === "localhost";
104
- }
105
- catch {
106
- return false;
107
- }
108
- };
109
- /** The `fallback chain` check: one fragment per step of every tier's chain saying whether the
110
- * step can run now — a local pi server that does not answer (warn), a step whose own daily cap is
111
- * spent, a free pair, a claude step with its billing, or a priced pair. Reads config, pi's
112
- * models.json and the loop states; only a pi step on 127.0.0.1/localhost makes a network
113
- * request. `probe` is injectable so tests need no real server. */
114
- export async function checkFallbackChain(root, modelsPath = piModelsPath(), probe = probeModels) {
115
- const config = loadConfigForCheck(root);
116
- if ("level" in config)
117
- return config;
118
- const steps = tierChainSteps(config);
119
- if (steps.length === 0)
120
- return okOutcome("none configured");
121
- const states = loadLoopStates(root, config);
122
- const providers = readPiProviders(modelsPath);
123
- const account = readClaudeAccount(root);
124
- const now = Date.now();
125
- let down = false;
126
- const fragments = [];
127
- for (const { tier, step, pair } of steps) {
128
- // A provider that names a backend other than pi (claude today; opencode from part 2) never
129
- // asks pi for a base URL or runs pi's price probe — only a pi step may.
130
- const nonPiBackend = isAgentId(pair.provider) && pair.provider !== "pi";
131
- const base = !nonPiBackend && pair.provider ? piProviderBaseUrl(modelsPath, pair.provider) : null;
132
- const local = base !== null && isLocalUrl(base);
133
- const up = local ? await probe(`${base}/models`) : null;
134
- const cap = pair.maxDailyCostUsd;
135
- const spent = cap !== undefined ? pairDailyCost(states, modelPairName(pair), now) : 0;
136
- let verdict;
137
- if (up === false) {
138
- down = true;
139
- verdict = `not running (GET ${base}/models failed)`;
140
- }
141
- else if (cap !== undefined && spent >= cap) {
142
- verdict = `cap reached (${usd(spent)}/${usdCap(cap)})`;
143
- }
144
- else if (nonPiBackend) {
145
- verdict = `claude ${pair.extraUsage ? "extra usage" : (account?.billing ?? "billing unknown")}`;
146
- }
147
- else if (piPairFree(providers, pair.provider, pair.model, account)) {
148
- verdict = up ? "free · server up" : "free";
149
- }
150
- else {
151
- verdict = cap !== undefined ? `priced, ${usd(spent)}/${usdCap(cap)} today` : "priced, team cap only";
152
- }
153
- fragments.push(`${tier} ${step + 1}: ${pair.provider ?? "?"}/${pair.model ?? "?"} — ${verdict}`);
154
- }
155
- const detail = fragments.join("; ");
156
- return down ? warnOutcome(detail) : okOutcome(detail);
157
- }
158
- /** How long the credential probe may take before it counts as unknown — a doctor run is a
159
- * pre-flight, not a place to wait out a hung backend. */
160
- export const AUTH_CHECK_TIMEOUT_MS = 15_000;
161
- /** Cap on the probe's captured stdout. The auth reply is a tiny JSON object; a binary whose
162
- * output is a runaway log (or a pipe held open with no EOF) must not grow `out` without bound.
163
- * Sibling probes share EXEC_MAX_BUFFER, but 1 MiB is already orders of magnitude past any real
164
- * reply, so it is the tighter bound here. Over it the probe kills the child and reads as
165
- * unknown. */
166
- export const AUTH_CHECK_MAX_OUTPUT_BYTES = 1024 * 1024;
167
61
  /** Every model a seam can run, paired with its backend (plans/agent-backends.md "Config"): the
168
62
  * distinct pairs of the one seamsInUse list — every enabled role, the review gate, the
169
63
  * resolver, the director, and each tier's budget/model-failure fallback — so a claude or pi
170
64
  * pair that exists only as a fallback is checked exactly like a primary one. Deduplicated by
171
65
  * backend/provider/model, so a model reached through several surfaces is checked once. */
172
- function seamPairs(config) {
66
+ export function seamPairs(config) {
173
67
  const pairs = new Map();
174
68
  for (const seam of seamsInUse(config)) {
175
69
  if (seam.model === undefined)
@@ -183,6 +77,46 @@ function seamPairs(config) {
183
77
  }
184
78
  return [...pairs.values()];
185
79
  }
80
+ /** The model-carrying seam pairs for one backend — `seamPairs` with model guaranteed present
81
+ * (seamPairs never emits a pair without a model, so the guard only narrows the type) and
82
+ * narrowed to `agent`'s pairs. The backend readiness checks (claude, codex, opencode) each
83
+ * walk exactly this filtered list, so they share one filter rather than three copies. */
84
+ export function seamedModels(config, agent) {
85
+ return seamPairs(config).filter((p) => p.agent === agent && p.model !== undefined);
86
+ }
87
+ /** The dotted version one of the backend CLIs prints in its `--version` output (`2.0.32`),
88
+ * or undefined when it names none. claude and codex print a bare version; opencode prints it
89
+ * prefixed with `v` (`opencode v2.0.32`), which the caller opts into with `requireVPrefix` so
90
+ * its check cannot mistake a version-like string missing the `v`. One home for the parser so
91
+ * the claude, codex and opencode version checks cannot drift on the version shape they accept.
92
+ */
93
+ function parseCliVersion(output, requireVPrefix = false) {
94
+ return (requireVPrefix ? /v(\d+\.\d+\.\d+)/ : /(\d+\.\d+\.\d+)/).exec(output)?.[1];
95
+ }
96
+ /** The dashed version verdict of one backend `--version` probe: the parsed dotted version
97
+ * when the probe ran and exited 0, or undefined when it could not run, errored, or named no
98
+ * version. The one home of the `code === 0 ? parseCliVersion : undefined` step the claude,
99
+ * codex and opencode version checks each repeated, so they cannot drift on how a null or
100
+ * non-zero probe reads (codex had already drifted to the `?.` form). `requireVPrefix` is
101
+ * parseCliVersion's, passed through for opencode's `v`-prefixed output. */
102
+ export function versionFromProbe(result, requireVPrefix = false) {
103
+ return result !== null && result.code === 0
104
+ ? parseCliVersion(result.stdout, requireVPrefix)
105
+ : undefined;
106
+ }
107
+ /** The warn/fail/ok verdict for a backend CLI's `--version` check against one flat minimum
108
+ * version (codex's checkCodexVersion and opencode's checkOpencodeVersion both require a
109
+ * single floor for the whole backend, unlike claude's per-model floors). `found` is the
110
+ * parsed dotted version, or undefined when the probe could not run or its output was
111
+ * unreadable. Centralizing the three messages and the belowNodeFloor comparison keeps the
112
+ * two backends' version verdicts from drifting. */
113
+ export function minVersionOutcome(name, found, min, upgradeHint) {
114
+ if (found === undefined)
115
+ return warnOutcome(`could not check — \`${name} --version\` did not run or its output was unreadable`);
116
+ if (belowNodeFloor(found, `>=${min}`))
117
+ return failOutcome(`${name} ${found} is older than ${min} — ${upgradeHint}`);
118
+ return okOutcome(`${name} ${found} (>= ${min})`);
119
+ }
186
120
  /** Whether any seam runs on the claude backend — the doctor's claude checks stand down when
187
121
  * none does (a pi-only team reads no `~/.claude` state and spawns no claude). */
188
122
  export function claudeSeamsInUse(config) {
@@ -193,6 +127,11 @@ export function claudeSeamsInUse(config) {
193
127
  export function opencodeSeamsInUse(config) {
194
128
  return seamPairs(config).some((pair) => pair.agent === "opencode");
195
129
  }
130
+ /** Whether any seam routes to codex — the doctor's codex checks stand down when none does
131
+ * (a pi-only team spawns no codex and reads no codex home state). */
132
+ export function codexSeamsInUse(config) {
133
+ return seamPairs(config).some((pair) => pair.agent === "codex");
134
+ }
196
135
  /** Whether any seam routes to the pi backend — the doctor's pi-extension row stands down when
197
136
  * none does (an all-claude or all-opencode team reads no `~/.pi` state). Unlike the model-based
198
137
  * seam consult, this scans the routed seams directly (not seamPairs' model-filtered view) so a
@@ -201,153 +140,6 @@ export function opencodeSeamsInUse(config) {
201
140
  export function piSeamsInUse(config) {
202
141
  return seamsInUse(config).some((s) => s.agent === "pi");
203
142
  }
204
- /** The default credential probe: run the resolved agent binary's own auth check for one provider
205
- * and read `ready` out of its JSON. The binary resolves exactly as the spawn does (resolveAgentBin
206
- * — the same source a real tick would launch), so doctor tests the auth of the pi it would actually
207
- * run. Never throws; every failure mode ends on "unknown". The bounded subprocess is the shared
208
- * boundedProbe (probe.ts), also used by the claude probes. */
209
- export async function piProviderAuth(config, provider, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
210
- const resolved = resolveAgentBin(config);
211
- const result = await boundedProbe(resolved.bin, ["auth", "check", "--provider", provider, "--json"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
212
- if (result === null)
213
- return "unknown";
214
- if (result.code !== 0)
215
- return "unknown";
216
- // An unreadable reply is unknown, never not-ready (which would blame the credentials).
217
- const doc = parseJsonObject(result.stdout);
218
- if (doc === null)
219
- return "unknown";
220
- if (doc.ready === true)
221
- return "ready";
222
- if (doc.ready === false)
223
- return "not-ready";
224
- return "unknown";
225
- }
226
- /** One bounded probe of the resolved `claude` binary under the scrubbed run environment a real
227
- * tick gets: resolve the binary through the claude adapter that would spawn it and run `args`
228
- * with a short deadline. Null when the probe could not run, the child errored, or the deadline
229
- * passed. The one home of the resolveBin + claudeEnv pairing the auth and version checks share,
230
- * so neither probes a different binary or environment than the other. */
231
- function claudeProbe(config, args, pathEnv, timeoutMs) {
232
- const resolved = claudeBackend.resolveBin(config);
233
- // The configured team config dir must reach the probe's env the way a real run's does, or
234
- // the auth/version rows would look in claude's default dir (usually ~/.claude) while a run
235
- // with backends.claude.configDir writes and reads elsewhere — the very mismatch doctor exists
236
- // to surface. Mirror backends.run's expansion: only claude accepts configDir, and unset leaves
237
- // today's env byte-identical.
238
- const configDir = resolvedAgentConfig(config, "claude").configDir;
239
- const expandedConfigDir = configDir !== undefined ? claudeConfigDir(process.env, os.homedir(), configDir) : undefined;
240
- const env = claudeEnv(childEnv({ PATH: pathEnv }), {
241
- quietTimeoutSeconds: 0,
242
- ...(expandedConfigDir !== undefined ? { configDir: expandedConfigDir } : {}),
243
- });
244
- return boundedProbe(resolved.bin, args, env, timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
245
- }
246
- /** Run `claude auth status` under the same scrubbed environment a real run gets (claudeEnv),
247
- * or null when the probe could not run or its reply was unreadable. */
248
- async function claudeAuthProbe(config, pathEnv, timeoutMs) {
249
- const result = await claudeProbe(config, ["auth", "status"], pathEnv, timeoutMs);
250
- if (result?.code !== 0)
251
- return null;
252
- return parseClaudeAuthStatus(result.stdout);
253
- }
254
- /** claude credential readiness (plans/agent-backends.md "Every seam, checked"): a claude seam
255
- * whose login is gone fails every tick before it can work, so doctor runs `claude auth status`
256
- * under the scrubbed run environment and fails when it reports `loggedIn: false`. An ok detail
257
- * names the billing the reply implies — the subscription type, or an API key — so an operator
258
- * telling subscription spend from API spend does not have to run the command themselves. A
259
- * probe that could not run or a reply this cannot read warns rather than guesses. Stands down
260
- * (ok) when no seam routes to claude, so a pi-only team spawns nothing. */
261
- export async function checkClaudeAuth(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
262
- const config = loadConfigForCheck(root);
263
- if ("level" in config)
264
- return config;
265
- if (!claudeSeamsInUse(config))
266
- return okOutcome("no model jobs run on claude");
267
- const auth = await claudeAuthProbe(config, pathEnv, timeoutMs);
268
- if (auth === null)
269
- return warnOutcome("could not check — `claude auth status` did not run or its output was unreadable");
270
- if (!auth.loggedIn)
271
- return failOutcome("claude reports logged in: false — run `claude auth login`");
272
- const subscription = claudeAuthUsesSubscription(auth);
273
- const billing = subscription
274
- ? `subscription${auth.subscriptionType !== undefined ? ` (${auth.subscriptionType})` : ""}`
275
- : "API key";
276
- return okOutcome(`logged in — billing: ${billing}`);
277
- }
278
- /** The subscription-vs-API billing trap (plans/provider-concurrency.md §9 gap 6): an operator
279
- * who exports `ANTHROPIC_API_KEY` for pi's `anthropic` provider will silently move every claude
280
- * seam to API billing — claude prefers the key over a claude.ai login — and subscription seams
281
- * stop counting as free. Doctor warns when the key is set, some seam routes to claude, and
282
- * `claude auth status` reports a claude.ai login (the subscription the key would override). It
283
- * stays silent (ok) when no seam runs on claude, when the key is unset, or when claude reports
284
- * an API-key login (then there is no subscription to lose). `env` is injectable so the check
285
- * stays hermetic; a probe that cannot run warns rather than guesses like checkClaudeAuth. */
286
- export async function checkAnthroApiKey(root, env = process.env, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
287
- const config = loadConfigForCheck(root);
288
- if ("level" in config)
289
- return config;
290
- if (!claudeSeamsInUse(config))
291
- return okOutcome("no model jobs run on claude");
292
- const apiKey = env.ANTHROPIC_API_KEY;
293
- if (apiKey === undefined || apiKey === "")
294
- return okOutcome("no ANTHROPIC_API_KEY set");
295
- const auth = await claudeAuthProbe(config, pathEnv, timeoutMs);
296
- if (auth === null)
297
- return warnOutcome("could not check — `claude auth status` did not run or its output was unreadable");
298
- if (!auth.loggedIn || !claudeAuthUsesSubscription(auth))
299
- return okOutcome("ANTHROPIC_API_KEY is set, but claude reports no claude.ai login");
300
- return warnOutcome("ANTHROPIC_API_KEY is set while claude reports a claude.ai login — model jobs on claude will bill the API key, not the subscription; unset it or route every anthropic model job on pi explicitly");
301
- }
302
- /** The dotted version in `claude --version`'s output, or undefined when it names none. */
303
- function parseClaudeCliVersion(output) {
304
- return /(\d+\.\d+\.\d+)/.exec(output)?.[1];
305
- }
306
- /** claude CLI-version readiness (plans/agent-backends.md finding 9): an installed CLI below a
307
- * configured model's minimum fails or misprices that model, and team runs disable the
308
- * auto-updater, so the version an operator has is the version ticks run. Fails below the
309
- * highest floor among the claude-routed models, naming which model needs it; an ok detail
310
- * prints the installed version and the floor it satisfied (or no floor when none applies).
311
- * The `off`/`minimal` thinking clamp is noted here too, since this check already has the
312
- * claude seams in hand. Stands down when no seam routes to claude. */
313
- export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
314
- const config = loadConfigForCheck(root);
315
- if ("level" in config)
316
- return config;
317
- const seams = seamPairs(config).filter((pair) => pair.agent === "claude" && pair.model !== undefined);
318
- if (seams.length === 0)
319
- return okOutcome("no model jobs run on claude");
320
- // The floor each configured model needs; a model the adapter does not gate contributes none.
321
- const floors = new Map();
322
- for (const seam of seams) {
323
- const min = claudeBackend.minVersion?.(seam.model);
324
- if (min !== undefined)
325
- floors.set(seam.model, min);
326
- }
327
- const result = await claudeProbe(config, ["--version"], pathEnv, timeoutMs);
328
- const found = result !== null && result.code === 0 ? parseClaudeCliVersion(result.stdout) : undefined;
329
- if (found === undefined)
330
- return warnOutcome("could not check — `claude --version` did not run or its output was unreadable");
331
- const clamped = seams.some((seam) => seam.thinking === "off" || seam.thinking === "minimal");
332
- const clampNote = clamped
333
- ? "; `off`/`minimal` thinking clamps to `--effort low` (claude has no lower setting)"
334
- : "";
335
- if (floors.size === 0)
336
- return okOutcome(`claude ${found}${clampNote}`);
337
- // The highest floor decides: whichever model needs the newest CLI. Below it, fail and name
338
- // that model; at or above, print the version against the floor.
339
- let required = "";
340
- let neededBy = "";
341
- for (const [model, min] of floors) {
342
- if (required === "" || belowNodeFloor(required, `>=${min}`)) {
343
- required = min;
344
- neededBy = model;
345
- }
346
- }
347
- if (belowNodeFloor(found, `>=${required}`))
348
- return failOutcome(`claude ${found} is older than ${required} required by ${neededBy} — run \`claude update\``);
349
- return okOutcome(`claude ${found} (>= ${required} for ${neededBy})${clampNote}`);
350
- }
351
143
  /** Declared model readiness — plans/model-tiers.md "Doctor" (part 7c/8): every model the config can
352
144
  * put on a seam must resolve in pi's definitions, and its provider must report `ready` from the
353
145
  * agent binary's own auth check. A strong tier pi cannot resolve would fail every review, and so
@@ -362,13 +154,16 @@ export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs =
362
154
  * undercount it (BUGS.md 2026-10-06). When one of PI_SMOL_MODEL / PI_SLOW_MODEL / PI_PLAN_MODEL is
363
155
  * set the check also reports that tumwater does not read it — the variables are oh-my-pi's, pi
364
156
  * ignores them, and with omp as `agentBin` they still reach omp through the inherited environment
365
- * (src/pi/pi-backend.ts, built on src/process/run-marker.ts's agentBaseEnv) — pointing at the tier
366
- * keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests need no
367
- * pi on PATH; `env` so the PI_* branches stay hermetic. */
368
- export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env) {
157
+ * (src/pi/pi-backend.ts, built on src/process/run-marker.ts's backendBaseEnv) — pointing at the
158
+ * tier keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests
159
+ * need no pi on PATH; `env` so the PI_* branches stay hermetic. */
160
+ 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)) {
369
161
  const config = loadConfigForCheck(root);
370
162
  if ("level" in config)
371
163
  return config;
164
+ // Asked lazily and at most once: only a pair models.json does not declare needs the listing.
165
+ let listing;
166
+ const listed = () => (listing ??= listModels());
372
167
  const pairs = new Map();
373
168
  // Routed (non-pi) models keyed by the backend they resolved to, so the routing note names
374
169
  // each backend correctly; opencode is not claude.
@@ -394,19 +189,14 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
394
189
  ? ""
395
190
  : ` — ${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`;
396
191
  if (pairs.size === 0 && routedModels.size === 0)
397
- return (ompVars.length === 0 ? okOutcome : warnOutcome)(`no models declared — every model job uses pi's own default${ompNote}`);
398
- const providers = readPiProviders(modelsPath);
192
+ return (ompVars.length === 0 ? okOutcome : warnOutcome)(`${PHRASE_NO_MODELS_DECLARED}${ompNote}`);
193
+ let providers = readPiProviders(modelsPath);
194
+ if (!providers && [...pairs.values()].some((p) => p.provider !== undefined) && (await listed()))
195
+ providers = new Map();
399
196
  const fails = [];
400
197
  const warns = [];
401
- // Reserved agent ids: a pi provider whose name is an agent backend's id can never reach pi
402
- // (the provider segment wins the routing), so its definitions are unreachable — warn.
403
- if (providers) {
404
- const reserved = [...providers.keys()].filter(isAgentId);
405
- if (reserved.length > 0)
406
- warns.push(`pi's definitions (${modelsPath}) declare provider ${reserved.map((p) => `"${p}"`).join(", ")} — the name is reserved for an agent backend, so model ids under it never reach pi`);
407
- }
408
198
  if (pairs.size > 0 && !providers)
409
- return warnOutcome(`cannot check — could not read pi's model definitions at ${modelsPath}${ompNote}`);
199
+ return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}${ompNote}`);
410
200
  const readyProviders = new Set();
411
201
  const checkedProviders = new Set();
412
202
  for (const { provider, model } of pairs.values()) {
@@ -423,19 +213,29 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
423
213
  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`);
424
214
  }
425
215
  const defs = providers?.get(provider);
426
- if (!defs) {
427
- fails.push(`provider ${provider} is not in pi's definitions (${modelsPath})`);
428
- continue;
429
- }
430
- const def = defs.find((d) => d.id === model);
216
+ const def = defs?.find((d) => d.id === model);
431
217
  if (!def) {
432
- // No exact entry: pi clones the provider's default model for the id, inheriting that
433
- // default's price and context window (BUGS.md 2026-10-06) — name that consequence
434
- // instead of only the failed lookup.
435
- 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`);
436
- continue;
218
+ const listedModels = await listed();
219
+ if (!listedModels?.has(`${provider}/${model}`)) {
220
+ const providerListed = listedModels != null && [...listedModels].some((k) => k.startsWith(`${provider}/`));
221
+ if (!defs && !providerListed) {
222
+ fails.push(listedModels === null
223
+ ? `provider ${provider} is not in pi's definitions (${modelsPath})`
224
+ : `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`);
225
+ }
226
+ else if (listedModels === null) {
227
+ // No exact entry: pi clones the provider's default model for the id, inheriting that
228
+ // default's price and context window (BUGS.md 2026-10-06) — name that consequence
229
+ // instead of only the failed lookup.
230
+ 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`);
231
+ }
232
+ else {
233
+ fails.push(`${provider}/${model} is neither in pi's definitions (${modelsPath}) nor listed by \`pi --list-models\``);
234
+ }
235
+ continue;
236
+ }
437
237
  }
438
- if (cacheReadUnpriced(providers, provider, model)) {
238
+ if (def && providers && cacheReadUnpriced(providers, provider, model)) {
439
239
  // A priced model whose cache reads declare $0: pi bills cache-read tokens at zero and
440
240
  // the daily budget undercounts the real spend (BUGS.md 2026-10-06).
441
241
  warns.push(`${provider}/${model} prices cache reads at $0 — the daily budget will undercount its spend`);
@@ -455,37 +255,44 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
455
255
  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` : ""}`;
456
256
  // One routing note per non-pi backend in use, so an opencode-routed model is never labeled
457
257
  // as claude's. The opencode note also carries the design §2 caveat that a bare `opencode/…`
458
- // id always means the OpenCode CLI, never pi's built-in `opencode` gateway.
258
+ // id always means the OpenCode CLI, never pi's built-in `opencode` gateway. Every note opens
259
+ // with the same `<n> <agent>-routed model[s] — ` frame — one phrase in one place (routedNote)
260
+ // so the branches cannot drift on it; the count prints beside the label, so the number form
261
+ // needs agree (not plural, which prints the count).
262
+ const routedNote = (agent, n, tail) => `${n} ${agent}-routed ${agree(n, "model", "models")} — ${tail}`;
459
263
  const routedNotes = [];
460
264
  for (const [agent, models] of routedModels) {
461
265
  const n = models.size;
462
266
  routedNotes.push(agent === "opencode"
463
- ? `${n} opencode-routed ${agree(n, "model", "models")} — \`opencode/…\` ids run on the OpenCode CLI, and pi's built-in \`opencode\` provider is not reachable from tumwater`
464
- : `${n} claude-routed ${agree(n, "model", "models")} — resolved by the claude backend, not pi's definitions`);
267
+ ? routedNote(agent, n, "`opencode/…` ids run on the OpenCode CLI, and pi's built-in `opencode` provider is not reachable from tumwater")
268
+ : agent === "codex"
269
+ ? routedNote(agent, n, "resolved by the codex backend, not pi's definitions")
270
+ : routedNote(agent, n, "resolved by the claude backend, not pi's definitions"));
465
271
  }
466
272
  const routedText = routedNotes.length > 0 ? routedNotes.join("; ") : "";
467
273
  const base = fails.length === 0 && warns.length === 0
468
- ? [pairs.size > 0 ? resolvedPi : "", routedText].filter(Boolean).join("; ")
274
+ ? joinParts([pairs.size > 0 ? resolvedPi : "", routedText])
469
275
  : found;
470
276
  if (fails.length > 0)
471
277
  return failOutcome(`${base}${ompNote}`);
472
278
  return (warns.length > 0 || ompVars.length > 0 ? warnOutcome : okOutcome)(`${base}${ompNote}`);
473
279
  }
474
280
  /** The doctor's image-input capability check (plans/image-input.md "Doctor", parts 1-2/5):
475
- * which of the team's seams claim to read images, and whether every text-only enabled loop can
281
+ * which of the team's seams claim to read images, and whether every text-only enabled role can
476
282
  * reroute to one that does. Part 2 adds the live probe — with `probeImages` "missing" (the
477
283
  * terminal `tumwater doctor` default) each claimed pi model with no fresh cached verdict is
478
- * shown a real image and the answer is checked; with "all" (the `--reprobe` flag) every claimed
479
- * model is re-probed; with "never" (the TUI Health tab and the GUI doctor endpoint) nothing is
480
- * spawned and only cached verdicts are read, so a refresh never spends a model call. claude
481
- * seams are never probed — every Claude model reads images, and a probe would spend subscription
482
- * window to confirm it.
284
+ * shown a real image and the answer is checked; with "all" (the `--reprobe` flag, the
285
+ * dashboard's Re-probe models button and the TUI Health tab's Ctrl+P) every claimed model is
286
+ * re-probed; with "never" (the TUI Health tab and GUI doctor endpoint on entry and refresh)
287
+ * nothing is spawned and only cached verdicts are read, so a refresh never spends a model call.
288
+ * claude seams are never probed — every Claude model reads images, and a probe would spend
289
+ * subscription window to confirm it.
483
290
  *
484
291
  * Covers the same seams as `tier models` (seamPairs — the tiers, enabled roles and the
485
292
  * reviewer) plus the tiers' fallback pairs (the reroute part picks its target among them, so a
486
293
  * text-only fallback would promise a reroute no fallback can serve). A claimed model whose probe
487
294
  * failed (fresh verdict) is dropped from the readers and reads false from seamImageInput, so the
488
- * reroute sweep never sends a text-only loop to a model that lost its probe. `providers`,
295
+ * reroute sweep never sends a text-only role to a model that lost its probe. `providers`,
489
296
  * `account`, `pathEnv` and `probe` are injectable so tests need no real pi install or account. */
490
297
  export async function checkImageInput(root, modelsPath = piModelsPath(), providers = readPiProviders(modelsPath), account, probeImages = "never", pathEnv = envPath(), probe = runImageProbe) {
491
298
  const config = loadConfigForCheck(root);
@@ -495,10 +302,13 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
495
302
  // answer; unknown (null) pairs are left to that check. A missing definitions file only blocks
496
303
  // this check when a seam actually needs it — an all-claude team reads images without it.
497
304
  const accountNow = account === undefined ? readClaudeAccount(root) : account;
305
+ // The free-ness lookup reads the image-input check's one claude account (part 2a/9).
306
+ const accounts = (b) => (b === "claude" ? accountNow : null);
307
+ const listed = readPiListedModels(root);
498
308
  const pairs = seamPairs(config);
499
309
  const needsDefinitions = pairs.some((p) => usesPiModels(p.agent ?? "pi"));
500
310
  if (providers === null && needsDefinitions)
501
- return warnOutcome(`cannot check — could not read pi's model definitions at ${modelsPath}`);
311
+ return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}`);
502
312
  const label = modelLabel;
503
313
  // The catalog claims (raw `input`, ignoring the cache): what to probe and report, including a
504
314
  // model whose earlier probe failed (it must still be named and re-probed by --reprobe).
@@ -506,7 +316,7 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
506
316
  for (const pair of pairs) {
507
317
  if (pair.model === undefined)
508
318
  continue;
509
- if (seamImageInput(pair, providers) === true)
319
+ if (seamImageInput(pair, providers, undefined, listed) === true)
510
320
  claimed.set(imageProbeKey(pair.agent, pair.provider, pair.model), {
511
321
  agent: pair.agent,
512
322
  provider: pair.provider,
@@ -516,16 +326,16 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
516
326
  }
517
327
  // The tiers' fallback pairs: a reroute target must itself read images to help, and part 4's
518
328
  // fallback regime picks among these, so a text-only fallback team is a coverage gap here.
519
- const resolved = resolveTierFallbacks(config, (p) => piPairFree(providers, p.provider, p.model, accountNow));
329
+ const resolved = resolveTierFallbacks(config, (p) => piPairFree(providers, p, accounts));
520
330
  for (const tier of MODEL_TIERS) {
521
331
  const pair = resolved[tier].pair;
522
332
  if (pair?.model === undefined)
523
333
  continue;
524
- // Fallback pairs are always pi (FallbackModelConfig has no agent), and the readers
525
- // (seamImageInput, imageReroute) resolve a pi seam to the "pi" agent, so the cache key
526
- // must carry "pi" — keying it `undefined` would write a verdict the readers never read.
527
- const agent = "pi";
528
- if (seamImageInput(pair, providers) === true)
334
+ // The readers (seamImageInput, imageReroute) resolve a pair with no backend to the "pi"
335
+ // agent, so the cache key must carry "pi" — keying it `undefined` would write a verdict the
336
+ // readers never read.
337
+ const agent = pair.backend ?? "pi";
338
+ if (seamImageInput({ ...pair, agent }, providers, undefined, listed) === true)
529
339
  claimed.set(imageProbeKey(agent, pair.provider, pair.model), {
530
340
  agent,
531
341
  provider: pair.provider,
@@ -547,7 +357,7 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
547
357
  const agent = seam.agent;
548
358
  // A backend that registers an imageInput hook trusts the hook (claude today, which reads
549
359
  // every model); a backend without one is probed through its model catalog.
550
- if (agent !== undefined && agentBackend(agent)?.imageInput !== undefined) {
360
+ if (agent !== undefined && backendById(agent)?.imageInput !== undefined) {
551
361
  readsItems.push(label(agent, seam.provider, seam.model));
552
362
  continue;
553
363
  }
@@ -582,207 +392,76 @@ export async function checkImageInput(root, modelsPath = piModelsPath(), provide
582
392
  readsItems.push(`${label(agent, seam.provider, seam.model)} (not verified yet — run \`tumwater doctor\`)`);
583
393
  }
584
394
  }
585
- if (probedAny)
586
- writeImageProbeCache(root, probes);
587
- // Text-only enabled loops: which can reroute to a tier that reads images (honoring failed
588
- // probes), and which cannot.
589
- const reroutes = [];
590
- const noTarget = [];
591
- for (const role of enabledRoleIds(config)) {
592
- const rc = configForRole(config, role);
593
- if (rc.model === undefined)
594
- continue;
595
- if (seamImageInput(rc, providers, probes) !== false)
596
- continue; // reads images, or unknown (to tier models)
597
- const rr = imageReroute(config, role, "primary", modelsPath, accountNow, probes);
598
- if (rr && seamImageInput(rr.config, providers, probes) === true)
599
- reroutes.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)}) — reroutes to ${rr.tier} (${label(rr.config.agent, rr.config.provider, rr.config.model)})`);
600
- else
601
- noTarget.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)})`);
602
- }
603
- // A team that declared no models at all leaves capability to pi's own defaults — the same
604
- // "nothing configured" stance as tier models, informational rather than a warning.
605
- const declared = pairs.some((p) => p.model !== undefined) ||
606
- MODEL_TIERS.some((t) => resolved[t].pair?.model !== undefined);
607
- if (!declared)
608
- return okOutcome("no models declared — every model job uses pi's own default");
609
- const readsLine = readsItems.length > 0 ? `reads images: ${[...readsItems].sort().join(", ")}` : "";
610
- const details = [readsLine, ...reroutes].filter(Boolean).join("; ");
611
- if (failed.length > 0)
612
- return failOutcome(`${failed.join("; ")} — remove "image" from its input in pi's models.json or fix the endpoint, then run \`tumwater doctor --reprobe\``);
613
- if (couldNot.length > 0)
614
- return warnOutcome(`${couldNot.join("; ")}; ${details}`.replace(/;\s*$/, ""));
615
- if (noTarget.length > 0)
616
- return warnOutcome(`${noTarget.join("; ")} — no configured model reads images, so a run that reads an image fails; declare image input on a vision model in pi's models.json, or point a tier at one`);
617
- if (readsItems.length === 0)
618
- return warnOutcome("no configured model reads images — a run that reads an image fails; declare image input on a vision model in pi's models.json, or point a tier at one");
619
- return okOutcome(details);
620
- }
621
- /** The provider of a non-gateway opencode model id (`opencode/<provider>/<model>`), or
622
- * undefined for a gateway id (a bare `opencode/<model>` or `<model>`, served by OpenCode's
623
- * own gateway with no credential). A model id with more than the `opencode/` namespace prefix
624
- * is provider-qualified and needs a credential OpenCode lists. */
625
- function opencodeProviderOf(model) {
626
- const parts = model.split("/");
627
- if (parts[0] === "opencode" && parts.length >= 3)
628
- return parts[1];
629
- if (parts[0] !== "opencode" && parts.length >= 2)
630
- return parts[0];
631
- return undefined;
632
- }
633
- /** Whether `opencode auth list --format json` (bounded) lists `provider` exactly. A probe that
634
- * cannot run, exits nonzero, or omits the provider reads as NOT listed — the safe direction,
635
- * since doctor is warning the operator to add the credential, never confirming one it did not
636
- * see. The list is matched EXACTLY, not as a raw substring: when the output parses as an array
637
- * (of names or of per-credential objects), a credential listed as `anthropic` must never vouch
638
- * for `anthropic-foo` twice over; when it cannot be parsed as JSON (a shape the CLI may change),
639
- * the text is matched word-bounded so a truncated or prose line still reads. */
640
- async function opencodeAuthLists(config, provider, pathEnv, timeoutMs) {
641
- const resolved = opencodeBackend.resolveBin(config);
642
- const result = await boundedProbe(resolved.bin, ["auth", "list", "--format", "json"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
643
- if (result?.code !== 0)
644
- return false;
645
- const text = result.stdout;
646
- try {
647
- const parsed = JSON.parse(text);
648
- if (Array.isArray(parsed)) {
649
- return parsed.some((item) => {
650
- if (typeof item === "string")
651
- return item === provider;
652
- return (item !== null &&
653
- typeof item === "object" &&
654
- Object.values(item).some((v) => v === provider));
655
- });
656
- }
657
- }
658
- catch {
659
- // Not an array-shaped JSON payload — fall through to the word-bounded text match.
660
- }
661
- const esc = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
662
- return new RegExp(`(^|[^A-Za-z0-9_-])${esc}([^A-Za-z0-9_-]|$)`).test(text);
663
- }
664
- /** Whether a JSON-ish key (optionally quoted, followed by a colon) appears in a config file's
665
- * text — a loose match that tolerates JSONC comments without parsing them. */
666
- function jsonKeyPresent(text, key) {
667
- return new RegExp(`["']?${key}["']?\\s*:`).test(text);
668
- }
669
- /** The operator's global opencode config files and AGENTS.md (plans/opencode-backend.md §7
670
- * "Leaks", part 7/7): the operator's own config and instructions still load into team runs,
671
- * so each MCP server (disabled for team runs) or plugin/agent/instructions entry or the
672
- * AGENTS.md file itself produces one warning naming the file and what leaks in: an MCP server
673
- * is switched off by the harness, while a plugin/agent/instructions entry or AGENTS.md is NOT
674
- * switched off and still loads. `configHome` is the config root to scan — the XDG config home
675
- * (when set) or `~/.config`, the SAME folder opencode's own operatorMcpNames reads, so the leak
676
- * scan matches the config a run actually loads. Config files
677
- * are JSON/C, so the keys are matched loosely (a word followed by a colon) rather than fully
678
- * parsed — enough to name what still loads before a run inherits it. */
679
- function opencodeConfigLeaks(configHome) {
680
- const dir = path.join(configHome, "opencode");
681
- const warns = [];
682
- for (const file of ["config.json", "opencode.json", "opencode.jsonc"]) {
683
- const text = readTextOrNull(path.join(dir, file));
684
- if (text === null)
685
- continue;
686
- const parts = [];
687
- let mcp = false;
688
- if (jsonKeyPresent(text, "mcp") && jsonKeyPresent(text, "servers")) {
689
- parts.push("an MCP server");
690
- mcp = true;
395
+ let saveProblem = null;
396
+ if (probedAny) {
397
+ try {
398
+ writeImageProbeCache(root, probes);
691
399
  }
692
- for (const key of ["plugin", "agent", "instructions"]) {
693
- if (jsonKeyPresent(text, key))
694
- parts.push(`a ${key} entry`);
400
+ catch (err) {
401
+ saveProblem = errorMessage(err);
695
402
  }
696
- if (parts.length === 0)
697
- continue;
698
- const joined = parts.join(" and ");
699
- // Only the MCP server is disabled for team runs; everything else still loads.
700
- const verdict = mcp && parts.length === 1 ? " — disabled for team runs" : " — still loads into team runs";
701
- warns.push(`${path.join(dir, file)} carries ${joined}${verdict}`);
702
403
  }
703
- const agentsMd = path.join(dir, "AGENTS.md");
704
- if (statOrNull(agentsMd)?.isFile() === true)
705
- warns.push(`${agentsMd} still loads into team runs`);
706
- return warns;
707
- }
708
- /** Total bytes of every regular file under `dir` (a shallow recursive walk), 0 for a missing
709
- * or unreadable dir. */
710
- function opencodeDirBytes(dir) {
711
- try {
712
- let total = 0;
713
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
714
- const p = path.join(dir, entry.name);
715
- if (entry.isDirectory())
716
- total += opencodeDirBytes(p);
717
- else if (entry.isFile())
718
- total += fs.statSync(p).size;
404
+ const verdict = () => {
405
+ // Text-only enabled roles: which can reroute to a tier that reads images (honoring failed
406
+ // probes), and which cannot.
407
+ const reroutes = [];
408
+ const noTarget = [];
409
+ for (const role of enabledRoleIds(config)) {
410
+ const rc = configForRole(config, role);
411
+ if (rc.model === undefined)
412
+ continue;
413
+ if (seamImageInput(rc, providers, probes, listed) !== false)
414
+ continue; // reads images, or unknown (to tier models)
415
+ const rr = imageReroute(config, role, "primary", modelsPath, accountNow, probes, null, listed);
416
+ if (rr && seamImageInput(rr.config, providers, probes, listed) === true)
417
+ reroutes.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)}) — reroutes to ${rr.tier} (${label(rr.config.agent, rr.config.provider, rr.config.model)})`);
418
+ else
419
+ noTarget.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)})`);
719
420
  }
720
- return total;
721
- }
722
- catch {
723
- return 0;
724
- }
725
- }
726
- /** A byte count for display: MB, KB, or bytes. */
727
- function opencodeBytes(n) {
728
- if (n >= 1024 * 1024)
729
- return `${(n / (1024 * 1024)).toFixed(1)} MB`;
730
- if (n >= 1024)
731
- return `${Math.round(n / 1024)} KB`;
732
- return `${n} B`;
421
+ // A team that declared no models at all leaves capability to pi's own defaults — the same
422
+ // "nothing configured" stance as tier models, informational rather than a warning.
423
+ const declared = pairs.some((p) => p.model !== undefined) ||
424
+ MODEL_TIERS.some((t) => resolved[t].pair?.model !== undefined);
425
+ if (!declared)
426
+ return okOutcome(PHRASE_NO_MODELS_DECLARED);
427
+ const readsLine = readsItems.length > 0 ? `reads images: ${[...readsItems].sort().join(", ")}` : "";
428
+ const details = joinParts([readsLine, ...reroutes]);
429
+ if (failed.length > 0)
430
+ return failOutcome(`${failed.join("; ")} — remove "image" from its input in pi's models.json or fix the endpoint, then run \`tumwater doctor --reprobe\``);
431
+ if (couldNot.length > 0)
432
+ return warnOutcome(`${couldNot.join("; ")}; ${details}`.replace(/;\s*$/, ""));
433
+ if (noTarget.length > 0)
434
+ return warnOutcome(`${noTarget.join("; ")} — no configured model reads images, so a run that reads an image fails; declare image input on a vision model in pi's models.json, or point a tier at one`);
435
+ if (readsItems.length === 0)
436
+ return warnOutcome("no configured model reads images — a run that reads an image fails; declare image input on a vision model in pi's models.json, or point a tier at one");
437
+ return okOutcome(details);
438
+ };
439
+ const outcome = verdict();
440
+ if (saveProblem === null)
441
+ return outcome;
442
+ return {
443
+ level: outcome.level === "ok" ? "warn" : outcome.level,
444
+ detail: `${outcome.detail} — could not save the image-probe cache (${saveProblem}), so the next doctor probes again`,
445
+ };
733
446
  }
734
- /** OpenCode model, credential and leak readiness (plans/opencode-backend.md §7, part 7/7):
735
- * every model routed to opencode must be in the catalog cache — an unknown model fails, a
736
- * cached one reads "free" or its per-M prices and context window, and a thinking level with no
737
- * catalog effort is noted as dropped; a non-gateway provider (`opencode/<provider>/…`) must
738
- * have a credential `opencode auth list` lists, else warns; and the operator's global opencode
739
- * config and AGENTS.md must not leak an MCP server or a plugin/agent/instructions entry into
740
- * team runs, else warns naming the file and key. Reports the team's opencode database and spill
741
- * sizes as information. Stands down (ok) when no seam routes to opencode, so a pi-only team
742
- * spawns nothing and reads nothing. `pathEnv` and `home` are injectable so tests need no real
743
- * PATH or HOME. The version half of the opencode pre-flight is the sibling checkOpencodeVersion
744
- * in doctor-checks.ts. */
745
- export async function checkOpencode(root, pathEnv = envPath(), home = os.homedir(), xdgConfigHome = process.env.XDG_CONFIG_HOME ?? "", timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
447
+ /** `gh` readiness for the opt-in `feedback` observer (plans/feedback-role.md "Shape" → "Part 1"):
448
+ * the role files issues as the operator's GitHub login through `gh`, so doctor confirms `gh`
449
+ * resolves and `gh auth status` exits 0 before a run tries to file — the codex-login pattern
450
+ * (plans/codex-backend.md part 9/9). Shown only when the role is enabled: on a default install
451
+ * nothing files upstream, so there is nothing to check and the row is omitted (null). `probe`
452
+ * is injectable so tests use a fake gh (test/fakes/fake-gh.ts); the real probe runs `gh` under
453
+ * the scrubbed run environment with a bounded deadline. */
454
+ export async function checkFeedbackFiling(root, pathEnv = envPath(), probe = (args) => boundedProbe("gh", args, childEnv({ PATH: pathEnv }), AUTH_CHECK_TIMEOUT_MS, AUTH_CHECK_MAX_OUTPUT_BYTES)) {
746
455
  const config = loadConfigForCheck(root);
747
456
  if ("level" in config)
748
457
  return config;
749
- if (!opencodeSeamsInUse(config))
750
- return okOutcome("no model jobs run on opencode");
751
- const models = seamPairs(config).filter((p) => p.agent === "opencode" && p.model !== undefined);
752
- const cache = readOpencodeCache(root);
753
- const fails = [];
754
- const warns = [];
755
- const notes = [];
756
- for (const pair of models) {
757
- const key = opencodeModelKey(pair.model);
758
- const entry = cache.models[key];
759
- if (entry === undefined) {
760
- fails.push(`${pair.model} is not in the opencode catalog cache`);
761
- continue;
762
- }
763
- const price = entry.free ? "free" : `$${entry.inputPerM ?? 0}/$${entry.outputPerM ?? 0} per M`;
764
- const window = entry.context !== undefined ? `, ${entry.context.toLocaleString("en-US")}-token window` : "";
765
- notes.push(`${pair.model} — ${price}${window}`);
766
- // A model with no effort list at all supports no thinking level, so a requested level is
767
- // dropped just the same as one the list omits.
768
- if (pair.thinking !== undefined &&
769
- pair.thinking !== "" &&
770
- !entry.efforts?.includes(pair.thinking)) {
771
- warns.push(`${pair.model} has no ${pair.thinking} effort — that thinking level is dropped`);
772
- }
773
- const provider = opencodeProviderOf(pair.model);
774
- if (provider !== undefined && !(await opencodeAuthLists(config, provider, pathEnv, timeoutMs))) {
775
- warns.push(`${pair.model} is a non-gateway provider opencode does not list a credential for`);
776
- }
777
- }
778
- warns.push(...opencodeConfigLeaks(xdgConfigHome !== "" ? xdgConfigHome : path.join(home, ".config")));
779
- const db = statOrNull(opencodeDbPath(root));
780
- const spillDir = opencodeSpillDir(root);
781
- const spill = spillDir !== "" && statOrNull(spillDir)?.isDirectory() === true ? opencodeDirBytes(spillDir) : null;
782
- const sizeNotes = `db ${opencodeBytes(db?.size ?? 0)}${spill !== null ? `, spill ${opencodeBytes(spill)}` : ", no spill dir"}`;
783
- const found = [...fails, ...warns];
784
- const base = found.length === 0 ? `opencode ready — ${notes.join("; ")}; ${sizeNotes}` : found.join("; ");
785
- if (fails.length > 0)
786
- return failOutcome(base);
787
- return (warns.length > 0 ? warnOutcome : okOutcome)(base);
458
+ if (!config.roles.feedback?.enabled)
459
+ return null;
460
+ const version = await probe(["--version"]);
461
+ if (version?.code !== 0)
462
+ return failOutcome("gh is missing or unusable — install GitHub CLI (https://cli.github.com) so the feedback role can file issues as your login");
463
+ const auth = await probe(["auth", "status"]);
464
+ if (auth?.code !== 0)
465
+ return failOutcome("gh is not authenticated — run `gh auth login` — the feedback role files issues as your GitHub login");
466
+ return okOutcome("gh present and authenticated — the feedback role can file issues on znation/tumwater");
788
467
  }