tumwater 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -3,8 +3,8 @@ import { daysBetween } from "../text/datetime.js";
3
3
  * QUESTIONS.md text, with no filesystem access — every function here takes the markdown text as
4
4
  * an argument. Split from src/backlog/backlog.ts, which keeps the stat-cached file readers
5
5
  * (sectionEntries, the root-based plannedPlans/openBugs/openQuestions family, and the
6
- * Done/Fixed date scan): consumers who only parse markdown (question-answer.ts's QUESTIONS.md
7
- * walks, backlog-write.ts's section appends, backlog-structure.ts's stranding checks)
6
+ * Done/Fixed date scan): consumers who only parse markdown (backlog-question-answer.ts's
7
+ * QUESTIONS.md walks, backlog-write.ts's section appends, backlog-structure.ts's stranding checks)
8
8
  * import from here without reaching the file/cache layer, and a caller who parses a string never
9
9
  * pays for a stat. Both layers share one fenceTracker state machine, so independent readers can
10
10
  * never disagree about what is body content. */
@@ -87,7 +87,7 @@ export function headingTitle(line, prefix) {
87
87
  * code block. The single home of the fence-aware heading guard (`!fenced.inside(line) &&
88
88
  * line.startsWith(prefix)` plus headingTitle) that every line-level reader of the backlog
89
89
  * docs repeats: sectionLines' section boundaries and parseEntryDetails' entry boundaries here,
90
- * and question-answer.ts's walks of QUESTIONS.md (the Open/Answered scan, the `### ` block
90
+ * and backlog-question-answer.ts's walks of QUESTIONS.md (the Open/Answered scan, the `### ` block
91
91
  * split, and the Answered section's start); that file's two pure next-`## ` boundary walks
92
92
  * go through nextSectionHeading below instead. Readers that walk
93
93
  * whole documents collecting heading lines go through fenceAwareHeadingLines instead; callers
@@ -120,7 +120,7 @@ export function sectionLines(md, sectionTitle) {
120
120
  /** The index of the next `## ` section heading at or after `from`, fence-aware through the
121
121
  * caller's `fenced` tracker — or `lines.length` when none follows. The section-end boundary
122
122
  * rule as an index, for readers that must cut or splice at the boundary rather than collect
123
- * its content (question-answer.ts cuts the Open section at its end and inserts a moved
123
+ * its content (backlog-question-answer.ts cuts the Open section at its end and inserts a moved
124
124
  * block before Answered's next `## `; backlog-write.ts cuts the section a filed entry is
125
125
  * appended to). Shares fencedHeadingTitle's guard with sectionLines, so
126
126
  * a boundary found here is the same boundary sectionLines would stop at. */
@@ -418,7 +418,7 @@ export function lastDate(text, dateRe) {
418
418
  * about when an entry opened. dateRe is cloned with the g flag so matchAll sees every date; a
419
419
  * text with no date yields null. Exported so the heading stamp parsers that apply the same
420
420
  * filing rule to a single title line — `filingDate` (backlog.ts) and `questionFilingDate`
421
- * (question-answer.ts) — share the one first-match mechanism instead of re-stating it;
421
+ * (backlog-question-answer.ts) — share the one first-match mechanism instead of re-stating it;
422
422
  * entryDates selects it with `first`, so the walker stays single-homed like lastDate's
423
423
  * completion rule. */
424
424
  export function firstDate(text, dateRe) {
@@ -16,25 +16,37 @@ function parseRef(chunk, fallbackSeries) {
16
16
  const m = /^(?:([\s\S]+?)\s+)?([0-9]+[a-z]?)\/(\d+)(?:\s*[–-]\s*(\d+)\/(\d+))?$/i.exec(text);
17
17
  if (m === null)
18
18
  return { refs: [], named: null };
19
- let named = (m[1]?.trim() ?? "").replace(/\s+parts?$/i, "");
20
- const series = named || fallbackSeries;
21
- if (series === null || series === "")
22
- return { refs: [], named: named || null };
19
+ const { named, series } = resolveRefSeries(m[1], fallbackSeries);
23
20
  const of = Number(m[3]);
24
- const start = m[2].toLowerCase();
25
- if (!Number.isInteger(of) || of < 1)
26
- return { refs: [], named: named || null };
27
- if (m[4] === undefined)
28
- return { refs: [{ series, part: start, of }], named: named || null };
21
+ if (series === null || !Number.isInteger(of) || of < 1)
22
+ return { refs: [], named };
23
+ return { refs: refsFromMatch(series, of, m[2], m[4] ?? null), named };
24
+ }
25
+ /** Whichever series a ref chunk resolves to: a captured name with any trailing `parts`/`part`
26
+ * keyword dropped, or — for a bare `parts a/n` — the clause's last-named series (null when it
27
+ * named none, so a bare ref there resolves to nothing). */
28
+ function resolveRefSeries(matchName, fallbackSeries) {
29
+ const named = (matchName?.trim() ?? "").replace(/\s+parts?$/i, "");
30
+ if (named !== "")
31
+ return { named, series: named };
32
+ return { named: null, series: fallbackSeries === "" ? null : fallbackSeries };
33
+ }
34
+ /** Build the resolved refs for a chunk whose regex matched: the one part, or an inclusive
35
+ * numeric range. The part token arrives verbatim (so `2a` survives); a range is numeric on
36
+ * both ends (the lettered parts never range) and expands to that many single refs. */
37
+ function refsFromMatch(series, of, startText, toText) {
38
+ const start = startText.toLowerCase();
39
+ if (toText === null)
40
+ return [{ series, part: start, of }];
29
41
  // A range is numeric on both ends (the lettered parts never range); expand it inclusively.
30
42
  const from = Number(start);
31
- const to = Number(m[4]);
43
+ const to = Number(toText);
32
44
  if (!Number.isInteger(from) || !Number.isInteger(to) || to < from)
33
- return { refs: [], named: named || null };
45
+ return [];
34
46
  const parts = [];
35
47
  for (let p = from; p <= to; p++)
36
48
  parts.push({ series, part: String(p), of });
37
- return { refs: parts, named: named || null };
49
+ return parts;
38
50
  }
39
51
  /** The stable identity of an entry heading: its `(planned …)`/`(done …)` stamp suffix
40
52
  * removed through the shared stripEntryStamp, whitespace collapsed, lowercased. Adding a
@@ -181,12 +193,45 @@ const MISWORDED_DEPENDENCY_RE = /\b(?:blocked by|blocked on|after|follows|depend
181
193
  * Refused and needs-review entries count like the rest, since their clauses still decide when
182
194
  * they unblock. */
183
195
  export function planDependencyProblems(planned, doneTitles) {
196
+ const known = dependencyKnownParts(planned, doneTitles);
197
+ const { nodes, dangling, unparsed, misworded } = dependencyEntryScan(planned, known);
198
+ return { cycle: firstDependencyCycle(nodes), dangling, unparsed, misworded };
199
+ }
200
+ /** The part keys that count as known for dangling checks: every still-planned entry's part plus
201
+ * every done title's part — a dangling ref is one no planned or done heading carries. */
202
+ function dependencyKnownParts(planned, doneTitles) {
184
203
  const known = plannedPartKeys(planned);
185
204
  for (const t of doneTitles) {
186
205
  const part = seriesPart(t);
187
206
  if (part !== null)
188
207
  known.add(partKey(part.series, part.part));
189
208
  }
209
+ return known;
210
+ }
211
+ /** The misworded refs a still-planned `Series, part i/n:` entry contributes: the series-parts its
212
+ * trailing-or-open parenthetical names through a dependency verb with no requires clause holding
213
+ * them, each `title`+`ref` deduped against the refs already collected across the whole planned
214
+ * list (a duplicate title contributes one ref, not one per occurrence). */
215
+ function miswordedDependencyRefs(entry, own, refs, plannedKeys, misworded) {
216
+ let meta = trailingParenthetical(entry.title);
217
+ if (meta === "")
218
+ meta = openParenthetical(entry.title);
219
+ const held = new Set(refs.map((ref) => partKey(ref.series, ref.part)));
220
+ for (const m of meta.matchAll(MISWORDED_DEPENDENCY_RE)) {
221
+ for (const ref of parseRef(m[1], own.series).refs) {
222
+ const key = partKey(ref.series, ref.part);
223
+ if (!plannedKeys.has(key) || held.has(key))
224
+ continue;
225
+ const name = `${ref.series} ${ref.part}/${ref.of}`;
226
+ if (!misworded.some((w) => w.title === entry.title && w.ref === name))
227
+ misworded.push({ title: entry.title, ref: name });
228
+ }
229
+ }
230
+ }
231
+ /** The one-pass scan over the planned entries: a clause that parses to no refs pushes its title to
232
+ * `unparsed`, a ref `known` does not carry lands in `dangling`, and each entry with a series part
233
+ * contributes its misworded refs and a graph node whose deps are its required parts' keys. */
234
+ function dependencyEntryScan(planned, known) {
190
235
  const dangling = [];
191
236
  const unparsed = [];
192
237
  const misworded = [];
@@ -203,25 +248,18 @@ export function planDependencyProblems(planned, doneTitles) {
203
248
  const own = seriesPart(entry.title);
204
249
  if (own === null)
205
250
  continue;
206
- let meta = trailingParenthetical(entry.title);
207
- if (meta === "")
208
- meta = openParenthetical(entry.title);
209
- const held = new Set(refs.map((ref) => partKey(ref.series, ref.part)));
210
- for (const m of meta.matchAll(MISWORDED_DEPENDENCY_RE)) {
211
- for (const ref of parseRef(m[1], own.series).refs) {
212
- const key = partKey(ref.series, ref.part);
213
- if (!plannedKeys.has(key) || held.has(key))
214
- continue;
215
- const name = `${ref.series} ${ref.part}/${ref.of}`;
216
- if (!misworded.some((w) => w.title === entry.title && w.ref === name))
217
- misworded.push({ title: entry.title, ref: name });
218
- }
219
- }
251
+ miswordedDependencyRefs(entry, own, refs, plannedKeys, misworded);
220
252
  nodes.set(partKey(own.series, own.part), {
221
253
  name: `${own.series} ${own.part}/${own.of}`,
222
254
  deps: refs.map((ref) => partKey(ref.series, ref.part)),
223
255
  });
224
256
  }
257
+ return { nodes, dangling, unparsed, misworded };
258
+ }
259
+ /** The first dependency cycle the depth-first walk finds, as `Series i/n` names in walk order with
260
+ * the first name repeated last (a self-reference is a one-hop cycle), or null when the graph is
261
+ * acyclic. The `open`/`done` state map and the walk order decide which cycle reports first. */
262
+ function firstDependencyCycle(nodes) {
225
263
  const state = new Map();
226
264
  const stack = [];
227
265
  const visit = (key) => {
@@ -252,7 +290,7 @@ export function planDependencyProblems(planned, doneTitles) {
252
290
  if (cycle !== null)
253
291
  break;
254
292
  }
255
- return { cycle, dangling, unparsed, misworded };
293
+ return cycle;
256
294
  }
257
295
  /** The prerequisite refs of `title`'s `requires … landed` clause when none of them is still in
258
296
  * `plannedParts` (built by plannedPartKeys), else []. A heading with no parseable clause is never
@@ -7,7 +7,7 @@
7
7
  * output helpers, so every surface can drive it without ending the process.
8
8
  *
9
9
  * Shared by every read/answer surface: the CLI's `tumwater questions` command body wraps it in
10
- * src/cli/question-commands.ts (the say/fail/editor layer), while the TUI's answer box
10
+ * src/cli/cli-question-commands.ts (the say/fail/editor layer), while the TUI's answer box
11
11
  * (src/ui/tui/tui-keys.ts) and the dashboard's POST route (src/gui/gui-endpoint-commands.ts)
12
12
  * import the non-exiting answer core directly. It lives with the rest of the BUGS/PLANS/QUESTIONS
13
13
  * markdown (src/backlog/), not under src/cli/, so the non-command surfaces never depend on the
@@ -1,35 +1,35 @@
1
1
  /** The hold-lifting and reordering writes of `tumwater bug|plan --release <n>` and `--top <n>`:
2
- * lift an entry's Refused or held-for-operator note, or move
2
+ * lift an entry's Refused or Needs-review note, or move
3
3
  * it to the head of its open
4
4
  * section. Each comes as an exiting writer (the CLI) and a non-exiting `try…` core that refuses as
5
5
  * data (the TUI and dashboard); entry lookup and the section's file target are
6
6
  * backlog-write.ts's. */
7
7
  import { backlogTarget, findEntry, locateDashboardEntry, readEntryLines } from "./backlog-write.js";
8
- import { withoutHeldForOperatorNotes, withoutRefusalNotes } from "./backlog-eligibility.js";
8
+ import { withoutNeedsReviewNotes, withoutRefusalNotes } from "./backlog-eligibility.js";
9
9
  import { writeTextAtomic } from "../files/files.js";
10
10
  import { fail } from "../cli/cli-output.js";
11
11
  import { trimTrailingBlankLines } from "../text/text.js";
12
12
  import { withPrimaryWorktreeLock } from "../concurrency/lock.js";
13
- /** The prose naming the note `lifted` kind a release removed, shared by the CLI, TUI and the
14
- * exit-announcers so their released lines cannot drift. */
13
+ /** The prose naming the note kind a release removed — plural, since a release lifts every line
14
+ * of the chosen kind — shared by the CLI, TUI and the exit-announcers so their released lines
15
+ * cannot drift. */
15
16
  export function liftedNotePhrase(lifted) {
16
17
  return lifted === "refused"
17
- ? "the Refused note"
18
- : "the held-for-operator note"; // vocabulary-ok: the held-for-operator note's release wording
18
+ ? "the Refused notes"
19
+ : "the Needs review notes";
19
20
  }
20
21
  /** Drop the Refused note from `entry`'s slice of `lines`, in place, and return its kind; an
21
- * entry with none loses its held-for-operator Needs-review note (the `(held N)` count at/above
22
- * threshold, plus its **Previously held N** line). Null (and `lines` untouched) when the entry
22
+ * entry with none loses its Needs-review note. Null (and `lines` untouched) when the entry
23
23
  * carries neither, shared by releaseEntry and tryReleaseEntry so they cannot drift. */
24
24
  function liftRefusal(lines, entry) {
25
25
  const slice = lines.slice(entry.start - 1, entry.end);
26
26
  let kept = withoutRefusalNotes(slice);
27
27
  if (kept.length === slice.length) {
28
- kept = withoutHeldForOperatorNotes(slice);
28
+ kept = withoutNeedsReviewNotes(slice);
29
29
  if (kept.length === slice.length)
30
30
  return null;
31
31
  lines.splice(entry.start - 1, slice.length, ...kept);
32
- return "held-for-operator"; // vocabulary-ok: the held-for-operator note's release wording
32
+ return "needs-review";
33
33
  }
34
34
  lines.splice(entry.start - 1, slice.length, ...kept);
35
35
  return "refused";
@@ -51,7 +51,7 @@ export function tryReleaseEntry(root, kind, n, expectedTitle) {
51
51
  return { status: "released", file: fileName, title: entry.title, lifted };
52
52
  });
53
53
  }
54
- /** Lift the Refused or held-for-operator note from the
54
+ /** Lift the Refused or Needs-review note from the
55
55
  * `n`th entry (1-based, as `--list` numbers them) of the kind's open section and write the
56
56
  * file, under the same lock the cancel and edit writers hold, returning which hold it lifted.
57
57
  * An out-of-range position fails with locateEntry's wording; an entry with neither note
@@ -62,7 +62,7 @@ export function releaseEntry(root, kind, n) {
62
62
  const { file, lines, entry } = readEntryLines(root, fileName, sectionTitle, n);
63
63
  const lifted = liftRefusal(lines, entry);
64
64
  if (lifted === null)
65
- fail(`${kind} #${n} "${entry.title}" carries no Refused or held-for-operator note`); // vocabulary-ok: the held-for-operator note's release wording
65
+ fail(`${kind} #${n} "${entry.title}" carries no Refused or Needs review note`);
66
66
  writeTextAtomic(file, lines.join("\n"));
67
67
  return { file: fileName, title: entry.title, lifted };
68
68
  });
@@ -215,6 +215,113 @@ export function doubleBlockFixedEntries(md) {
215
215
  }
216
216
  return found;
217
217
  }
218
+ /** The duplicate-heading rule (a): the first `## ` title `head` carries more of than `base` did,
219
+ * or undefined when none. A base that already carries a duplicate never blocks unrelated edits —
220
+ * this fires only when the head's count EXCEEDS the base's, so a change that removes a duplicate
221
+ * always passes. */
222
+ function duplicateSectionReason(file, headCounts, baseCounts) {
223
+ for (const [title, count] of headCounts)
224
+ if (count > 1 && count > (baseCounts.get(title) ?? 0))
225
+ return (`${file} adds another "## ${title}" heading — readers take the first section of a ` +
226
+ `name, so anything under the duplicate is invisible; keep one "## ${title}" per file`);
227
+ return undefined;
228
+ }
229
+ /** The dropped-known-section rule (b): the first `## ` title the base carried but `head` dropped,
230
+ * restricted to this file's KNOWN_SECTIONS, or undefined when none. A `## ` title that is not one
231
+ * of this file's known sections is not load-bearing: ADDING one is a legitimate project extension
232
+ * (this repo grew `## Verified`, a doc-only stack can grow `## Notes`), so a new one passes — but
233
+ * DROPPING one is the repair for a misplaced heading, so this rule guards only known sections and
234
+ * leaves an unknown one free to be demoted to `### ` under a known section (BUGS.md 2026-10-23). */
235
+ function droppedSectionReason(file, headCounts, baseCounts) {
236
+ // A `## ` title that is not one of this file's known sections (KNOWN_SECTIONS) is not
237
+ // load-bearing: ADDING one is a legitimate project extension (this repo grew `## Verified`,
238
+ // a doc-only stack can grow `## Notes`), so a new one passes — but DROPPING one is the repair
239
+ // for a misplaced heading, so rule (b) guards only known sections and leaves an unknown one
240
+ // free to be demoted to `### ` under a known section (BUGS.md 2026-10-23).
241
+ const known = KNOWN_SECTIONS[file] ?? [];
242
+ for (const title of baseCounts.keys())
243
+ if (!headCounts.has(title) && known.includes(title))
244
+ return (`${file} drops the "## ${title}" section the base had — entries filed under it would ` +
245
+ `be invisible to every section reader; restore "## ${title}"`);
246
+ return undefined;
247
+ }
248
+ /** The re-added-archive-heading rule: for PLANS.md's and BUGS.md's archives only, rejects a head
249
+ * that adds a copy of a `### ` heading the base archive already carries (readdedHeadings) —
250
+ * finished history pasted back, never a new finished entry — or undefined for every other file.
251
+ * An archive only grows by new headings: a copy of one the base archive already carries is
252
+ * finished history pasted back (readdedHeadings), never a new finished entry.
253
+ */
254
+ function readdedHeadingReason(file, base, head) {
255
+ if (file === PLANS_ARCHIVE || file === BUGS_ARCHIVE) {
256
+ const readded = readdedHeadings(base, head);
257
+ if (readded.length > 0)
258
+ return (`${file} adds ${plural(readded.length, '"### " heading', '"### " headings')} it already carries ` +
259
+ `(first: "${truncate(readded[0], 160)}") — the archive holds each finished entry once, so the ` +
260
+ `copies duplicate its history; remove them, and edit an archived entry in place instead`);
261
+ }
262
+ return undefined;
263
+ }
264
+ /** The misplaced-plan rule (part 4/4), for PLANS.md and its archive only, else undefined: rejects
265
+ * a head that ADDS a `### ` entry directly under `## Done` whose joined heading metadata carries
266
+ * `(planned YYYY-MM-DD` but no `done YYYY-MM-DD`, and whose key the base's PLANS.md and archive
267
+ * never carried in ANY section — a plan filed into the wrong section. strandedPlanEntries already
268
+ * does the fence-aware, joined-metadata reading the first two conditions need; the base's key set
269
+ * — over both files, since tumwater moves a finished plan from one to the other
270
+ * (plans/backlog-archive.md) — supplies the third. Judged against the merge-base (the same base
271
+ * the heading rules use), so a Planned → Done move of an entry the base already had passes and a
272
+ * pre-existing stranded entry (whose key the base has) never blocks unrelated merges. */
273
+ async function misplacedPlanReason(file, wt, baseRev, base, head) {
274
+ // Part 4/4's rule, PLANS.md and its archive (see this function's doc comment): a NEW plan
275
+ // filed directly under ## Done with no done date. strandedPlanEntries already does the
276
+ // fence-aware, joined-metadata reading the first two conditions need; the base's key set —
277
+ // over both files, since tumwater moves a finished plan from one to the other
278
+ // (plans/backlog-archive.md) — supplies the third.
279
+ if (file === "PLANS.md" || file === PLANS_ARCHIVE) {
280
+ const baseKeys = new Set([
281
+ ...planHeadingKeys(file === "PLANS.md" ? base : await fileContentAt(wt, baseRev, "PLANS.md")),
282
+ ...planHeadingKeys(file === PLANS_ARCHIVE ? base : await fileContentAt(wt, baseRev, PLANS_ARCHIVE)),
283
+ ]);
284
+ const newcomer = strandedPlanEntries(head).find((e) => e.section === "Done" && !baseKeys.has(planHeadingKey(e.title)));
285
+ if (newcomer)
286
+ // The fault and its fix differ by file: in PLANS.md a NEW plan (the base never carried
287
+ // it in any section) was pasted under ## Done unfinished and belongs under ## Planned;
288
+ // in the archive the same heading is a finished plan the commit-time sweep already moved
289
+ // here, whose only fault is a missing `done YYYY-MM-DD` token — telling its author to
290
+ // "file it under ## Planned" is the opposite of the fix and wastes a revision.
291
+ return file === PLANS_ARCHIVE
292
+ ? `${file} files "${newcomer.title}" directly under "## Done" with no done date — the ` +
293
+ `entry is finished and archived, so the only fix is to add \`done YYYY-MM-DD\` to its ` +
294
+ `"### " heading; do not move it back under "## Planned"`
295
+ : `${file} files "${newcomer.title}" directly under "## Done" with no done date — a plan ` +
296
+ `written into the done section is invisible to every Planned reader; file it under ` +
297
+ `"## Planned" instead`;
298
+ }
299
+ return undefined;
300
+ }
301
+ /** The hidden-fixed-record rule, for BUGS.md and its archive only, else undefined: rejects a head
302
+ * whose multi-block `## Fixed` entries (doubleBlockFixedEntries) the base did not carry — a new
303
+ * headingless record merged into a neighbor's body — or undefined when none. A new headingless
304
+ * record cannot hide in a Fixed entry's body: reject a head whose multi-block entries the base did
305
+ * not carry (an existing one never blocks unrelated edits, matching the heading-set rules above).
306
+ * The Fix paragraph is the guard's unit, so a merged second block would otherwise sit inside the
307
+ * neighbor it was symbol-checked against. BUGS-FIXED.md holds the same entries once archived.
308
+ */
309
+ function hiddenFixedRecordReason(file, base, head) {
310
+ // A new headingless record cannot hide in a Fixed entry's body: reject a head whose
311
+ // multi-block entries the base did not carry (an existing one never blocks unrelated
312
+ // edits, matching the heading-set rules above). The Fix paragraph is the guard's unit, so
313
+ // a merged second block would otherwise sit inside the neighbor it was symbol-checked
314
+ // against. BUGS-FIXED.md holds the same entries once archived.
315
+ if (file === "BUGS.md" || file === BUGS_ARCHIVE) {
316
+ const baseTitles = new Set(doubleBlockFixedEntries(base));
317
+ const newcomer = doubleBlockFixedEntries(head).find((title) => !baseTitles.has(title));
318
+ if (newcomer)
319
+ return (`${file}'s Fixed entry "${newcomer}" holds more than one **Symptom:**/**Fix:** block — ` +
320
+ `a record with no "### " heading has merged into it, so the hidden record is invisible ` +
321
+ `to every section reader; give it its own "### " heading`);
322
+ }
323
+ return undefined;
324
+ }
218
325
  /** The first structural fault a diff merging into `mainBranch` leaves in a backlog file, or
219
326
  * undefined when none: for each touched backlog file it compares the `## ` heading set of the tree
220
327
  * being merged (`wt`) against the diff's merge-base — the same base falseFixReason measures
@@ -268,67 +375,13 @@ export async function backlogStructureReason(wt, mainBranch, files) {
268
375
  const base = await fileContentAt(wt, baseRev, file);
269
376
  const headCounts = sectionTitleCounts(head);
270
377
  const baseCounts = sectionTitleCounts(base);
271
- for (const [title, count] of headCounts)
272
- if (count > 1 && count > (baseCounts.get(title) ?? 0))
273
- return (`${file} adds another "## ${title}" heading — readers take the first section of a ` +
274
- `name, so anything under the duplicate is invisible; keep one "## ${title}" per file`);
275
- // A `## ` title that is not one of this file's known sections (KNOWN_SECTIONS) is not
276
- // load-bearing: ADDING one is a legitimate project extension (this repo grew `## Verified`,
277
- // a doc-only stack can grow `## Notes`), so a new one passes — but DROPPING one is the repair
278
- // for a misplaced heading, so rule (b) guards only known sections and leaves an unknown one
279
- // free to be demoted to `### ` under a known section (BUGS.md 2026-10-23).
280
- const known = KNOWN_SECTIONS[file] ?? [];
281
- for (const title of baseCounts.keys())
282
- if (!headCounts.has(title) && known.includes(title))
283
- return (`${file} drops the "## ${title}" section the base had — entries filed under it would ` +
284
- `be invisible to every section reader; restore "## ${title}"`);
285
- // An archive only grows by new headings: a copy of one the base archive already carries is
286
- // finished history pasted back (readdedHeadings), never a new finished entry.
287
- if (file === PLANS_ARCHIVE || file === BUGS_ARCHIVE) {
288
- const readded = readdedHeadings(base, head);
289
- if (readded.length > 0)
290
- return (`${file} adds ${plural(readded.length, '"### " heading', '"### " headings')} it already carries ` +
291
- `(first: "${truncate(readded[0], 160)}") — the archive holds each finished entry once, so the ` +
292
- `copies duplicate its history; remove them, and edit an archived entry in place instead`);
293
- }
294
- // Part 4/4's rule, PLANS.md and its archive (see this function's doc comment): a NEW plan
295
- // filed directly under ## Done with no done date. strandedPlanEntries already does the
296
- // fence-aware, joined-metadata reading the first two conditions need; the base's key set —
297
- // over both files, since tumwater moves a finished plan from one to the other
298
- // (plans/backlog-archive.md) — supplies the third.
299
- if (file === "PLANS.md" || file === PLANS_ARCHIVE) {
300
- const baseKeys = new Set([
301
- ...planHeadingKeys(file === "PLANS.md" ? base : await fileContentAt(wt, baseRev, "PLANS.md")),
302
- ...planHeadingKeys(file === PLANS_ARCHIVE ? base : await fileContentAt(wt, baseRev, PLANS_ARCHIVE)),
303
- ]);
304
- const newcomer = strandedPlanEntries(head).find((e) => e.section === "Done" && !baseKeys.has(planHeadingKey(e.title)));
305
- if (newcomer)
306
- // The fault and its fix differ by file: in PLANS.md a NEW plan (the base never carried
307
- // it in any section) was pasted under ## Done unfinished and belongs under ## Planned;
308
- // in the archive the same heading is a finished plan the commit-time sweep already moved
309
- // here, whose only fault is a missing `done YYYY-MM-DD` token — telling its author to
310
- // "file it under ## Planned" is the opposite of the fix and wastes a revision.
311
- return file === PLANS_ARCHIVE
312
- ? `${file} files "${newcomer.title}" directly under "## Done" with no done date — the ` +
313
- `entry is finished and archived, so the only fix is to add \`done YYYY-MM-DD\` to its ` +
314
- `"### " heading; do not move it back under "## Planned"`
315
- : `${file} files "${newcomer.title}" directly under "## Done" with no done date — a plan ` +
316
- `written into the done section is invisible to every Planned reader; file it under ` +
317
- `"## Planned" instead`;
318
- }
319
- // A new headingless record cannot hide in a Fixed entry's body: reject a head whose
320
- // multi-block entries the base did not carry (an existing one never blocks unrelated
321
- // edits, matching the heading-set rules above). The Fix paragraph is the guard's unit, so
322
- // a merged second block would otherwise sit inside the neighbor it was symbol-checked
323
- // against. BUGS-FIXED.md holds the same entries once archived.
324
- if (file === "BUGS.md" || file === BUGS_ARCHIVE) {
325
- const baseTitles = new Set(doubleBlockFixedEntries(base));
326
- const newcomer = doubleBlockFixedEntries(head).find((title) => !baseTitles.has(title));
327
- if (newcomer)
328
- return (`${file}'s Fixed entry "${newcomer}" holds more than one **Symptom:**/**Fix:** block — ` +
329
- `a record with no "### " heading has merged into it, so the hidden record is invisible ` +
330
- `to every section reader; give it its own "### " heading`);
331
- }
378
+ const reason = duplicateSectionReason(file, headCounts, baseCounts) ??
379
+ droppedSectionReason(file, headCounts, baseCounts) ??
380
+ readdedHeadingReason(file, base, head) ??
381
+ (await misplacedPlanReason(file, wt, baseRev, base, head)) ??
382
+ hiddenFixedRecordReason(file, base, head);
383
+ if (reason !== undefined)
384
+ return reason;
332
385
  }
333
386
  return undefined;
334
387
  }
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { cachedTextByStat } from "../files/stat-cache.js";
3
+ import { DAY_MS } from "../text/datetime.js";
3
4
  import { entryDates, entryLags, finishedArchiveOf, firstDate, lastDate, parseEntryDetails } from "./backlog-md.js";
4
5
  import { CHECK_DECLARATION_MARKER } from "../roles/role-guidance.js";
5
6
  /** The stat-cached file readers of the project backlog data shown on both dashboards: planned
@@ -81,7 +82,7 @@ export function sectionCompletionLags(root, fileName, sectionTitle, dateRe) {
81
82
  * because the stamp is the entry's own and the body may name other dates.
82
83
  * null when the heading carries no such clause. Routed through backlog-md.ts's firstDate -
83
84
  * the one first-match mechanism - with this module's own stamp regex.
84
- * questionFilingDate (question-answer.ts) is the same recipe for the `asked by` verb. */
85
+ * questionFilingDate (backlog-question-answer.ts) is the same recipe for the `asked by` verb. */
85
86
  const STAMP_DATE_RE = /(?:planned|reported|found by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/;
86
87
  export function filingDate(title) {
87
88
  return firstDate(title, STAMP_DATE_RE);
@@ -104,7 +105,7 @@ export function completionDate(title, dateRe) {
104
105
  /** Whole days between a filing stamp and `now`, floored at 0 so a future-dated entry reads as
105
106
  * fresh (0d) rather than negative. Shared by the suffix and the `--list --json` `ageDays` field. */
106
107
  export function ageInDays(since, now = Date.now()) {
107
- return Math.max(0, Math.floor((now - Date.parse(since)) / 86400000));
108
+ return Math.max(0, Math.floor((now - Date.parse(since)) / DAY_MS));
108
109
  }
109
110
  /** The ` · since YYYY-MM-DD (Nd)`/`(today)` tail a listing line gains for its entry's filing
110
111
  * stamp — the one place the wording and the today/`Nd` choice live, so the plans/bugs list