tumwater 0.4.2 → 0.4.3

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 (305) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +25 -12
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +1 -8
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -18,12 +18,11 @@ import { show } from "./config-field-checks.js";
18
18
  import { canonicalConfigKey } from "./config-aliases.js";
19
19
  import { unknownRoleMessage } from "../roles/roles.js";
20
20
  import { loadConfig, collapseBackendAliases, knownRoleIdsCached, } from "./config.js";
21
- import { checkDailyBudgetUsd, checkProviderConcurrencyCap, checkQuietHours, parseConfigKey, PER_KEY_VALIDATORS, unknownConfigKeyError, unknownRoleFieldError, } from "./config-keys.js";
21
+ import { checkDailyBudgetUsd, checkQuietHours, parseConfigKey, PER_KEY_VALIDATORS, unknownConfigKeyError, unknownRoleFieldError, } from "./config-keys.js";
22
22
  import { writeConfigMutation, fail, rawConfigObject, without, preservingFileRoles, preserveFileRoles, withSectionOrDropped, } from "./config-write-core.js";
23
23
  /** Drop `field` from the `backends.<backend>` entry of the config `next`, dropping the whole
24
24
  * backend entry when it empties. The one home of the "remove a nested backend field, drop the
25
- * backend when empty" step the legacy `agentBin`/`piArgs` collapse and the `claudeWindowReserve`
26
- * collapse share, so the empty-entry rule cannot drift between the two. */
25
+ * backend when empty" step the legacy `agentBin`/`piArgs` collapse uses. */
27
26
  function dropNestedBackendField(next, backend, field) {
28
27
  const record = next;
29
28
  const backends = { ...(record.backends ?? {}) };
@@ -62,8 +61,7 @@ export function setDailyBudgetUsd(root, value) {
62
61
  * that setConfigKey's JSON.parse would read past validateConfig's integer check and silently
63
62
  * write — the same silent misread the per-role instances/interval guards, the TUI team-key
64
63
  * editors, and the GUI Settings rows refuse — so setConfigKey refuses it, naming the key and
65
- * writing nothing. The per-provider map (maxConcurrentPerProvider.<provider>) has its own
66
- * checkProviderConcurrencyCap screen and is not in this set. */
64
+ * writing nothing. */
67
65
  const PLAIN_INTEGER_TOP_LEVEL_KEYS = new Set([
68
66
  "maxConcurrent",
69
67
  "mergeBatchMax",
@@ -93,7 +91,6 @@ export function setConfigKey(root, key, rawValue) {
93
91
  // and the per-kind merge groups close over that call's `value`/`unchanged`/`oldValue`.
94
92
  const exponentRefusal = (parsed, key, trimmed) => {
95
93
  const isPlainIntegerKey = (parsed.kind === "top" && PLAIN_INTEGER_TOP_LEVEL_KEYS.has(key)) ||
96
- (parsed.kind === "map" && parsed.map === "maxConcurrentPerProvider") ||
97
94
  (parsed.kind === "role" && parsed.field === "instances") ||
98
95
  (parsed.kind === "section" && parsed.section === "bootstrap" && parsed.field === "untilPlansDone");
99
96
  if (isPlainIntegerKey && trimmed !== "" && isExponentSpelling(trimmed))
@@ -112,9 +109,7 @@ export function setConfigKey(root, key, rawValue) {
112
109
  const label = `${parsed.map}.${parsed.role}`;
113
110
  const validator = parsed.map === "maxDailyCostUsdPerRole"
114
111
  ? checkDailyBudgetUsd
115
- : parsed.map === "maxConcurrentPerProvider"
116
- ? checkProviderConcurrencyCap
117
- : checkQuietHours;
112
+ : checkQuietHours;
118
113
  const problem = validator(value, label);
119
114
  if (problem)
120
115
  return fail(problem);
@@ -185,12 +180,6 @@ export function setConfigKey(root, key, rawValue) {
185
180
  else
186
181
  delete nextRecord.piArgs;
187
182
  }
188
- // Setting the nested claude reserve drops the top-level spelling, so the file can never
189
- // hold both (validateConfig rejects a file that does, mirroring the agentBin/piArgs pair).
190
- if (parsed.id === "claude" && parsed.field === "windowReserve") {
191
- const nextRecord = next;
192
- delete nextRecord.claudeWindowReserve;
193
- }
194
183
  return preservingFileRoles(root, next);
195
184
  };
196
185
  const mergeRoleEntry = (record, parsed) => {
@@ -281,10 +270,6 @@ export function setConfigKey(root, key, rawValue) {
281
270
  // Writers keep one spelling: a legacy top-level set drops the matching backends.pi field, so
282
271
  // the file can never hold both (validateConfig's raw pass rejects a file that does).
283
272
  const dropLegacyPiAlias = (next, key) => dropNestedBackendField(next, "pi", key === "agentBin" ? "bin" : "args");
284
- // Writers keep one spelling: setting the top-level `claudeWindowReserve` drops the nested
285
- // `backends.claude.windowReserve`, so a supported `config set` never writes the rejected pair
286
- // (validateConfig still refuses a hand-edited file holding both).
287
- const dropNestedReserve = (next) => dropNestedBackendField(next, "claude", "windowReserve");
288
273
  const result = writeConfigMutation(root, (cfg) => {
289
274
  oldValue = cfg[key];
290
275
  if (isDeepStrictEqual(oldValue, value)) {
@@ -306,8 +291,6 @@ export function setConfigKey(root, key, rawValue) {
306
291
  return collapseBackendAliases(next);
307
292
  if (key === "agentBin" || key === "piArgs")
308
293
  return dropLegacyPiAlias(next, key);
309
- if (key === "claudeWindowReserve")
310
- return dropNestedReserve(next);
311
294
  return next;
312
295
  });
313
296
  if (!result.ok)
@@ -322,8 +305,8 @@ export function setConfigKey(root, key, rawValue) {
322
305
  return fail(parsed.error);
323
306
  // Shape, before the JSON.parse, for every plain-positive-integer key this verb can write —
324
307
  // the top-level run-concurrency knobs and legacy merge batch cap, plus the integer-bound
325
- // team-scope keys that arrive as a dotted key (`maxConcurrentPerProvider.<provider>`,
326
- // `roles.<id>.instances`, the `bootstrap.untilPlansDone` section field): refuse an exponent
308
+ // team-scope keys that arrive as a dotted key (`roles.<id>.instances`, the
309
+ // `bootstrap.untilPlansDone` section field): refuse an exponent
327
310
  // spelling ("4e1" → 40) that JSON.parse would read past validateConfig's integer check and
328
311
  // silently write, naming the key and writing nothing — the twin of the per-role guards, the
329
312
  // TUI team-key editors, and the GUI Settings rows. A non-exponent invalid falls through for
@@ -34,7 +34,7 @@ export function parseJsonConfig(file, basename) {
34
34
  * defaults for concurrency, timeouts, log size, retention, thrash detection, idle backoff,
35
35
  * self-redeploy, and review settings. The return type is the resolved view (config-views.ts): a
36
36
  * freshly defaulted config has no model map to resolve — only a loaded file can carry one — so
37
- * every call site that hands a default into a pi run (run-agent.ts's PiRunOptions, tests)
37
+ * every call site that hands a default into a pi run (agent-run.ts's PiRunOptions, tests)
38
38
  * satisfies ResolvedModelConfig directly. */
39
39
  export function defaultConfig() {
40
40
  const roles = {};
@@ -285,7 +285,7 @@ function cloneConfig(c) {
285
285
  * reconfiguration (BUGS.md 2026-09-23: a merge's fast-forward deleted it and the team ran
286
286
  * 8.6 h on defaults), so callers keep their last-known-good exactly as for a broken file, or
287
287
  * fall back to defaults only when they never saw one. Startup's "missing ⇒ not initialized"
288
- * gate (startup-gate.ts) and loadConfig's first-run defaults are unaffected. */
288
+ * gate (startup-gates.ts) and loadConfig's first-run defaults are unaffected. */
289
289
  export function loadConfigCached(root) {
290
290
  const file = configPath(root);
291
291
  try {
@@ -353,13 +353,12 @@ function stableJson(value) {
353
353
  }
354
354
  /** Sorted names of the config settings whose value differs between two loaded configs, for the
355
355
  * orchestrator's live-reload event. `maxConcurrent` and `sessionRetentionDays` are skipped: each
356
- * already has its own, more informative event. `maxConcurrentPerProvider` is skipped too — each
357
- * changed provider fires its own `provider_concurrency_changed` event. The `roles` map is never
356
+ * already has its own, more informative event. The `roles` map is never
358
357
  * reported whole — only
359
358
  * each differing `roles.<id>` — so a `customRoles` add/remove names both the section and the
360
359
  * affected role. */
361
360
  export function changedConfigKeys(prev, next) {
362
- const skip = new Set(["maxConcurrent", "maxConcurrentPerProvider", "sessionRetentionDays", "roles"]);
361
+ const skip = new Set(["maxConcurrent", "sessionRetentionDays", "roles"]);
363
362
  const keys = [];
364
363
  const prevRecord = prev;
365
364
  const nextRecord = next;
@@ -2,14 +2,14 @@ import { andMore, quoteHeading, readBacklogChecked, FIX_CLAIMS_CHECKED, REFUSAL_
2
2
  import { truncate } from "../text/text.js";
3
3
  import { plural, agree } from "../text/phrases.js";
4
4
  import { ageInDays, filingDate, fixedBugEntries, openBugEntries, openQuestionEntries, plannedPlanEntries } from "../backlog/backlog.js";
5
- import { questionFilingDate } from "../backlog/question-answer.js";
5
+ import { questionFilingDate } from "../backlog/backlog-question-answer.js";
6
6
  import { needsReviewNote, refusalNote } from "../backlog/backlog-eligibility.js";
7
7
  import { plannedPartKeys, plansWaitingOn, prerequisitesMerged } from "../backlog/backlog-parts.js";
8
8
  import { lastDate } from "../backlog/backlog-md.js";
9
9
  import { formatTimestamp } from "../text/datetime.js";
10
10
  import { stringList } from "../files/json-object.js";
11
11
  import { readEventsSince } from "../events/event-window.js";
12
- import { okOutcome, warnOutcome } from "./outcome.js";
12
+ import { okOutcome, warnOutcome } from "./doctor-outcome.js";
13
13
  /** The doctor's open-bug and question checks, split out of doctor-backlog.ts: questions that
14
14
  * have waited too long for an answer (checkStaleQuestions), bugs left unfixed (checkStaleBugs),
15
15
  * fixed records whose quoted test regressed (checkRegressedFixes), and
@@ -17,7 +17,8 @@ import { okOutcome, warnOutcome } from "./outcome.js";
17
17
  * (readBacklogChecked, andMore) from doctor-backlog.ts, plus the age/trim bounds, and
18
18
  * all warn instead of failing: a stale or
19
19
  * parked entry is operator signal, not a broken environment. doctor.ts composes them beside the
20
- * plan-structure and queue checks; the shared CheckOutcome shape is imported from outcome.ts. */
20
+ * plan-structure and queue checks; the shared CheckOutcome shape is imported from
21
+ * doctor-outcome.ts. */
21
22
  /** Stale questions — open QUESTIONS.md questions that have waited more than 7 days for an
22
23
  * answer. A team can stay parked on a question for days while `tumwater doctor` reports
23
24
  * nothing; the `--list` age (`questionAgeSuffix` on `tumwater questions`) is the only other
@@ -143,7 +144,7 @@ function sinceWindowMs(sinces) {
143
144
  * cannot drift on the selection. `since` is the day-after the fix date — the recurrence scan's
144
145
  * boundary (the heading carries only a date, so a same-day failure cannot be told from the one
145
146
  * the fix answered). Returns `[]` when no candidate has both a quoted test and a date. */
146
- export function regressedFixCandidates(root) {
147
+ function regressedFixCandidates(root) {
147
148
  const candidates = fixedBugEntries(root).slice(0, FIX_CLAIMS_CHECKED).flatMap((entry) => {
148
149
  const name = quotedTestName(entry.title);
149
150
  const date = lastDate(entry.title, /(\d{4}-\d{2}-\d{2})/);
@@ -159,14 +160,14 @@ export function regressedFixCandidates(root) {
159
160
  * `failureMentions` per candidate over `events`, keeping the last mention (the doctor's exact
160
161
  * match). The caller supplies the event window — the doctor reads back to the oldest candidate's
161
162
  * day-after, the always-on alert the trailing day. */
162
- export function matchedRegressedFixes(candidates, events) {
163
+ function matchedRegressedFixes(candidates, events) {
163
164
  return candidates.flatMap((c) => {
164
165
  const newest = lastFailureMention(events, c.name, c.since);
165
166
  return newest ? [{ ...c, newest }] : [];
166
167
  });
167
168
  }
168
169
  /** The doctor's `regressed fixes` warn sentence, verbatim, or null when nothing regressed. */
169
- export function regressedFixesPhrase(regressed) {
170
+ function regressedFixesPhrase(regressed) {
170
171
  const [first, ...rest] = regressed;
171
172
  if (!first)
172
173
  return null;
@@ -1,17 +1,18 @@
1
1
  import { andMore, quoteHeading, readDoc, readBacklogChecked, FIX_CLAIMS_CHECKED, REFUSAL_NOTE_MAX } from "./doctor-backlog.js";
2
+ import { agree } from "../text/phrases.js";
2
3
  import { cachedByStat } from "../files/stat-cache.js";
3
4
  import { VALIDATION_GAP_TAGS } from "../roles/role-guidance.js";
4
5
  import path from "node:path";
5
6
  import { truncate } from "../text/text.js";
6
7
  import { donePlans, fixedBugEntries, openBugEntries, plannedPlanEntries } from "../backlog/backlog.js";
7
- import { entryHold, entryKindLabel, heldCount } from "../backlog/backlog-eligibility.js";
8
+ import { entryHold, entryKindLabel, isPlanStalled } from "../backlog/backlog-eligibility.js";
8
9
  import { planDependencyProblems } from "../backlog/backlog-parts.js";
9
10
  import { BACKLOG_FILES, FINISHED_ARCHIVES, validationGapTag } from "../backlog/backlog-md.js";
10
11
  import { statOrNull } from "../files/files.js";
11
12
  import { duplicateHeadings, strandedPlanEntriesFor } from "../backlog/backlog-structure.js";
12
13
  import { liveFinishedHistory, liveFinishedRowList } from "../backlog/backlog-archive.js";
13
14
  import { fixSymbols, missingSymbolNames, cachedSourceHaystack, unbackedSymbols } from "../verdict/fix-claim.js";
14
- import { okOutcome, warnOutcome } from "./outcome.js";
15
+ import { okOutcome, warnOutcome } from "./doctor-outcome.js";
15
16
  /** The doctor's PLANS.md-structure and fix-claim checks, split out of doctor-backlog.ts: the
16
17
  * checks that re-verify BUGS.md's newest Fixed records against the tree (checkFixClaims,
17
18
  * checkValidationGaps) and the PLANS.md / backlog-file integrity checks that read sections and
@@ -21,7 +22,7 @@ import { okOutcome, warnOutcome } from "./outcome.js";
21
22
  * heading-trim bounds,
22
23
  * and all of them warn instead of failing: damaged documentation is operator signal, not a
23
24
  * broken environment. doctor.ts composes them beside the open-item and queue checks; the shared
24
- * CheckOutcome shape is imported from outcome.ts. */
25
+ * CheckOutcome shape is imported from doctor-outcome.ts. */
25
26
  /** Fix claims — the standalone half of the merge gate's false-fix check (src/verdict/fix-claim.ts):
26
27
  * that gate fires only when an md-only diff moves a BUGS.md entry to Fixed, so a phantom fix that
27
28
  * reached main any other way (merged before the gate existed, or through a path it never sees) was
@@ -44,7 +45,7 @@ export function checkFixClaims(root) {
44
45
  const { heading, missing } = first;
45
46
  const names = missingSymbolNames(missing);
46
47
  // Every other suspect is still named, shortened: a doctor check is one line.
47
- const more = andMore(rest, (p) => quoteHeading(p.heading), "record(s)");
48
+ const more = andMore(rest, (p) => quoteHeading(p.heading), agree(rest.length, "record", "records"));
48
49
  return warnOutcome(`BUGS.md records "${heading}" as Fixed, but none of the symbols its Fix paragraph names ` +
49
50
  `exist on this tree: ${names}${more} — land the fix or keep the bug Open / refresh a stale record`);
50
51
  }
@@ -147,14 +148,14 @@ export function checkValidationGaps(root) {
147
148
  if (!first) {
148
149
  if (unpinned.length > 0) {
149
150
  const up = unpinned[0];
150
- const more = andMore(unpinned.slice(1), (s) => quoteHeading(s.title), "record(s)");
151
+ const more = andMore(unpinned.slice(1), (s) => quoteHeading(s.title), agree(unpinned.slice(1).length, "record", "records"));
151
152
  return warnOutcome(`BUGS.md record "${up.title}" is an \`unclear-invariant\` fix but names no ` +
152
153
  `docs/invariants.md section — record the reconstructed guarantee there (a "Pinned by" line) in the ` +
153
154
  `same commit so the next bug in that area does not reconstruct it again${more}`);
154
155
  }
155
156
  return okOutcome(`newest ${validationGapRecords(root).length} Fixed record(s) carry a known Validation gap tag`);
156
157
  }
157
- const more = andMore(rest, (s) => quoteHeading(s.title), "record(s)");
158
+ const more = andMore(rest, (s) => quoteHeading(s.title), agree(rest.length, "record", "records"));
158
159
  const what = first.kind === "missing"
159
160
  ? `BUGS.md record "${first.title}" has no **Validation gap:** line`
160
161
  : `BUGS.md record "${first.title}" names unrecognized Validation gap tag "${first.tag}"`;
@@ -309,24 +310,27 @@ export function checkBacklogHeadings(root) {
309
310
  `later entries are invisible; keep one heading per name (the gate blocks only a merge ` +
310
311
  `that adds another copy — remove the duplicate in a deliberate edit)`);
311
312
  }
312
- /** Stalled entries — plans or bugs held by a **Needs review …** note,
313
- * parked until the plan role splits or replans them, plus the held-for-operator entries
314
- * parked until the operator releases them. No role takes a held entry (the claim gate refuses
315
- * one) and nothing clears the note on its own, so the state is visible only as the
313
+ /** Stalled entries — plans or bugs parked with the plan role by a **Needs review …** note
314
+ * that the plan role clears by splitting or replanning (the feature role's too-large note, the
315
+ * convergence hold, a final-round rejection). The bugfix role's wait-for-the-next-failure
316
+ * parks ("by bugfix:") are excluded — selfcheck lifts them, not the plan role, so they are
317
+ * not the plan role's stalled work. No role takes a held entry (the claim gate refuses one)
318
+ * and nothing clears a plan-role note on its own, so the state is visible only as the
316
319
  * `[needs review]` marks in the backlog listings; this names them so an
317
320
  * operator gets a single-line health signal from `tumwater doctor`. Reads the tree at `root`
318
321
  * — the primary checkout IS main's tree — like checkRefusedEntries, which is also where
319
- * refusal notes (a different hold) are surfaced. entryHold (src/backlog/backlog-eligibility.ts)
320
- * already classifies the notes, so this check adds no parsing: only the entries whose hold is
321
- * exactly needs-review or held-for-operator are collected, from both PLANS.md
322
+ * refusal notes (a different hold) are surfaced. entryHold + isPlanStalled
323
+ * (src/backlog/backlog-eligibility.ts) already classify the notes, so this check adds no
324
+ * parsing: only the entries whose hold is exactly needs-review and which isPlanStalled
325
+ * accepts are collected, from both PLANS.md
322
326
  * and BUGS.md. A warn, never a fail — the checkRefusedEntries precedent: a parked entry is
323
- * operator signal, not a broken environment; the plan role resolves the first itself, and
324
- * a held-for-operator entry is the operator's to release (`tumwater plan|bug --release`). */
327
+ * operator signal, not a broken environment; the plan role resolves it by splitting or
328
+ * replanning. */
325
329
  export function checkStalledEntries(root) {
326
330
  const planned = plannedPlanEntries(root);
327
331
  const collect = (entry, file) => {
328
332
  const hold = entryHold(entry, planned);
329
- return hold === "needs-review" || hold === "held-for-operator" // vocabulary-ok: the held-for-operator hold's key
333
+ return hold === "needs-review" && isPlanStalled(entry)
330
334
  ? { file, entry }
331
335
  : null;
332
336
  };
@@ -336,15 +340,11 @@ export function checkStalledEntries(root) {
336
340
  ].filter((x) => x !== null);
337
341
  const [first, ...rest] = stalled;
338
342
  if (!first)
339
- return okOutcome("no planned plan or open bug is held by a Needs-review or held-for-operator note"); // vocabulary-ok: names the held-for-operator hold
343
+ return okOutcome("no planned plan or open bug is held by a Needs-review note");
340
344
  const more = andMore(rest, (s) => quoteHeading(s.entry.title));
341
345
  const kind = entryKindLabel(first.file); // "Bug" | "Plan"
342
346
  const head = `${first.file === "bugs" ? "BUGS.md" : "PLANS.md"} ${kind.toLowerCase()}`;
343
- const hold = entryHold(first.entry, planned);
344
347
  const note = (first.entry.body.split(/\n/).find((line) => line.trimStart().startsWith("**Needs review ")) ?? "").replace(/^\s*\*\*/, "").replace("**", "");
345
- if (hold === "held-for-operator") { // vocabulary-ok: the held-for-operator hold's key
346
- return warnOutcome(`${head} ${quoteHeading(first.entry.title)} is held for the operator ${heldCount(first.entry.body)} times${more} — release it with tumwater ${kind.toLowerCase()} --release`); // vocabulary-ok: a held-for-operator entry is the operator's to release
347
- }
348
348
  return warnOutcome(`${head} ${quoteHeading(first.entry.title)} is held by a Needs review note ` +
349
349
  `(${truncate(note, REFUSAL_NOTE_MAX)})${more} — the plan role resolves it by splitting or replanning`);
350
350
  }
@@ -375,16 +375,16 @@ export function planDependencyProblem(root) {
375
375
  }
376
376
  if (firstDangling) {
377
377
  return { kind: "dangling", message: danglingMsg(firstDangling) +
378
- andMore(restDangling, (d) => `${quote(d.title)} → ${d.ref}`, "ref(s)") +
378
+ andMore(restDangling, (d) => `${quote(d.title)} → ${d.ref}`, agree(restDangling.length, "ref", "refs")) +
379
379
  others([[unparsed.length, "unparsed clause(s)"], [misworded.length, "misworded dependency(ies)"]]) };
380
380
  }
381
381
  if (firstUnparsed) {
382
- return { kind: "unparsed", message: unparsedMsg(firstUnparsed) + andMore(restUnparsed, quote, "clause(s)") +
382
+ return { kind: "unparsed", message: unparsedMsg(firstUnparsed) + andMore(restUnparsed, quote, agree(restUnparsed.length, "clause", "clauses")) +
383
383
  others([[misworded.length, "misworded dependency(ies)"]]) };
384
384
  }
385
385
  if (firstMisworded) {
386
386
  return { kind: "misworded", message: miswordedMsg(firstMisworded) +
387
- andMore(restMisworded, (w) => `${quote(w.title)} → ${w.ref}`, "dependency(ies)") };
387
+ andMore(restMisworded, (w) => `${quote(w.title)} → ${w.ref}`, agree(restMisworded.length, "dependency", "dependencies")) };
388
388
  }
389
389
  return null;
390
390
  }
@@ -8,7 +8,7 @@ import { humanSeconds, secondsSince, HOUR_MS, DAY_MS } from "../text/datetime.js
8
8
  import { eventsSince } from "../events/event-window.js";
9
9
  import { finiteNumber } from "../files/json-object.js";
10
10
  import { lowerMedian } from "../collections.js";
11
- import { plural, shortSpanPhrase } from "../text/phrases.js";
11
+ import { agree, plural, shortSpanPhrase } from "../text/phrases.js";
12
12
  import { queuedMerges, unreadableMergeQueueFiles } from "../merge/merge-queue.js";
13
13
  import { mergeChanges, readMergeMarker } from "../merge/merge-slot.js";
14
14
  import { listWideScope, queuedListRows } from "../inbox/inbox.js";
@@ -19,7 +19,7 @@ import { stampedSubject } from "../git/commit-message.js";
19
19
  import { commitOperatorBacklogEdit } from "../backlog/backlog-commit.js";
20
20
  import { briefFile } from "../brief/brief.js";
21
21
  import { BACKLOG_FILES } from "../backlog/backlog-md.js";
22
- import { okOutcome, warnOutcome } from "./outcome.js";
22
+ import { okOutcome, warnOutcome } from "./doctor-outcome.js";
23
23
  /** The doctor's backlog modules' shared scaffolding and the check groups that do not read a
24
24
  * single backlog document: the merge-queue, post-merge-review and queued-prompt checks
25
25
  * (checkStaleQueuedMerges, checkMergeQueueFiles, checkPostReviews, checkStalePrompts and the
@@ -30,7 +30,8 @@ import { okOutcome, warnOutcome } from "./outcome.js";
30
30
  * checks live in doctor-backlog-plans.ts (the plan-document structure and fix-claim checks) and
31
31
  * doctor-backlog-bugs.ts (open bugs, questions and holds). All of them warn instead of failing:
32
32
  * damaged documentation is operator signal, not a broken environment. doctor.ts composes them
33
- * beside the environment checks; the shared CheckOutcome shape is imported from outcome.ts. */
33
+ * beside the environment checks; the shared CheckOutcome shape is imported from
34
+ * doctor-outcome.ts. */
34
35
  /** How many Fixed records the two newest-first Fixed-record checks (fix claims and validation
35
36
  * gaps) verify: the newest, since the section is newest-first by template convention. Older
36
37
  * records drift as the code evolves — a symbol legitimately fixed long ago gets renamed later,
@@ -55,7 +56,8 @@ export const STALE_BUG_DAYS = 3;
55
56
  * shortened, since a doctor check renders one line. `render` makes each extra item's short
56
57
  * form — the fix-claim and stranded-plan sites quote a heading trimmed to
57
58
  * FIX_CLAIM_HEADING_MAX, the duplicate-headings site passes its items through as built;
58
- * `noun` names what the extras are ("record(s)" for fix claims, empty for the other two).
59
+ * `noun` names what the extras are and must already agree with `rest.length` — callers pass
60
+ * `agree(rest.length, "record", "records")`, or empty where no noun is named.
59
61
  * Empty string when there are no extras. */
60
62
  export function andMore(rest, render, noun = "") {
61
63
  return rest.length > 0
@@ -175,7 +177,7 @@ export function checkMergeQueueFiles(root) {
175
177
  if (files.length === 0)
176
178
  return okOutcome("the queue reader skips no merge-queue file");
177
179
  const [first, ...rest] = files;
178
- const more = andMore(rest, (f) => f, "file(s)");
180
+ const more = andMore(rest, (f) => f, agree(rest.length, "file", "files"));
179
181
  return warnOutcome(`the merge queue holds ${plural(files.length, "file")} the reader skips — ${first}${more}; ` +
180
182
  `a torn or foreign file is never cleared, so nothing ages it or surfaces its merge — remove it (a crashed enqueue's merge ref still recovers on the next run)`);
181
183
  }
@@ -1,9 +1,8 @@
1
1
  /** Every row name `tumwater doctor` can print, in report order — the names `tumwater doctor
2
2
  * <check>...` accepts. A leaf with no imports: cli.ts loads doctor.ts with a dynamic import()
3
3
  * while cli-command-args.ts is imported eagerly, so the argument parser reads the names from
4
- * here instead of pulling every check module into each CLI start. `pi extensions`, `funes`, and
5
- * the `claude window`/`codex window` subscription rows are conditional: a named one whose check
6
- * returns null leaves no row. */
4
+ * here instead of pulling every check module into each CLI start. `pi extensions` and `funes`
5
+ * are conditional: a named one whose check returns null leaves no row. */
7
6
  export const DOCTOR_CHECK_NAMES = [
8
7
  "node",
9
8
  "node binary",
@@ -17,11 +16,8 @@ export const DOCTOR_CHECK_NAMES = [
17
16
  "pause",
18
17
  "quiet hours",
19
18
  "budget",
20
- "claude window",
21
- "codex window",
22
19
  "provider waits",
23
20
  "maintenance allowance",
24
- "provider concurrency",
25
21
  "provider contention",
26
22
  "check contention",
27
23
  "repeated failures",
@@ -29,6 +25,7 @@ export const DOCTOR_CHECK_NAMES = [
29
25
  "over-cap model",
30
26
  "fallback chain",
31
27
  "models for each tier",
28
+ "image input",
32
29
  "claude auth",
33
30
  "claude api key",
34
31
  "claude version",
@@ -17,13 +17,13 @@ import { errorMessage } from "../text/text.js";
17
17
  import { formatBytes } from "../text/format.js";
18
18
  import { BRIEF_TEXT_MAX_CHARS, briefFile, promptBlockOf, readBrief } from "../brief/brief.js";
19
19
  import { plural } from "../text/phrases.js";
20
- import { backendsInUse, unregisteredBackendInUse } from "../gates/startup-gate.js";
20
+ import { backendsInUse, unregisteredBackendInUse } from "../gates/startup-gates.js";
21
21
  import { claudeSeamsInUse, minVersionOutcome, opencodeSeamsInUse, versionFromProbe, piSeamsInUse } from "./doctor-model-checks.js";
22
22
  import { claudeConfigDir } from "../agent/claude/claude-backend.js";
23
- import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
23
+ import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./doctor-probe.js";
24
24
  import { childEnv } from "../process/process.js";
25
25
  import { opencodeBackend, OPENCODE_MIN_VERSION } from "../agent/opencode/opencode-backend.js";
26
- import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
26
+ import { failOutcome, noConfigOutcome, okOutcome, warnOutcome } from "./doctor-outcome.js";
27
27
  import { spawnSync } from "node:child_process";
28
28
  /** The floor doctor falls back to when package.json — the one source of the declared
29
29
  * engines.node spec, read below — cannot be read (a broken install, the version command's
@@ -238,7 +238,7 @@ export function checkBackendBinary(root, pathEnv = envPath()) {
238
238
  export async function checkOpencodeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
239
239
  const { config } = loadConfigSafe(root);
240
240
  if (config === undefined)
241
- return okOutcome("no readable tumwater.json — nothing to verify");
241
+ return noConfigOutcome();
242
242
  if (!opencodeSeamsInUse(config))
243
243
  return okOutcome("no model jobs run on opencode");
244
244
  const resolved = opencodeBackend.resolveBin(config);
@@ -255,7 +255,7 @@ export async function checkOpencodeVersion(root, pathEnv = envPath(), timeoutMs
255
255
  * `homeDir` and `env` are test seams. */
256
256
  export function checkClaudeTranscripts(config, homeDir = os.homedir(), env = process.env) {
257
257
  if (!config)
258
- return okOutcome("no readable tumwater.json — nothing to verify");
258
+ return noConfigOutcome();
259
259
  if (!claudeSeamsInUse(config))
260
260
  return okOutcome("no model jobs run on claude");
261
261
  const configDir = resolvedBackendConfig(config, "claude").configDir;
@@ -1,13 +1,13 @@
1
1
  import os from "node:os";
2
2
  import { childEnv } from "../process/process.js";
3
- import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
3
+ import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./doctor-probe.js";
4
4
  import { claudeBackend, claudeConfigDir } from "../agent/claude/claude-backend.js";
5
5
  import { claudeEnv } from "../agent/claude/claude-env.js";
6
6
  import { claudeAuthUsesSubscription, parseClaudeAuthStatus } from "../agent/claude/claude-account.js";
7
7
  import { resolvedBackendConfig } from "../config/config-views.js";
8
8
  import { envPath } from "../files/files.js";
9
9
  import { belowNodeFloor } from "../version.js";
10
- import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
10
+ import { failOutcome, okOutcome, warnOutcome } from "./doctor-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
13
  * doctor-model-checks.ts (whose shared seam/model framework and the pi and tier-models
@@ -16,9 +16,9 @@ import { claudeSeamsInUse, loadConfigForCheck, versionFromProbe, seamedModels, }
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
18
18
  * (CheckOutcome) and the seam/model framework (loadConfigForCheck, claudeSeamsInUse,
19
- * seamPairs) come from doctor-model-checks.ts, and the AUTH_CHECK_* probe budgets from probe.ts,
20
- * with the shared report contract (CheckOutcome) from outcome.ts, exactly as the other doctor-*
21
- * modules share them. */
19
+ * seamPairs) come from doctor-model-checks.ts, and the AUTH_CHECK_* probe budgets
20
+ * from doctor-probe.ts, with the shared report contract (CheckOutcome) from
21
+ * doctor-outcome.ts, exactly as the other doctor-* modules share them. */
22
22
  /** One bounded probe of the resolved `claude` binary under the scrubbed run environment a real
23
23
  * run gets: resolve the binary through the claude adapter that would spawn it and run `args`
24
24
  * with a short deadline. Null when the probe could not run, the child errored, or the deadline
@@ -95,39 +95,27 @@ export async function checkAnthroApiKey(root, env = process.env, pathEnv = envPa
95
95
  return okOutcome("ANTHROPIC_API_KEY is set, but claude reports no claude.ai login");
96
96
  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");
97
97
  }
98
- /** claude CLI-version readiness (plans/agent-backends.md finding 9): an installed CLI below a
99
- * configured model's minimum fails or misprices that model, and team runs disable the
100
- * auto-updater, so the version an operator has is the version agent runs use. Fails below the
101
- * highest floor among the claude-routed models, naming which model needs it; an ok detail
102
- * prints the installed version and the floor it satisfied (or no floor when none applies).
103
- * The `off`/`minimal` thinking clamp is noted here too, since this check already has the
104
- * claude seams in hand. Stands down when no seam routes to claude. */
105
- export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
106
- const config = loadConfigForCheck(root);
107
- if ("level" in config)
108
- return config;
109
- const seams = seamedModels(config, "claude");
110
- if (seams.length === 0)
111
- return okOutcome("no model jobs run on claude");
112
- // The floor each configured model needs; a model the adapter does not gate contributes none.
98
+ /** The floor each configured model needs, keyed by model; a model the adapter does not gate
99
+ * contributes none. */
100
+ function claudeVersionFloors(seams) {
113
101
  const floors = new Map();
114
102
  for (const seam of seams) {
115
103
  const min = claudeBackend.minVersion?.(seam.model);
116
104
  if (min !== undefined)
117
105
  floors.set(seam.model, min);
118
106
  }
119
- const result = await claudeProbe(config, ["--version"], pathEnv, timeoutMs);
120
- const found = versionFromProbe(result);
121
- if (found === undefined)
122
- return warnOutcome("could not check — `claude --version` did not run or its output was unreadable");
107
+ return floors;
108
+ }
109
+ /** The clamp note when any claude seam clamps thinking, else empty. */
110
+ function claudeVersionClampNote(seams) {
123
111
  const clamped = seams.some((seam) => seam.thinking === "off" || seam.thinking === "minimal");
124
112
  const clampNote = clamped
125
113
  ? "; `off`/`minimal` thinking clamps to `--effort low` (claude has no lower setting)"
126
114
  : "";
127
- if (floors.size === 0)
128
- return okOutcome(`claude ${found}${clampNote}`);
129
- // The highest floor decides: whichever model needs the newest CLI. Below it, fail and name
130
- // that model; at or above, print the version against the floor.
115
+ return clampNote;
116
+ }
117
+ /** The highest floor decides: whichever model needs the newest CLI. */
118
+ function claudeVersionNeededBy(floors) {
131
119
  let required = "";
132
120
  let neededBy = "";
133
121
  for (const [model, min] of floors) {
@@ -136,7 +124,35 @@ export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs =
136
124
  neededBy = model;
137
125
  }
138
126
  }
127
+ return { required, neededBy };
128
+ }
129
+ /** Verdict on the found version against the claude seams' floors, carrying the clamp note. */
130
+ function claudeVersionOutcome(found, floors, clampNote) {
131
+ if (floors.size === 0)
132
+ return okOutcome(`claude ${found}${clampNote}`);
133
+ const { required, neededBy } = claudeVersionNeededBy(floors);
139
134
  if (belowNodeFloor(found, `>=${required}`))
140
135
  return failOutcome(`claude ${found} is older than ${required} required by ${neededBy} — run \`claude update\``);
141
136
  return okOutcome(`claude ${found} (>= ${required} for ${neededBy})${clampNote}`);
142
137
  }
138
+ /** claude CLI-version readiness (plans/agent-backends.md finding 9): an installed CLI below a
139
+ * configured model's minimum fails or misprices that model, and team runs disable the
140
+ * auto-updater, so the version an operator has is the version agent runs use. Fails below the
141
+ * highest floor among the claude-routed models, naming which model needs it; an ok detail
142
+ * prints the installed version and the floor it satisfied (or no floor when none applies).
143
+ * The `off`/`minimal` thinking clamp is noted here too, since this check already has the
144
+ * claude seams in hand. Stands down when no seam routes to claude. */
145
+ export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
146
+ const config = loadConfigForCheck(root);
147
+ if ("level" in config)
148
+ return config;
149
+ const seams = seamedModels(config, "claude");
150
+ if (seams.length === 0)
151
+ return okOutcome("no model jobs run on claude");
152
+ const floors = claudeVersionFloors(seams);
153
+ const result = await claudeProbe(config, ["--version"], pathEnv, timeoutMs);
154
+ const found = versionFromProbe(result);
155
+ if (found === undefined)
156
+ return warnOutcome("could not check — `claude --version` did not run or its output was unreadable");
157
+ return claudeVersionOutcome(found, floors, claudeVersionClampNote(seams));
158
+ }
@@ -1,19 +1,19 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { childEnv } from "../process/process.js";
4
- import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
4
+ import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./doctor-probe.js";
5
5
  import { codexBackend, CODEX_MIN_VERSION } from "../agent/codex/codex-backend.js";
6
6
  import { codexEffort, EFFORT_BY_THINKING } from "../agent/codex/codex-args.js";
7
7
  import { codexEnv } from "../agent/codex/codex-env.js";
8
8
  import { codexHome, readCodexCatalog } from "../agent/codex/codex-catalog.js";
9
9
  import { codexHookTrust } from "../agent/codex/codex-hook-trust.js";
10
- import { parseCodexLoginStatus } from "../agent/codex/codex-account.js";
10
+ import { codexApiKeyRefusalMessage, 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
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
- import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
16
+ import { failOutcome, okOutcome, warnOutcome } from "./doctor-outcome.js";
17
17
  import { codexSeamsInUse, loadConfigForCheck, minVersionOutcome, versionFromProbe, seamedModels, } from "./doctor-model-checks.js";
18
18
  /** The doctor's codex rows (part 9a/9, plans/codex-backend.md §4, §7, "Hand steps"): one
19
19
  * exported check per row — install/version, login, home, models, account, leaks, sessions
@@ -25,8 +25,8 @@ import { codexSeamsInUse, loadConfigForCheck, minVersionOutcome, versionFromProb
25
25
  * pi-only team shows no codex row and spawns nothing and reads nothing. The shared
26
26
  * report contract (CheckOutcome) and the seam/model framework (loadConfigForCheck,
27
27
  * codexSeamsInUse, seamPairs) come from doctor-model-checks.ts, the AUTH_CHECK_* probe budgets
28
- * from probe.ts, and the shared report contract (CheckOutcome) from outcome.ts, as the other
29
- * doctor-* modules share them. */
28
+ * from doctor-probe.ts, and the shared report contract (CheckOutcome) from
29
+ * doctor-outcome.ts, as the other doctor-* modules share them. */
30
30
  /** The config and codex home a codex readiness row reads, or the warn outcome a broken
31
31
  * `tumwater.json` earns, or null when no seam routes to codex (the row's stand-down). All
32
32
  * eight rows repeat the same three-line guard and the same home lookup
@@ -83,7 +83,7 @@ export async function checkCodexAuth(root, pathEnv = envPath(), timeoutMs = AUTH
83
83
  if (status === "subscription")
84
84
  return okOutcome("logged in using ChatGPT — billing: subscription");
85
85
  if (status === "api")
86
- return failOutcome(`Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run \`CODEX_HOME=${home} codex login --device-auth\``);
86
+ return failOutcome(codexApiKeyRefusalMessage(home));
87
87
  if (/Not logged in/i.test(result.stdout ?? ""))
88
88
  return failOutcome(`codex is not logged in — run \`CODEX_HOME=${home} codex login --device-auth\``);
89
89
  return warnOutcome("could not check — `codex login status` reply was unreadable");