tumwater 0.4.0 → 0.4.2

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 (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { enabledRoleIds, parseJsonConfig, loadConfig, loadConfigSafe, } from "../config/config.js";
5
5
  import { exampleConfigProblem, exampleDrift } from "../config/config-example.js";
6
6
  import { bootstrapStatus } from "../gates/bootstrap-gates.js";
7
- import { envPath, findOnPath, readTextOrNull, treeBytes } from "../files/files.js";
7
+ import { envPath, findOnPath, readTextOrNull, treeBytes, xdgConfigHomeDir } from "../files/files.js";
8
8
  import { readJsonFile } from "../files/json-files.js";
9
9
  import { PACKAGE_JSON, belowNodeFloor, packageEnginesNode } from "../version.js";
10
10
  import { GIT_MISSING_MESSAGE } from "../git/git-run.js";
@@ -15,7 +15,7 @@ import { resolvedBackendConfig } from "../config/config-views.js";
15
15
  import { CONFIG_BASENAME, EXAMPLE_CONFIG_BASENAME, configPath, owningTeamRoot } from "../paths.js";
16
16
  import { errorMessage } from "../text/text.js";
17
17
  import { formatBytes } from "../text/format.js";
18
- import { briefFile } from "../brief/brief.js";
18
+ import { BRIEF_TEXT_MAX_CHARS, briefFile, promptBlockOf, readBrief } from "../brief/brief.js";
19
19
  import { plural } from "../text/phrases.js";
20
20
  import { backendsInUse, unregisteredBackendInUse } from "../gates/startup-gate.js";
21
21
  import { claudeSeamsInUse, minVersionOutcome, opencodeSeamsInUse, versionFromProbe, piSeamsInUse } from "./doctor-model-checks.js";
@@ -157,8 +157,20 @@ export function checkBrief(root) {
157
157
  catch (err) {
158
158
  return failOutcome(errorMessage(err));
159
159
  }
160
- if (owner)
160
+ if (owner) {
161
+ const prompt = readBrief(root)?.prompt ?? "";
162
+ if (prompt === "") {
163
+ return warnOutcome(`${owner} carries an empty tumwater:prompt block — every role would run without its project brief; add the brief text or re-run \`tumwater init\``);
164
+ }
165
+ const raw = readTextOrNull(path.join(root, owner));
166
+ if (raw !== null) {
167
+ const block = promptBlockOf(raw);
168
+ if (block !== null && block.length > BRIEF_TEXT_MAX_CHARS) {
169
+ return warnOutcome(`brief in ${owner} is ${block.length} characters, over the ${BRIEF_TEXT_MAX_CHARS}-character read cap every role sees (the prompt is truncated at ${BRIEF_TEXT_MAX_CHARS} chars); hand-edit ${owner} to shrink the block below ${BRIEF_TEXT_MAX_CHARS} characters, or paste a shorter brief in the dashboard's Brief card`);
170
+ }
171
+ }
161
172
  return okOutcome(`brief in ${owner}`);
173
+ }
162
174
  if (fs.existsSync(path.join(root, "README.md"))) {
163
175
  return warnOutcome(`README.md carries no tumwater:prompt markers — the team would run without its project brief; add the block or re-run \`tumwater init\``);
164
176
  }
@@ -353,7 +365,7 @@ export function checkPiExtensions(config, homeDir = os.homedir(), pathEnv = envP
353
365
  /** The operator's global opencode config files, in XDG order — the same home `opencode-config.ts`
354
366
  * reads for MCP names. Home is the operator's home; `$XDG_CONFIG_HOME`, when set, wins. */
355
367
  function opencodeGlobalConfigPaths(homeDir) {
356
- const configHome = process.env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config");
368
+ const configHome = xdgConfigHomeDir(process.env, homeDir);
357
369
  return ["config.json", "opencode.json", "opencode.jsonc"].map((file) => path.join(configHome, "opencode", file));
358
370
  }
359
371
  /** Report the operator's global opencode config file that mentions funes, or null.
@@ -10,8 +10,8 @@ import { belowNodeFloor } from "../version.js";
10
10
  import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
11
11
  import { claudeSeamsInUse, loadConfigForCheck, versionFromProbe, seamedModels, } from "./doctor-model-checks.js";
12
12
  /** The doctor's claude credential and version readiness checks, split out of
13
- * doctor-model-checks.ts (whose shared seam/model framework and the pi, tier-models and
14
- * image-input checks stay there): checkClaudeAuth, checkAnthroApiKey and checkClaudeVersion.
13
+ * doctor-model-checks.ts (whose shared seam/model framework and the pi and tier-models
14
+ * checks stay there): checkClaudeAuth, checkAnthroApiKey and checkClaudeVersion.
15
15
  * These are the doctor's only checks that spawn the `claude` binary and read its auth — a
16
16
  * distinct concern from the filesystem/git checks, kept together so a change to how doctor
17
17
  * spawns or scrubs claude does not touch the other model checks. The shared report contract
@@ -10,7 +10,7 @@ import { codexHookTrust } from "../agent/codex/codex-hook-trust.js";
10
10
  import { parseCodexLoginStatus } from "../agent/codex/codex-account.js";
11
11
  import { codexWriteGuardDefinition } from "../agent/codex/codex-write-guard.js";
12
12
  import { resolvedBackendConfig } from "../config/config-views.js";
13
- import { envPath, isDirectory, readTextOrNull, statOrNull, treeBytes } from "../files/files.js";
13
+ import { envPath, isDirectory, readTextOrNull, statOrNull, treeBytes, walkFiles } from "../files/files.js";
14
14
  import { formatBytes, tokenWindowLabel } from "../text/format.js";
15
15
  import { errorMessage } from "../text/text.js";
16
16
  import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
@@ -152,26 +152,15 @@ function codexSessionCredits(home) {
152
152
  if (!isDirectory(sessions))
153
153
  return null;
154
154
  const rollouts = [];
155
- const walk = (dir) => {
156
- let entries;
157
- try {
158
- entries = fs.readdirSync(dir, { withFileTypes: true });
155
+ // The walk-and-swallow recursion is files.ts's walkFiles; each rollout file is statted so
156
+ // the newest (by mtime) can win below.
157
+ walkFiles(sessions, (file, entry) => {
158
+ if (/^rollout-.*\.jsonl$/.test(entry.name)) {
159
+ const st = statOrNull(file);
160
+ if (st !== null)
161
+ rollouts.push({ file, mtimeMs: st.mtimeMs });
159
162
  }
160
- catch {
161
- return;
162
- }
163
- for (const entry of entries) {
164
- const p = path.join(dir, entry.name);
165
- if (entry.isDirectory())
166
- walk(p);
167
- else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) {
168
- const st = statOrNull(p);
169
- if (st !== null)
170
- rollouts.push({ file: p, mtimeMs: st.mtimeMs });
171
- }
172
- }
173
- };
174
- walk(sessions);
163
+ });
175
164
  rollouts.sort((a, b) => b.mtimeMs - a.mtimeMs);
176
165
  for (const r of rollouts) {
177
166
  const text = readTextOrNull(r.file);
@@ -70,7 +70,7 @@ export function staleWorkspacePins(root, now = Date.now()) {
70
70
  }));
71
71
  }
72
72
  /** The shared one-pin clause of the doctor's `worktree pool` row (checkWorktreePool) and the
73
- * standing `workspace-pins` alert (team-alerts.ts): `<role> pinned on <dir> for <Nh>`. One
73
+ * standing `workspace-pins` alert (team-alert-tail.ts): `<role> pinned on <dir> for <Nh>`. One
74
74
  * home so the row and the alert cannot drift on how a pin's age is phrased (whole hours, off
75
75
  * the pin's whole-ms age). `role` is a non-optional string on StaleWorkspacePin, but the
76
76
  * clause renders it through textOr like the row always did, so a malformed pin can never
@@ -433,7 +433,9 @@ function claimCounts(claims) {
433
433
  * multi-instance role configured with fewer pooled slots than maxConcurrent also warns:
434
434
  * instances then contend for the same checkouts. A disabled role runs none of its
435
435
  * configured instances, so it never contends and
436
- * stays out of that warning.
436
+ * stays out of that warning. The pool-mismatch clause comes from the shared
437
+ * `smallPoolProblems` selector, so the row and the always-on `instance-pool` alert cannot
438
+ * drift on the trigger, the role list, or the wording.
437
439
  * Read-only, like every check; `now` is the claim-age test seam, and an unreadable
438
440
  * tumwater.json (config undefined) skips the walk — checkInit reports the config itself. */
439
441
  export function checkWorkInstances(root, config, now = Date.now()) {
@@ -443,17 +445,29 @@ export function checkWorkInstances(root, config, now = Date.now()) {
443
445
  const entries = staleClaimEntries(root, runnerIds(config), now);
444
446
  const gone = entries.filter((e) => e.kind === "gone");
445
447
  const stale = entries.filter((e) => e.kind === "idle");
448
+ const smallPool = smallPoolProblems(config);
449
+ if (gone.length === 0 && stale.length === 0 && smallPool.length === 0)
450
+ return okOutcome(claimCounts(claims));
451
+ const parts = [...gone.map(staleClaimDetail), ...stale.map(staleClaimDetail)];
452
+ parts.push(...smallPool);
453
+ return warnOutcome(parts.join("; "));
454
+ }
455
+ /** An enabled multi-instance role contending for a worktree pool smaller than the run cap —
456
+ * the pool-mismatch clause of the doctor's `parallel instances` row (checkWorkInstances) and
457
+ * the always-on `instance-pool` alert (alert-feeds.ts instancePoolProblems). Returns the
458
+ * `INSTANCE_ROLES` ids with more than one configured instance while `slotCount(config)`
459
+ * (worktreeSlots, else maxConcurrent + 1) is below `config.maxConcurrent`, phrased as the
460
+ * row's one clause; `[]` when no such role or the pool is at least `maxConcurrent`. Shared by
461
+ * the doctor row and the alert so they cannot drift on the trigger, the role list, or the
462
+ * wording. */
463
+ export function smallPoolProblems(config) {
446
464
  const multi = Object.entries(config.roles)
447
465
  .filter(([id, role]) => INSTANCE_ROLES.has(id) && role.enabled && (role.instances ?? 1) > 1)
448
466
  .map(([id]) => id);
449
467
  const slots = slotCount(config);
450
- const smallPool = multi.length > 0 && slots < config.maxConcurrent;
451
- if (gone.length === 0 && stale.length === 0 && !smallPool)
452
- return okOutcome(claimCounts(claims));
453
- const parts = [...gone.map(staleClaimDetail), ...stale.map(staleClaimDetail)];
454
- if (smallPool)
455
- parts.push(`${multi.join(", ")} run instances but worktreeSlots ${slots} is below maxConcurrent ${config.maxConcurrent}`);
456
- return warnOutcome(parts.join("; "));
468
+ if (multi.length === 0 || slots >= config.maxConcurrent)
469
+ return [];
470
+ return [`${multi.join(", ")} run instances but worktreeSlots ${slots} is below maxConcurrent ${config.maxConcurrent}`];
457
471
  }
458
472
  /** Provider concurrency caps (plans/provider-concurrency.md §8): audit the
459
473
  * `maxConcurrentPerProvider` map against the seams the config can actually spawn — each role,
@@ -466,17 +480,22 @@ export function checkWorkInstances(root, config, now = Date.now()) {
466
480
  * well-formed map; a broken config is reported by the init check. Takes `root` to read pi's
467
481
  * own default model (piSettingsPath), the same seam the orchestrator's admission uses, so a
468
482
  * bare no-model pi seam keys exactly as the team would key it. */
469
- export function checkProviderConcurrency(config, root) {
483
+ /** The warnings behind checkProviderConcurrency, as a pure function of the config and pi's
484
+ * default model name: a cap key no seam resolves to (a typo, or a provider not in use), a cap
485
+ * at or above `maxConcurrent` (which never binds), and a capped team whose seams include one
486
+ * with no key (pi's default provider is unreadable, so that seam cannot be capped). The doctor's
487
+ * `provider concurrency` row and the always-on `provider-caps` team alert both read this one
488
+ * computation, so the row and the alert cannot drift on the trigger or the wording. `[]` for no
489
+ * config, a map with no enforcedKeyCaps entries, or a map with no problem. `piDefault` is the
490
+ * bare default model the orchestrator's admission seam carries, read once by the callers
491
+ * through readPiDefaultModel(piSettingsPath(piModelsPath()), root). */
492
+ export function providerConcurrencyProblems(config, piDefault) {
470
493
  if (!config)
471
- return okOutcome("no config to audit");
494
+ return [];
472
495
  const caps = config.maxConcurrentPerProvider;
473
496
  const capEntries = enforcedKeyCaps(caps);
474
497
  if (capEntries.length === 0)
475
- return okOutcome("no per-provider caps configured");
476
- // The pi default model the orchestrator's admission seam carries: a bare no-model pi seam
477
- // resolves its key only through it, so a team whose default is readable has keyed seams and
478
- // only an unreadable default leaves a key-less seam to warn about.
479
- const piDefault = readPiDefaultModel(piSettingsPath(piModelsPath()), root);
498
+ return [];
480
499
  const seams = seamsInUse(config);
481
500
  const keyed = [];
482
501
  for (const s of seams) {
@@ -498,9 +517,23 @@ export function checkProviderConcurrency(config, root) {
498
517
  const capSet = new Set(capKeys);
499
518
  const keyless = seams.filter((s) => seamConcurrencyKey(s, piDefault) === undefined);
500
519
  if (capSet.size > 0 && keyless.length > 0) {
501
- warnings.push(`${keyless.length} model job(s) resolve to no provider key (pi's default provider is unreadable, so they cannot be capped): ${keyless.map((s) => s.label).join(", ")}`);
520
+ warnings.push(`${plural(keyless.length, "model job")} ${agree(keyless.length, "resolves", "resolve")} to no provider key (pi's default provider is unreadable, so they cannot be capped): ${keyless.map((s) => s.label).join(", ")}`);
502
521
  }
503
- if (warnings.length === 0)
522
+ return warnings;
523
+ }
524
+ export function checkProviderConcurrency(config, root) {
525
+ if (!config)
526
+ return okOutcome("no config to audit");
527
+ const caps = config.maxConcurrentPerProvider;
528
+ const capEntries = enforcedKeyCaps(caps);
529
+ if (capEntries.length === 0)
530
+ return okOutcome("no per-provider caps configured");
531
+ // The pi default model the orchestrator's admission seam carries: a bare no-model pi seam
532
+ // resolves its key only through it, so a team whose default is readable has keyed seams and
533
+ // only an unreadable default leaves a key-less seam to warn about.
534
+ const piDefault = readPiDefaultModel(piSettingsPath(piModelsPath()), root);
535
+ const problems = providerConcurrencyProblems(config, piDefault);
536
+ if (problems.length === 0)
504
537
  return okOutcome(`${plural(capEntries.length, "provider cap")} — all model jobs resolve`);
505
- return warnOutcome(warnings.join("; "));
538
+ return warnOutcome(problems.join("; "));
506
539
  }
@@ -1,4 +1,5 @@
1
1
  import { roleLabel } from "../roles/role-labels.js";
2
+ import { sortEntriesByKey } from "../collections.js";
2
3
  import { pausedRoles, pausedRolesUntil, standingTeamPause } from "../team/team-state.js";
3
4
  import { MODEL_TIERS } from "../config/config-schema.js";
4
5
  import { withModelOverrides } from "../config/config-views.js";
@@ -10,7 +11,7 @@ import { MAINTENANCE_DAILY_FLOOR } from "../gates/maintenance-quota.js";
10
11
  import { heldByMainRed, mainCheckForPoll, maintenanceQuotaForPoll } from "../status/status-polls.js";
11
12
  import { loadRunnerState } from "../runner/runner-state-load.js";
12
13
  import { baseRoleOf, runnerIds, runnerPaused } from "../roles/runner-ids.js";
13
- import { ERROR_STREAK_BREAKER, ERROR_STREAK_WARN } from "../run/run-apply.js";
14
+ import { ERROR_STREAK_BREAKER, errorStreakAtWarn } from "../run/run-apply.js";
14
15
  import { squash } from "../text/text.js";
15
16
  import { usd } from "../text/format.js";
16
17
  import { parseQuietHours, quietHoursStatus, quietWindowEnd, roleQuietHold } from "../scheduling/quiet-hours.js";
@@ -32,6 +33,12 @@ export function loadRunnerStates(root, config) {
32
33
  .map((id) => loadRunnerState(root, id))
33
34
  .sort((a, b) => a.role.localeCompare(b.role));
34
35
  }
36
+ /** A doctor row's `not checked` outcome when tumwater.json is unreadable: the row blanks its
37
+ * verdict rather than claim a state it cannot see (checkInit owns that error). Every check that
38
+ * reads the config returns this same outcome when the file is unreadable. */
39
+ function uncheckedConfigOutcome() {
40
+ return okOutcome("not checked — tumwater.json unreadable");
41
+ }
35
42
  /** The `pause` check: names a standing operator pause so doctor does not print "ready to run"
36
43
  * over a team that starts no run. Read-only: it reads the same team and per-role pause
37
44
  * markers the scheduler reads (standingTeamPause, pausedRoles, pausedRolesUntil — all treat
@@ -95,14 +102,11 @@ export function checkQuietHours(config, now = new Date()) {
95
102
  * per-role caps reached alone are ok because the operator chose them by name. */
96
103
  export function checkBudget(root, config, published, now = Date.now()) {
97
104
  if (config === undefined)
98
- return okOutcome("not checked — tumwater.json unreadable");
105
+ return uncheckedConfigOutcome();
99
106
  const states = loadRunnerStates(root, config);
100
107
  const spent = published?.spentUsd ?? teamDailyCost(states, now);
101
108
  const caps = config.maxDailyCostUsdPerRole;
102
- const capped = [...roleCapVerdicts(states.map((s) => ({ role: s.role, state: s })), caps, now)]
103
- .filter(([, v]) => v.over)
104
- .sort(([a], [b]) => a.localeCompare(b))
105
- .map(([base, v]) => `${base} (${budgetPhrase(v.spentUsd, caps?.[base])})`);
109
+ const capped = sortEntriesByKey([...roleCapVerdicts(states.map((s) => ({ role: s.role, state: s })), caps, now)].filter(([, v]) => v.over)).map(([base, v]) => `${base} (${budgetPhrase(v.spentUsd, caps?.[base])})`);
106
110
  const cappedPhrase = capped.length > 0 ? `; capped roles: ${capped.join(", ")}` : "";
107
111
  const cap = config.maxDailyCostUsd;
108
112
  if (!(cap > 0))
@@ -124,7 +128,7 @@ export function checkBudget(root, config, published, now = Date.now()) {
124
128
  * because it stops a whole tier. */
125
129
  export function checkMaintenanceQuota(root, config, aliveTeam, now = Date.now()) {
126
130
  if (config === undefined)
127
- return okOutcome("not checked — tumwater.json unreadable");
131
+ return uncheckedConfigOutcome();
128
132
  const runners = loadRunnerStates(root, config).map((s) => ({ role: s.role, running: aliveTeam && s.running === true }));
129
133
  const queued = aliveTeam ? new Set(readMergeQueue(root).entries.map((e) => e.role)) : new Set();
130
134
  const { allowance, used, held } = maintenanceQuotaForPoll(root, config.maintenancePerWorkMerge, runners, queued, now);
@@ -201,9 +205,7 @@ export function checkProviderHolds(root, now = Date.now()) {
201
205
  standing.delete(typeof ev.provider === "string" ? ev.provider : undefined);
202
206
  }
203
207
  }
204
- const held = [...standing.entries()]
205
- .filter(([, h]) => h.end > now)
206
- .sort(([a], [b]) => (a ?? "").localeCompare(b ?? ""));
208
+ const held = sortEntriesByKey([...standing.entries()].filter(([, h]) => h.end > now));
207
209
  if (held.length === 0)
208
210
  return okOutcome("no provider holds");
209
211
  const lines = held.map(([provider, h]) => {
@@ -221,9 +223,9 @@ export function checkProviderHolds(root, now = Date.now()) {
221
223
  * fail: a failing role does not stop the team from running. */
222
224
  export function checkErrorStreaks(root, config) {
223
225
  if (config === undefined)
224
- return okOutcome("not checked — tumwater.json unreadable");
226
+ return uncheckedConfigOutcome();
225
227
  const failing = loadRunnerStates(root, config)
226
- .filter((s) => (s.consecutiveErrors ?? 0) >= ERROR_STREAK_WARN);
228
+ .filter((s) => errorStreakAtWarn(s));
227
229
  if (failing.length === 0)
228
230
  return okOutcome("no role failing repeatedly");
229
231
  const paused = new Set(pausedRoles(root));
@@ -243,16 +245,16 @@ export function checkErrorStreaks(root, config) {
243
245
  });
244
246
  return warnOutcome(`failing run after run: ${phrases.join("; ")}`);
245
247
  }
246
- /** The `main red` check: names the code roles the red-main gate (mainRedGate) holds, ending
247
- * each blocked run `main_red` while main's suite is red, and warns when main has been red over
248
- * RED_MAIN_WARN_MS even with no role held (the red-main gate's guardrail, G1). Read-only: it
249
- * asks heldByMainRed, the predicate the status phase's `main red` label reads, so the two
250
- * cannot disagree on the held roles, and redMainWarning over the event trail, so both phrases
251
- * can appear in one row. A warn, not a fail: a red main does not stop the team from running.
252
- * `now` is injected so a test can pin a red's age. */
248
+ /** 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
251
+ * guardrail, G1). Read-only: it asks heldByMainRed, the predicate the status phase's `main red`
252
+ * label reads, so the two cannot disagree on the held runs, and redMainWarning over the event
253
+ * trail, so both phrases can appear in one row. A warn, not a fail: a red main does not stop
254
+ * the team from running. `now` is injected so a test can pin a red's age. */
253
255
  export function checkMainRed(root, config, now = Date.now()) {
254
256
  if (config === undefined)
255
- return okOutcome("not checked — tumwater.json unreadable");
257
+ return uncheckedConfigOutcome();
256
258
  const mainCheck = mainCheckForPoll(root, config);
257
259
  const ids = runnerIds(config);
258
260
  const held = loadRunnerStates(root, config)
@@ -1,31 +1,22 @@
1
1
  import { childEnv } from "../process/process.js";
2
2
  import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
3
- import { enabledRoleIds, defaultConfig, loadConfigSafe } from "../config/config.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";
7
- import { MODEL_TIERS } from "../config/config-schema.js";
8
- import { imageReroute, seamImageInput } from "../agent/image-input.js";
9
- import { modelLabel, usesPiModels } from "../agent/backend-traits.js";
10
- import { backendById } from "../agent/agent-backend.js";
11
- import { imageProbeEntryFresh, imageProbeKey, readImageProbeCache, runImageProbe, writeImageProbeCache, } from "./image-probe.js";
12
- import { readClaudeAccount } from "../agent/claude/claude-account.js";
3
+ import { defaultConfig, loadConfigSafe } from "../config/config.js";
4
+ import { seamsInUse } from "../config/config-fallbacks.js";
5
+ import { cacheReadUnpriced, piModelsPath, readPiProviders } from "../pi/pi-models.js";
13
6
  import { envPath } from "../files/files.js";
14
7
  import { piListedModels, piProviderAuth } from "./doctor-pi-checks.js";
15
- import { readPiListedModels } from "../pi/pi-listed-models.js";
16
8
  import { agree } from "../text/phrases.js";
17
- import { formatDate } from "../text/datetime.js";
18
- import { errorMessage, joinParts } from "../text/text.js";
9
+ import { joinParts } from "../text/text.js";
19
10
  import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
20
11
  import { belowNodeFloor } from "../version.js";
21
12
  /** The one phrase a configured-models check uses when the config declares no models at all:
22
13
  * 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. */
14
+ * checkTierModels so the check words the same "nothing configured"
15
+ * stance. */
25
16
  const PHRASE_NO_MODELS_DECLARED = "no models declared — every model job uses pi's own default";
26
17
  /** The one phrase a configured-models check uses when pi's definitions file could not be read
27
18
  * 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
19
+ * checkTierModels so the check words the "cannot check" stance
29
20
  * identically; each appends its own modelsPath after the shared stem, and checkTierModels
30
21
  * also appends its ompNote. */
31
22
  const PHRASE_CANNOT_READ_MODELS = "cannot check — could not read pi's model definitions at ";
@@ -33,7 +24,7 @@ const PHRASE_CANNOT_READ_MODELS = "cannot check — could not read pi's model de
33
24
  * repo-setup checks stay there, and whose state/disk/build checks live in
34
25
  * doctor-project-checks.ts), and split apart again so one theme owns one module: this file holds
35
26
  * 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
27
+ * and the declared-tier check (checkTierModels). The cap
37
28
  * fallback price and fallback-chain checks (checkFallbackModel, checkFallbackChain) live in
38
29
  * doctor-fallback-checks.ts, importing this file's loadConfigForCheck just as the per-backend
39
30
  * modules do. The claude, opencode, codex and pi provider checks live in their own per-backend
@@ -277,173 +268,6 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
277
268
  return failOutcome(`${base}${ompNote}`);
278
269
  return (warns.length > 0 || ompVars.length > 0 ? warnOutcome : okOutcome)(`${base}${ompNote}`);
279
270
  }
280
- /** The doctor's image-input capability check (plans/image-input.md "Doctor", parts 1-2/5):
281
- * which of the team's seams claim to read images, and whether every text-only enabled role can
282
- * reroute to one that does. Part 2 adds the live probe — with `probeImages` "missing" (the
283
- * terminal `tumwater doctor` default) each claimed pi model with no fresh cached verdict is
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.
290
- *
291
- * Covers the same seams as `tier models` (seamPairs — the tiers, enabled roles and the
292
- * reviewer) plus the tiers' fallback pairs (the reroute part picks its target among them, so a
293
- * text-only fallback would promise a reroute no fallback can serve). A claimed model whose probe
294
- * failed (fresh verdict) is dropped from the readers and reads false from seamImageInput, so the
295
- * reroute sweep never sends a text-only role to a model that lost its probe. `providers`,
296
- * `account`, `pathEnv` and `probe` are injectable so tests need no real pi install or account. */
297
- export async function checkImageInput(root, modelsPath = piModelsPath(), providers = readPiProviders(modelsPath), account, probeImages = "never", pathEnv = envPath(), probe = runImageProbe) {
298
- const config = loadConfigForCheck(root);
299
- if ("level" in config)
300
- return config;
301
- // `tier models` already fails on a missing/unknown pair, so this check names only what it can
302
- // answer; unknown (null) pairs are left to that check. A missing definitions file only blocks
303
- // this check when a seam actually needs it — an all-claude team reads images without it.
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);
308
- const pairs = seamPairs(config);
309
- const needsDefinitions = pairs.some((p) => usesPiModels(p.agent ?? "pi"));
310
- if (providers === null && needsDefinitions)
311
- return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}`);
312
- const label = modelLabel;
313
- // The catalog claims (raw `input`, ignoring the cache): what to probe and report, including a
314
- // model whose earlier probe failed (it must still be named and re-probed by --reprobe).
315
- const claimed = new Map();
316
- for (const pair of pairs) {
317
- if (pair.model === undefined)
318
- continue;
319
- if (seamImageInput(pair, providers, undefined, listed) === true)
320
- claimed.set(imageProbeKey(pair.agent, pair.provider, pair.model), {
321
- agent: pair.agent,
322
- provider: pair.provider,
323
- model: pair.model,
324
- thinking: pair.thinking,
325
- });
326
- }
327
- // The tiers' fallback pairs: a reroute target must itself read images to help, and part 4's
328
- // fallback regime picks among these, so a text-only fallback team is a coverage gap here.
329
- const resolved = resolveTierFallbacks(config, (p) => piPairFree(providers, p, accounts));
330
- for (const tier of MODEL_TIERS) {
331
- const pair = resolved[tier].pair;
332
- if (pair?.model === undefined)
333
- continue;
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)
339
- claimed.set(imageProbeKey(agent, pair.provider, pair.model), {
340
- agent,
341
- provider: pair.provider,
342
- model: pair.model,
343
- thinking: pair.thinking,
344
- });
345
- }
346
- const probes = readImageProbeCache(root);
347
- const now = Date.now();
348
- const env = childEnv({ PATH: pathEnv });
349
- const readsItems = [];
350
- const failed = [];
351
- const couldNot = [];
352
- let probedAny = false;
353
- // Settle one claimed seam: probe when asked and uncached, read a fresh cached verdict
354
- // otherwise, or report "not verified yet". Only pass/fail are cached; could-not-verify is
355
- // reported but never cached.
356
- for (const seam of claimed.values()) {
357
- const agent = seam.agent;
358
- // A backend that registers an imageInput hook trusts the hook (claude today, which reads
359
- // every model); a backend without one is probed through its model catalog.
360
- if (agent !== undefined && backendById(agent)?.imageInput !== undefined) {
361
- readsItems.push(label(agent, seam.provider, seam.model));
362
- continue;
363
- }
364
- const key = imageProbeKey(agent, seam.provider, seam.model);
365
- const cached = probes[key];
366
- const valid = imageProbeEntryFresh(cached, now);
367
- const wantProbe = probeImages !== "never" && (probeImages === "all" || !valid);
368
- if (wantProbe) {
369
- probedAny = true;
370
- const result = await probe(config, { provider: seam.provider, model: seam.model, thinking: seam.thinking }, env);
371
- if (result.verdict === "pass") {
372
- probes[key] = { verdict: "pass", detail: result.detail, at: now, claim: true };
373
- readsItems.push(`${label(agent, seam.provider, seam.model)} (verified ${formatDate(new Date(now))})`);
374
- }
375
- else if (result.verdict === "fail") {
376
- probes[key] = { verdict: "fail", detail: result.detail, at: now, claim: true };
377
- failed.push(`${label(agent, seam.provider, seam.model)} failed the image probe — ${result.detail}`);
378
- }
379
- else {
380
- couldNot.push(`${label(agent, seam.provider, seam.model)} could not be verified — ${result.detail}`);
381
- }
382
- }
383
- else if (cached && imageProbeEntryFresh(cached, now)) {
384
- if (cached.verdict === "pass") {
385
- readsItems.push(`${label(agent, seam.provider, seam.model)} (verified ${formatDate(new Date(cached.at))})`);
386
- }
387
- else {
388
- failed.push(`${label(agent, seam.provider, seam.model)} failed the image probe — ${cached.detail}`);
389
- }
390
- }
391
- else {
392
- readsItems.push(`${label(agent, seam.provider, seam.model)} (not verified yet — run \`tumwater doctor\`)`);
393
- }
394
- }
395
- let saveProblem = null;
396
- if (probedAny) {
397
- try {
398
- writeImageProbeCache(root, probes);
399
- }
400
- catch (err) {
401
- saveProblem = errorMessage(err);
402
- }
403
- }
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)})`);
420
- }
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
- };
446
- }
447
271
  /** `gh` readiness for the opt-in `feedback` observer (plans/feedback-role.md "Shape" → "Part 1"):
448
272
  * the role files issues as the operator's GitHub login through `gh`, so doctor confirms `gh`
449
273
  * resolves and `gh auth status` exits 0 before a run tries to file — the codex-login pattern
@@ -6,11 +6,8 @@ import { errorMessage, squash } from "../text/text.js";
6
6
  import { okOutcome, warnOutcome } from "./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
- * up, short enough that a page that did not go through is still the operator's business. The
10
- * always-on notify team alert (src/ui/status-payload.ts notifyFailures) reuses this same
11
- * threshold and the same isNotifyFailure window, so it cannot drift from the doctor's check
12
- * — the STALE_BUG_DAYS precedent. */
13
- export const NOTIFY_CHECK_WINDOW_MS = DAY_MS;
9
+ * up, short enough that a page that did not go through is still the operator's business. */
10
+ const NOTIFY_CHECK_WINDOW_MS = DAY_MS;
14
11
  /** The `notify` check: whether a notify command is configured, and a warning when it failed to
15
12
  * page within the last day (a nonzero exit, a kill, or a failed start — the warnings
16
13
  * spawnNotifyCommand logs). Warns but never fails, like the hold rows: a dead webhook must not