tumwater 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -1,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
- * 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";
11
- import { trailingParenthetical } from "./backlog-md.js";
17
+ import { roleLabel } from "../roles/role-labels.js";
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,6 +28,47 @@ const REFUSED_PREFIX = "**Refused ";
21
28
  function hasNoteLine(body, prefix) {
22
29
  return body.split("\n").some((line) => line.trimStart().startsWith(prefix));
23
30
  }
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));
36
+ return line === undefined ? null : line.replace(/^\*\*/, "").replace("**", "");
37
+ }
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) {
50
+ const out = [];
51
+ for (let i = 0; i < lines.length; i++) {
52
+ const line = lines[i];
53
+ if (!line.trimStart().startsWith(prefix)) {
54
+ out.push(line);
55
+ continue;
56
+ }
57
+ if ((lines[i - 1] ?? "x").trim() === "" && (lines[i + 1] ?? "x").trim() === "")
58
+ i++;
59
+ }
60
+ return out;
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
+ }
24
72
  /** One prerequisite ref, resolved against the entry's own series when it names none. */
25
73
  function parseRef(chunk, ownSeries) {
26
74
  const text = chunk.trim().replace(/^parts?\s+/i, "");
@@ -69,6 +117,53 @@ export function seriesPart(title) {
69
117
  function partKey(series, part) {
70
118
  return `${series.toLowerCase()}\u0000${part}`;
71
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
+ }
72
167
  /** The prerequisite-set identities of the still-planned entries — seriesPart(title) of each,
73
168
  * keyed by partKey — built once for a batch and reused across every entry judged against it, so
74
169
  * judging n entries is n seriesPart parses rather than n² (entryHold rebuilt the set per entry).
@@ -83,29 +178,167 @@ export function plannedPartKeys(planned) {
83
178
  }
84
179
  return keys;
85
180
  }
86
- /** The prerequisite parts a plan heading's trailing parenthetical names through the clause
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
+ }
213
+ /** The prerequisite parts a plan heading's trailing parenthetical (or, when the heading wraps
214
+ * and the parenthetical stays open past the line, its open parenthetical) names through the clause
87
215
  * `requires <ref>((, | and )<ref>)* landed`, with each range (e.g. `parts 1/5–3/5`) expanded.
88
216
  * Returns [] when the heading has no such clause or the clause does not parse — an agent judges
89
217
  * an unparseable clause, exactly as it does today. Only the heading is read, never the body. */
90
218
  export function requiredParts(title) {
91
- const meta = trailingParenthetical(title);
92
- if (meta === "")
93
- return [];
94
- const m = /requires\s+(.+?)\s+landed\b/i.exec(meta);
95
- if (m === null)
219
+ const clause = requiresClause(title);
220
+ if (clause === null)
96
221
  return [];
97
222
  const ownSeries = seriesPart(title)?.series ?? null;
98
223
  const refs = [];
99
- 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))
100
225
  refs.push(...parseRef(chunk, ownSeries));
101
226
  return refs;
102
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
+ }
328
+ /** The prerequisite refs of `title`'s `requires … landed` clause when none of them is still in
329
+ * `plannedParts` (built by plannedPartKeys), else []. A heading with no parseable clause is never
330
+ * reported, so only a refusal whose named prerequisites have all left `## Planned` reads as
331
+ * possibly stale. */
332
+ export function prerequisitesMerged(title, plannedParts) {
333
+ const refs = requiredParts(title);
334
+ return refs.some((ref) => plannedParts.has(partKey(ref.series, ref.part))) ? [] : refs;
335
+ }
103
336
  /** Why `entry` is held against the still-`planned` entries, or null when it may be taken: a
104
337
  * `**Refused …` line (refused), a Needs-review line (needs-review), a Needs-replan line
105
338
  * (needs-replan — the plan loop owns it), or a prerequisite `(series, part)` still among
106
339
  * `planned` ({ blockedBy }). Series compare case-insensitively; part tokens compare verbatim.
107
- * Bodies mentioning "requires" are never consulted — only the heading's trailing
108
- * parenthetical. */
340
+ * Bodies mentioning "requires" are never consulted — only the heading's trailing or
341
+ * still-open parenthetical. */
109
342
  export function entryHold(entry, planned) {
110
343
  return entryHoldAgainst(entry, plannedPartKeys(planned));
111
344
  }
@@ -119,6 +352,8 @@ export function entryHoldAgainst(entry, plannedParts) {
119
352
  return "needs-review";
120
353
  if (hasNoteLine(entry.body, NEEDS_REPLAN_PREFIX))
121
354
  return "needs-replan";
355
+ if (hasNoteLine(entry.body, WAITING_FOR_FAILURE_PREFIX))
356
+ return "waiting-for-failure";
122
357
  const refs = requiredParts(entry.title);
123
358
  if (refs.length === 0)
124
359
  return null;
@@ -135,34 +370,110 @@ export function holdMark(hold) {
135
370
  if (hold === null)
136
371
  return "";
137
372
  if (hold === "refused")
138
- return " [refused]";
373
+ return " [blocked: refused]";
139
374
  if (hold === "needs-review")
140
- return " [needs review]";
375
+ return " [blocked: needs review]";
141
376
  if (hold === "needs-replan")
142
- return " [needs replan]";
377
+ return " [blocked: needs replan]";
378
+ if (hold === "waiting-for-failure")
379
+ return " [blocked: waiting for the next failure]";
143
380
  return ` [blocked: requires ${hold.blockedBy.join(", ")}]`;
144
381
  }
145
- /** Why each entry the operator's `tumwater backlog` surfaces list is held, as two arrays
146
- * parallel to backlogPayload's `plans` and `bugs`: the same entryHold decision the prompt's
147
- * backlog index carries, so a hold is observable rather than inferred. The planned entries
148
- * are read once and reused as the prerequisite set. Questions carry no holds. */
149
- export function backlogHolds(root) {
150
- 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);
151
417
  const parts = plannedPartKeys(planned);
152
418
  return {
153
419
  plans: planned.map((entry) => entryHoldAgainst(entry, parts)),
154
- bugs: openBugEntries(root).map((entry) => entryHoldAgainst(entry, parts)),
420
+ bugs: holdsBacklogReaders.openBugEntries(root).map((entry) => entryHoldAgainst(entry, parts)),
155
421
  };
156
422
  }
157
- /** The eligibility mark (holdMark) for each planned plan and open bug, two arrays parallel
158
- * to the entries the observer surfaces list (backlogPayload's `plans`/`bugs`, the TUI's list
159
- * and entry modes): backlogHolds' verdicts projected through holdMark, so a surface derives
160
- * its marks from the one hold decision rather than re-walking the entries. holdMark renders
161
- * an eligible entry as the empty string, so a client can append the mark without a separate
162
- * 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. */
163
441
  export function backlogMarks(root) {
164
442
  const holds = backlogHolds(root);
165
- 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)}]`;
166
477
  }
167
478
  /** The entries a `role` loop may take now, in file order: PLANS.md's `## Planned` entries for
168
479
  * feature, BUGS.md's `## Open` entries for bugfix, minus every entry entryHold holds, each with
@@ -178,11 +489,24 @@ export function eligibleEntries(root, role) {
178
489
  const ranges = actionableEntryRangesFor(root, file, section);
179
490
  const planned = plannedPlanEntries(root);
180
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
+ }
181
503
  const out = [];
182
504
  for (let i = 0; i < entries.length && i < ranges.length; i++) {
183
505
  const entry = entries[i];
184
506
  if (entryHoldAgainst(entry, parts) !== null)
185
507
  continue;
508
+ if (claimedElsewhere.has(entryKey(entry.title)))
509
+ continue;
186
510
  const range = ranges[i];
187
511
  out.push({
188
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);
@@ -262,7 +310,8 @@ export function headingMetadata(lines, start) {
262
310
  * and silently dropped the epitaph's date from the day report (BUGS.md 2026-09-29). Unbalanced
263
311
  * text (no matching open paren) yields "" — the guard then treats the bullet as body text, as
264
312
  * before. The one home for this rule: entryDates reads it for `- ` epitaphs and
265
- * backlog-eligibility.ts's requiredParts for a heading's prerequisite clause. */
313
+ * backlog-eligibility.ts's requiredParts for a heading's prerequisite clause (falling back to
314
+ * openParenthetical when the heading's parenthetical wraps past the line). */
266
315
  export function trailingParenthetical(line) {
267
316
  if (!line.endsWith(")"))
268
317
  return "";
@@ -276,14 +325,45 @@ export function trailingParenthetical(line) {
276
325
  }
277
326
  return "";
278
327
  }
328
+ /** The text after the outermost "(" still open at the end of `line`, or "" when every group
329
+ * closes: a wrapped `### ` heading's stamp parenthetical continues on the following lines, so
330
+ * the heading line has no closing ")". A ")" at depth 0 is ignored. */
331
+ export function openParenthetical(line) {
332
+ let depth = 0;
333
+ let open = -1;
334
+ for (let i = 0; i < line.length; i++) {
335
+ const ch = line[i];
336
+ if (ch === "(") {
337
+ if (depth++ === 0)
338
+ open = i;
339
+ }
340
+ else if (ch === ")" && depth > 0)
341
+ depth--;
342
+ }
343
+ return depth > 0 ? line.slice(open + 1) : "";
344
+ }
279
345
  /** A text's LAST `<dateRe>` capture — the completion rule both branches of entryDates share:
280
346
  * an entry's completion is its LAST dated verb, not the first, because found-by,
281
347
  * decomposition, and sibling mentions ("decomposed from the X bug fixed <date>") all precede
282
348
  * the completion record, so first-match let a sibling's date steal the entry's count onto the
283
- * 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
284
351
  * date; a text with no date yields null. */
285
- function lastDate(text, dateRe) {
352
+ export function lastDate(text, dateRe) {
286
353
  const global = new RegExp(dateRe.source, dateRe.flags.includes("g") ? dateRe.flags : `${dateRe.flags}g`);
287
354
  const all = [...text.matchAll(global)];
288
355
  return all[all.length - 1]?.[1] ?? null;
289
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
+ }