tumwater 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -1,25 +1,57 @@
1
+ // vocabulary: plain
2
+ import { roleLabel } from "../roles/role-labels.js";
3
+ import { VALIDATION_GAP_TAGS } from "../roles/role-guidance.js";
1
4
  import fs from "node:fs";
2
5
  import path from "node:path";
3
6
  import { errorMessage, truncate } from "../text/text.js";
4
- import { BACKLOG_FILES } from "../backlog/backlog-md.js";
5
- import { duplicateHeadings, strandedPlanEntries } from "../backlog/backlog-structure.js";
6
- import { fixSymbols, fixedEntries, missingSymbolNames, sourceHaystack, unbackedSymbols } from "../verdict/fix-claim.js";
7
+ import { ageInDays, donePlans, filingDate, openBugEntries, openQuestionEntries, plannedPlanEntries } from "../backlog/backlog.js";
8
+ import { questionFilingDate } from "../backlog/question-answer.js";
9
+ import { entryHold, planDependencyProblems, plannedPartKeys, plansWaitingOn, prerequisitesMerged, refusalNote, waitingNote } from "../backlog/backlog-eligibility.js";
10
+ import { BACKLOG_FILES, lastDate, validationGapTag } from "../backlog/backlog-md.js";
11
+ import { duplicateHeadings, strandedPlanEntriesFor } from "../backlog/backlog-structure.js";
12
+ import { liveFinishedHistory } from "../backlog/backlog-archive.js";
13
+ import { fixSymbols, fixedEntriesAt, missingSymbolNames, sourceHaystack, unbackedSymbols } from "../verdict/fix-claim.js";
14
+ import { briefFile } from "../brief/brief.js";
15
+ import { currentBranch, targetBranch } from "../git/git.js";
16
+ import { gitTry } from "../git/git-run.js";
17
+ import { queuedMerges } from "../merge/merge-queue.js";
18
+ import { mergeChanges, readMergeMarker } from "../merge/merge-slot.js";
19
+ import { listWideScope, queuedListRows } from "../inbox/inbox.js";
20
+ import { deliverableAt } from "../inbox/prompt-not-before.js";
21
+ import { formatTimestamp, humanSeconds, secondsSince } from "../text/datetime.js";
22
+ import { eventsSince, readEventsSince } from "../events/event-window.js";
23
+ import { finiteNumber, stringList } from "../files/json-object.js";
24
+ import { lowerMedian } from "../collections.js";
25
+ import { plural, shortSpanPhrase } from "../text/phrases.js";
7
26
  import { okOutcome, warnOutcome } from "./outcome.js";
8
- /** The doctor's backlog-document checks, split out of doctor-checks.ts: the three checks that
9
- * read the project's tracked Markdown backlog (BUGS.md, PLANS.md, QUESTIONS.md) rather than the
10
- * environment doctor-checks.ts inspects — fix claims, stranded plans, and duplicated `## `
11
- * headings. They share this module's helpers (readDoc and its checked wrapper readDocChecked,
12
- * andMore) and the heading-trim constant, and all three warn instead of failing: damaged
13
- * documentation is operator signal, not a broken environment. doctor.ts composes them beside
14
- * the environment checks; the shared CheckOutcome shape is imported from outcome.ts. */
15
- /** How many Fixed records the fix-claims check verifies: the newest, since the section is
16
- * newest-first by template convention. Older records drift as the code evolves — a symbol
17
- * legitimately fixed long ago gets renamed later — so a whole-section scan would warn forever
18
- * on records nobody should rewrite. */
27
+ /** The doctor's backlog-document checks, split out of doctor-checks.ts: the checks that read
28
+ * the project's tracked Markdown backlog (BUGS.md, PLANS.md, QUESTIONS.md) and queues rather
29
+ * than the environment doctor-checks.ts inspects — fix claims, stranded plans, duplicated
30
+ * `## ` headings, refused entries, stale open bugs, stale queued merges, stale queued prompts,
31
+ * and uncommitted edits to the backlog files in the primary checkout. They share this module's
32
+ * helpers (readDoc and its checked wrapper readDocChecked, andMore) and the heading-trim
33
+ * constant, and all of them warn instead of failing: damaged documentation is operator signal,
34
+ * not a broken environment. doctor.ts composes them beside the environment checks; the shared
35
+ * CheckOutcome shape is imported from outcome.ts. */
36
+ /** How many Fixed records the two newest-first Fixed-record checks (fix claims and validation
37
+ * gaps) verify: the newest, since the section is newest-first by template convention. Older
38
+ * records drift as the code evolves — a symbol legitimately fixed long ago gets renamed later,
39
+ * and pre-convention Fixed entries predate the validation-gap line — so a whole-section scan
40
+ * would warn forever on records nobody should rewrite. */
19
41
  const FIX_CLAIMS_CHECKED = 10;
20
42
  /** A second or later suspect record's heading is trimmed to this many characters in the warn —
21
43
  * the first is named in full, the rest only enough to find them in BUGS.md. */
22
44
  const FIX_CLAIM_HEADING_MAX = 60;
45
+ /** A refused entry's note is trimmed to this many characters in the refusals warn. */
46
+ const REFUSAL_NOTE_MAX = 120;
47
+ /** The whole days an open question may wait before the stale-questions check warns about it:
48
+ * an opinionated default, matching the 7-day window the `--list` age is meant to surface.
49
+ * A question awaiting a human answer is operator signal, so the value needs no knob. */
50
+ const STALE_QUESTION_DAYS = 7;
51
+ /** The whole days an open bug may wait before the stale-bugs check warns about it: an
52
+ * opinionated default with no knob, like STALE_QUESTION_DAYS. Three days is long enough that a
53
+ * bug filed late one evening and fixed the next day never warns. */
54
+ const STALE_BUG_DAYS = 3;
23
55
  /** The `(and N more …)` suffix three one-line doctor details append after naming their first
24
56
  * suspect (fix claims, stranded plans, duplicated headings): the rest stay visible but
25
57
  * shortened, since a doctor check renders one line. `render` makes each extra item's short
@@ -48,32 +80,30 @@ function readDoc(p) {
48
80
  * naming the file. Returns the document, or the outcome the caller should return as-is. */
49
81
  function readDocChecked(p, file, missingDetail) {
50
82
  if (!fs.existsSync(p))
51
- return { outcome: okOutcome(missingDetail) };
83
+ return okOutcome(missingDetail);
52
84
  const read = readDoc(p);
53
85
  if ("error" in read)
54
- return { outcome: warnOutcome(`cannot read ${file} — ${read.error}`) };
55
- return read;
56
- }
57
- /** Fix claims — the standalone half of the landing gate's false-fix check
58
- * (src/verdict/fix-claim.ts): that gate fires only when an md-only diff moves a BUGS.md entry
59
- * to Fixed, so a phantom fix
60
- * that reached main any other way (landed before the gate existed, or through a path it never
61
- * sees) was visible only to a human reading raw history. This re-verifies the newest Fixed
62
- * records against the tree at `root` — the primary checkout IS main's tree — with the gate's
63
- * own parsing and existence rules. Deliberately looser than the gate: a record warns only
64
- * when EVERY symbol its Fix paragraph names is absent (one live symbol passes — the phantom
65
- * signature, not a half-stale narrative), and a record naming no symbols is skipped, since
66
- * pure-documentation fixes are legitimate. A warn, never a fail (the checkFallbackModel
67
- * precedent): a suspicious record is operator signal, not a broken environment. */
86
+ return warnOutcome(`cannot read ${file} — ${read.error}`);
87
+ return read.doc;
88
+ }
89
+ /** Fix claims — the standalone half of the merge gate's false-fix check (src/verdict/fix-claim.ts):
90
+ * that gate fires only when an md-only diff moves a BUGS.md entry to Fixed, so a phantom fix that
91
+ * reached main any other way (merged before the gate existed, or through a path it never sees) was
92
+ * visible only to a human reading raw history. This re-verifies the newest Fixed records against
93
+ * the tree at `root` — the primary checkout IS main's tree — with the gate's own parsing and
94
+ * existence rules. Deliberately looser than the gate: a record warns only when EVERY symbol its Fix
95
+ * paragraph names is absent (one live symbol passes — the phantom signature, not a half-stale
96
+ * narrative), and a record naming no symbols is skipped, since pure-documentation fixes are
97
+ * legitimate. A warn, never a fail (the checkFallbackModel precedent): a suspicious record is
98
+ * operator signal, not a broken environment. */
68
99
  export function checkFixClaims(root) {
69
- const read = readDocChecked(path.join(root, "BUGS.md"), "BUGS.md", "no BUGS.md — nothing to verify");
70
- if ("outcome" in read)
71
- return read.outcome;
72
- const { doc } = read;
100
+ const doc = readDocChecked(path.join(root, "BUGS.md"), "BUGS.md", "no BUGS.md — nothing to verify");
101
+ if (typeof doc !== "string")
102
+ return doc;
73
103
  // One parse feeds every record's title and body: bugEntryBody re-parsed the whole document
74
104
  // per heading, and BUGS.md grows without bound, so the newest-10 scan paid 1 + 10 full-document
75
105
  // walks where one suffices.
76
- const records = fixedEntries(doc).slice(0, FIX_CLAIMS_CHECKED);
106
+ const records = fixedEntriesAt(root, doc).slice(0, FIX_CLAIMS_CHECKED);
77
107
  // The haystack walks src/, test/ and scripts/: build it only once a record names something.
78
108
  let haystack;
79
109
  const phantoms = [];
@@ -97,6 +127,55 @@ export function checkFixClaims(root) {
97
127
  return warnOutcome(`BUGS.md records "${heading}" as Fixed, but none of the symbols its Fix paragraph names ` +
98
128
  `exist on this tree: ${names}${more} — land the fix or keep the bug Open / refresh a stale record`);
99
129
  }
130
+ /** The fixed-record validation-gap twin of checkFixClaims, standing in for plans/repair-traces.md's
131
+ * invariant 2 ("the vocabulary is closed and lives in one place"): the bugfix guidance tells every
132
+ * run to write exactly one `**Validation gap:**` line with a known tag, and `none` rather than no
133
+ * line at all so a tally's denominator stays honest. Nothing checks that a Fixed entry actually
134
+ * carries such a line or that its tag is one of the closed vocabulary — a typo like `no-reprod`
135
+ * lands as its own key on `tumwater report`'s `Validation gaps` line and matches no
136
+ * closed-vocabulary consumer that enumerates the six non-`none` tags, and an entry with no line at
137
+ * all is invisible to every one of them. Re-reads the newest Fixed records with the same
138
+ * FIX_CLAIMS_CHECKED bound (pre-convention history would otherwise warn forever) and warns on a
139
+ * missing or unrecognized tag. An entry declaring `unclear-invariant` should also have pinned the
140
+ * reconstructed guarantee in docs/invariants.md in the same commit — a fix that names no such
141
+ * section warns with that nudge (informational: code-pinned or prose-that-skips-the-filename fixes
142
+ * may spuriously warn). A warn, never a fail, like its sibling: a suspect record is
143
+ * operator signal, not a broken environment. */
144
+ export function checkValidationGaps(root) {
145
+ const doc = readDocChecked(path.join(root, "BUGS.md"), "BUGS.md", "no BUGS.md — nothing to verify");
146
+ if (typeof doc !== "string")
147
+ return doc;
148
+ const records = fixedEntriesAt(root, doc).slice(0, FIX_CLAIMS_CHECKED);
149
+ const suspects = [];
150
+ const unpinned = [];
151
+ for (const { title, body } of records) {
152
+ const tag = validationGapTag(body);
153
+ if (tag === null)
154
+ suspects.push({ title, problem: "missing" });
155
+ else if (!VALIDATION_GAP_TAGS.includes(tag))
156
+ suspects.push({ title, problem: "unrecognized", tag });
157
+ else if (tag === "unclear-invariant" && !body.toLowerCase().includes("docs/invariants.md"))
158
+ unpinned.push({ title });
159
+ }
160
+ const [first, ...rest] = suspects;
161
+ if (!first) {
162
+ if (unpinned.length > 0) {
163
+ const up = unpinned[0];
164
+ const more = andMore(unpinned.slice(1), (s) => `"${truncate(s.title, FIX_CLAIM_HEADING_MAX)}"`, "record(s)");
165
+ return warnOutcome(`BUGS.md record "${up.title}" is an \`unclear-invariant\` fix but names no ` +
166
+ `docs/invariants.md section — record the reconstructed guarantee there (a "Pinned by" line) in the ` +
167
+ `same commit so the next bug in that area does not reconstruct it again${more}`);
168
+ }
169
+ return okOutcome(`newest ${records.length} Fixed record(s) carry a known Validation gap tag`);
170
+ }
171
+ const more = andMore(rest, (s) => `"${truncate(s.title, FIX_CLAIM_HEADING_MAX)}"`, "record(s)");
172
+ const what = first.problem === "missing"
173
+ ? `BUGS.md record "${first.title}" has no **Validation gap:** line`
174
+ : `BUGS.md record "${first.title}" names unrecognized Validation gap tag "${first.tag}"`;
175
+ const valid = VALIDATION_GAP_TAGS.join(", ");
176
+ return warnOutcome(`${what}${more} — every Fixed entry writes exactly one of: ${valid}; ` +
177
+ `write \`none\` rather than omitting the line (the bugfix role records it; see plans/repair-traces.md)`);
178
+ }
100
179
  /** Stranded plans — plan headings filed under the wrong PLANS.md section (the stranded-plan
101
180
  * detector, src/backlog/backlog-structure.ts): a `(planned …)` heading sitting under `## Done` is
102
181
  * invisible to every Planned reader, and a done-dated heading still under `## Planned` invites
@@ -105,11 +184,12 @@ export function checkFixClaims(root) {
105
184
  * the primary checkout IS main's tree — like checkFixClaims. A warn, never a fail: a misplaced
106
185
  * heading is operator signal, not a broken environment. */
107
186
  export function checkStrandedPlans(root) {
108
- const read = readDocChecked(path.join(root, "PLANS.md"), "PLANS.md", "no PLANS.md — nothing to verify");
109
- if ("outcome" in read)
110
- return read.outcome;
111
- const { doc } = read;
112
- const stranded = strandedPlanEntries(doc);
187
+ const doc = readDocChecked(path.join(root, "PLANS.md"), "PLANS.md", "no PLANS.md — nothing to verify");
188
+ if (typeof doc !== "string")
189
+ return doc;
190
+ // PLANS-DONE.md too: finished plans are archived there (plans/backlog-archive.md), and a plan
191
+ // filed under its ## Done is as invisible as one under PLANS.md's. Each entry names its file.
192
+ const stranded = strandedPlanEntriesFor(root);
113
193
  const clean = "no plan headings filed under the wrong PLANS.md section";
114
194
  if (stranded.length === 0)
115
195
  return okOutcome(clean);
@@ -117,18 +197,34 @@ export function checkStrandedPlans(root) {
117
197
  if (!first)
118
198
  return okOutcome(clean);
119
199
  const more = andMore(rest, (e) => `"${truncate(e.title, FIX_CLAIM_HEADING_MAX)}"`);
120
- return warnOutcome(`PLANS.md has a plan stranded under ## ${first.section}: "${first.title}"${more} — ` +
121
- `move it under ## ${first.section === "Done" ? "Planned" : "Done"} (the clean loop repairs these)`);
200
+ const file = first.file ?? "PLANS.md";
201
+ return warnOutcome(`${file} has a plan stranded under ## ${first.section}: "${first.title}"${more} — ` +
202
+ `move it under ## ${first.section === "Done" ? "Planned" : "Done"}${file === "PLANS.md" ? "" : " in PLANS.md"} ` +
203
+ "(the clean role repairs these)");
204
+ }
205
+ /** Finished history still in the live backlog files (plans/backlog-archive.md): entries under
206
+ * PLANS.md's `## Done` or BUGS.md's `## Fixed` that have not moved to PLANS-DONE.md or
207
+ * BUGS-FIXED.md. Every run that reads the live file pays for them. A starting team archives them
208
+ * unless that is refused (a dirty file, a checkout off the base branch), and a hand commit can put
209
+ * new ones there between starts, so this names how many there are and the fix. A warn, never a
210
+ * fail: the team works either way, only more expensively. */
211
+ export function checkLiveFinished(root) {
212
+ const live = liveFinishedHistory(root);
213
+ if (live.length === 0)
214
+ return okOutcome("PLANS.md and BUGS.md hold no finished entries — they are archived");
215
+ const parts = live.map((l) => `${l.file} ## ${l.section} holds ${plural(l.entries, "finished entry", "finished entries")} (${Math.round(l.bytes / 1024)} KB)`);
216
+ return warnOutcome(`${parts.join("; ")} — every run that reads the file pays for them; ` +
217
+ "`tumwater archive` moves them to the archive files and commits them (a starting team does too)");
122
218
  }
123
219
  /** Duplicated `## ` headings already on main (the duplicate-heading half of the backlog-structure
124
- * check, src/backlog/backlog-structure.ts): a `## Done` a past landing or conflict resolution added
220
+ * check, src/backlog/backlog-structure.ts): a `## Done` a past merge or conflict resolution added
125
221
  * twice leaves every later entry after the first section invisible to the section readers, and
126
- * nothing repairs it on its own. Reads the tree at `root` — the primary checkout IS main's tree
127
- * — like checkFixClaims. A warn, never a fail: existing damage is operator signal, not a broken
128
- * environment, and a landing that adds another copy of the heading trips the gate's same
129
- * rule (a) — src/backlog/backlog-structure.ts fires only when a count EXCEEDS the merge-base's,
130
- * so an edit that leaves an existing duplicate alone passes and the duplicate itself needs a
131
- * deliberate removal edit. */
222
+ * nothing repairs it on its own. Reads the tree at `root` — the primary checkout IS main's tree —
223
+ * like checkFixClaims. A warn, never a fail: existing damage is operator signal, not a broken
224
+ * environment, and a merge that adds another copy of the heading trips the gate's same rule (a) —
225
+ * src/backlog/backlog-structure.ts fires only when a count EXCEEDS the merge-base's, so an edit
226
+ * that leaves an existing duplicate alone passes and the duplicate itself needs a deliberate
227
+ * removal edit. */
132
228
  export function checkBacklogHeadings(root) {
133
229
  const duplicates = [];
134
230
  for (const file of BACKLOG_FILES) {
@@ -149,6 +245,407 @@ export function checkBacklogHeadings(root) {
149
245
  const [first, ...rest] = duplicates;
150
246
  const more = andMore(rest, (d) => d);
151
247
  return warnOutcome(`${first}${more} appears more than once — readers take the first section of a name, so ` +
152
- `later entries are invisible; keep one heading per name (the gate blocks only a landing ` +
248
+ `later entries are invisible; keep one heading per name (the gate blocks only a merge ` +
153
249
  `that adds another copy — remove the duplicate in a deliberate edit)`);
154
250
  }
251
+ /** Stalled plans — planned plans held by a **Needs review …** or **Needs replan …** note,
252
+ * parked until the plan role splits or replans them. No loop takes a held entry (the claim
253
+ * gate refuses one) and nothing clears the note on its own, so the state is visible only as
254
+ * the `[needs review]`/`[needs replan]` marks in the backlog listings; this names them so an
255
+ * operator gets a single-line health signal from `tumwater doctor`. Reads the tree at `root`
256
+ * — the primary checkout IS main's tree — like checkRefusedEntries, which is also where
257
+ * refusal notes (a different hold) are surfaced. entryHold (src/backlog/backlog-eligibility.ts)
258
+ * already classifies the notes, so this check adds no parsing: only the planned entries whose
259
+ * hold is exactly needs-review or needs-replan are collected. A warn, never a fail — the
260
+ * checkRefusedEntries precedent: a parked plan is operator signal, not a broken environment,
261
+ * and the plan role resolves it itself. */
262
+ export function checkStalledPlans(root) {
263
+ const planned = plannedPlanEntries(root);
264
+ const stalled = planned.filter((entry) => {
265
+ const hold = entryHold(entry, planned);
266
+ return hold === "needs-review" || hold === "needs-replan";
267
+ });
268
+ const [first, ...rest] = stalled;
269
+ if (!first)
270
+ return okOutcome("no planned plan is held by a Needs-review or Needs-replan note");
271
+ const more = andMore(rest, (e) => `"${truncate(e.title, FIX_CLAIM_HEADING_MAX)}"`);
272
+ const hold = entryHold(first, planned);
273
+ const note = (first.body.split(/\n/).find((line) => line.trimStart().startsWith("**Needs review ") || line.trimStart().startsWith("**Needs replan ")) ?? "").replace(/^\s*\*\*/, "").replace("**", "");
274
+ return warnOutcome(`PLANS.md plan "${truncate(first.title, FIX_CLAIM_HEADING_MAX)}" is held by a ${hold === "needs-review" ? "Needs review" : "Needs replan"} note ` +
275
+ `(${truncate(note, REFUSAL_NOTE_MAX)})${more} — the plan role resolves it by splitting or replanning`);
276
+ }
277
+ /** Plan dependencies — mistakes in the planned plans' `requires … landed` clauses that the claim
278
+ * allocator (entryHoldAgainst) cannot see: a ref naming no heading (the hold is void, so the
279
+ * dependent is claimable now), a clause that parses to no refs (it holds nothing), and a
280
+ * dependency cycle (every part on it stays blocked forever). Reads the tree at `root` through
281
+ * planDependencyProblems. A warn, never a fail, like checkStalledPlans: the plan role writes the
282
+ * clauses by hand and fixes them itself. The most serious kind is named — cycle, then dangling,
283
+ * then unparsed — with a count of the other kinds. */
284
+ export function checkPlanDependencies(root) {
285
+ const { cycle, dangling, unparsed, misworded } = planDependencyProblems(plannedPlanEntries(root), donePlans(root));
286
+ const [firstDangling, ...restDangling] = dangling;
287
+ const [firstUnparsed, ...restUnparsed] = unparsed;
288
+ const [firstMisworded, ...restMisworded] = misworded;
289
+ const quote = (t) => `"${truncate(t, FIX_CLAIM_HEADING_MAX)}"`;
290
+ const others = (parts) => parts.some(([n]) => n > 0) ? ` (also ${parts.map(([n, noun]) => `${n} ${noun}`).join(", ")})` : "";
291
+ const danglingMsg = (d) => `PLANS.md plan ${quote(d.title)} requires ${d.ref}, which no PLANS.md heading carries — the hold is void, ` +
292
+ "so the plan is claimable now; fix the series name or part number";
293
+ const unparsedMsg = (t) => `PLANS.md plan ${quote(t)} has a \`requires … landed\` clause that names no part the allocator can read — ` + // vocabulary-ok: PLANS.md heading syntax
294
+ "it holds nothing; write each ref as `<Series> i/n` or give the heading a `<Series>, part i/n:` prefix";
295
+ const miswordedMsg = (w) => `PLANS.md plan ${quote(w.title)} names ${w.ref} as a prerequisite without a \`requires … landed\` clause — ` + // vocabulary-ok: PLANS.md heading syntax
296
+ "the claim allocator ignores that wording and hands the part out now; write it as `requires part i/n landed`"; // vocabulary-ok: PLANS.md heading syntax
297
+ if (cycle !== null) {
298
+ return warnOutcome(`plan dependency cycle: ${cycle.join(" → ")} — every part on it stays blocked forever; ` +
299
+ "edit one heading's `requires … landed` clause to break it" + // vocabulary-ok: PLANS.md heading syntax
300
+ others([[dangling.length, "dangling ref(s)"], [unparsed.length, "unparsed clause(s)"], [misworded.length, "misworded dependency(ies)"]]));
301
+ }
302
+ if (firstDangling) {
303
+ return warnOutcome(danglingMsg(firstDangling) +
304
+ andMore(restDangling, (d) => `${quote(d.title)} → ${d.ref}`, "ref(s)") +
305
+ others([[unparsed.length, "unparsed clause(s)"], [misworded.length, "misworded dependency(ies)"]]));
306
+ }
307
+ if (firstUnparsed) {
308
+ return warnOutcome(unparsedMsg(firstUnparsed) + andMore(restUnparsed, quote, "clause(s)") +
309
+ others([[misworded.length, "misworded dependency(ies)"]]));
310
+ }
311
+ if (firstMisworded) {
312
+ return warnOutcome(miswordedMsg(firstMisworded) +
313
+ andMore(restMisworded, (w) => `${quote(w.title)} → ${w.ref}`, "dependency(ies)"));
314
+ }
315
+ return okOutcome("every planned plan's requires clause names a known part, with no cycle or misworded dependency");
316
+ }
317
+ /** How long a queued merge may wait without its change starting before the stale-queued-merge check
318
+ * warns: 6 hours, fixed. A verify plus a merge completes in minutes (plans/merge-queue.md), so a
319
+ * full six-hour silence while the team is healthy — not on a hold, which doctor already reports
320
+ * separately — is never a merely busy pipeline: it reads as a drain that has stalled. The one
321
+ * opinionated default, like STALE_QUESTION_DAYS, so no knob. */
322
+ const MERGE_QUEUE_STALE_MS = 6 * 60 * 60 * 1000;
323
+ /** The window of the merge-wait phrase on the stale merge-queue row. */
324
+ const MERGE_WAIT_WINDOW_MS = 24 * 3600_000;
325
+ /** Stale queued merges — a change that has sat in the durable merge queue too long without starting
326
+ * (plans/merge-queue.md). Every queued-change health fact is otherwise a hook into its own role or
327
+ * stage: a verify under way reads in the team row, a team hold gets its own banner, and
328
+ * team-alerts' STALL_RE catches a RUNNING stage frozen. But a change (or a batch behind the single
329
+ * merge slot) that quietly never starts — neither verified nor flagged — reads as healthy: the
330
+ * header's `merge queue: N` badge counts it and `tumwater queue` ages it, yet nothing calls it out
331
+ * on its own. This names the single oldest queued merge that is NOT currently in-flight and has
332
+ * waited past MERGE_QUEUE_STALE_MS, so an operator sees a stalled drain instead of only a growing
333
+ * depth. The in-flight marker (readMergeMarker via mergeChanges) contributes the set of roles
334
+ * holding a change being verified or merged right now — a slow review is not a stalled drain, and
335
+ * an absent or never-throwing unreadable marker contributes the empty set. Reads the tree at `root`
336
+ * with the already-exported readers, changing nothing in src/merge/. A warn, never a fail — the
337
+ * checkStaleQuestions/checkStalledPlans precedent: a queued wait is legitimate while the team is
338
+ * genuinely held, and those holds already warn separately. */
339
+ export function checkStaleQueuedMerges(root, now) {
340
+ const withWait = (out) => {
341
+ let phrase;
342
+ try {
343
+ phrase = mergeWaitPhrase(eventsSince(root, MERGE_WAIT_WINDOW_MS, now));
344
+ }
345
+ catch {
346
+ return out; // an unreadable event log never changes this row's level
347
+ }
348
+ return phrase ? { ...out, detail: `${out.detail} · ${phrase}` } : out;
349
+ };
350
+ const marker = readMergeMarker(root);
351
+ const inFlight = new Set(marker ? mergeChanges(marker).map((c) => c.role) : []);
352
+ const stale = queuedMerges(root)
353
+ .filter((e) => !inFlight.has(e.role) && now - e.enqueuedAt > MERGE_QUEUE_STALE_MS)
354
+ .map((e) => ({ role: e.role, summary: e.summary, age: humanSeconds(secondsSince(e.enqueuedAt, now)) }));
355
+ const [first, ...rest] = stale;
356
+ if (!first)
357
+ return withWait(okOutcome(`no merge-queue entry has waited more than ${humanSeconds(MERGE_QUEUE_STALE_MS / 1000)} to start`));
358
+ const more = andMore(rest, (e) => `${e.role} — "${truncate(e.summary, FIX_CLAIM_HEADING_MAX)}"`);
359
+ return withWait(warnOutcome(`merge-queue entry of ${roleLabel(first.role)} — "${truncate(first.summary, FIX_CLAIM_HEADING_MAX)}" — has waited ` +
360
+ `${first.age} to start${more}; a queued change the pipeline neither approves nor flags reads as healthy, so the merge queue has stopped moving`));
361
+ }
362
+ /** How long a queued prompt may wait for its role to dequeue it before the stale-prompt check
363
+ * warns: a day, fixed. An opinionated default with no knob, like MERGE_QUEUE_STALE_MS: a day is
364
+ * long enough that a healthy role's next run would have dequeued the prompt, and short enough
365
+ * that a paused or disabled role's stranded prompt shows up. */
366
+ const STALE_PROMPT_MS = 24 * 60 * 60 * 1000;
367
+ /** Stale queued prompts — a prompt queued for a role (`tumwater prompt --role <id>`, the
368
+ * dashboard composer, the TUI, a `--at` delay whose time has now passed) that has waited more
369
+ * than a day for that role to dequeue it. A role that is paused, disabled, or failing never
370
+ * reaches the run that would dequeue the prompt, so it waits indefinitely and only the Inbox
371
+ * views (`tumwater prompt --list`, the dashboard Inbox tab, the TUI Inbox pane) show its age —
372
+ * and only when the operator looks. This names the oldest due-but-undelivered prompt, its role,
373
+ * and the deliver-or-drop commands. Reads the queue with the shared `queuedListRows`/
374
+ * `listWideScope` traversal the Inbox views use (no new reader in src/inbox/), keeping only
375
+ * rows that are deliverable now — a `--at 7d` prompt still counting down is never stale — and
376
+ * stamped more than STALE_PROMPT_MS ago. A warn, never a fail — the checkStaleQueuedMerges
377
+ * precedent: a stranded prompt is operator signal, not a broken environment. */
378
+ export function checkStalePrompts(root, now) {
379
+ const stale = queuedListRows(root, null, listWideScope(root))
380
+ .filter((p) => p.queuedAtMs !== null && deliverableAt(p.notBeforeMs, now) && now - p.queuedAtMs > STALE_PROMPT_MS)
381
+ .sort((a, b) => a.queuedAtMs - b.queuedAtMs)
382
+ .map((p) => ({ ...p, queuedAtMs: p.queuedAtMs }));
383
+ const [first, ...rest] = stale;
384
+ if (!first)
385
+ return okOutcome("no queued prompt has waited more than 1 day for a role to dequeue it");
386
+ const more = andMore(rest, (p) => `${p.role} #${p.position}`);
387
+ return warnOutcome(`inbox prompt for ${roleLabel(first.role)} #${first.position} has waited ${humanSeconds(secondsSince(first.queuedAtMs, now))} — ` +
388
+ "the role is not dequeuing it (paused, disabled, or failing); resume or enable it, or drop the prompt with " +
389
+ `\`tumwater prompt --cancel ${first.position} --role ${first.role}\`${more}`);
390
+ }
391
+ /** How long merges that reached main waited: the lower median and the longest `queuedMs` of the
392
+ * `merge_done` events given, undefined when none qualifies. The wait runs from the entry's last
393
+ * enqueue to its outcome, so it includes the merge check. A merge logged before `queuedMs` existed
394
+ * (missing or 0) is skipped, and a `merge_failed` is not a wait that ended on main. */
395
+ export function mergeWaitPhrase(events) {
396
+ const waits = events
397
+ .filter((e) => e.type === "merge_done")
398
+ .map((e) => finiteNumber(e.queuedMs, 0))
399
+ .filter((ms) => ms > 0);
400
+ if (waits.length === 0)
401
+ return undefined;
402
+ const median = lowerMedian(waits);
403
+ const longest = Math.max(...waits);
404
+ return `merges in the last 24h waited ${shortSpanPhrase(median)} median, ${shortSpanPhrase(longest)} longest (${plural(waits.length, "merge")})`;
405
+ }
406
+ /** The read-events window (ms) that reaches the oldest of `sinces` — `Date.now()` minus the
407
+ * oldest timestamp, floored at 0 — so a recurrence check reads events past every candidate's
408
+ * `since`. The single home of the "oldest of a set of since-times" step, shared by
409
+ * checkWaitingBugs and checkRegressedFixes, so those sites cannot drift on the clamp or the
410
+ * time base. */
411
+ function sinceWindowMs(sinces) {
412
+ return Math.max(0, Date.now() - Math.min(...sinces));
413
+ }
414
+ /** Stale questions — open QUESTIONS.md questions that have waited more than 7 days for an
415
+ * answer. A team can stay parked on a question for days while `tumwater doctor` reports
416
+ * nothing; the `--list` age (`questionAgeSuffix` on `tumwater questions`) is the only other
417
+ * place the wait shows, and only when the operator asks. Reads the tree at `root` — the
418
+ * primary checkout IS main's tree — like checkRefusedEntries, reusing
419
+ * `questionFilingDate`/`openQuestionEntries` (src/backlog) unchanged. The age is the whole
420
+ * days between the heading's `(asked by …)` stamp and now through backlog.js's ageInDays,
421
+ * the one age formula, floored at 0; an undated heading is skipped. A warn, never a fail —
422
+ * the checkStalledPlans/checkRefusedEntries precedent: a question awaiting a human is
423
+ * operator signal, not a broken environment. */
424
+ export function checkStaleQuestions(root) {
425
+ const doc = readDocChecked(path.join(root, "QUESTIONS.md"), "QUESTIONS.md", "no QUESTIONS.md — nothing to verify");
426
+ if (typeof doc !== "string")
427
+ return doc;
428
+ const stale = openQuestionEntries(root).flatMap((q) => {
429
+ const stamp = questionFilingDate(q.title);
430
+ if (stamp === null)
431
+ return [];
432
+ const days = ageInDays(stamp);
433
+ return days >= STALE_QUESTION_DAYS ? [{ title: q.title, days }] : [];
434
+ });
435
+ const [first, ...rest] = stale;
436
+ if (!first)
437
+ return okOutcome("no open question has waited more than 7 days for an answer");
438
+ const more = andMore(rest, (q) => `"${truncate(q.title, FIX_CLAIM_HEADING_MAX)}"`);
439
+ return warnOutcome(`QUESTIONS.md question "${truncate(first.title, FIX_CLAIM_HEADING_MAX)}" has waited ${first.days} d for an answer${more} — ` +
440
+ `a question that waits too long for a human answer is worth your attention`);
441
+ }
442
+ /** Stale bugs — unrefused open BUGS.md bugs whose `(found by … YYYY-MM-DD)` or `(reported …)`
443
+ * stamp is at least 3 days old. The bugfix role fixes a bug within hours, so one that sits means
444
+ * no loop is fixing it. Positions are the 1-based `bug --list` numbers; a refused bug is skipped
445
+ * (checkRefusedEntries names it), a waiting bug is skipped (checkWaitingBugs names it) and so is
446
+ * an undated heading. A warn, never a fail. */
447
+ export function checkStaleBugs(root) {
448
+ const doc = readDocChecked(path.join(root, "BUGS.md"), "BUGS.md", "no BUGS.md — nothing to verify");
449
+ if (typeof doc !== "string")
450
+ return doc;
451
+ const stale = openBugEntries(root).flatMap((entry, index) => {
452
+ if (refusalNote(entry.body) !== null || waitingNote(entry.body) !== null)
453
+ return [];
454
+ const stamp = filingDate(entry.title);
455
+ if (stamp === null)
456
+ return [];
457
+ const days = ageInDays(stamp);
458
+ return days >= STALE_BUG_DAYS ? [{ position: index + 1, title: entry.title, days }] : [];
459
+ });
460
+ const [first, ...rest] = stale;
461
+ if (!first)
462
+ return okOutcome("no open bug has waited more than 3 days for a fix");
463
+ const more = andMore(rest, (b) => `#${b.position} "${truncate(b.title, FIX_CLAIM_HEADING_MAX)}"`);
464
+ return warnOutcome(`BUGS.md bug #${first.position} "${truncate(first.title, FIX_CLAIM_HEADING_MAX)}" has waited ${first.days} d for a fix${more} — ` +
465
+ `no role is fixing it: check that the bugfix role is enabled and not failing, or retract it with \`tumwater bug --cancel ${first.position}\``);
466
+ }
467
+ /** The first span between straight double quotes in a bug heading that is at least 12
468
+ * characters long — the test name a flake bug's heading quotes — or null. The floor stops a
469
+ * short quoted word from matching unrelated failures. */
470
+ export function quotedTestName(title) {
471
+ return /"([^"]{12,})"/.exec(title)?.[1] ?? null;
472
+ }
473
+ /** Local midnight at the end of a `YYYY-MM-DD` day, in ms: the earliest instant a failure can
474
+ * be told apart from the one a same-day note or fix record was written about. NaN for a
475
+ * malformed date. */
476
+ function dayAfterMs(date) {
477
+ return new Date(Number(date.slice(0, 4)), Number(date.slice(5, 7)) - 1, Number(date.slice(8, 10)) + 1).getTime();
478
+ }
479
+ /** The first `YYYY-MM-DD` in a Waiting for the next failure note's text, or null. */
480
+ export function waitingNoteDate(note) {
481
+ return /\d{4}-\d{2}-\d{2}/.exec(note)?.[0] ?? null;
482
+ }
483
+ /** Every event at or after `sinceMs` that records a failure naming `name`: a `warning` whose
484
+ * message includes it (gate and merge flake warnings, a merge check kept for one retry), or a
485
+ * `review_rejected` with a reason that includes it (its text is the first such reason). In
486
+ * event order. */
487
+ export function failureMentions(events, name, sinceMs) {
488
+ return events.flatMap((e) => {
489
+ if (typeof e.ts !== "number" || e.ts < sinceMs)
490
+ return [];
491
+ if (e.type === "warning" && typeof e.message === "string" && e.message.includes(name))
492
+ return [{ ts: e.ts, text: e.message }];
493
+ if (e.type === "review_rejected") {
494
+ const reason = stringList(e.reasons).find((r) => r.includes(name));
495
+ if (reason !== undefined)
496
+ return [{ ts: e.ts, text: reason }];
497
+ }
498
+ return [];
499
+ });
500
+ }
501
+ /** Waiting bugs — open BUGS.md bugs held by a **Waiting for the next failure …** note, which
502
+ * parks a bug until it fails again. Positions are the 1-based `bug --list` numbers; a bug that
503
+ * is also Refused is left to checkRefusedEntries. Ok when the hold is undisturbed — doctor
504
+ * then only names the bugs and how to release one — and a warn when a held bug's quoted test
505
+ * has failed in a gate or merge check since the day after its note (the note carries only a
506
+ * date, so a same-day failure cannot be told from the one it was written about). A warn is
507
+ * never a fail, and doctor writes nothing to BUGS.md. */
508
+ export function checkWaitingBugs(root) {
509
+ const doc = readDocChecked(path.join(root, "BUGS.md"), "BUGS.md", "no BUGS.md — nothing to verify");
510
+ if (typeof doc !== "string")
511
+ return doc;
512
+ const held = openBugEntries(root).flatMap((entry, index) => {
513
+ const note = waitingNote(entry.body);
514
+ if (note === null || refusalNote(entry.body) !== null)
515
+ return [];
516
+ const name = quotedTestName(entry.title);
517
+ const date = waitingNoteDate(note);
518
+ const since = name !== null && date !== null ? dayAfterMs(date) : null;
519
+ return [{ position: index + 1, title: entry.title, note, name, since }];
520
+ });
521
+ const [first, ...rest] = held;
522
+ if (!first)
523
+ return okOutcome("no open bug is waiting for its next failure");
524
+ const sinces = held.flatMap((b) => (b.since !== null && !Number.isNaN(b.since) ? [b.since] : []));
525
+ if (sinces.length > 0) {
526
+ const { events } = readEventsSince(root, sinceWindowMs(sinces));
527
+ const recurred = held.flatMap((b) => {
528
+ if (b.name === null || b.since === null)
529
+ return [];
530
+ const mentions = failureMentions(events, b.name, b.since);
531
+ const newest = mentions[mentions.length - 1];
532
+ return newest ? [{ ...b, newest }] : [];
533
+ });
534
+ const [again, ...others] = recurred;
535
+ if (again) {
536
+ const more = andMore(others, (b) => `#${b.position} "${truncate(b.title, FIX_CLAIM_HEADING_MAX)}"`);
537
+ return warnOutcome(`BUGS.md bug #${again.position} "${truncate(again.title, FIX_CLAIM_HEADING_MAX)}" failed again since its Waiting for the next failure note ` +
538
+ `(${formatTimestamp(again.newest.ts)}: ${truncate(again.newest.text, REFUSAL_NOTE_MAX)})${more} — ` +
539
+ `record the occurrence under the entry and release it with \`tumwater bug --release ${again.position}\``);
540
+ }
541
+ }
542
+ const more = andMore(rest, (b) => `#${b.position} "${truncate(b.title, FIX_CLAIM_HEADING_MAX)}"`);
543
+ return okOutcome(`BUGS.md bug #${first.position} "${truncate(first.title, FIX_CLAIM_HEADING_MAX)}" is held until it fails again ` +
544
+ `(${truncate(first.note, REFUSAL_NOTE_MAX)})${more} — selfcheck lifts the note when it records a new occurrence; ` +
545
+ `release it now with \`tumwater bug --release ${first.position}\``);
546
+ }
547
+ /** Regressed fixes — a newest-10 BUGS.md Fixed record whose heading quotes a test name that a
548
+ * gate or merge check has failed on since the day after its fix (the heading carries only a
549
+ * date, so a same-day failure cannot be told from the one the fix answered), unless an Open bug
550
+ * already quotes the same test, which means the regression is filed. A warn, never a fail, and
551
+ * doctor writes nothing to BUGS.md. */
552
+ export function checkRegressedFixes(root) {
553
+ const doc = readDocChecked(path.join(root, "BUGS.md"), "BUGS.md", "no BUGS.md — nothing to verify");
554
+ if (typeof doc !== "string")
555
+ return doc;
556
+ const candidates = fixedEntriesAt(root, doc).slice(0, FIX_CLAIMS_CHECKED).flatMap((entry) => {
557
+ const name = quotedTestName(entry.title);
558
+ const date = lastDate(entry.title, /(\d{4}-\d{2}-\d{2})/);
559
+ if (name === null || date === null)
560
+ return [];
561
+ const since = dayAfterMs(date);
562
+ return Number.isNaN(since) ? [] : [{ title: entry.title, name, date, since }];
563
+ });
564
+ const open = candidates.length > 0 ? openBugEntries(root).map((e) => quotedTestName(e.title)) : [];
565
+ const unfiled = candidates.filter((c) => !open.includes(c.name));
566
+ if (unfiled.length > 0) {
567
+ const { events } = readEventsSince(root, sinceWindowMs(unfiled.map((c) => c.since)));
568
+ const regressed = unfiled.flatMap((c) => {
569
+ const mentions = failureMentions(events, c.name, c.since);
570
+ const newest = mentions[mentions.length - 1];
571
+ return newest ? [{ ...c, newest }] : [];
572
+ });
573
+ const [first, ...rest] = regressed;
574
+ if (first) {
575
+ const more = andMore(rest, (c) => `"${truncate(c.title, FIX_CLAIM_HEADING_MAX)}"`);
576
+ return warnOutcome(`BUGS.md Fixed record "${truncate(first.title, FIX_CLAIM_HEADING_MAX)}" quotes a test that failed again after its ${first.date} fix ` +
577
+ `(${formatTimestamp(first.newest.ts)}: ${truncate(first.newest.text, REFUSAL_NOTE_MAX)})${more} — ` +
578
+ "file it as a new bug with `tumwater bug`, quoting the test name and citing the Fixed record");
579
+ }
580
+ }
581
+ return okOutcome("no recently Fixed bug's quoted test has failed since its fix");
582
+ }
583
+ /** Refused entries — planned plans and open bugs held by a **Refused …** note. No loop clears
584
+ * a Refused note, so the entry stays parked until a human or the director edits it, or an
585
+ * operator releases it with `--release <n>`, and the
586
+ * only other signs are the `[refused]` marks in the backlog listings. Reads the tree at `root`
587
+ * — the primary checkout IS main's tree — like checkFixClaims, in `--list` order (PLANS.md
588
+ * Planned, then BUGS.md Open). A warn, never a fail: a refusal is operator signal, not a broken
589
+ * environment. The warning also counts the planned parts a refused series part holds. */
590
+ export function checkRefusedEntries(root) {
591
+ const planned = plannedPlanEntries(root);
592
+ const parts = plannedPartKeys(planned);
593
+ const sources = [
594
+ { file: "PLANS.md", command: "plan", entries: planned },
595
+ { file: "BUGS.md", command: "bug", entries: openBugEntries(root) },
596
+ ];
597
+ const held = sources.flatMap(({ file, command, entries }) => entries.flatMap((entry, index) => {
598
+ const note = refusalNote(entry.body);
599
+ if (note === null)
600
+ return [];
601
+ const mergeDone = prerequisitesMerged(entry.title, parts);
602
+ const waiting = file === "PLANS.md" ? plansWaitingOn(planned, index).length : 0;
603
+ return [{ file, command, position: index + 1, title: entry.title, note, mergeDone, waiting }];
604
+ }));
605
+ const [first, ...rest] = held;
606
+ if (!first)
607
+ return okOutcome("no planned plan or open bug is held by a Refused note");
608
+ const more = andMore(rest, (e) => `${e.file} ${e.command} #${e.position} "${truncate(e.title, FIX_CLAIM_HEADING_MAX)}"` +
609
+ (e.mergeDone.length > 0 ? " [prerequisites no longer planned]" : "") +
610
+ (e.waiting > 0 ? ` [${e.waiting} part(s) wait on it]` : ""));
611
+ const stale = first.mergeDone.length > 0
612
+ ? `; its prerequisite(s) ${first.mergeDone.map((r) => `${r.series} ${r.part}/${r.of}`).join(", ")} are no longer planned, so the refusal may be stale`
613
+ : "";
614
+ const waiting = first.waiting > 0
615
+ ? `; ${first.waiting} planned part(s) wait on it, directly or through an earlier part`
616
+ : "";
617
+ return warnOutcome(`${first.file} ${first.command} #${first.position} "${first.title}" is held by a Refused note ` +
618
+ `(${truncate(first.note, REFUSAL_NOTE_MAX)})${stale}${waiting}${more} — no role takes a refused entry until it is edited: ` +
619
+ `answer the objection and release the entry with \`tumwater ${first.command} --release ${first.position}\`, ` +
620
+ `or retract it with \`tumwater ${first.command} --cancel ${first.position}\``);
621
+ }
622
+ /** Names the backlog files (and the brief file) with uncommitted edits in the primary checkout. An
623
+ * operator write is meant to be committed on the base branch by commitOperatorBacklogEdit; when
624
+ * that commit does not happen the edit is invisible to every loop worktree (they branch from main)
625
+ * and a merge that touches the file cannot fast-forward the checkout (merge_blocked). A primary off
626
+ * the base branch is skipped: the ref-push merge arm leaves its working tree alone. Untracked files
627
+ * are not edits of a tracked file and are ignored. */
628
+ export async function checkUncommittedBacklog(root, config) {
629
+ const primary = await currentBranch(root);
630
+ const base = targetBranch(config?.baseBranch, primary);
631
+ if (primary === null || primary !== base) {
632
+ return okOutcome(`your checkout is not on ${base}; backlog writes from you are not committed from it`);
633
+ }
634
+ const brief = briefFile(root);
635
+ const files = [...new Set([...BACKLOG_FILES, ...(brief ? [brief] : [])])];
636
+ const out = await gitTry(root, "status", "--porcelain", "--", ...files);
637
+ if (out === null)
638
+ return okOutcome("could not read git status; skipped");
639
+ const dirty = new Set(out.split("\n")
640
+ .filter((line) => line.length > 3 && !line.startsWith("??"))
641
+ .map((line) => line.slice(3)));
642
+ const named = files.filter((f) => dirty.has(f));
643
+ if (named.length === 0) {
644
+ const list = files.slice(0, -1).join(", ");
645
+ return okOutcome(`${list} and ${files[files.length - 1]} are committed`);
646
+ }
647
+ const list = named.join(", ");
648
+ return warnOutcome(`${list} ${named.length > 1 ? "have" : "has"} uncommitted changes in your checkout — ` +
649
+ `roles branch from main and do not see them, and a merge that touches one cannot fast-forward the checkout ` +
650
+ `(merge_blocked); review them with \`git diff -- ${list}\` and commit them on ${base}`);
651
+ }