tumwater 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (359) hide show
  1. package/README.md +63 -28
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-eligibility.js +332 -43
  26. package/dist/src/backlog/backlog-md.js +79 -17
  27. package/dist/src/backlog/backlog-render.js +62 -11
  28. package/dist/src/backlog/backlog-structure.js +261 -58
  29. package/dist/src/backlog/backlog-write.js +178 -441
  30. package/dist/src/backlog/backlog.js +113 -16
  31. package/dist/src/backlog/question-answer.js +339 -0
  32. package/dist/src/baseline/main-baseline.js +75 -88
  33. package/dist/src/baseline/main-red.js +32 -30
  34. package/dist/src/{brief-edit.js → brief/brief-edit.js} +12 -11
  35. package/dist/src/{brief.js → brief/brief.js} +33 -8
  36. package/dist/src/budget/budget.js +32 -15
  37. package/dist/src/budget/fallback-breaker.js +8 -7
  38. package/dist/src/build/build-check-detect.js +8 -7
  39. package/dist/src/build/build-check-events.js +68 -22
  40. package/dist/src/build/build-check-report.js +22 -23
  41. package/dist/src/build/build-check-scoped.js +51 -62
  42. package/dist/src/build/build-check.js +15 -14
  43. package/dist/src/build/build-info.js +2 -1
  44. package/dist/src/build/build-stage.js +23 -22
  45. package/dist/src/build/dep-install.js +22 -21
  46. package/dist/src/build/host-sleep.js +1 -1
  47. package/dist/src/change/change-data.js +13 -11
  48. package/dist/src/change/change-render.js +11 -10
  49. package/dist/src/cli/brief-command.js +9 -7
  50. package/dist/src/cli/cli-args.js +25 -5
  51. package/dist/src/cli/cli-command-args.js +43 -10
  52. package/dist/src/cli/cli-editor.js +13 -1
  53. package/dist/src/cli/cli-flag-specs.js +31 -6
  54. package/dist/src/cli/cli-marker-commands.js +13 -10
  55. package/dist/src/cli/cli-query-commands.js +439 -56
  56. package/dist/src/cli/cli-repo-gate.js +1 -1
  57. package/dist/src/cli/cli-run.js +124 -68
  58. package/dist/src/cli/completion.js +70 -12
  59. package/dist/src/cli/config-commands.js +14 -11
  60. package/dist/src/cli/help.js +264 -124
  61. package/dist/src/cli/log-commands.js +127 -21
  62. package/dist/src/cli/notify-command.js +33 -18
  63. package/dist/src/cli/question-commands.js +13 -224
  64. package/dist/src/cli.js +78 -36
  65. package/dist/src/collections.js +66 -16
  66. package/dist/src/concurrency/check-permit.js +16 -17
  67. package/dist/src/concurrency/lock.js +6 -5
  68. package/dist/src/concurrency/provider-lease.js +69 -0
  69. package/dist/src/concurrency/semaphore.js +258 -36
  70. package/dist/src/config/config-aliases.js +106 -0
  71. package/dist/src/config/config-editable-keys.js +3 -3
  72. package/dist/src/config/config-fallbacks.js +379 -0
  73. package/dist/src/config/config-field-checks.js +19 -10
  74. package/dist/src/config/config-keys.js +160 -0
  75. package/dist/src/config/config-live.js +64 -12
  76. package/dist/src/config/config-schema.js +34 -22
  77. package/dist/src/config/config-validation.js +132 -48
  78. package/dist/src/config/config-views.js +107 -325
  79. package/dist/src/config/config-write.js +138 -177
  80. package/dist/src/config/config.js +60 -49
  81. package/dist/src/config/model-selector.js +28 -2
  82. package/dist/src/doctor/doctor-backlog.js +494 -68
  83. package/dist/src/doctor/doctor-checks.js +509 -58
  84. package/dist/src/doctor/doctor-holds.js +139 -78
  85. package/dist/src/doctor/doctor-install.js +30 -0
  86. package/dist/src/doctor/doctor-launch-services.js +2 -2
  87. package/dist/src/doctor/doctor-model-checks.js +549 -157
  88. package/dist/src/doctor/doctor-notify.js +2 -3
  89. package/dist/src/doctor/doctor-orphans.js +10 -9
  90. package/dist/src/doctor/doctor-render.js +36 -9
  91. package/dist/src/doctor/doctor.js +76 -25
  92. package/dist/src/doctor/image-probe.js +244 -0
  93. package/dist/src/doctor/probe.js +64 -0
  94. package/dist/src/events/event-format.js +219 -106
  95. package/dist/src/events/event-labels.js +116 -0
  96. package/dist/src/events/event-legacy.js +140 -0
  97. package/dist/src/events/event-read.js +58 -22
  98. package/dist/src/events/event-window.js +27 -7
  99. package/dist/src/events/events.js +18 -14
  100. package/dist/src/events/notify.js +80 -30
  101. package/dist/src/failure/failure-cluster.js +12 -9
  102. package/dist/src/failure/failure-data.js +96 -48
  103. package/dist/src/failure/failure-render.js +69 -58
  104. package/dist/src/failure/failure-state-change.js +33 -26
  105. package/dist/src/failure/time-spend.js +79 -59
  106. package/dist/src/files/file-queue.js +16 -4
  107. package/dist/src/files/files.js +18 -6
  108. package/dist/src/files/json-object.js +18 -3
  109. package/dist/src/files/tail.js +1 -1
  110. package/dist/src/gates/bootstrap-gates.js +5 -4
  111. package/dist/src/gates/budget-gates.js +250 -34
  112. package/dist/src/gates/disk-gate.js +18 -18
  113. package/dist/src/gates/gate-polls.js +117 -69
  114. package/dist/src/gates/maintenance-quota.js +29 -29
  115. package/dist/src/gates/pause-gates.js +11 -11
  116. package/dist/src/gates/readiness.js +2 -1
  117. package/dist/src/gates/role-cap-gates.js +16 -21
  118. package/dist/src/gates/startup-gate.js +75 -31
  119. package/dist/src/gates/streak-gate.js +18 -16
  120. package/dist/src/git/commit-message.js +6 -5
  121. package/dist/src/git/git-diff.js +16 -16
  122. package/dist/src/git/git-run.js +8 -8
  123. package/dist/src/git/git.js +79 -59
  124. package/dist/src/git/slots-state.js +14 -5
  125. package/dist/src/git/worktree-pool.js +25 -18
  126. package/dist/src/git/worktree-use.js +15 -14
  127. package/dist/src/git/worktree.js +18 -18
  128. package/dist/src/gui/gui-args.js +10 -14
  129. package/dist/src/gui/gui-backlog-commands.js +273 -0
  130. package/dist/src/gui/gui-command.js +1 -1
  131. package/dist/src/gui/gui-config-commands.js +440 -0
  132. package/dist/src/gui/gui-endpoint-commands.js +16 -716
  133. package/dist/src/gui/gui-endpoint-common.js +49 -0
  134. package/dist/src/gui/gui-endpoints.js +105 -54
  135. package/dist/src/gui/gui-operator-commands.js +145 -0
  136. package/dist/src/gui/gui-prompt-commands.js +234 -0
  137. package/dist/src/gui/gui-server.js +25 -8
  138. package/dist/src/gui/gui-team-commands.js +164 -0
  139. package/dist/src/gui/http-body.js +17 -1
  140. package/dist/src/history/history-data.js +179 -104
  141. package/dist/src/history/history-render.js +76 -0
  142. package/dist/src/history/history.js +29 -70
  143. package/dist/src/inbox/inbox-attachments.js +66 -10
  144. package/dist/src/inbox/inbox-cancel.js +7 -5
  145. package/dist/src/inbox/inbox-edit.js +1 -1
  146. package/dist/src/inbox/inbox-submit.js +44 -4
  147. package/dist/src/inbox/inbox-top.js +83 -0
  148. package/dist/src/inbox/inbox.js +8 -2
  149. package/dist/src/inbox/pending-prompt.js +1 -1
  150. package/dist/src/inbox/prompt-commands.js +114 -73
  151. package/dist/src/init/init-templates.js +16 -14
  152. package/dist/src/init/init.js +12 -11
  153. package/dist/src/loop/leftover.js +28 -27
  154. package/dist/src/loop/loop-fallback.js +2 -2
  155. package/dist/src/loop/loop-pi.js +104 -50
  156. package/dist/src/loop/loop-recovery.js +15 -15
  157. package/dist/src/loop/loop-state.js +130 -13
  158. package/dist/src/loop/loop-tick.js +190 -88
  159. package/dist/src/loop/loop.js +68 -36
  160. package/dist/src/loop/model-fallback.js +3 -2
  161. package/dist/src/loop/revision.js +16 -7
  162. package/dist/src/merge/backlog-conflicts.js +155 -0
  163. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  164. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  165. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  166. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  167. package/dist/src/merge/merge-drain.js +212 -0
  168. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -20
  169. package/dist/src/merge/merge-pipeline.js +99 -0
  170. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  171. package/dist/src/merge/merge-queue-render.js +75 -0
  172. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  173. package/dist/src/merge/merge-slot.js +340 -0
  174. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  175. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  176. package/dist/src/merge/merge-verify.js +212 -0
  177. package/dist/src/notes-cli.js +97 -0
  178. package/dist/src/operator/operator-commands.js +113 -45
  179. package/dist/src/operator/operator-intent.js +74 -53
  180. package/dist/src/operator/operator-requests.js +52 -52
  181. package/dist/src/operator/retire.js +47 -28
  182. package/dist/src/operator/revert.js +71 -0
  183. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  184. package/dist/src/orchestrator/orchestrator-scheduling.js +74 -43
  185. package/dist/src/orchestrator/orchestrator.js +149 -96
  186. package/dist/src/orchestrator/retention.js +29 -12
  187. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  188. package/dist/src/orchestrator/startup-migrations.js +40 -0
  189. package/dist/src/paths.js +124 -38
  190. package/dist/src/pi/pi-args.js +26 -3
  191. package/dist/src/pi/pi-backend.js +15 -23
  192. package/dist/src/pi/pi-bin.js +18 -12
  193. package/dist/src/pi/pi-event-line.js +17 -1
  194. package/dist/src/pi/pi-failure.js +87 -0
  195. package/dist/src/pi/pi-models.js +126 -38
  196. package/dist/src/pi/pi-run-result.js +1 -1
  197. package/dist/src/pi/pi-settings.js +86 -0
  198. package/dist/src/pi/pi-stream.js +131 -107
  199. package/dist/src/pi/pi-watchdogs.js +4 -3
  200. package/dist/src/pi-extension/bounded-output.js +20 -5
  201. package/dist/src/pi-extension/context-budget.js +3 -1
  202. package/dist/src/pi-extension/context-shake.js +54 -21
  203. package/dist/src/pi-extension/context-window.js +37 -0
  204. package/dist/src/pi-extension/full-output.js +2 -2
  205. package/dist/src/pi-extension/strip-thinking.js +80 -0
  206. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  207. package/dist/src/process/launch-services.js +11 -10
  208. package/dist/src/process/process-table.js +1 -1
  209. package/dist/src/process/process.js +7 -7
  210. package/dist/src/process/run-marker.js +29 -6
  211. package/dist/src/process/supervisor.js +36 -25
  212. package/dist/src/prompt/dialect.js +30 -10
  213. package/dist/src/prompt/gate-prompts.js +170 -67
  214. package/dist/src/prompt/principles.js +1 -0
  215. package/dist/src/prompt/prompt-followup.js +29 -20
  216. package/dist/src/prompt/prompt.js +249 -91
  217. package/dist/src/redeploy/redeploy-policy.js +10 -10
  218. package/dist/src/redeploy/redeploy.js +6 -4
  219. package/dist/src/redeploy/redeployer.js +63 -55
  220. package/dist/src/redeploy/self-reload.js +1 -1
  221. package/dist/src/report/report-data.js +259 -254
  222. package/dist/src/report/report-fold.js +353 -0
  223. package/dist/src/report/report-render.js +235 -44
  224. package/dist/src/request-timeouts.js +1 -1
  225. package/dist/src/review/known-flakes.js +34 -14
  226. package/dist/src/review/protected-edits.js +4 -3
  227. package/dist/src/review/review-followup.js +16 -4
  228. package/dist/src/review/review-precheck.js +14 -13
  229. package/dist/src/review/review-verdict.js +14 -0
  230. package/dist/src/review/review.js +55 -34
  231. package/dist/src/review/secret-leaks.js +127 -0
  232. package/dist/src/review/suite-rerun.js +4 -3
  233. package/dist/src/roles/legacy-role-ids.js +21 -0
  234. package/dist/src/roles/legacy-role-migration.js +62 -0
  235. package/dist/src/roles/loop-ids.js +19 -2
  236. package/dist/src/roles/role-catalog.js +109 -131
  237. package/dist/src/roles/role-guidance.js +64 -31
  238. package/dist/src/roles/role-labels.js +10 -0
  239. package/dist/src/roles/role-render.js +194 -18
  240. package/dist/src/roles/role-view.js +216 -17
  241. package/dist/src/roles/roles.js +16 -14
  242. package/dist/src/scheduling/backoff.js +29 -19
  243. package/dist/src/scheduling/claims.js +15 -8
  244. package/dist/src/scheduling/once-round.js +11 -11
  245. package/dist/src/scheduling/quiet-hours.js +25 -32
  246. package/dist/src/scheduling/scheduling.js +28 -25
  247. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  248. package/dist/src/status/status-budget.js +115 -0
  249. package/dist/src/status/status-data.js +72 -184
  250. package/dist/src/status/status-inbox.js +47 -0
  251. package/dist/src/status/status-install.js +27 -0
  252. package/dist/src/status/status-polls.js +50 -39
  253. package/dist/src/status/status-roles.js +105 -0
  254. package/dist/src/{fleet → team}/error-storm.js +10 -10
  255. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  256. package/dist/src/{fleet → team}/orchestrator-info.js +84 -18
  257. package/dist/src/{fleet → team}/reclaim.js +5 -4
  258. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  259. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  260. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +39 -38
  261. package/dist/src/text/datetime.js +42 -2
  262. package/dist/src/text/format.js +10 -1
  263. package/dist/src/text/markdown.js +1 -1
  264. package/dist/src/text/phrases.js +35 -34
  265. package/dist/src/text/suggest.js +3 -2
  266. package/dist/src/text/text.js +2 -2
  267. package/dist/src/tick/qa-coverage.js +2 -1
  268. package/dist/src/tick/stage-check.js +142 -25
  269. package/dist/src/tick/telemetry-digest.js +10 -9
  270. package/dist/src/tick/tick-apply.js +101 -72
  271. package/dist/src/tick/tick-detail-data.js +23 -22
  272. package/dist/src/tick/tick-detail.js +37 -24
  273. package/dist/src/tick/tick-finalize.js +29 -22
  274. package/dist/src/tick/tick-outcome.js +2 -1
  275. package/dist/src/tick/tick-prompt.js +94 -26
  276. package/dist/src/tick/tick-resume.js +29 -13
  277. package/dist/src/tick/tick-stage.js +67 -53
  278. package/dist/src/tick/tick-timing.js +60 -59
  279. package/dist/src/tick/tick-usage.js +53 -23
  280. package/dist/src/tick/tick-verdict.js +75 -10
  281. package/dist/src/ui/badges.js +113 -69
  282. package/dist/src/ui/gui/gui-client-boot.js +202 -29
  283. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  284. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  285. package/dist/src/ui/gui/gui-client-history.js +94 -37
  286. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  287. package/dist/src/ui/gui/gui-client-loops.js +90 -47
  288. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  289. package/dist/src/ui/gui/gui-client-model.js +60 -43
  290. package/dist/src/ui/gui/gui-client-operator.js +73 -43
  291. package/dist/src/ui/gui/gui-client-pending.js +34 -31
  292. package/dist/src/ui/gui/gui-client-report.js +330 -47
  293. package/dist/src/ui/gui/gui-client-settings.js +126 -18
  294. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  295. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +190 -74
  296. package/dist/src/ui/gui/gui-client.js +26 -20
  297. package/dist/src/ui/gui/gui-page.js +18 -19
  298. package/dist/src/ui/gui/gui-styles.js +12 -4
  299. package/dist/src/ui/progress-data.js +5 -3
  300. package/dist/src/ui/status-model.js +176 -102
  301. package/dist/src/ui/status-payload.js +114 -36
  302. package/dist/src/ui/status-render.js +121 -52
  303. package/dist/src/ui/team-alerts.js +341 -0
  304. package/dist/src/ui/tick-progress-model.js +14 -14
  305. package/dist/src/ui/tone.js +9 -8
  306. package/dist/src/ui/transcript-tail.js +1 -0
  307. package/dist/src/ui/transcript.js +23 -16
  308. package/dist/src/ui/tui/tui-app.js +1 -0
  309. package/dist/src/ui/tui/tui-backlog.js +85 -11
  310. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  311. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  312. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  313. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  314. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  315. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  316. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  317. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  318. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  319. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  320. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  321. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  322. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  323. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  324. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  325. package/dist/src/ui/tui/tui-editors.js +152 -0
  326. package/dist/src/ui/tui/tui-frame.js +61 -57
  327. package/dist/src/ui/tui/tui-history.js +12 -7
  328. package/dist/src/ui/tui/tui-input.js +44 -50
  329. package/dist/src/ui/tui/tui-keymap.js +1 -0
  330. package/dist/src/ui/tui/tui-keys.js +87 -1821
  331. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  332. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  333. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  334. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  335. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  336. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  337. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  338. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  339. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  340. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  341. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  342. package/dist/src/ui/tui/tui-pane.js +52 -28
  343. package/dist/src/ui/tui/tui-panes.js +294 -0
  344. package/dist/src/ui/tui/tui.js +44 -26
  345. package/dist/src/verdict/fix-claim.js +56 -33
  346. package/dist/src/verdict/no-change.js +19 -0
  347. package/dist/src/verdict/refusal.js +43 -11
  348. package/dist/src/verdict/reply-contract.js +17 -5
  349. package/dist/src/version.js +3 -2
  350. package/package.json +4 -2
  351. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  352. package/dist/src/landing/backlog-conflicts.js +0 -116
  353. package/dist/src/landing/landing-drain.js +0 -204
  354. package/dist/src/landing/landing-pipeline.js +0 -96
  355. package/dist/src/landing/landing-queue-render.js +0 -56
  356. package/dist/src/landing/landing-slot.js +0 -256
  357. package/dist/src/landing/landing-vetting.js +0 -196
  358. package/dist/src/pi-extension/role-notes.js +0 -40
  359. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -1,16 +1,23 @@
1
+ // vocabulary: plain
1
2
  /** Backlog eligibility: which PLANS.md / BUGS.md entries a work loop may take now (plans,
2
3
  * parallel-work-instances, part 2/7). An entry is held when its body carries a **Refused …**,
3
- * **Needs review …** or **Needs replan …** note, or when its heading's trailing parenthetical
4
- * (or a parenthetical left open by a wrapped heading) names a prerequisite (`requires parts 1/5–3/5 landed`) that is itself still listed under
5
- * `## Planned`. The parsers are pure over markdown text; `eligibleEntries` is the stat-backed
6
- * reader for a caller that wants the list. The backlog index marks every held entry so a loop
4
+ * **Needs review …**, **Needs replan …** or **Waiting for the next failure …** note, or when
5
+ * its heading's trailing parenthetical
6
+ * (or a parenthetical left open by a wrapped heading) names a prerequisite (`requires parts
7
+ * 1/5–3/5 landed`) that is itself still listed under `## Planned`. The parsers are pure over
8
+ * markdown text; `eligibleEntries` is the stat-backed reader for a caller that wants the list.
9
+ * The backlog index marks every held entry so a loop
7
10
  * skips it without re-deriving the rule. */
11
+ import path from "node:path";
12
+ import { statOrNull } from "../files/files.js";
13
+ import { allLiveClaims } from "../loop/loop-state.js";
8
14
  import { collapseWhitespace } from "../text/text.js";
9
- import { NEEDS_REPLAN_PREFIX, NEEDS_REVIEW_PREFIX } from "../roles/role-guidance.js";
15
+ import { NEEDS_REPLAN_PREFIX, NEEDS_REVIEW_PREFIX, WAITING_FOR_FAILURE_PREFIX } from "../roles/role-guidance.js";
10
16
  import { baseRoleOf } from "../roles/loop-ids.js";
17
+ import { roleLabel } from "../roles/role-labels.js";
11
18
  import { openParenthetical, trailingParenthetical } from "./backlog-md.js";
12
19
  import { actionableEntryRangesFor, stripEntryStamp } from "./backlog-structure.js";
13
- import { openBugEntries, plannedPlanEntries } from "./backlog.js";
20
+ import { finishedFiles, openBugEntries, plannedPlanEntries } from "./backlog.js";
14
21
  /** The `**Refused …` note prefix a refusing tick writes. Every hold note — this one and the
15
22
  * imported Needs-review and Needs-replan prefixes — is matched as a line prefix (hasNoteLine)
16
23
  * so a mention in prose never holds an entry. */
@@ -21,22 +28,29 @@ const REFUSED_PREFIX = "**Refused ";
21
28
  function hasNoteLine(body, prefix) {
22
29
  return body.split("\n").some((line) => line.trimStart().startsWith(prefix));
23
30
  }
24
- /** The text of `body`'s Refused note: the first line (ignoring leading indentation) that begins
25
- * with REFUSED_PREFIX, trimmed, with the leading `**` and the first closing `**` removed. Null
26
- * when no line matches — the same line-prefix rule entryHold's "refused" hold uses. */
27
- export function refusalNote(body) {
28
- const line = body.split("\n").map((l) => l.trim()).find((l) => l.startsWith(REFUSED_PREFIX));
31
+ /** The text of `body`'s note for `prefix`: the first line (ignoring leading indentation) that
32
+ * begins with it, trimmed, with the leading `**` and the first closing `**` removed. Null when
33
+ * no line matches — the same line-prefix rule entryHold uses. */
34
+ function noteText(body, prefix) {
35
+ const line = body.split("\n").map((l) => l.trim()).find((l) => l.startsWith(prefix));
29
36
  return line === undefined ? null : line.replace(/^\*\*/, "").replace("**", "");
30
37
  }
31
- /** `lines` without any Refused note line (the same line-prefix rule hasNoteLine applies). When a
32
- * dropped note sat between two blank lines, the blank line after it goes too, so releasing
33
- * leaves one blank line where the note was. Every other line, Needs-review and needs-replan
34
- * notes included, is kept. */
35
- export function withoutRefusalNotes(lines) {
38
+ /** The text of `body`'s Refused note, or null when it has none (entryHold's "refused" hold). */
39
+ export function refusalNote(body) {
40
+ return noteText(body, REFUSED_PREFIX);
41
+ }
42
+ /** The text of `body`'s Waiting-for-the-next-failure note, or null when it has none. */
43
+ export function waitingNote(body) {
44
+ return noteText(body, WAITING_FOR_FAILURE_PREFIX);
45
+ }
46
+ /** `lines` without any line starting with `prefix` (the same line-prefix rule hasNoteLine
47
+ * applies). When a dropped note sat between two blank lines, the blank line after it goes too,
48
+ * so releasing leaves one blank line where the note was. Every other line is kept. */
49
+ function withoutNoteLines(lines, prefix) {
36
50
  const out = [];
37
51
  for (let i = 0; i < lines.length; i++) {
38
52
  const line = lines[i];
39
- if (!line.trimStart().startsWith(REFUSED_PREFIX)) {
53
+ if (!line.trimStart().startsWith(prefix)) {
40
54
  out.push(line);
41
55
  continue;
42
56
  }
@@ -45,6 +59,16 @@ export function withoutRefusalNotes(lines) {
45
59
  }
46
60
  return out;
47
61
  }
62
+ /** `lines` without any Refused note line. Every other line, Needs-review, needs-replan and
63
+ * waiting notes included, is kept, so refusal lifting never lifts another hold. */
64
+ export function withoutRefusalNotes(lines) {
65
+ return withoutNoteLines(lines, REFUSED_PREFIX);
66
+ }
67
+ /** `lines` without any Waiting-for-the-next-failure note line, collapsing blank lines as
68
+ * withoutRefusalNotes does. */
69
+ export function withoutWaitingNotes(lines) {
70
+ return withoutNoteLines(lines, WAITING_FOR_FAILURE_PREFIX);
71
+ }
48
72
  /** One prerequisite ref, resolved against the entry's own series when it names none. */
49
73
  function parseRef(chunk, ownSeries) {
50
74
  const text = chunk.trim().replace(/^parts?\s+/i, "");
@@ -93,6 +117,53 @@ export function seriesPart(title) {
93
117
  function partKey(series, part) {
94
118
  return `${series.toLowerCase()}\u0000${part}`;
95
119
  }
120
+ /** The Done entries of the parts `title` requires, in the order its requires clause names them:
121
+ * for each requiredParts ref, the first `## Done` entry whose seriesPart has the same partKey,
122
+ * searching PLANS.md's live section and then PLANS-DONE.md (plans/backlog-archive.md), each
123
+ * carrying the file it was found in. Done is newest-first, so the first match is the most recent
124
+ * merge. A ref with no Done match (still planned, or renamed) adds nothing, so a title with no
125
+ * requires clause, or missing files, gives []. Lets the assignment prompt point at the exact
126
+ * range to read instead of leaving the implementer to search a history that runs to thousands of
127
+ * lines. */
128
+ export function mergedPrerequisiteRanges(root, title) {
129
+ const refs = requiredParts(title);
130
+ if (refs.length === 0)
131
+ return [];
132
+ const done = finishedFiles("PLANS.md").flatMap((file) => actionableEntryRangesFor(root, file, "Done").map((e) => ({ ...e, file })));
133
+ const out = [];
134
+ for (const ref of refs) {
135
+ const want = partKey(ref.series, ref.part);
136
+ const hit = done.find((e) => {
137
+ const part = seriesPart(e.title);
138
+ return part !== null && partKey(part.series, part.part) === want;
139
+ });
140
+ if (hit !== undefined) {
141
+ out.push({ key: entryKey(hit.title), title: hit.title, start: hit.start, end: hit.end, file: hit.file });
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ /** The still-planned parts that wait on the planned entry titled `title`, with their line ranges,
147
+ * in file order: plansWaitingOn's indexes (transitive, so a part waiting through a chain is
148
+ * listed) paired by index with the Planned ranges, as eligibleEntries pairs them. [] when no
149
+ * planned entry has that title, the entry has no series part, or PLANS.md is missing. Lets the
150
+ * assignment prompt name the work a series part must leave to the parts after it. */
151
+ export function waitingPartRanges(root, title) {
152
+ const planned = plannedPlanEntries(root);
153
+ const ranges = actionableEntryRangesFor(root, "PLANS.md", "Planned");
154
+ const want = entryKey(title);
155
+ const index = planned.findIndex((e) => entryKey(e.title) === want);
156
+ if (index < 0 || seriesPart(planned[index].title) === null)
157
+ return [];
158
+ const out = [];
159
+ for (const i of plansWaitingOn(planned, index)) {
160
+ const range = ranges[i];
161
+ if (range === undefined)
162
+ continue;
163
+ out.push({ key: entryKey(planned[i].title), title: planned[i].title, start: range.start, end: range.end });
164
+ }
165
+ return out;
166
+ }
96
167
  /** The prerequisite-set identities of the still-planned entries — seriesPart(title) of each,
97
168
  * keyed by partKey — built once for a batch and reused across every entry judged against it, so
98
169
  * judging n entries is n seriesPart parses rather than n² (entryHold rebuilt the set per entry).
@@ -107,31 +178,158 @@ export function plannedPartKeys(planned) {
107
178
  }
108
179
  return keys;
109
180
  }
181
+ /** The doctor's count of a refusal's stranded dependents: the ascending indexes into `planned` of
182
+ * the entries that require `planned[index]`'s own part, directly or through a chain of parts.
183
+ * Matching is by partKey, as entryHoldAgainst matches. [] when that entry has no series part. A
184
+ * dependency cycle ends the walk, and `index` itself is never listed. */
185
+ export function plansWaitingOn(planned, index) {
186
+ const root = planned[index];
187
+ const own = root === undefined ? null : seriesPart(root.title);
188
+ if (own === null)
189
+ return [];
190
+ const dependents = new Map();
191
+ planned.forEach((entry, i) => {
192
+ if (seriesPart(entry.title) === null)
193
+ return;
194
+ for (const ref of requiredParts(entry.title)) {
195
+ const key = partKey(ref.series, ref.part);
196
+ dependents.set(key, [...(dependents.get(key) ?? []), i]);
197
+ }
198
+ });
199
+ const seen = new Set([index]);
200
+ const queue = [partKey(own.series, own.part)];
201
+ for (let key = queue.shift(); key !== undefined; key = queue.shift()) {
202
+ for (const i of dependents.get(key) ?? []) {
203
+ if (seen.has(i))
204
+ continue;
205
+ seen.add(i);
206
+ const part = seriesPart(planned[i].title);
207
+ queue.push(partKey(part.series, part.part));
208
+ }
209
+ }
210
+ seen.delete(index);
211
+ return [...seen].sort((a, b) => a - b);
212
+ }
110
213
  /** The prerequisite parts a plan heading's trailing parenthetical (or, when the heading wraps
111
214
  * and the parenthetical stays open past the line, its open parenthetical) names through the clause
112
215
  * `requires <ref>((, | and )<ref>)* landed`, with each range (e.g. `parts 1/5–3/5`) expanded.
113
216
  * Returns [] when the heading has no such clause or the clause does not parse — an agent judges
114
217
  * an unparseable clause, exactly as it does today. Only the heading is read, never the body. */
115
218
  export function requiredParts(title) {
116
- let meta = trailingParenthetical(title);
117
- if (meta === "")
118
- meta = openParenthetical(title);
119
- if (meta === "")
120
- return [];
121
- const m = /requires\s+(.+?)\s+landed\b/i.exec(meta);
122
- if (m === null)
219
+ const clause = requiresClause(title);
220
+ if (clause === null)
123
221
  return [];
124
222
  const ownSeries = seriesPart(title)?.series ?? null;
125
223
  const refs = [];
126
- for (const chunk of m[1].split(/\s*,\s*|\s+and\s+/i))
224
+ for (const chunk of clause.split(/\s*,\s*|\s+and\s+/i))
127
225
  refs.push(...parseRef(chunk, ownSeries));
128
226
  return refs;
129
227
  }
228
+ /** The ref text a heading's `requires <refs> landed` clause captures — read from the trailing
229
+ * parenthetical, or the open one of a wrapped heading — or null when the heading has no such
230
+ * clause. requiredParts parses it; planDependencyProblems uses it to spot a clause that parses to
231
+ * nothing. */
232
+ function requiresClause(title) {
233
+ let meta = trailingParenthetical(title);
234
+ if (meta === "")
235
+ meta = openParenthetical(title);
236
+ if (meta === "")
237
+ return null;
238
+ const m = /requires\s+(.+?)\s+landed\b/i.exec(meta); // vocabulary-ok: parses the requires … landed heading syntax
239
+ return m === null ? null : m[1];
240
+ }
241
+ /** A dependency verb followed by a series part ref in a heading's parenthetical — the wording the
242
+ * claim allocator ignores (BUGS.md 2026-10-14: a part headed "blocked by part 1/2 above" was handed
243
+ * out six times while part 1/2 was in flight). planDependencyProblems reports each such ref. */
244
+ const MISWORDED_DEPENDENCY_RE = /\b(?:blocked by|blocked on|after|follows|depends on|waits on|waits for|once)\s+(?:the\s+)?(parts?\s+[0-9]+[a-z]?\/[0-9]+)/gi;
245
+ /** The mistakes in the planned entries' `requires … landed` clauses that the claim allocator
246
+ * cannot see: `dangling` — a ref naming a part no planned or done heading carries (its hold is
247
+ * void), `unparsed` — titles whose clause yields no refs, and `cycle` — the first dependency cycle
248
+ * among planned `Series, part i/n:` entries as `Series i/n` names in walk order, first name
249
+ * repeated last (a self-reference is a one-hop cycle), or null — and `misworded`: a series part
250
+ * whose heading parenthetical names a still-planned part through a dependency verb ("blocked by
251
+ * part 1/2") with no requires clause holding that part, so the allocator hands it out now.
252
+ * Refused and needs-review entries count like the rest, since their clauses still decide when
253
+ * they unblock. */
254
+ export function planDependencyProblems(planned, doneTitles) {
255
+ const known = plannedPartKeys(planned);
256
+ for (const t of doneTitles) {
257
+ const part = seriesPart(t);
258
+ if (part !== null)
259
+ known.add(partKey(part.series, part.part));
260
+ }
261
+ const dangling = [];
262
+ const unparsed = [];
263
+ const misworded = [];
264
+ const plannedKeys = plannedPartKeys(planned);
265
+ const nodes = new Map();
266
+ for (const entry of planned) {
267
+ const refs = requiredParts(entry.title);
268
+ if (refs.length === 0 && requiresClause(entry.title) !== null)
269
+ unparsed.push(entry.title);
270
+ for (const ref of refs) {
271
+ if (!known.has(partKey(ref.series, ref.part)))
272
+ dangling.push({ title: entry.title, ref: `${ref.series} ${ref.part}/${ref.of}` });
273
+ }
274
+ const own = seriesPart(entry.title);
275
+ if (own === null)
276
+ continue;
277
+ let meta = trailingParenthetical(entry.title);
278
+ if (meta === "")
279
+ meta = openParenthetical(entry.title);
280
+ const held = new Set(refs.map((ref) => partKey(ref.series, ref.part)));
281
+ for (const m of meta.matchAll(MISWORDED_DEPENDENCY_RE)) {
282
+ for (const ref of parseRef(m[1], own.series)) {
283
+ const key = partKey(ref.series, ref.part);
284
+ if (!plannedKeys.has(key) || held.has(key))
285
+ continue;
286
+ const name = `${ref.series} ${ref.part}/${ref.of}`;
287
+ if (!misworded.some((w) => w.title === entry.title && w.ref === name))
288
+ misworded.push({ title: entry.title, ref: name });
289
+ }
290
+ }
291
+ nodes.set(partKey(own.series, own.part), {
292
+ name: `${own.series} ${own.part}/${own.of}`,
293
+ deps: refs.map((ref) => partKey(ref.series, ref.part)),
294
+ });
295
+ }
296
+ const state = new Map();
297
+ const stack = [];
298
+ const visit = (key) => {
299
+ state.set(key, "open");
300
+ stack.push(key);
301
+ for (const dep of nodes.get(key).deps) {
302
+ if (!nodes.has(dep))
303
+ continue;
304
+ const seen = state.get(dep);
305
+ if (seen === "open") {
306
+ return [...stack.slice(stack.indexOf(dep)), dep].map((k) => nodes.get(k).name);
307
+ }
308
+ if (seen === undefined) {
309
+ const found = visit(dep);
310
+ if (found !== null)
311
+ return found;
312
+ }
313
+ }
314
+ stack.pop();
315
+ state.set(key, "done");
316
+ return null;
317
+ };
318
+ let cycle = null;
319
+ for (const key of nodes.keys()) {
320
+ if (state.has(key))
321
+ continue;
322
+ cycle = visit(key);
323
+ if (cycle !== null)
324
+ break;
325
+ }
326
+ return { cycle, dangling, unparsed, misworded };
327
+ }
130
328
  /** The prerequisite refs of `title`'s `requires … landed` clause when none of them is still in
131
329
  * `plannedParts` (built by plannedPartKeys), else []. A heading with no parseable clause is never
132
330
  * reported, so only a refusal whose named prerequisites have all left `## Planned` reads as
133
331
  * possibly stale. */
134
- export function prerequisitesLanded(title, plannedParts) {
332
+ export function prerequisitesMerged(title, plannedParts) {
135
333
  const refs = requiredParts(title);
136
334
  return refs.some((ref) => plannedParts.has(partKey(ref.series, ref.part))) ? [] : refs;
137
335
  }
@@ -154,6 +352,8 @@ export function entryHoldAgainst(entry, plannedParts) {
154
352
  return "needs-review";
155
353
  if (hasNoteLine(entry.body, NEEDS_REPLAN_PREFIX))
156
354
  return "needs-replan";
355
+ if (hasNoteLine(entry.body, WAITING_FOR_FAILURE_PREFIX))
356
+ return "waiting-for-failure";
157
357
  const refs = requiredParts(entry.title);
158
358
  if (refs.length === 0)
159
359
  return null;
@@ -170,34 +370,110 @@ export function holdMark(hold) {
170
370
  if (hold === null)
171
371
  return "";
172
372
  if (hold === "refused")
173
- return " [refused]";
373
+ return " [blocked: refused]";
174
374
  if (hold === "needs-review")
175
- return " [needs review]";
375
+ return " [blocked: needs review]";
176
376
  if (hold === "needs-replan")
177
- return " [needs replan]";
377
+ return " [blocked: needs replan]";
378
+ if (hold === "waiting-for-failure")
379
+ return " [blocked: waiting for the next failure]";
178
380
  return ` [blocked: requires ${hold.blockedBy.join(", ")}]`;
179
381
  }
180
- /** Why each entry the operator's `tumwater backlog` surfaces list is held, as two arrays
181
- * parallel to backlogPayload's `plans` and `bugs`: the same entryHold decision the prompt's
182
- * backlog index carries, so a hold is observable rather than inferred. The planned entries
183
- * are read once and reused as the prerequisite set. Questions carry no holds. */
184
- export function backlogHolds(root) {
185
- const planned = plannedPlanEntries(root);
382
+ /** `/api/status` polls the holds on the 1-second dashboard poll and the TUI recomputes them each
383
+ * frame while PLANS.md/BUGS.md change only when a loop merges an edit. The file reads beneath are
384
+ * already stat-cached (plannedPlanEntries/openBugEntries), but the per-entry eligibility walk
385
+ * (entryHoldAgainst's body scan + heading clause) is pure CPU re-derived on every call, growing
386
+ * with the append-only backlog. Cache the derived holds keyed on both files' stat identities — the
387
+ * same dev/ino/mtimeMs/size freshness check stat-cache.ts applies — so an unchanged backlog costs
388
+ * two stat syscalls per poll instead of the walk. Bounded (like sectionCache) so many short-lived
389
+ * roots in tests cannot grow it unbounded. */
390
+ const HOLDS_CACHE_MAX = 64;
391
+ const holdsCache = new Map();
392
+ function holdsStatKey(root) {
393
+ const statId = (file) => {
394
+ const st = statOrNull(file);
395
+ return st === null ? "-" : `${st.dev}:${st.ino}:${st.mtimeMs}:${st.size}`;
396
+ };
397
+ return `${statId(path.join(root, "PLANS.md"))}\u0000${statId(path.join(root, "BUGS.md"))}`;
398
+ }
399
+ const cloneHold = (hold) => hold !== null && typeof hold === "object" ? { blockedBy: [...hold.blockedBy] } : hold;
400
+ /** A copy of the holds so each caller owns its data, matching stat-cache's contract (mutating
401
+ * one result must not poison later polls). */
402
+ function cloneHolds(holds) {
403
+ return { plans: holds.plans.map(cloneHold), bugs: holds.bugs.map(cloneHold) };
404
+ }
405
+ /** The two reader calls computeBacklogHolds derives from, grouped in one object — the one
406
+ * deliberate mock seam in this module. Both readers are themselves stat-cached, so a repeat
407
+ * poll's only cost is this per-entry walk; exposing them on an object lets a test count whether
408
+ * a poll re-runs the walk at all, which is the holds cache's observable win (a hit serves the
409
+ * cached derives without calling these). */
410
+ export const holdsBacklogReaders = {
411
+ plannedPlanEntries,
412
+ openBugEntries,
413
+ };
414
+ /** The uncached derivation, shared by backlogHolds' miss path. */
415
+ function computeBacklogHolds(root) {
416
+ const planned = holdsBacklogReaders.plannedPlanEntries(root);
186
417
  const parts = plannedPartKeys(planned);
187
418
  return {
188
419
  plans: planned.map((entry) => entryHoldAgainst(entry, parts)),
189
- bugs: openBugEntries(root).map((entry) => entryHoldAgainst(entry, parts)),
420
+ bugs: holdsBacklogReaders.openBugEntries(root).map((entry) => entryHoldAgainst(entry, parts)),
190
421
  };
191
422
  }
192
- /** The eligibility mark (holdMark) for each planned plan and open bug, two arrays parallel
193
- * to the entries the observer surfaces list (backlogPayload's `plans`/`bugs`, the TUI's list
194
- * and entry modes): backlogHolds' verdicts projected through holdMark, so a surface derives
195
- * its marks from the one hold decision rather than re-walking the entries. holdMark renders
196
- * an eligible entry as the empty string, so a client can append the mark without a separate
197
- * eligibility check. Questions carry no holds. */
423
+ export function backlogHolds(root) {
424
+ const key = holdsStatKey(root);
425
+ const hit = holdsCache.get(key);
426
+ if (hit !== undefined)
427
+ return cloneHolds(hit);
428
+ const value = computeBacklogHolds(root);
429
+ if (holdsCache.size >= HOLDS_CACHE_MAX)
430
+ holdsCache.clear();
431
+ holdsCache.set(key, value);
432
+ return cloneHolds(value);
433
+ }
434
+ /** The mark for each planned plan and open bug, two arrays parallel to the entries the
435
+ * observer surfaces list (backlogPayload's `plans`/`bugs`, the TUI's list and entry modes):
436
+ * the eligibility hold (holdMark) then the working-on-it mark (inProgressMark), the order
437
+ * `tumwater backlog` prints. A surface derives its marks from the one hold decision and the
438
+ * one claim lookup rather than re-walking the entries. An eligible entry nobody is working on
439
+ * renders as the empty string, so a client can append the mark without a separate check.
440
+ * Questions carry no holds. backlogHolds alone still drives the allocator. */
198
441
  export function backlogMarks(root) {
199
442
  const holds = backlogHolds(root);
200
- return { plans: holds.plans.map(holdMark), bugs: holds.bugs.map(holdMark) };
443
+ const working = backlogInProgress(root);
444
+ const mark = (hold, runner) => holdMark(hold) + inProgressMark(runner ?? null);
445
+ return {
446
+ plans: holds.plans.map((h, i) => mark(h, working.plans[i])),
447
+ bugs: holds.bugs.map((h, i) => mark(h, working.bugs[i])),
448
+ };
449
+ }
450
+ /** The verbatim titles of the planned plans, then the open bugs, whose hold is `"refused"`, in
451
+ * file order: the entries that wait on an operator decision (the `refused` team alert's
452
+ * input). Pairs backlogHolds' arrays with the entries the same readers return, like
453
+ * backlogInProgress. A missing file yields `[]`. */
454
+ export function refusedEntryTitles(root) {
455
+ const holds = backlogHolds(root);
456
+ const titlesOf = (entries, held) => entries.filter((_, i) => held[i] === "refused").map((e) => e.title);
457
+ return [
458
+ ...titlesOf(plannedPlanEntries(root), holds.plans),
459
+ ...titlesOf(openBugEntries(root), holds.bugs),
460
+ ];
461
+ }
462
+ /** The runner id holding a live claim on each planned plan and open bug, two arrays parallel
463
+ * to backlogHolds' (same readers, same order); `null` when no role is working on the entry.
464
+ * Keyed by entryKey alone, exactly as the backlog index's claim lookup is. */
465
+ export function backlogInProgress(root) {
466
+ const claims = allLiveClaims(root);
467
+ const runnerOf = (entry) => claims.get(entryKey(entry.title)) ?? null;
468
+ return {
469
+ plans: plannedPlanEntries(root).map(runnerOf),
470
+ bugs: openBugEntries(root).map(runnerOf),
471
+ };
472
+ }
473
+ /** The heading mark for an entry a role is working on: empty when nobody is, else
474
+ * ` [working on it: feature #2]` (the runner id through roleLabel). */
475
+ export function inProgressMark(runner) {
476
+ return runner === null ? "" : ` [working on it: ${roleLabel(runner)}]`;
201
477
  }
202
478
  /** The entries a `role` loop may take now, in file order: PLANS.md's `## Planned` entries for
203
479
  * feature, BUGS.md's `## Open` entries for bugfix, minus every entry entryHold holds, each with
@@ -213,11 +489,24 @@ export function eligibleEntries(root, role) {
213
489
  const ranges = actionableEntryRangesFor(root, file, section);
214
490
  const planned = plannedPlanEntries(root);
215
491
  const parts = plannedPartKeys(planned);
492
+ // The cross-role in-flight hold (BUGS.md 2026-10-09): an entry another role's base has
493
+ // claimed is not eligible here. Same-base claims are excluded from the hold — the scheduling
494
+ // pass's claim groups and tick-stage's staged claims already hold those — so the queried
495
+ // role's own claim (and a sibling instance's) stays eligible and its release rules are
496
+ // unchanged; only a plan another base currently owns is taken off this base's hands.
497
+ const ownBase = baseRoleOf(role);
498
+ const claimedElsewhere = new Set();
499
+ for (const [key, claimer] of allLiveClaims(root)) {
500
+ if (baseRoleOf(claimer) !== ownBase)
501
+ claimedElsewhere.add(key);
502
+ }
216
503
  const out = [];
217
504
  for (let i = 0; i < entries.length && i < ranges.length; i++) {
218
505
  const entry = entries[i];
219
506
  if (entryHoldAgainst(entry, parts) !== null)
220
507
  continue;
508
+ if (claimedElsewhere.has(entryKey(entry.title)))
509
+ continue;
221
510
  const range = ranges[i];
222
511
  out.push({
223
512
  key: entryKey(entry.title),
@@ -2,17 +2,36 @@
2
2
  * QUESTIONS.md text, with no filesystem access — every function here takes the markdown text as
3
3
  * an argument. Split from src/backlog/backlog.ts, which keeps the stat-cached file readers
4
4
  * (sectionEntries, the root-based plannedPlans/openBugs/openQuestions family, and the
5
- * Done/Fixed date scan): consumers who only parse markdown (question-commands.ts's QUESTIONS.md
5
+ * Done/Fixed date scan): consumers who only parse markdown (question-answer.ts's QUESTIONS.md
6
6
  * walks, backlog-write.ts's section appends, backlog-structure.ts's stranding checks)
7
7
  * import from here without reaching the file/cache layer, and a caller who parses a string never
8
8
  * pays for a stat. Both layers share one fenceTracker state machine, so independent readers can
9
9
  * never disagree about what is body content. */
10
+ /** Where each live backlog file's finished entries go (plans/backlog-archive.md): PLANS.md's
11
+ * `## Done` empties into PLANS-DONE.md and BUGS.md's `## Fixed` into BUGS-FIXED.md, each under
12
+ * the same `## ` heading, newest first, so one section parser reads both files. The live file
13
+ * keeps the heading, empty: roles paste a finished entry there, and tumwater moves it into the
14
+ * archive (src/backlog/backlog-archive.ts). */
15
+ export const FINISHED_ARCHIVES = [
16
+ { file: "PLANS.md", section: "Done", archive: "PLANS-DONE.md" },
17
+ { file: "BUGS.md", section: "Fixed", archive: "BUGS-FIXED.md" },
18
+ ];
19
+ /** The archive row for a live backlog file. */
20
+ export function finishedArchiveOf(file) {
21
+ return file === "PLANS.md" ? FINISHED_ARCHIVES[0] : FINISHED_ARCHIVES[1];
22
+ }
10
23
  /** The repo-root backlog markdown files shared by every layer that knows them by name: the
11
24
  * tracked files loops edit and readers parse by `## ` section — read by backlog-structure.ts's
12
- * structural checks, landing/backlog-conflicts.ts's mechanical insert-conflict resolver, and
25
+ * structural checks, merge/backlog-conflicts.ts's mechanical insert-conflict resolver, and
13
26
  * doctor-backlog.ts's duplicate-heading check. One definition, so the three cannot disagree
14
- * about which files count. */
15
- export const BACKLOG_FILES = new Set(["PLANS.md", "BUGS.md", "QUESTIONS.md"]);
27
+ * about which files count. The archive files count too: merges insert at their top, and their
28
+ * `## ` heading is as load-bearing as the live files'. */
29
+ export const BACKLOG_FILES = new Set([
30
+ "PLANS.md",
31
+ "BUGS.md",
32
+ "QUESTIONS.md",
33
+ ...FINISHED_ARCHIVES.map((a) => a.archive),
34
+ ]);
16
35
  /** A per-line CommonMark fenced-code state machine, shared by every line-level parser of
17
36
  * backlog markdown. `inside(line)` feeds one line and returns whether it is fence syntax or
18
37
  * fenced content — never markdown structure: a fence opens at a ```` ``` ````/`~~~` line (an
@@ -67,7 +86,7 @@ export function headingTitle(line, prefix) {
67
86
  * code block. The single home of the fence-aware heading guard (`!fenced.inside(line) &&
68
87
  * line.startsWith(prefix)` plus headingTitle) that every line-level reader of the backlog
69
88
  * docs repeats: sectionLines' section boundaries and parseEntryDetails' entry boundaries here,
70
- * and question-commands.ts's walks of QUESTIONS.md (the Open/Answered scan, the `### ` block
89
+ * and question-answer.ts's walks of QUESTIONS.md (the Open/Answered scan, the `### ` block
71
90
  * split, and the Answered section's start); that file's two pure next-`## ` boundary walks
72
91
  * go through nextSectionHeading below instead. Readers that walk
73
92
  * whole documents collecting heading lines go through fenceAwareHeadingLines instead; callers
@@ -100,7 +119,7 @@ export function sectionLines(md, sectionTitle) {
100
119
  /** The index of the next `## ` section heading at or after `from`, fence-aware through the
101
120
  * caller's `fenced` tracker — or `lines.length` when none follows. The section-end boundary
102
121
  * rule as an index, for readers that must cut or splice at the boundary rather than collect
103
- * its content (question-commands.ts cuts the Open section at its end and inserts a moved
122
+ * its content (question-answer.ts cuts the Open section at its end and inserts a moved
104
123
  * block before Answered's next `## `; backlog-write.ts cuts the section a filed entry is
105
124
  * appended to). Shares fencedHeadingTitle's guard with sectionLines, so
106
125
  * a boundary found here is the same boundary sectionLines would stop at. */
@@ -148,6 +167,29 @@ export function fenceAwareHeadingLines(md, prefix) {
148
167
  * non-heading placeholders like `_None yet._` and any prose before the first heading, keeps
149
168
  * interior blank lines within a body while trimming leading/trailing ones, and ends an open
150
169
  * entry's body at EOF as well as at the next heading. */
170
+ /** The `Validation gap` tag of one already-parsed entry body — the first line outside a fence
171
+ * matching `` **Validation gap:** <tag> `` (an optional `- ` bullet before the marker is
172
+ * allowed), the tag lowercased, or `null` when no such line exists. Fence-aware because a
173
+ * `## Fixed` body may quote the bug template (the case BUGS.md's "a fix narrative can land on
174
+ * main without its fix" entry records): a quoted `**Validation gap:**` line inside a fenced
175
+ * block is an example, not this entry's own trace, and must not count. The consumer that owns
176
+ * the closed tag vocabulary (the bugfix role guidance's VALIDATION_GAP_TAGS) validates the
177
+ * returned tag; this reader only records that a trace line exists and what it named. Walked
178
+ * line by line through the module's fenceTracker so this reader can never disagree with
179
+ * sectionLines/parseEntryDetails about what is fenced content. Returns the FIRST such line:
180
+ * a body with two validation-gap lines records the first (the tally counts an entry once). */
181
+ export function validationGapTag(body) {
182
+ const fenced = fenceTracker();
183
+ const re = /^\s*(?:[-*]\s+)?\*\*Validation gap:\*\*\s*([A-Za-z][A-Za-z-]*)/;
184
+ for (const line of body.split("\n")) {
185
+ if (fenced.inside(line))
186
+ continue;
187
+ const m = re.exec(line);
188
+ if (m)
189
+ return m[1].toLowerCase();
190
+ }
191
+ return null;
192
+ }
151
193
  export function parseEntryDetails(md, sectionTitle) {
152
194
  const entries = [];
153
195
  let title = null; // The open entry's heading (null = no entry open yet).
@@ -189,14 +231,20 @@ export function parseEntryDetails(md, sectionTitle) {
189
231
  *
190
232
  * A `- ` line needs more than a date to be an entry: the sections also hold body bullets (an
191
233
  * entry's repro steps, a plan's task breakdown), and a body bullet that merely mentions a
192
- * completion — "- same shape as the sibling bug (fixed 2026-09-24)" — is not one. The epitaph
193
- * shape separates them: an epitaph always closes its line with a parenthetical that records
194
- * both the completion date and the landing commit ("(planned …, done …; commit abc1234)"),
195
- * so a bullet counts only when its trailing `(…)` group carries the date AND a `commit`
196
- * reference; a heading entry keeps the plain metadata match (headings are the primary entry
197
- * format and always close their metadata parenthetical by convention). */
198
- export function entryDates(md, sectionTitle, dateRe) {
234
+ * completion — "- same shape as the sibling bug (fixed 2026-09-24)" — is not one. The epitaph shape
235
+ * separates them: an epitaph always closes its line with a parenthetical that records both the
236
+ * completion date and the merge commit ("(planned …, done …; commit abc1234)"), so a bullet counts
237
+ * only when its trailing `(…)` group carries the date AND a `commit` reference; a heading entry
238
+ * keeps the plain metadata match (headings are the primary entry format and always close their
239
+ * metadata parenthetical by convention).
240
+ *
241
+ * The LAST dated match is the completion rule when `first` is false (the default): found-by,
242
+ * decomposition, and sibling mentions precede the completion record, so a sibling's date must
243
+ * not steal the count. `first` selects the FIRST dated match instead — the filing rule the
244
+ * filing read uses, matching the canonical `filingDate` (via firstDate). */
245
+ export function entryDates(md, sectionTitle, dateRe, first = false) {
199
246
  const dates = [];
247
+ const pick = first ? firstDate : lastDate;
200
248
  const lines = sectionBodyLines(md, sectionTitle);
201
249
  for (let i = 0; i < lines.length; i++) {
202
250
  const line = lines[i] ?? "";
@@ -212,10 +260,10 @@ export function entryDates(md, sectionTitle, dateRe) {
212
260
  // <date>, fixed <date>") precedes the entry's own completion record.
213
261
  const tail = trailingParenthetical(line);
214
262
  if (/\bcommits?\b/.test(tail))
215
- date = lastDate(tail, dateRe);
263
+ date = pick(tail, dateRe);
216
264
  }
217
265
  else {
218
- date = lastDate(meta.text, dateRe);
266
+ date = pick(meta.text, dateRe);
219
267
  }
220
268
  if (date)
221
269
  dates.push(date);
@@ -298,10 +346,24 @@ export function openParenthetical(line) {
298
346
  * an entry's completion is its LAST dated verb, not the first, because found-by,
299
347
  * decomposition, and sibling mentions ("decomposed from the X bug fixed <date>") all precede
300
348
  * the completion record, so first-match let a sibling's date steal the entry's count onto the
301
- * wrong day (BUGS.md 2026-09-29). dateRe is cloned with the g flag so matchAll sees every
349
+ * wrong day (BUGS.md 2026-09-29). Also read by completionDate (backlog.ts) for the finished-entry
350
+ * listings. dateRe is cloned with the g flag so matchAll sees every
302
351
  * date; a text with no date yields null. */
303
- function lastDate(text, dateRe) {
352
+ export function lastDate(text, dateRe) {
304
353
  const global = new RegExp(dateRe.source, dateRe.flags.includes("g") ? dateRe.flags : `${dateRe.flags}g`);
305
354
  const all = [...text.matchAll(global)];
306
355
  return all[all.length - 1]?.[1] ?? null;
307
356
  }
357
+ /** A text's FIRST `<dateRe>` capture — the filing rule, the mirror of lastDate: an entry's
358
+ * filing month is its FIRST stamp verb, because a later re-filing or a cross-reference
359
+ * ("found by bugfix <date>; found by doctor <date>") records a later date that must not move
360
+ * the filing onto a later day. This is the same first-match the canonical `filingDate`
361
+ * (backlog.ts) applies to a heading line, so the filing read and `plan|bug --list`'s age agree
362
+ * about when an entry opened. dateRe is cloned with the g flag so matchAll sees every date; a
363
+ * text with no date yields null. Module-local: entryDates (its only caller) selects it with
364
+ * `first`, so the walker stays single-homed like lastDate's completion rule. */
365
+ function firstDate(text, dateRe) {
366
+ const global = new RegExp(dateRe.source, dateRe.flags.includes("g") ? dateRe.flags : `${dateRe.flags}g`);
367
+ const match = global.exec(text);
368
+ return match?.[1] ?? null;
369
+ }