tumwater 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -1,17 +1,17 @@
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
- import { BACKLOG_FILES, FINISHED_ARCHIVES, validationGapTag } from "../backlog/backlog-md.js";
10
- import { statOrNull } from "../files/files.js";
10
+ import { BACKLOG_FILES, validationGapTag } from "../backlog/backlog-md.js";
11
11
  import { duplicateHeadings, strandedPlanEntriesFor } from "../backlog/backlog-structure.js";
12
12
  import { liveFinishedHistory, liveFinishedRowList } from "../backlog/backlog-archive.js";
13
- import { fixSymbols, missingSymbolNames, cachedSourceHaystack, unbackedSymbols } from "../verdict/fix-claim.js";
14
- import { okOutcome, warnOutcome } from "./outcome.js";
13
+ import { fixSymbols, isCommitRef, missingSymbolNames, cachedSourceHaystack, unbackedSymbols } from "../verdict/fix-claim.js";
14
+ import { okOutcome, warnOutcome } from "./doctor-outcome.js";
15
15
  /** The doctor's PLANS.md-structure and fix-claim checks, split out of doctor-backlog.ts: the
16
16
  * checks that re-verify BUGS.md's newest Fixed records against the tree (checkFixClaims,
17
17
  * checkValidationGaps) and the PLANS.md / backlog-file integrity checks that read sections and
@@ -21,7 +21,7 @@ import { okOutcome, warnOutcome } from "./outcome.js";
21
21
  * heading-trim bounds,
22
22
  * and all of them warn instead of failing: damaged documentation is operator signal, not a
23
23
  * broken environment. doctor.ts composes them beside the open-item and queue checks; the shared
24
- * CheckOutcome shape is imported from outcome.ts. */
24
+ * CheckOutcome shape is imported from doctor-outcome.ts. */
25
25
  /** Fix claims — the standalone half of the merge gate's false-fix check (src/verdict/fix-claim.ts):
26
26
  * that gate fires only when an md-only diff moves a BUGS.md entry to Fixed, so a phantom fix that
27
27
  * reached main any other way (merged before the gate existed, or through a path it never sees) was
@@ -44,20 +44,22 @@ export function checkFixClaims(root) {
44
44
  const { heading, missing } = first;
45
45
  const names = missingSymbolNames(missing);
46
46
  // Every other suspect is still named, shortened: a doctor check is one line.
47
- const more = andMore(rest, (p) => quoteHeading(p.heading), "record(s)");
47
+ const more = andMore(rest, (p) => quoteHeading(p.heading), agree(rest.length, "record", "records"));
48
48
  return warnOutcome(`BUGS.md records "${heading}" as Fixed, but none of the symbols its Fix paragraph names ` +
49
49
  `exist on this tree: ${names}${more} — land the fix or keep the bug Open / refresh a stale record`);
50
50
  }
51
- /** The fix-claim rows shared by the doctor's check and the always-on alert: each newest Fixed
51
+ /** The fix-claim rows the doctor's check warns about: each newest Fixed
52
52
  * record classified through fixSymbols and unbackedSymbols — the gate's own parsing and
53
53
  * existence rules — against ONE cachedSourceHaystack built for the whole scan, keeping a record
54
54
  * only when EVERY symbol its Fix paragraph names is absent, and skipping a record that names no
55
- * symbols (pure-documentation fixes are legitimate). */
55
+ * symbols (pure-documentation fixes are legitimate). A commit id is a citation, not a symbol:
56
+ * this synchronous scan cannot ask git whether it exists (the gate does), so a record that cites
57
+ * only the commit it reviewed names nothing checkable. */
56
58
  function classifyFixClaims(records, root) {
57
59
  const problems = [];
58
60
  let haystack;
59
61
  for (const { title, body } of records) {
60
- const symbols = fixSymbols(body);
62
+ const symbols = fixSymbols(body).filter((symbol) => !isCommitRef(symbol));
61
63
  if (symbols.length === 0)
62
64
  continue;
63
65
  haystack ??= cachedSourceHaystack(root);
@@ -70,24 +72,20 @@ function classifyFixClaims(records, root) {
70
72
  /** The ONE shared fix-claim scan: the newest Fixed records (bounded to FIX_CLAIMS_CHECKED so
71
73
  * pre-convention history never warns forever) as fixedBugEntries reads them — the stat-cached
72
74
  * section reader BUGS.md + BUGS-FIXED.md, bodies kept whole — classified through
73
- * classifyFixClaims. Reading through this same reader (and not a one-off fixedEntriesAt walk) is
74
- * what keeps the alert and the doctor on the identical problem set; there is only one scan. */
75
+ * classifyFixClaims. */
75
76
  function fixClaimRecords(root) {
76
77
  return fixedBugEntries(root).slice(0, FIX_CLAIMS_CHECKED);
77
78
  }
78
- /** The problems the ONE shared fix-claim scan found among the newest Fixed records at `root`
79
+ /** The problems the ONE fix-claim scan found among the newest Fixed records at `root`
79
80
  * (empty when BUGS.md is unreadable or every newest Fixed record names at least one live
80
- * symbol). Feeds the always-on amber alert through the stat-cached fixedBugEntries reader and
81
- * cachedSourceHaystack, so it stays in step with the doctor and adds no re-read to the 1 Hz TUI
82
- * and status-poll path. */
81
+ * symbol). */
83
82
  export function fixClaimProblems(root) {
84
83
  return classifyFixClaims(fixClaimRecords(root), root);
85
84
  }
86
- /** The validation-gap rows shared by the doctor's check and the always-on alert: each newest
85
+ /** The validation-gap rows the doctor's check warns about: each newest
87
86
  * Fixed record classified against the closed `**Validation gap:**` vocabulary through
88
87
  * validationGapTag, which is fence-aware — suspects (missing/unrecognized) first, then
89
- * `unpinned`, so the first row is the one the doctor's message names and the alert's reason
90
- * picks. */
88
+ * `unpinned`, so the first row is the one the doctor's message names. */
91
89
  function classifyValidationGaps(records) {
92
90
  const suspects = [];
93
91
  const unpinned = [];
@@ -102,22 +100,13 @@ function classifyValidationGaps(records) {
102
100
  }
103
101
  return [...suspects, ...unpinned];
104
102
  }
105
- /** The ONE scan the doctor's `validation gaps` check and the always-on alert both hand their
106
- * rows from: the newest Fixed records (bounded to FIX_CLAIMS_CHECKED so pre-convention history
107
- * never warns forever) as fixedBugEntries reads them — the stat-cached section reader BUGS.md +
108
- * BUGS-FIXED.md, bodies kept whole — classified through classifyValidationGaps. Reading both
109
- * through this same reader (and not the doctor's one-off fixedEntriesAt/fixedSection walk) is
110
- * what keeps the alert and the doctor on the identical problem set; there is only one scan. */
103
+ /** The ONE scan the doctor's `validation gaps` check hands its rows from: the newest Fixed
104
+ * records (bounded to FIX_CLAIMS_CHECKED so pre-convention history never warns forever) as
105
+ * fixedBugEntries reads them — the stat-cached section reader BUGS.md + BUGS-FIXED.md, bodies
106
+ * kept whole — classified through classifyValidationGaps. */
111
107
  function validationGapRecords(root) {
112
108
  return fixedBugEntries(root).slice(0, FIX_CLAIMS_CHECKED);
113
109
  }
114
- /** The problems the ONE shared validation-gap scan found among the newest Fixed records at `root`
115
- * (empty when BUGS.md/BUGS-FIXED.md are unreadable or every newest Fixed record carries a known
116
- * tag/trace). Feeds the always-on amber alert through the stat-cached fixedBugEntries reader, so
117
- * it stays in step with the doctor and adds no re-read to the 1 Hz TUI and status-poll path. */
118
- export function validationGapProblems(root) {
119
- return classifyValidationGaps(validationGapRecords(root));
120
- }
121
110
  /** The fixed-record validation-gap twin of checkFixClaims, standing in for plans/repair-traces.md's
122
111
  * invariant 2 ("the vocabulary is closed and lives in one place"): the bugfix guidance tells every
123
112
  * run to write exactly one `**Validation gap:**` line with a known tag, and `none` rather than no
@@ -135,8 +124,7 @@ export function validationGapProblems(root) {
135
124
  export function checkValidationGaps(root) {
136
125
  // The doctor's check reads BUGS.md itself so it can return its no-file outcome (a missing
137
126
  // BUGS.md — nothing to verify); the classification then runs on the SAME fixedBugEntries
138
- // reader the always-on alert feeds from, so the two cannot drift on the problem set. There
139
- // is only one scan (validationGapRecords).
127
+ // reader, so the problem set cannot drift. There is only one scan (validationGapRecords).
140
128
  const doc = readBacklogChecked(root, "BUGS.md");
141
129
  if (typeof doc !== "string")
142
130
  return doc;
@@ -147,14 +135,14 @@ export function checkValidationGaps(root) {
147
135
  if (!first) {
148
136
  if (unpinned.length > 0) {
149
137
  const up = unpinned[0];
150
- const more = andMore(unpinned.slice(1), (s) => quoteHeading(s.title), "record(s)");
138
+ const more = andMore(unpinned.slice(1), (s) => quoteHeading(s.title), agree(unpinned.slice(1).length, "record", "records"));
151
139
  return warnOutcome(`BUGS.md record "${up.title}" is an \`unclear-invariant\` fix but names no ` +
152
140
  `docs/invariants.md section — record the reconstructed guarantee there (a "Pinned by" line) in the ` +
153
141
  `same commit so the next bug in that area does not reconstruct it again${more}`);
154
142
  }
155
143
  return okOutcome(`newest ${validationGapRecords(root).length} Fixed record(s) carry a known Validation gap tag`);
156
144
  }
157
- const more = andMore(rest, (s) => quoteHeading(s.title), "record(s)");
145
+ const more = andMore(rest, (s) => quoteHeading(s.title), agree(rest.length, "record", "records"));
158
146
  const what = first.kind === "missing"
159
147
  ? `BUGS.md record "${first.title}" has no **Validation gap:** line`
160
148
  : `BUGS.md record "${first.title}" names unrecognized Validation gap tag "${first.tag}"`;
@@ -201,22 +189,17 @@ export function checkLiveFinished(root) {
201
189
  return warnOutcome(`${liveFinishedRowList(live)} — every run that reads the file pays for them; ` +
202
190
  "`tumwater archive` moves them to the archive files and commits them (a starting team does too)");
203
191
  }
204
- /** The always-on `backlog-headings` alert polls this on every observer surface and every
205
- * observer tick, so the per-file read must be stat-cached: a steady-state poll pays one stat
206
- * per file, not a full re-read of every BACKLOG_FILES member — the archives grow without bound
207
- * (PLANS-DONE.md and BUGS-FIXED.md run to MBs), and an uncached read on every poll would copy
208
- * them wholesale each time, unlike the stat-cached section readers the rest of the poll path
209
- * uses. cachedByStat serves an unchanged file from cache (dev/ino/mtime/size) and re-reads on
210
- * a change, so this shares the poll path's freshness contract. The one loop over BACKLOG_FILES
211
- * that feeds both checkBacklogHeadings and the always-on `backlog-headings` team alert: absent
192
+ /** The one loop over BACKLOG_FILES the doctor's `backlog headings` check scans for duplicated
193
+ * `## ` titles. The per-file read is stat-cached (backlogHeadingFileCache) so repeated checks
194
+ * pay one stat per unchanged file rather than re-reading the whole BACKLOG_FILES member — the
195
+ * archives grow without bound (PLANS-DONE.md and BUGS-FIXED.md run to MBs) — and cachedByStat
196
+ * serves an unchanged file from cache (dev/ino/mtime/size) and re-reads on a change. Absent
212
197
  * files (an early repo) contribute nothing, an existing file that cannot be read adds
213
198
  * `${file} (unreadable)` to `rows` (its duplicate headings, if any, are invisible to every
214
- * reader), and each duplicate `## ` title joins both `duplicates` and, as
215
- * `${file} "## ${title}"`, `rows` — which therefore keeps BACKLOG_FILES order, the order the
216
- * doctor's `backlog headings` row lists its messages in. Two public functions share this so
217
- * they cannot drift on the file set, the unreadable clause, or the headings. */
199
+ * reader), and each duplicate `## ` title appears as `${file} "## ${title}"` in
200
+ * `rows` — which therefore keeps BACKLOG_FILES order, the order the doctor's `backlog
201
+ * headings` row lists its messages in. */
218
202
  function scanBacklogHeadings(root) {
219
- const duplicates = [];
220
203
  const rows = [];
221
204
  for (const file of BACKLOG_FILES) {
222
205
  const p = path.join(root, file);
@@ -233,63 +216,14 @@ function scanBacklogHeadings(root) {
233
216
  continue;
234
217
  }
235
218
  for (const heading of scan.headings) {
236
- duplicates.push({ file, heading });
237
219
  rows.push(`${file} "## ${heading}"`);
238
220
  }
239
221
  }
240
- return { duplicates, rows };
222
+ return rows;
241
223
  }
242
- /** The module-level stat cache backing scanBacklogHeadings, shared by the doctor row and the
243
- * always-on alert so the poll path never re-reads an unchanged backlog or archive file. */
224
+ /** The module-level stat cache backing scanBacklogHeadings, so a repeated check never re-reads
225
+ * an unchanged backlog or archive file. */
244
226
  const backlogHeadingFileCache = new Map();
245
- /** The duplicated `## ` headings already on main, exported so the always-on `backlog-headings`
246
- * team alert (src/ui/team-alert-backlog.ts) renders the same set the doctor's `backlog
247
- * headings` row warns about. */
248
- export function duplicatedBacklogHeadings(root) {
249
- return scanBacklogHeadings(root).duplicates;
250
- }
251
- /** The module-level stat cache backing liveFinishedEntriesFor, keyed by root, so a steady-state
252
- * status poll pays four stat calls (two live files, two archives) instead of re-parsing the
253
- * live files and the unbounded archives (PLANS-DONE.md runs to MBs) on every poll. Keyed by a
254
- * combined stat signature rather than the shared cachedByStat because each row spans FOUR files
255
- * (a live file, its finished entries, and the archive it empties into), so no single-file
256
- * stat-keyed cache entry can serve it. */
257
- const liveFinishedCache = new Map();
258
- /** The finished-history rows already on main, exported so the always-on `backlog-archive` team
259
- * alert (src/ui/team-alert-backlog.ts) renders the same set the doctor's `backlog archive`
260
- * row warns about — stat-gated so a steady-state poll pays four stats, not a full re-parse.
261
- * The cache key is the stat identity (`dev:ino:mtimeMs:size`, `absent` when the stat is
262
- * null) of every
263
- * FINISHED_ARCHIVES live file and archive under `root`, so any write that changes finished
264
- * history or the archive refreshes the rows on the next call without a process restart. Returns
265
- * a copy on a hit; on a miss it recomputes via liveFinishedHistory, and returns `[]` (uncached)
266
- * when that throws, so an unreadable file stays checkLiveFinished's loud signal and never
267
- * breaks a status poll. checkLiveFinished keeps calling liveFinishedHistory directly and is
268
- * unchanged. */
269
- export function liveFinishedEntriesFor(root) {
270
- const key = FINISHED_ARCHIVES
271
- .flatMap((spec) => [path.join(root, spec.file), path.join(root, spec.archive)])
272
- .map((p) => {
273
- const st = statOrNull(p);
274
- return st ? `${st.dev}:${st.ino}:${st.mtimeMs}:${st.size}` : "absent";
275
- })
276
- .join("|");
277
- const cached = liveFinishedCache.get(root);
278
- if (cached?.key === key)
279
- return cached.rows.map((r) => ({ ...r }));
280
- let rows;
281
- try {
282
- rows = liveFinishedHistory(root);
283
- }
284
- catch {
285
- liveFinishedCache.delete(root); // Never serve a stale read for an unreadable tree.
286
- return []; // Unreadable — checkLiveFinished's signal; a status poll must not throw.
287
- }
288
- if (liveFinishedCache.size >= 64)
289
- liveFinishedCache.clear(); // Safety cap, like backlogHeadingFileCache.
290
- liveFinishedCache.set(root, { key, rows });
291
- return rows.map((r) => ({ ...r }));
292
- }
293
227
  /** Duplicated `## ` headings already on main (the duplicate-heading half of the backlog-structure
294
228
  * check, src/backlog/backlog-structure.ts): a `## Done` a past merge or conflict resolution added
295
229
  * twice leaves every later entry after the first section invisible to the section readers, and
@@ -300,7 +234,7 @@ export function liveFinishedEntriesFor(root) {
300
234
  * that leaves an existing duplicate alone passes and the duplicate itself needs a deliberate
301
235
  * removal edit. */
302
236
  export function checkBacklogHeadings(root) {
303
- const { rows } = scanBacklogHeadings(root);
237
+ const rows = scanBacklogHeadings(root);
304
238
  if (rows.length === 0)
305
239
  return okOutcome("no duplicated ## section headings in the backlog files");
306
240
  const [first, ...rest] = rows;
@@ -309,24 +243,27 @@ export function checkBacklogHeadings(root) {
309
243
  `later entries are invisible; keep one heading per name (the gate blocks only a merge ` +
310
244
  `that adds another copy — remove the duplicate in a deliberate edit)`);
311
245
  }
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
246
+ /** Stalled entries — plans or bugs parked with the plan role by a **Needs review …** note
247
+ * that the plan role clears by splitting or replanning (the feature role's too-large note, the
248
+ * convergence hold, a final-round rejection). The bugfix role's wait-for-the-next-failure
249
+ * parks ("by bugfix:") are excluded — selfcheck lifts them, not the plan role, so they are
250
+ * not the plan role's stalled work. No role takes a held entry (the claim gate refuses one)
251
+ * and nothing clears a plan-role note on its own, so the state is visible only as the
316
252
  * `[needs review]` marks in the backlog listings; this names them so an
317
253
  * operator gets a single-line health signal from `tumwater doctor`. Reads the tree at `root`
318
254
  * — 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
255
+ * refusal notes (a different hold) are surfaced. entryHold + isPlanStalled
256
+ * (src/backlog/backlog-eligibility.ts) already classify the notes, so this check adds no
257
+ * parsing: only the entries whose hold is exactly needs-review and which isPlanStalled
258
+ * accepts are collected, from both PLANS.md
322
259
  * 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`). */
260
+ * operator signal, not a broken environment; the plan role resolves it by splitting or
261
+ * replanning. */
325
262
  export function checkStalledEntries(root) {
326
263
  const planned = plannedPlanEntries(root);
327
264
  const collect = (entry, file) => {
328
265
  const hold = entryHold(entry, planned);
329
- return hold === "needs-review" || hold === "held-for-operator" // vocabulary-ok: the held-for-operator hold's key
266
+ return hold === "needs-review" && isPlanStalled(entry)
330
267
  ? { file, entry }
331
268
  : null;
332
269
  };
@@ -336,15 +273,11 @@ export function checkStalledEntries(root) {
336
273
  ].filter((x) => x !== null);
337
274
  const [first, ...rest] = stalled;
338
275
  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
276
+ return okOutcome("no planned plan or open bug is held by a Needs-review note");
340
277
  const more = andMore(rest, (s) => quoteHeading(s.entry.title));
341
278
  const kind = entryKindLabel(first.file); // "Bug" | "Plan"
342
279
  const head = `${first.file === "bugs" ? "BUGS.md" : "PLANS.md"} ${kind.toLowerCase()}`;
343
- const hold = entryHold(first.entry, planned);
344
280
  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
281
  return warnOutcome(`${head} ${quoteHeading(first.entry.title)} is held by a Needs review note ` +
349
282
  `(${truncate(note, REFUSAL_NOTE_MAX)})${more} — the plan role resolves it by splitting or replanning`);
350
283
  }
@@ -375,16 +308,16 @@ export function planDependencyProblem(root) {
375
308
  }
376
309
  if (firstDangling) {
377
310
  return { kind: "dangling", message: danglingMsg(firstDangling) +
378
- andMore(restDangling, (d) => `${quote(d.title)} → ${d.ref}`, "ref(s)") +
311
+ andMore(restDangling, (d) => `${quote(d.title)} → ${d.ref}`, agree(restDangling.length, "ref", "refs")) +
379
312
  others([[unparsed.length, "unparsed clause(s)"], [misworded.length, "misworded dependency(ies)"]]) };
380
313
  }
381
314
  if (firstUnparsed) {
382
- return { kind: "unparsed", message: unparsedMsg(firstUnparsed) + andMore(restUnparsed, quote, "clause(s)") +
315
+ return { kind: "unparsed", message: unparsedMsg(firstUnparsed) + andMore(restUnparsed, quote, agree(restUnparsed.length, "clause", "clauses")) +
383
316
  others([[misworded.length, "misworded dependency(ies)"]]) };
384
317
  }
385
318
  if (firstMisworded) {
386
319
  return { kind: "misworded", message: miswordedMsg(firstMisworded) +
387
- andMore(restMisworded, (w) => `${quote(w.title)} → ${w.ref}`, "dependency(ies)") };
320
+ andMore(restMisworded, (w) => `${quote(w.title)} → ${w.ref}`, agree(restMisworded.length, "dependency", "dependencies")) };
388
321
  }
389
322
  return null;
390
323
  }
@@ -4,11 +4,11 @@ import path from "node:path";
4
4
  import { errorMessage, truncate } from "../text/text.js";
5
5
  import { shortSha } from "../text/format.js";
6
6
  import { readPostReviews, POST_REVIEW_FAILURE_LIMIT } from "../review/post-merge-review.js";
7
- import { humanSeconds, secondsSince, HOUR_MS, DAY_MS } from "../text/datetime.js";
7
+ import { humanSeconds, humanSince, 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
- import { lowerMedian } from "../collections.js";
11
- import { plural, shortSpanPhrase } from "../text/phrases.js";
10
+ import { distinctOrdered, lowerMedian } from "../collections.js";
11
+ import { agree, plural, trailingDayMedianPhrase } 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
@@ -134,7 +136,7 @@ export function checkStaleQueuedMerges(root, now) {
134
136
  }
135
137
  return phrase ? { ...out, detail: `${out.detail} · ${phrase}` } : out;
136
138
  };
137
- const stale = staleQueuedMergeRows(root, now).map((e) => ({ ...e, age: humanSeconds(secondsSince(now - e.waitedMs, now)) }));
139
+ const stale = staleQueuedMergeRows(root, now).map((e) => ({ ...e, age: humanSince(now - e.waitedMs, now) }));
138
140
  const [first, ...rest] = stale;
139
141
  if (!first)
140
142
  return withWait(okOutcome(`no merge-queue entry has waited more than ${humanSeconds(MERGE_QUEUE_STALE_MS / 1000)} to start`));
@@ -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
  }
@@ -260,7 +262,7 @@ export function checkStalePrompts(root, now) {
260
262
  if (!first)
261
263
  return okOutcome("no queued prompt has waited more than 1 day for a role to dequeue it");
262
264
  const more = andMore(rest, (p) => `${p.role} #${p.position}`);
263
- return warnOutcome(`inbox prompt for ${roleLabel(first.role)} #${first.position} has waited ${humanSeconds(secondsSince(first.queuedAtMs, now))} — ` +
265
+ return warnOutcome(`inbox prompt for ${roleLabel(first.role)} #${first.position} has waited ${humanSince(first.queuedAtMs, now)} — ` +
264
266
  "the role is not dequeuing it (paused, disabled, or failing); resume or enable it, or drop the prompt with " +
265
267
  `\`tumwater prompt --cancel ${first.position} --role ${first.role}\`${more}`);
266
268
  }
@@ -277,7 +279,7 @@ export function mergeWaitPhrase(events) {
277
279
  return undefined;
278
280
  const median = lowerMedian(waits);
279
281
  const longest = Math.max(...waits);
280
- return `merges in the last 24h waited ${shortSpanPhrase(median)} median, ${shortSpanPhrase(longest)} longest (${plural(waits.length, "merge")})`;
282
+ return `merges in the last 24h waited ${trailingDayMedianPhrase(median, longest, "longest", waits.length, "merge")}`;
281
283
  }
282
284
  /** Discover the tracked backlog/brief files with uncommitted edits in the primary checkout (the
283
285
  * shared discovery behind checkUncommittedBacklog and commitUncommittedBacklog). A primary off
@@ -299,7 +301,7 @@ export async function dirtyBacklogFiles(root, config) {
299
301
  // An unreadable brief is a doctor finding of its own; it must not abort the uncommitted-backlog
300
302
  // check or `--commit-backlog`, so the tracked backlog set is watched without it.
301
303
  }
302
- const watched = [...new Set([...BACKLOG_FILES, ...(brief ? [brief] : [])])];
304
+ const watched = distinctOrdered([...BACKLOG_FILES, ...(brief ? [brief] : [])]);
303
305
  const out = await gitTry(root, "status", "--porcelain", "--", ...watched);
304
306
  if (out === null)
305
307
  return { base, primary, watched, dirty: null };
@@ -357,9 +359,9 @@ export async function commitUncommittedBacklog(root, config) {
357
359
  * for a BacklogCommitReport, so the three surfaces cannot drift: one `committed <file> on
358
360
  * <base>` line per committed file, one `could not commit <file> — commit it by hand` line per
359
361
  * failed file, then the skipped sentence when set. `[]` when the report committed nothing,
360
- * failed nothing, and carried no skip sentence. `tumwater doctor --commit-backlog` (src/cli.ts),
361
- * the dashboard Health card's Commit backlog (handleCommitBacklog) and the TUI Health tab's
362
- * Ctrl+A all render from this one source. */
362
+ * failed nothing, and carried no skip sentence. `tumwater doctor --commit-backlog`
363
+ * (src/cli/cli-doctor-command.ts), the dashboard Health card's Commit backlog
364
+ * (handleCommitBacklog) and the TUI Health tab's Ctrl+A all render from this one source. */
363
365
  export function backlogCommitLines(report) {
364
366
  const lines = [
365
367
  ...report.committed.map((file) => `committed ${file} on ${report.base}`),
@@ -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";
21
- import { claudeSeamsInUse, minVersionOutcome, opencodeSeamsInUse, versionFromProbe, piSeamsInUse } from "./doctor-model-checks.js";
20
+ import { backendsInUse, unregisteredBackendInUse } from "../gates/startup-gates.js";
21
+ import { claudeSeamsInUse, loadConfigForCheck, 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
@@ -236,9 +236,9 @@ export function checkBackendBinary(root, pathEnv = envPath()) {
236
236
  * (a 1.x binary included) fails naming `brew install opencode`; at or above it reads the
237
237
  * version. Stands down when no seam routes to opencode, so a pi-only team spawns no opencode. */
238
238
  export async function checkOpencodeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
239
- const { config } = loadConfigSafe(root);
240
- if (config === undefined)
241
- return okOutcome("no readable tumwater.json — nothing to verify");
239
+ const config = loadConfigForCheck(root);
240
+ if ("level" in config)
241
+ return config;
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;