tumwater 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (464) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +55 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +6 -4
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +404 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +281 -55
  41. package/dist/src/backlog/backlog-cli-view.js +255 -0
  42. package/dist/src/backlog/backlog-cli.js +91 -335
  43. package/dist/src/backlog/backlog-commit.js +20 -13
  44. package/dist/src/backlog/backlog-eligibility.js +193 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +264 -0
  48. package/dist/src/backlog/backlog-release.js +134 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-guardrails.js +136 -0
  56. package/dist/src/baseline/main-red.js +153 -36
  57. package/dist/src/baseline/main-watch.js +232 -0
  58. package/dist/src/brief/brief-edit.js +12 -12
  59. package/dist/src/brief/brief.js +31 -51
  60. package/dist/src/budget/budget-fallback-view.js +288 -0
  61. package/dist/src/budget/budget-wiring.js +158 -0
  62. package/dist/src/budget/budget.js +31 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +65 -3
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +181 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +183 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +291 -598
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-review-commands.js +113 -0
  84. package/dist/src/cli/cli-role-command.js +525 -0
  85. package/dist/src/cli/cli-start-summary.js +167 -0
  86. package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
  87. package/dist/src/cli/completion.js +169 -22
  88. package/dist/src/cli/config-commands.js +58 -26
  89. package/dist/src/cli/follow-view.js +75 -0
  90. package/dist/src/cli/help.js +258 -82
  91. package/dist/src/cli/log-commands.js +164 -85
  92. package/dist/src/cli/question-commands.js +211 -10
  93. package/dist/src/cli.js +173 -45
  94. package/dist/src/collections.js +56 -13
  95. package/dist/src/concurrency/check-permit.js +20 -3
  96. package/dist/src/concurrency/lock.js +17 -13
  97. package/dist/src/concurrency/provider-lease.js +44 -8
  98. package/dist/src/concurrency/semaphore.js +102 -26
  99. package/dist/src/config/config-aliases.js +0 -1
  100. package/dist/src/config/config-editable-keys.js +16 -1
  101. package/dist/src/config/config-fallbacks.js +94 -56
  102. package/dist/src/config/config-field-checks.js +40 -10
  103. package/dist/src/config/config-keys.js +30 -8
  104. package/dist/src/config/config-live.js +63 -24
  105. package/dist/src/config/config-request.js +137 -0
  106. package/dist/src/config/config-schema.js +31 -24
  107. package/dist/src/config/config-selector-checks.js +123 -0
  108. package/dist/src/config/config-validation-agents.js +80 -0
  109. package/dist/src/config/config-validation-model.js +116 -0
  110. package/dist/src/config/config-validation.js +169 -222
  111. package/dist/src/config/config-views.js +136 -54
  112. package/dist/src/config/config-write-core.js +135 -0
  113. package/dist/src/config/config-write.js +164 -265
  114. package/dist/src/config/config.js +53 -38
  115. package/dist/src/config/model-selector.js +34 -34
  116. package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
  117. package/dist/src/doctor/doctor-backlog-plans.js +398 -0
  118. package/dist/src/doctor/doctor-backlog.js +249 -512
  119. package/dist/src/doctor/doctor-check-names.js +85 -0
  120. package/dist/src/doctor/doctor-checks.js +61 -593
  121. package/dist/src/doctor/doctor-claude-checks.js +142 -0
  122. package/dist/src/doctor/doctor-codex-checks.js +276 -0
  123. package/dist/src/doctor/doctor-contention-checks.js +506 -0
  124. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  125. package/dist/src/doctor/doctor-holds.js +80 -66
  126. package/dist/src/doctor/doctor-install.js +2 -1
  127. package/dist/src/doctor/doctor-model-checks.js +216 -537
  128. package/dist/src/doctor/doctor-notify.js +9 -5
  129. package/dist/src/doctor/doctor-opencode-checks.js +164 -0
  130. package/dist/src/doctor/doctor-orphans.js +182 -63
  131. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  132. package/dist/src/doctor/doctor-project-checks.js +397 -0
  133. package/dist/src/doctor/doctor.js +126 -77
  134. package/dist/src/doctor/image-probe.js +21 -8
  135. package/dist/src/doctor/outcome.js +3 -2
  136. package/dist/src/doctor/probe.js +21 -12
  137. package/dist/src/events/event-format.js +185 -89
  138. package/dist/src/events/event-labels.js +11 -5
  139. package/dist/src/events/event-legacy.js +39 -15
  140. package/dist/src/events/event-read.js +52 -39
  141. package/dist/src/events/event-tally.js +16 -0
  142. package/dist/src/events/event-window.js +80 -26
  143. package/dist/src/events/events.js +30 -13
  144. package/dist/src/events/notify.js +7 -7
  145. package/dist/src/failure/failure-cluster.js +12 -12
  146. package/dist/src/failure/failure-data.js +65 -60
  147. package/dist/src/failure/failure-render.js +29 -21
  148. package/dist/src/failure/failure-state-change.js +17 -12
  149. package/dist/src/failure/time-spend.js +35 -35
  150. package/dist/src/files/file-queue.js +17 -0
  151. package/dist/src/files/files.js +111 -35
  152. package/dist/src/files/json-files.js +3 -3
  153. package/dist/src/files/json-object.js +28 -7
  154. package/dist/src/files/stat-cache.js +2 -2
  155. package/dist/src/files/tail.js +1 -1
  156. package/dist/src/gates/bootstrap-gates.js +4 -5
  157. package/dist/src/gates/budget-gates.js +88 -225
  158. package/dist/src/gates/disk-gate.js +1 -1
  159. package/dist/src/gates/gate-polls.js +101 -143
  160. package/dist/src/gates/maintenance-quota.js +37 -30
  161. package/dist/src/gates/pause-gates.js +28 -10
  162. package/dist/src/gates/readiness.js +7 -3
  163. package/dist/src/gates/role-cap-gates.js +8 -8
  164. package/dist/src/gates/startup-gate.js +94 -32
  165. package/dist/src/gates/streak-gate.js +54 -30
  166. package/dist/src/git/commit-message.js +89 -46
  167. package/dist/src/git/git-diff.js +16 -10
  168. package/dist/src/git/git-run.js +79 -38
  169. package/dist/src/git/git.js +54 -34
  170. package/dist/src/git/slot-salvage.js +253 -0
  171. package/dist/src/git/slots-state.js +6 -6
  172. package/dist/src/git/worktree-pool.js +132 -38
  173. package/dist/src/git/worktree-use.js +4 -11
  174. package/dist/src/git/worktree.js +94 -37
  175. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  176. package/dist/src/gui/gui-args.js +79 -8
  177. package/dist/src/gui/gui-backlog-commands.js +43 -19
  178. package/dist/src/gui/gui-backlog-endpoints.js +156 -0
  179. package/dist/src/gui/gui-command.js +26 -13
  180. package/dist/src/gui/gui-config-commands.js +168 -80
  181. package/dist/src/gui/gui-endpoint-commands.js +5 -4
  182. package/dist/src/gui/gui-endpoint-common.js +4 -4
  183. package/dist/src/gui/gui-endpoints.js +11 -380
  184. package/dist/src/gui/gui-history-endpoints.js +301 -0
  185. package/dist/src/gui/gui-operator-commands.js +235 -13
  186. package/dist/src/gui/gui-prompt-commands.js +26 -20
  187. package/dist/src/gui/gui-report-endpoints.js +97 -0
  188. package/dist/src/gui/gui-server.js +26 -5
  189. package/dist/src/gui/gui-team-commands.js +7 -7
  190. package/dist/src/gui/http-body.js +1 -1
  191. package/dist/src/history/history-data.js +122 -234
  192. package/dist/src/history/history-queued.js +155 -0
  193. package/dist/src/history/history-render.js +42 -22
  194. package/dist/src/history/history.js +190 -49
  195. package/dist/src/inbox/inbox-attachments.js +29 -30
  196. package/dist/src/inbox/inbox-cancel.js +10 -11
  197. package/dist/src/inbox/inbox-edit.js +9 -9
  198. package/dist/src/inbox/inbox-submit.js +18 -18
  199. package/dist/src/inbox/inbox-top.js +28 -17
  200. package/dist/src/inbox/inbox.js +42 -43
  201. package/dist/src/inbox/pending-prompt.js +26 -26
  202. package/dist/src/inbox/prompt-commands.js +62 -49
  203. package/dist/src/inbox/prompt-not-before.js +2 -2
  204. package/dist/src/init/init-templates.js +1 -2
  205. package/dist/src/init/init.js +28 -23
  206. package/dist/src/merge/backlog-conflicts.js +215 -35
  207. package/dist/src/merge/merge-batch.js +14 -8
  208. package/dist/src/merge/merge-cancel.js +53 -0
  209. package/dist/src/merge/merge-check-failures.js +44 -22
  210. package/dist/src/merge/merge-core.js +58 -32
  211. package/dist/src/merge/merge-diff.js +5 -4
  212. package/dist/src/merge/merge-drain.js +45 -19
  213. package/dist/src/merge/merge-git.js +177 -38
  214. package/dist/src/merge/merge-pipeline.js +15 -3
  215. package/dist/src/merge/merge-post-review.js +214 -0
  216. package/dist/src/merge/merge-queue-render.js +48 -11
  217. package/dist/src/merge/merge-queue.js +52 -15
  218. package/dist/src/merge/merge-requeue.js +103 -0
  219. package/dist/src/merge/merge-slot.js +61 -16
  220. package/dist/src/merge/merge-stack.js +27 -11
  221. package/dist/src/merge/merge-to-main.js +77 -26
  222. package/dist/src/merge/merge-top.js +83 -0
  223. package/dist/src/merge/merge-verify.js +75 -18
  224. package/dist/src/notes-cli.js +35 -15
  225. package/dist/src/operator/operator-commands.js +192 -81
  226. package/dist/src/operator/operator-intent.js +69 -42
  227. package/dist/src/operator/operator-requests.js +35 -40
  228. package/dist/src/operator/retire.js +5 -6
  229. package/dist/src/operator/revert.js +142 -34
  230. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  231. package/dist/src/orchestrator/orchestrator-poll.js +352 -0
  232. package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
  233. package/dist/src/orchestrator/orchestrator.js +129 -369
  234. package/dist/src/orchestrator/retention.js +42 -8
  235. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  236. package/dist/src/orchestrator/scheduling-scope.js +245 -0
  237. package/dist/src/orchestrator/scheduling-skip.js +195 -0
  238. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  239. package/dist/src/orchestrator/startup-migrations.js +11 -7
  240. package/dist/src/paths.js +126 -49
  241. package/dist/src/pi/command-shape.js +12 -1
  242. package/dist/src/pi/pi-args.js +6 -4
  243. package/dist/src/pi/pi-backend.js +6 -6
  244. package/dist/src/pi/pi-bin.js +54 -63
  245. package/dist/src/pi/pi-listed-models.js +53 -0
  246. package/dist/src/pi/pi-models.js +63 -30
  247. package/dist/src/pi/pi-run-result.js +1 -1
  248. package/dist/src/pi/pi-settings.js +1 -1
  249. package/dist/src/pi/pi-stream.js +6 -6
  250. package/dist/src/pi/pi-watchdogs.js +18 -19
  251. package/dist/src/pi-extension/bounded-output.js +5 -3
  252. package/dist/src/pi-extension/context-budget.js +2 -2
  253. package/dist/src/pi-extension/context-shake.js +1 -1
  254. package/dist/src/pi-extension/context-window.js +0 -1
  255. package/dist/src/pi-extension/full-output.js +3 -10
  256. package/dist/src/pi-extension/strip-thinking.js +0 -1
  257. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  258. package/dist/src/pi-extension/write-guard.js +37 -0
  259. package/dist/src/process/launch-services.js +1 -2
  260. package/dist/src/process/node-bin.js +16 -0
  261. package/dist/src/process/process-group.js +8 -18
  262. package/dist/src/process/process.js +41 -2
  263. package/dist/src/process/run-marker.js +26 -6
  264. package/dist/src/process/supervisor.js +22 -21
  265. package/dist/src/prompt/dialect.js +17 -2
  266. package/dist/src/prompt/gate-notes.js +203 -0
  267. package/dist/src/prompt/gate-prompts.js +150 -217
  268. package/dist/src/prompt/notes-backlog.js +184 -0
  269. package/dist/src/prompt/principles.js +1 -2
  270. package/dist/src/prompt/prompt-followup.js +19 -14
  271. package/dist/src/prompt/prompt.js +131 -204
  272. package/dist/src/redeploy/redeploy-policy.js +24 -19
  273. package/dist/src/redeploy/redeploy-probes.js +1 -1
  274. package/dist/src/redeploy/redeploy.js +2 -3
  275. package/dist/src/redeploy/redeployer.js +20 -20
  276. package/dist/src/redeploy/self-reload.js +2 -1
  277. package/dist/src/report/report-data.js +188 -124
  278. package/dist/src/report/report-fold.js +130 -54
  279. package/dist/src/report/report-main-red.js +55 -0
  280. package/dist/src/report/report-render.js +136 -65
  281. package/dist/src/report/report-types.js +1 -0
  282. package/dist/src/report/report.js +23 -9
  283. package/dist/src/request-timeouts.js +2 -2
  284. package/dist/src/review/diff-rules.js +53 -0
  285. package/dist/src/review/known-flakes.js +112 -1
  286. package/dist/src/review/post-merge-review-accept.js +51 -0
  287. package/dist/src/review/post-merge-review-render.js +112 -0
  288. package/dist/src/review/post-merge-review.js +239 -0
  289. package/dist/src/review/protected-edits.js +24 -10
  290. package/dist/src/review/quarantine-render.js +65 -0
  291. package/dist/src/review/review-followup.js +8 -8
  292. package/dist/src/review/review-precheck.js +66 -23
  293. package/dist/src/review/review-run.js +395 -0
  294. package/dist/src/review/review-verdict.js +2 -2
  295. package/dist/src/review/review.js +180 -401
  296. package/dist/src/review/secret-leaks.js +36 -16
  297. package/dist/src/review/suite-rerun.js +0 -1
  298. package/dist/src/roles/legacy-role-migration.js +80 -7
  299. package/dist/src/roles/role-catalog.js +127 -12
  300. package/dist/src/roles/role-guidance.js +65 -20
  301. package/dist/src/roles/role-labels.js +7 -5
  302. package/dist/src/roles/role-render.js +108 -56
  303. package/dist/src/roles/role-view.js +95 -56
  304. package/dist/src/roles/roles.js +33 -33
  305. package/dist/src/roles/runner-ids.js +97 -0
  306. package/dist/src/run/acceptance-greps.js +119 -0
  307. package/dist/src/run/ci-parity.js +121 -0
  308. package/dist/src/run/feedback-filings.js +51 -0
  309. package/dist/src/run/qa-coverage.js +125 -0
  310. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  311. package/dist/src/run/run-detail-data.js +175 -0
  312. package/dist/src/run/run-detail-render.js +91 -0
  313. package/dist/src/run/run-detail.js +168 -0
  314. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
  315. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  316. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
  317. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  318. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
  319. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  320. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  321. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
  322. package/dist/src/run/selfcheck-digest.js +40 -0
  323. package/dist/src/run/stage-backlog-checks.js +249 -0
  324. package/dist/src/run/stage-check.js +178 -0
  325. package/dist/src/run/stage-checks-shared.js +65 -0
  326. package/dist/src/run/stage-name-checks.js +204 -0
  327. package/dist/src/run/stage-path-checks.js +231 -0
  328. package/dist/src/run/stage-prose-checks.js +261 -0
  329. package/dist/src/runner/leftover.js +216 -0
  330. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  331. package/dist/src/runner/revision.js +191 -0
  332. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  333. package/dist/src/runner/runner-outcome.js +109 -0
  334. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  335. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
  336. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
  337. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  338. package/dist/src/runner/runner-state.js +103 -0
  339. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  340. package/dist/src/scheduling/backoff.js +38 -38
  341. package/dist/src/scheduling/claim-entries.js +49 -0
  342. package/dist/src/scheduling/claims.js +35 -22
  343. package/dist/src/scheduling/once-round.js +22 -22
  344. package/dist/src/scheduling/quiet-hours.js +5 -6
  345. package/dist/src/scheduling/scheduling.js +74 -64
  346. package/dist/src/scheduling/work-merged-cache.js +2 -2
  347. package/dist/src/status/status-budget.js +55 -47
  348. package/dist/src/status/status-data.js +49 -17
  349. package/dist/src/status/status-inbox.js +2 -2
  350. package/dist/src/status/status-install.js +0 -1
  351. package/dist/src/status/status-polls.js +43 -28
  352. package/dist/src/status/status-roles.js +50 -13
  353. package/dist/src/team/error-storm.js +8 -8
  354. package/dist/src/team/failure-spread.js +2 -2
  355. package/dist/src/team/orchestrator-info.js +15 -9
  356. package/dist/src/team/reclaim.js +7 -8
  357. package/dist/src/team/team-hold.js +7 -7
  358. package/dist/src/team/team-polls.js +91 -50
  359. package/dist/src/team/team-state.js +8 -2
  360. package/dist/src/text/datetime.js +65 -18
  361. package/dist/src/text/format.js +48 -11
  362. package/dist/src/text/markdown.js +8 -5
  363. package/dist/src/text/phrases.js +60 -23
  364. package/dist/src/text/suggest.js +1 -1
  365. package/dist/src/text/text.js +78 -13
  366. package/dist/src/ui/badges.js +99 -38
  367. package/dist/src/ui/gui/gui-client-backlog.js +314 -0
  368. package/dist/src/ui/gui/gui-client-boot.js +89 -61
  369. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  370. package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
  371. package/dist/src/ui/gui/gui-client-drawer.js +155 -289
  372. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  373. package/dist/src/ui/gui/gui-client-history.js +437 -68
  374. package/dist/src/ui/gui/gui-client-labels.js +6 -3
  375. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  376. package/dist/src/ui/gui/gui-client-model.js +27 -36
  377. package/dist/src/ui/gui/gui-client-operator.js +60 -52
  378. package/dist/src/ui/gui/gui-client-pending.js +405 -24
  379. package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
  380. package/dist/src/ui/gui/gui-client-report.js +105 -430
  381. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  382. package/dist/src/ui/gui/gui-client-settings.js +163 -5
  383. package/dist/src/ui/gui/gui-client-team.js +36 -465
  384. package/dist/src/ui/gui/gui-client.js +38 -26
  385. package/dist/src/ui/gui/gui-icons.js +0 -2
  386. package/dist/src/ui/gui/gui-page.js +10 -7
  387. package/dist/src/ui/gui/gui-styles.js +38 -34
  388. package/dist/src/ui/progress-data.js +9 -11
  389. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
  390. package/dist/src/ui/status-model.js +133 -125
  391. package/dist/src/ui/status-payload.js +364 -49
  392. package/dist/src/ui/status-render.js +109 -82
  393. package/dist/src/ui/team-alerts.js +728 -62
  394. package/dist/src/ui/tone.js +12 -6
  395. package/dist/src/ui/transcript-run.js +58 -0
  396. package/dist/src/ui/transcript-tail.js +28 -20
  397. package/dist/src/ui/transcript.js +63 -23
  398. package/dist/src/ui/tui/tui-app.js +0 -1
  399. package/dist/src/ui/tui/tui-backlog.js +53 -14
  400. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  401. package/dist/src/ui/tui/tui-editor-attach.js +4 -12
  402. package/dist/src/ui/tui/tui-editor-backends.js +127 -0
  403. package/dist/src/ui/tui/tui-editor-brief.js +14 -36
  404. package/dist/src/ui/tui/tui-editor-budget.js +36 -39
  405. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  406. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  407. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  408. package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
  409. package/dist/src/ui/tui/tui-editor-config.js +99 -0
  410. package/dist/src/ui/tui/tui-editor-entry.js +39 -64
  411. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  412. package/dist/src/ui/tui/tui-editor-instances.js +46 -0
  413. package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
  414. package/dist/src/ui/tui/tui-editor-interval.js +29 -16
  415. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  416. package/dist/src/ui/tui/tui-editor-notes.js +9 -29
  417. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  418. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  419. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  420. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  421. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  422. package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
  423. package/dist/src/ui/tui/tui-editor-retire.js +5 -13
  424. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  425. package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
  426. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  427. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  428. package/dist/src/ui/tui/tui-editor-session.js +101 -34
  429. package/dist/src/ui/tui/tui-editor-slots.js +24 -0
  430. package/dist/src/ui/tui/tui-editors.js +44 -2
  431. package/dist/src/ui/tui/tui-frame.js +68 -11
  432. package/dist/src/ui/tui/tui-history.js +8 -9
  433. package/dist/src/ui/tui/tui-input.js +13 -11
  434. package/dist/src/ui/tui/tui-keymap.js +0 -1
  435. package/dist/src/ui/tui/tui-keys.js +33 -9
  436. package/dist/src/ui/tui/tui-pane-activity.js +35 -19
  437. package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
  438. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  439. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  440. package/dist/src/ui/tui/tui-pane-health.js +168 -8
  441. package/dist/src/ui/tui/tui-pane-history.js +446 -82
  442. package/dist/src/ui/tui/tui-pane-pending.js +539 -15
  443. package/dist/src/ui/tui/tui-pane-queued.js +78 -57
  444. package/dist/src/ui/tui/tui-pane-report.js +54 -16
  445. package/dist/src/ui/tui/tui-pane-session.js +34 -6
  446. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  447. package/dist/src/ui/tui/tui-pane.js +91 -28
  448. package/dist/src/ui/tui/tui-panes.js +153 -34
  449. package/dist/src/ui/tui/tui.js +18 -15
  450. package/dist/src/verdict/fix-claim.js +129 -33
  451. package/dist/src/verdict/no-change.js +4 -4
  452. package/dist/src/verdict/refusal.js +40 -16
  453. package/dist/src/verdict/reply-contract.js +77 -31
  454. package/dist/src/version.js +0 -1
  455. package/package.json +2 -1
  456. package/dist/src/loop/leftover.js +0 -130
  457. package/dist/src/loop/revision.js +0 -70
  458. package/dist/src/roles/loop-ids.js +0 -77
  459. package/dist/src/tick/qa-coverage.js +0 -108
  460. package/dist/src/tick/stage-check.js +0 -419
  461. package/dist/src/tick/telemetry-digest.js +0 -25
  462. package/dist/src/tick/tick-detail-data.js +0 -108
  463. package/dist/src/tick/tick-detail.js +0 -163
  464. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,57 +1,62 @@
1
- // vocabulary: plain
2
1
  import { roleLabel } from "../roles/role-labels.js";
3
- import { VALIDATION_GAP_TAGS } from "../roles/role-guidance.js";
4
2
  import fs from "node:fs";
5
3
  import path from "node:path";
6
4
  import { errorMessage, truncate } from "../text/text.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";
5
+ import { shortSha } from "../text/format.js";
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";
8
+ import { eventsSince } from "../events/event-window.js";
9
+ import { finiteNumber } from "../files/json-object.js";
10
+ import { lowerMedian } from "../collections.js";
11
+ import { plural, shortSpanPhrase } from "../text/phrases.js";
12
+ import { queuedMerges, unreadableMergeQueueFiles } from "../merge/merge-queue.js";
18
13
  import { mergeChanges, readMergeMarker } from "../merge/merge-slot.js";
19
14
  import { listWideScope, queuedListRows } from "../inbox/inbox.js";
20
15
  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";
16
+ import { currentBranch, targetBranch } from "../git/git.js";
17
+ import { gitTry } from "../git/git-run.js";
18
+ import { stampedSubject } from "../git/commit-message.js";
19
+ import { commitOperatorBacklogEdit } from "../backlog/backlog-commit.js";
20
+ import { briefFile } from "../brief/brief.js";
21
+ import { BACKLOG_FILES } from "../backlog/backlog-md.js";
26
22
  import { okOutcome, warnOutcome } from "./outcome.js";
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. */
23
+ /** The doctor's backlog modules' shared scaffolding and the check groups that do not read a
24
+ * single backlog document: the merge-queue, post-merge-review and queued-prompt checks
25
+ * (checkStaleQueuedMerges, checkMergeQueueFiles, checkPostReviews, checkStalePrompts and the
26
+ * mergeWaitPhrase helper) and the uncommitted-
27
+ * edits-in-the-primary-checkout check. This module also hosts the helpers and constants the
28
+ * per-document checks import — readDoc and its checked wrapper readDocChecked, andMore, and the
29
+ * age/heading-trim bounds — so the per-document reads cannot drift. The per-document
30
+ * checks live in doctor-backlog-plans.ts (the plan-document structure and fix-claim checks) and
31
+ * doctor-backlog-bugs.ts (open bugs, questions and holds). All of them warn instead of failing:
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. */
36
34
  /** How many Fixed records the two newest-first Fixed-record checks (fix claims and validation
37
35
  * gaps) verify: the newest, since the section is newest-first by template convention. Older
38
36
  * records drift as the code evolves — a symbol legitimately fixed long ago gets renamed later,
39
37
  * and pre-convention Fixed entries predate the validation-gap line — so a whole-section scan
40
38
  * would warn forever on records nobody should rewrite. */
41
- const FIX_CLAIMS_CHECKED = 10;
39
+ export const FIX_CLAIMS_CHECKED = 10;
42
40
  /** A second or later suspect record's heading is trimmed to this many characters in the warn —
43
41
  * the first is named in full, the rest only enough to find them in BUGS.md. */
44
- const FIX_CLAIM_HEADING_MAX = 60;
42
+ export const FIX_CLAIM_HEADING_MAX = 60;
45
43
  /** A refused entry's note is trimmed to this many characters in the refusals warn. */
46
- const REFUSAL_NOTE_MAX = 120;
44
+ export const REFUSAL_NOTE_MAX = 120;
47
45
  /** The whole days an open question may wait before the stale-questions check warns about it:
48
46
  * an opinionated default, matching the 7-day window the `--list` age is meant to surface.
49
47
  * A question awaiting a human answer is operator signal, so the value needs no knob. */
50
- const STALE_QUESTION_DAYS = 7;
48
+ export const STALE_QUESTION_DAYS = 7;
51
49
  /** The whole days an open bug may wait before the stale-bugs check warns about it: an
52
50
  * opinionated default with no knob, like STALE_QUESTION_DAYS. Three days is long enough that a
53
51
  * bug filed late one evening and fixed the next day never warns. */
54
- const STALE_BUG_DAYS = 3;
52
+ export const STALE_BUG_DAYS = 3;
53
+ /** The whole days a bug may stay held by a Waiting for the next failure note before the
54
+ * waiting-bugs check warns that its flake has not come back. A note parks a bug until it fails
55
+ * again, but the failure may never come, so a hold has to lapse or the entry parks indefinitely;
56
+ * one week without a recurrence is long enough that a recurring flake would almost surely have
57
+ * shown up, and short enough that a bug which never returns stops squatting in the backlog as
58
+ * an ok. An opinionated default with no knob, like STALE_BUG_DAYS. */
59
+ export const STALE_WAIT_DAYS = 7;
55
60
  /** The `(and N more …)` suffix three one-line doctor details append after naming their first
56
61
  * suspect (fix claims, stranded plans, duplicated headings): the rest stay visible but
57
62
  * shortened, since a doctor check renders one line. `render` makes each extra item's short
@@ -59,7 +64,7 @@ const STALE_BUG_DAYS = 3;
59
64
  * FIX_CLAIM_HEADING_MAX, the duplicate-headings site passes its items through as built;
60
65
  * `noun` names what the extras are ("record(s)" for fix claims, empty for the other two).
61
66
  * Empty string when there are no extras. */
62
- function andMore(rest, render, noun = "") {
67
+ export function andMore(rest, render, noun = "") {
63
68
  return rest.length > 0
64
69
  ? ` (and ${rest.length} more${noun ? ` ${noun}` : ""}: ${rest.map(render).join(", ")})`
65
70
  : "";
@@ -67,7 +72,7 @@ function andMore(rest, render, noun = "") {
67
72
  /** Read one backlog file for a check, or the failure message explaining why it could not be
68
73
  * read (text.ts's errorMessage). Callers shape the failure themselves — a returned warn for a
69
74
  * single-file check, an "(unreadable)" list entry for the multi-file heading sweep. */
70
- function readDoc(p) {
75
+ export function readDoc(p) {
71
76
  try {
72
77
  return { doc: fs.readFileSync(p, "utf8") };
73
78
  }
@@ -75,10 +80,11 @@ function readDoc(p) {
75
80
  return { error: errorMessage(err) };
76
81
  }
77
82
  }
78
- /** The single-file check's read prologue, shared by checkFixClaims and checkStrandedPlans:
79
- * an absent file is that check's own "nothing to verify" ok outcome, an unreadable one a warn
80
- * naming the file. Returns the document, or the outcome the caller should return as-is. */
81
- function readDocChecked(p, file, missingDetail) {
83
+ /** The single-file check's read prologue: an absent file is that check's own "nothing to
84
+ * verify" ok outcome, an unreadable one a warn naming the file. Returns the document, or the
85
+ * outcome the caller should return as-is. The per-file doctor checks go through readBacklogChecked.
86
+ * */
87
+ export function readDocChecked(p, file, missingDetail) {
82
88
  if (!fs.existsSync(p))
83
89
  return okOutcome(missingDetail);
84
90
  const read = readDoc(p);
@@ -86,242 +92,26 @@ function readDocChecked(p, file, missingDetail) {
86
92
  return warnOutcome(`cannot read ${file} — ${read.error}`);
87
93
  return read.doc;
88
94
  }
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. */
99
- export function checkFixClaims(root) {
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;
103
- // One parse feeds every record's title and body: bugEntryBody re-parsed the whole document
104
- // per heading, and BUGS.md grows without bound, so the newest-10 scan paid 1 + 10 full-document
105
- // walks where one suffices.
106
- const records = fixedEntriesAt(root, doc).slice(0, FIX_CLAIMS_CHECKED);
107
- // The haystack walks src/, test/ and scripts/: build it only once a record names something.
108
- let haystack;
109
- const phantoms = [];
110
- for (const { title: heading, body } of records) {
111
- const symbols = fixSymbols(body);
112
- if (symbols.length === 0)
113
- continue;
114
- haystack ??= sourceHaystack(root);
115
- const missing = unbackedSymbols(root, symbols, haystack);
116
- if (missing.length === symbols.length)
117
- phantoms.push({ heading, missing });
118
- }
119
- const [first, ...rest] = phantoms;
120
- if (!first)
121
- return okOutcome(`newest ${records.length} Fixed record(s) name code that exists on this tree`);
122
- // falseFixReason's message shape: the heading, then up to 3 missing names.
123
- const { heading, missing } = first;
124
- const names = missingSymbolNames(missing);
125
- // Every other suspect is still named, shortened: a doctor check is one line.
126
- const more = andMore(rest, (p) => `"${truncate(p.heading, FIX_CLAIM_HEADING_MAX)}"`, "record(s)");
127
- return warnOutcome(`BUGS.md records "${heading}" as Fixed, but none of the symbols its Fix paragraph names ` +
128
- `exist on this tree: ${names}${more} — land the fix or keep the bug Open / refresh a stale record`);
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
- }
179
- /** Stranded plans — plan headings filed under the wrong PLANS.md section (the stranded-plan
180
- * detector, src/backlog/backlog-structure.ts): a `(planned …)` heading sitting under `## Done` is
181
- * invisible to every Planned reader, and a done-dated heading still under `## Planned` invites
182
- * a second implementation. The clean loop repairs these when it happens to tick; this makes
183
- * the state visible to an operator without waiting for that tick. Reads the tree at `root` —
184
- * the primary checkout IS main's tree — like checkFixClaims. A warn, never a fail: a misplaced
185
- * heading is operator signal, not a broken environment. */
186
- export function checkStrandedPlans(root) {
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);
193
- const clean = "no plan headings filed under the wrong PLANS.md section";
194
- if (stranded.length === 0)
195
- return okOutcome(clean);
196
- const [first, ...rest] = stranded;
197
- if (!first)
198
- return okOutcome(clean);
199
- const more = andMore(rest, (e) => `"${truncate(e.title, FIX_CLAIM_HEADING_MAX)}"`);
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)");
218
- }
219
- /** Duplicated `## ` headings already on main (the duplicate-heading half of the backlog-structure
220
- * check, src/backlog/backlog-structure.ts): a `## Done` a past merge or conflict resolution added
221
- * twice leaves every later entry after the first section invisible to the section readers, and
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. */
228
- export function checkBacklogHeadings(root) {
229
- const duplicates = [];
230
- for (const file of BACKLOG_FILES) {
231
- const p = path.join(root, file);
232
- if (!fs.existsSync(p))
233
- continue; // Absent file (early repo): contributes nothing.
234
- const read = readDoc(p);
235
- if ("error" in read) {
236
- duplicates.push(`${file} (unreadable)`);
237
- continue;
238
- }
239
- const { doc } = read;
240
- for (const title of duplicateHeadings(doc))
241
- duplicates.push(`${file} "## ${title}"`);
242
- }
243
- if (duplicates.length === 0)
244
- return okOutcome("no duplicated ## section headings in the backlog files");
245
- const [first, ...rest] = duplicates;
246
- const more = andMore(rest, (d) => d);
247
- return warnOutcome(`${first}${more} appears more than once — readers take the first section of a name, so ` +
248
- `later entries are invisible; keep one heading per name (the gate blocks only a merge ` +
249
- `that adds another copy — remove the duplicate in a deliberate edit)`);
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");
95
+ /** The standard single-file check's read prologue for a backlog document at `root`: the
96
+ * readDocChecked call every per-file doctor check (checkFixClaims, checkValidationGaps,
97
+ * checkStrandedPlans, checkStaleQuestions, and the stale/waiting bug checks) repeats, with the
98
+ * `no <file> — nothing to verify` missing-detail derived from the file name so the file triple
99
+ * (path, display name, message) and the message template cannot drift across the seven call
100
+ * sites. Returns the document, or the outcome the caller should return as-is. */
101
+ export function readBacklogChecked(root, file) {
102
+ return readDocChecked(path.join(root, file), file, `no ${file} — nothing to verify`);
316
103
  }
317
104
  /** How long a queued merge may wait without its change starting before the stale-queued-merge check
318
105
  * warns: 6 hours, fixed. A verify plus a merge completes in minutes (plans/merge-queue.md), so a
319
106
  * full six-hour silence while the team is healthy — not on a hold, which doctor already reports
320
107
  * 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;
108
+ * opinionated default, like STALE_QUESTION_DAYS, so no knob. The always-on `stale-merges` team
109
+ * alert (src/ui/status-payload.ts staleMerges) reuses this same window through
110
+ * staleQueuedMergeRows, so the doctor's row and the alert cannot drift on the threshold (the
111
+ * STALE_PROMPT_MS/STALE_BUG_DAYS precedent). */
112
+ export const MERGE_QUEUE_STALE_MS = 6 * HOUR_MS;
323
113
  /** The window of the merge-wait phrase on the stale merge-queue row. */
324
- const MERGE_WAIT_WINDOW_MS = 24 * 3600_000;
114
+ const MERGE_WAIT_WINDOW_MS = DAY_MS;
325
115
  /** Stale queued merges — a change that has sat in the durable merge queue too long without starting
326
116
  * (plans/merge-queue.md). Every queued-change health fact is otherwise a hook into its own role or
327
117
  * stage: a verify under way reads in the team row, a team hold gets its own banner, and
@@ -334,7 +124,7 @@ const MERGE_WAIT_WINDOW_MS = 24 * 3600_000;
334
124
  * holding a change being verified or merged right now — a slow review is not a stalled drain, and
335
125
  * an absent or never-throwing unreadable marker contributes the empty set. Reads the tree at `root`
336
126
  * 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
127
+ * checkStaleQuestions/checkStalledEntries precedent: a queued wait is legitimate while the team is
338
128
  * genuinely held, and those holds already warn separately. */
339
129
  export function checkStaleQueuedMerges(root, now) {
340
130
  const withWait = (out) => {
@@ -347,11 +137,7 @@ export function checkStaleQueuedMerges(root, now) {
347
137
  }
348
138
  return phrase ? { ...out, detail: `${out.detail} · ${phrase}` } : out;
349
139
  };
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)) }));
140
+ const stale = staleQueuedMergeRows(root, now).map((e) => ({ ...e, age: humanSeconds(secondsSince(now - e.waitedMs, now)) }));
355
141
  const [first, ...rest] = stale;
356
142
  if (!first)
357
143
  return withWait(okOutcome(`no merge-queue entry has waited more than ${humanSeconds(MERGE_QUEUE_STALE_MS / 1000)} to start`));
@@ -359,27 +145,125 @@ export function checkStaleQueuedMerges(root, now) {
359
145
  return withWait(warnOutcome(`merge-queue entry of ${roleLabel(first.role)} — "${truncate(first.summary, FIX_CLAIM_HEADING_MAX)}" — has waited ` +
360
146
  `${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
147
  }
148
+ /** The one traversal shared by the doctor's `stale merge-queue entries` row
149
+ * (checkStaleQueuedMerges) and the always-on `stale-merges` team alert
150
+ * (src/ui/status-payload.ts staleMerges): every queued merge whose role is not in the in-flight
151
+ * marker and whose wait strictly exceeds MERGE_QUEUE_STALE_MS (6 h), in queue-file order (oldest
152
+ * first). The in-flight set comes from readMergeMarker via mergeChanges (an absent or
153
+ * never-throwing unreadable marker contributes the empty set) — a slow review is not a stalled
154
+ * drain. Each row carries { role, summary, waitedMs }; the doctor's row adds its age phrase and
155
+ * the alert names the wait, so the rule and its threshold live in one place and cannot drift. */
156
+ export function staleQueuedMergeRows(root, now) {
157
+ const marker = readMergeMarker(root);
158
+ const inFlight = new Set(marker ? mergeChanges(marker).map((c) => c.role) : []);
159
+ return queuedMerges(root)
160
+ .filter((e) => !inFlight.has(e.role) && now - e.enqueuedAt > MERGE_QUEUE_STALE_MS)
161
+ .map((e) => ({ role: e.role, summary: e.summary, waitedMs: now - e.enqueuedAt }));
162
+ }
163
+ /** The merge-queue files `readMergeQueue` skips — torn, foreign, or vanished mid-listing — as
164
+ * checkout-relative paths. One traversal shared by the doctor's `checkMergeQueueFiles` row and
165
+ * the always-on `skipped-merges` team alert (src/ui/status-payload.ts skippedMergeFiles), so
166
+ * the two surfaces select the same files and name the same paths and cannot drift. */
167
+ export function skippedMergeQueueFiles(root) {
168
+ return unreadableMergeQueueFiles(root).map((f) => path.relative(root, f));
169
+ }
170
+ /** The merge-queue files `readMergeQueue` skips — torn, foreign, or vanished mid-listing —
171
+ * named so a stranded queue entry is a health signal instead of only a number on `tumwater
172
+ * queue`'s output. It exists because `tumwater queue` counts the skips but does not name them,
173
+ * and the drain drops only an unreadable head, so a non-head skip is otherwise silent: it sits
174
+ * in `.tumwater/merge-queue/` forever, never drained, and neither the queue reader nor the
175
+ * stale-queued-merges doctor row (which reads only readable entries) surfaces it. Warn, never
176
+ * fail, like the module's other document checks: a foreign file is operator signal, not a
177
+ * broken environment. */
178
+ export function checkMergeQueueFiles(root) {
179
+ const files = skippedMergeQueueFiles(root);
180
+ if (files.length === 0)
181
+ return okOutcome("the queue reader skips no merge-queue file");
182
+ const [first, ...rest] = files;
183
+ const more = andMore(rest, (f) => f, "file(s)");
184
+ return warnOutcome(`the merge queue holds ${plural(files.length, "file")} the reader skips — ${first}${more}; ` +
185
+ `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)`);
186
+ }
187
+ /** The failures count at which a post-merge review is one failed run from being dropped: one
188
+ * below POST_REVIEW_FAILURE_LIMIT, so the drain's drop limit and this at-risk bar cannot drift
189
+ * (the STALE_BUG_DAYS precedent). A review at this bar has as many unreviewed failures as the
190
+ * drain allows before it drops the commit from the queue forever. */
191
+ export const POST_REVIEW_AT_RISK_FAILURES = POST_REVIEW_FAILURE_LIMIT - 1;
192
+ /** The post-merge reviews one failed run from being dropped, in file (FIFO) order: every entry
193
+ * whose `failures` is at least POST_REVIEW_AT_RISK_FAILURES, each with `commit`, `role`,
194
+ * `summary` and `failures` carried out (a missing failures reads as 0, so it is never at risk).
195
+ * A review that has already been rejected is left out: the drain settles a rejection as
196
+ * `{ ...entry, rejected }` (it preserves `failures`) and never re-runs it, so a rejected commit
197
+ * is not waiting on a verdict and must not read as one failed run from being dropped.
198
+ * A review that has already landed its fix (`fixed` set) is left out too — it is a settled
199
+ * record naming both shas, not a verdict-less entry the next run could drop.
200
+ * One traversal shared by the doctor's `post-merge reviews` row (checkPostReviews) and the
201
+ * always-on `post-merge-risk` team alert (src/ui/status-payload.ts postReviewRiskAlert), so the
202
+ * two surfaces select the same entries and cannot drift. Reads .tumwater/state/post-review.json
203
+ * through readPostReviews, which degrades a missing or torn file to the well-formed subset or
204
+ * `[]` and never throws. */
205
+ export function atRiskPostReviews(root) {
206
+ return readPostReviews(root)
207
+ .filter((e) => e.rejected === undefined && e.fixed === undefined && (e.failures ?? 0) >= POST_REVIEW_AT_RISK_FAILURES)
208
+ .map((e) => ({ commit: e.commit, role: e.role, summary: e.summary, failures: e.failures ?? 0 }));
209
+ }
210
+ /** The doctor's one sentence naming the post-merge reviews one failed run from being dropped
211
+ * (the checkStaleQueuedMerges phrase precedent): the first in full — role, short commit, summary
212
+ * and the failures count — the rest folded through andMore, and the one sentence every surface
213
+ * shows, so the doctor row and the alert cannot drift on the wording. null for an empty list. */
214
+ export function atRiskPostReviewsPhrase(rows) {
215
+ const [first, ...rest] = rows;
216
+ if (!first)
217
+ return null;
218
+ const more = andMore(rest, (r) => `${roleLabel(r.role)} ${shortSha(r.commit)}`);
219
+ return `the post-merge review of ${roleLabel(first.role)} ${shortSha(first.commit)} — "${truncate(first.summary, FIX_CLAIM_HEADING_MAX)}" — has ended without a verdict ${first.failures} times${more}; ` +
220
+ `one more failed run drops it and the commit stays on main unreviewed — fix the reviewer backend or accept the commit unreviewed deliberately`;
221
+ }
222
+ /** A post-merge review one failed run from being dropped (src/review/post-merge-review.ts, the
223
+ * drain drops an entry at POST_REVIEW_FAILURE_LIMIT failed runs) — the last moment an operator
224
+ * can fix the reviewer backend or deliberately accept the commit unreviewed before the drain
225
+ * drops it from the queue and it stays on main unreviewed. Reads .tumwater/state/post-review.json
226
+ * through the shared atRiskPostReviews traversal the always-on post-merge-risk team alert
227
+ * (src/ui/status-payload.ts postReviewRiskAlert) uses, so the two surfaces cannot drift. A warn,
228
+ * never a fail — the checkStaleQueuedMerges precedent: a failing reviewer is operator signal,
229
+ * not a broken environment; readPostReviews never throws (a missing or torn file degrades to the
230
+ * well-formed subset or `[]`), so the check needs no read-error arm. */
231
+ export function checkPostReviews(root) {
232
+ const rows = atRiskPostReviews(root);
233
+ const phrase = atRiskPostReviewsPhrase(rows);
234
+ return phrase === null ? okOutcome("no post-merge review is one failed run from being dropped") : warnOutcome(phrase);
235
+ }
362
236
  /** How long a queued prompt may wait for its role to dequeue it before the stale-prompt check
363
237
  * warns: a day, fixed. An opinionated default with no knob, like MERGE_QUEUE_STALE_MS: a day is
364
238
  * 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;
239
+ * that a paused or disabled role's stranded prompt shows up. The always-on stale-prompt team
240
+ * alert (src/ui/status-payload.ts stalePrompts) reuses this same threshold, so it cannot drift
241
+ * from the doctor's check — the STALE_BUG_DAYS precedent. */
242
+ export const STALE_PROMPT_MS = DAY_MS;
243
+ /** The queued inbox prompts the doctor's stale-prompt rule calls stale — deliverable now (a
244
+ * `--at` prompt still counting down is never stale) and queued more than STALE_PROMPT_MS,
245
+ * oldest-first, `queuedAtMs` narrowed to a number. One traversal shared by the doctor's
246
+ * `checkStalePrompts` check and the always-on `stalePrompts` team alert (src/ui/status-payload.ts),
247
+ * so the two surfaces read the same rule at the same threshold and cannot drift. */
248
+ export function staleQueuedPromptRows(root, now) {
249
+ return queuedListRows(root, null, listWideScope(root))
250
+ .filter((p) => p.queuedAtMs !== null && deliverableAt(p.notBeforeMs, now) && now - p.queuedAtMs > STALE_PROMPT_MS)
251
+ .sort((a, b) => a.queuedAtMs - b.queuedAtMs)
252
+ .map((p) => ({ ...p, queuedAtMs: p.queuedAtMs }));
253
+ }
367
254
  /** Stale queued prompts — a prompt queued for a role (`tumwater prompt --role <id>`, the
368
255
  * dashboard composer, the TUI, a `--at` delay whose time has now passed) that has waited more
369
256
  * than a day for that role to dequeue it. A role that is paused, disabled, or failing never
370
257
  * reaches the run that would dequeue the prompt, so it waits indefinitely and only the Inbox
371
258
  * views (`tumwater prompt --list`, the dashboard Inbox tab, the TUI Inbox pane) show its age —
372
259
  * 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
260
+ * and the deliver-or-drop commands. Reads the queue through the shared `staleQueuedPromptRows`
261
+ * traversal the always-on stale-prompts alert uses (no new reader in src/inbox/), keeping only
375
262
  * rows that are deliverable now — a `--at 7d` prompt still counting down is never stale — and
376
263
  * stamped more than STALE_PROMPT_MS ago. A warn, never a fail — the checkStaleQueuedMerges
377
264
  * precedent: a stranded prompt is operator signal, not a broken environment. */
378
265
  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 }));
266
+ const stale = staleQueuedPromptRows(root, now);
383
267
  const [first, ...rest] = stale;
384
268
  if (!first)
385
269
  return okOutcome("no queued prompt has waited more than 1 day for a role to dequeue it");
@@ -403,249 +287,102 @@ export function mergeWaitPhrase(events) {
403
287
  const longest = Math.max(...waits);
404
288
  return `merges in the last 24h waited ${shortSpanPhrase(median)} median, ${shortSpanPhrase(longest)} longest (${plural(waits.length, "merge")})`;
405
289
  }
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
- }
290
+ /** Discover the tracked backlog/brief files with uncommitted edits in the primary checkout (the
291
+ * shared discovery behind checkUncommittedBacklog and commitUncommittedBacklog). A primary off
292
+ * the base branch is skipped by its callers: the ref-push merge arm leaves its working tree
293
+ * alone. Untracked files are not edits of a tracked file and are ignored. */
294
+ export async function dirtyBacklogFiles(root, config) {
295
+ const primary = await currentBranch(root);
296
+ const base = targetBranch(config?.baseBranch, primary);
297
+ // A primary off the base branch is skipped by every caller before it reads `watched`/`dirty`, so
298
+ // the brief is not touched here either: an unreadable brief must not turn that skip into a throw
299
+ // (the ref-push merge arm leaves an off-base working tree alone).
300
+ if (primary === null || primary !== base)
301
+ return { base, primary, watched: [], dirty: [] };
302
+ let brief = null;
303
+ try {
304
+ brief = briefFile(root);
541
305
  }
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
- }
306
+ catch {
307
+ // An unreadable brief is a doctor finding of its own; it must not abort the uncommitted-backlog
308
+ // check or `--commit-backlog`, so the tracked backlog set is watched without it.
580
309
  }
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}\``);
310
+ const watched = [...new Set([...BACKLOG_FILES, ...(brief ? [brief] : [])])];
311
+ const out = await gitTry(root, "status", "--porcelain", "--", ...watched);
312
+ if (out === null)
313
+ return { base, primary, watched, dirty: null };
314
+ const dirtySet = new Set(out.split("\n")
315
+ .filter((line) => line.length > 3 && !line.startsWith("??"))
316
+ .map((line) => line.slice(3)));
317
+ return { base, primary, watched, dirty: watched.filter((f) => dirtySet.has(f)) };
621
318
  }
622
319
  /** Names the backlog files (and the brief file) with uncommitted edits in the primary checkout. An
623
320
  * 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)
321
+ * that commit does not happen the edit is invisible to every role worktree (they branch from main)
625
322
  * and a merge that touches the file cannot fast-forward the checkout (merge_blocked). A primary off
626
323
  * the base branch is skipped: the ref-push merge arm leaves its working tree alone. Untracked files
627
324
  * are not edits of a tracked file and are ignored. */
628
325
  export async function checkUncommittedBacklog(root, config) {
629
- const primary = await currentBranch(root);
630
- const base = targetBranch(config?.baseBranch, primary);
326
+ const { base, primary, watched, dirty } = await dirtyBacklogFiles(root, config);
631
327
  if (primary === null || primary !== base) {
632
328
  return okOutcome(`your checkout is not on ${base}; backlog writes from you are not committed from it`);
633
329
  }
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)
330
+ if (dirty === null)
638
331
  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`);
332
+ if (dirty.length === 0) {
333
+ const list = watched.slice(0, -1).join(", ");
334
+ return okOutcome(`${list} and ${watched[watched.length - 1]} are committed`);
646
335
  }
647
- const list = named.join(", ");
648
- return warnOutcome(`${list} ${named.length > 1 ? "have" : "has"} uncommitted changes in your checkout — ` +
336
+ const list = dirty.join(", ");
337
+ return warnOutcome(`${list} ${dirty.length > 1 ? "have" : "has"} uncommitted changes in your checkout — ` +
649
338
  `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}`);
339
+ `(merge_blocked); review them with \`git diff -- ${list}\` and commit them on ${base} ` +
340
+ `(or run \`tumwater doctor --commit-backlog\`, or the Health card's Commit backlog, or the TUI Health tab's Ctrl+A)`);
341
+ }
342
+ /** Commit every dirty tracked backlog or brief file in the primary checkout on the base branch,
343
+ * through the same commitOperatorBacklogEdit core every operator backlog write uses, running
344
+ * under the primary-worktree lock it takes. The dirtyBacklogFiles discovery and branch precedence
345
+ * are checkUncommittedBacklog's own, so a `tumwater doctor --commit-backlog` unblocks exactly the
346
+ * merges the `uncommitted backlog` row warns would be blocked. When the primary is off the base
347
+ * branch or the status cannot be read it commits nothing and reports a skip sentence; a file whose
348
+ * commit fails stays dirty and lands in `failed` while the other listed files are still attempted.
349
+ * Never throws. */
350
+ export async function commitUncommittedBacklog(root, config) {
351
+ const d = await dirtyBacklogFiles(root, config);
352
+ if (d.primary === null || d.primary !== d.base)
353
+ return { base: d.base, committed: [], failed: [], skipped: `your checkout is not on ${d.base}; nothing committed` };
354
+ if (d.dirty === null)
355
+ return { base: d.base, committed: [], failed: [], skipped: "could not read git status; nothing committed" };
356
+ const committed = [];
357
+ const failed = [];
358
+ for (const name of d.dirty) {
359
+ const ok = await commitOperatorBacklogEdit(root, name, stampedSubject("operator", `Commit uncommitted ${name}`)); // vocabulary-ok: the tumwater(operator): commit prefix is a machine string
360
+ (ok ? committed : failed).push(name);
361
+ }
362
+ return { base: d.base, committed, failed, skipped: null };
363
+ }
364
+ /** The committed/failed/skip wording the CLI, the dashboard handler and the TUI flash all share
365
+ * for a BacklogCommitReport, so the three surfaces cannot drift: one `committed <file> on
366
+ * <base>` line per committed file, one `could not commit <file> — commit it by hand` line per
367
+ * failed file, then the skipped sentence when set. `[]` when the report committed nothing,
368
+ * failed nothing, and carried no skip sentence. `tumwater doctor --commit-backlog` (src/cli.ts),
369
+ * the dashboard Health card's Commit backlog (handleCommitBacklog) and the TUI Health tab's
370
+ * Ctrl+A all render from this one source. */
371
+ export function backlogCommitLines(report) {
372
+ const lines = [
373
+ ...report.committed.map((file) => `committed ${file} on ${report.base}`),
374
+ ...report.failed.map((file) => `could not commit ${file} — commit it by hand`),
375
+ ];
376
+ if (report.skipped !== null)
377
+ lines.push(report.skipped);
378
+ return lines;
379
+ }
380
+ /** The single-line message a surface flashes or answers for a BacklogCommitReport: the shared
381
+ * backlogCommitLines joined with "; ", or the nothing-to-commit sentence when the report
382
+ * committed nothing, failed nothing and carried no skip sentence. Lives here beside
383
+ * backlogCommitLines so the dashboard Health card's Commit backlog handler and the TUI Health
384
+ * tab's Ctrl+A flash cannot drift from each other. */
385
+ export function backlogCommitMessage(report) {
386
+ const lines = backlogCommitLines(report);
387
+ return lines.length ? lines.join("; ") : "nothing to commit — every watched backlog or brief file is committed";
651
388
  }