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,19 +1,47 @@
1
- import { holdMark } from "./backlog-eligibility.js";
1
+ import { groupBlockedSeries, partsLabel } from "./backlog-structure.js";
2
+ import { holdMark, inProgressMark } from "./backlog-eligibility.js";
2
3
  /** One `## ` section: its entries verbatim, or the single `_(none)_` placeholder line. When
3
4
  * `holds` is given, entry `i`'s hold mark rides on its `### ` heading. */
4
- function renderSection(title, entries, holds) {
5
+ function renderSection(title, entries, holds, inProgress) {
5
6
  const lines = [`## ${title}`, ""];
6
7
  if (entries.length === 0) {
7
8
  lines.push("_(none)_", "");
8
9
  return lines;
9
10
  }
10
- for (let i = 0; i < entries.length; i++) {
11
- const entry = entries[i];
12
- lines.push(`### ${entry.title}${holdMark(holds?.[i] ?? null)}`, "");
13
- // The body is already trimmed by parseEntryDetails; only the indent is added here.
14
- for (const line of entry.body.split("\n"))
15
- lines.push(line === "" ? "" : ` ${line}`);
16
- lines.push("");
11
+ for (let i = 0; i < entries.length; i++)
12
+ lines.push(...renderEntry(entries[i], holds?.[i] ?? null, inProgress?.[i] ?? null));
13
+ return lines;
14
+ }
15
+ /** One entry: its heading with hold and in-progress marks, then its body indented two spaces. */
16
+ function renderEntry(entry, hold, runner) {
17
+ const lines = [`### ${entry.title}${holdMark(hold)}${inProgressMark(runner)}`, ""];
18
+ // The body is already trimmed by parseEntryDetails; only the indent is added here.
19
+ for (const line of entry.body.split("\n"))
20
+ lines.push(line === "" ? "" : ` ${line}`);
21
+ lines.push("");
22
+ return lines;
23
+ }
24
+ /** The Planned section. With holds, each series with two or more blocked parts prints as one
25
+ * body-less heading at its first part's place (the grouping `plan --list` uses); every other
26
+ * entry prints as renderSection would. Without holds it is renderSection itself. */
27
+ function renderPlannedSection(entries, holds, inProgress) {
28
+ if (!holds || entries.length === 0)
29
+ return renderSection("Planned features", entries, holds, inProgress);
30
+ const rows = entries.map((entry, index) => ({
31
+ index, title: entry.title, hold: holds[index] ?? null, claimer: inProgress?.[index] ?? undefined,
32
+ }));
33
+ const lines = ["## Planned features", ""];
34
+ let grouped = false;
35
+ for (const g of groupBlockedSeries(rows)) {
36
+ if (g.kind === "row") {
37
+ lines.push(...renderEntry(entries[g.row.index], g.row.hold, inProgress?.[g.row.index] ?? null));
38
+ continue;
39
+ }
40
+ grouped = true;
41
+ lines.push(`### ${g.series}, ${g.rows.length} blocked parts (${partsLabel(g.rows)}) [blocked: each waits on an earlier part]`, "");
42
+ }
43
+ if (grouped) {
44
+ lines.push("_(each blocked series is one heading; `tumwater plan --show <n>` prints a part, `tumwater backlog --json` lists every entry)_", "");
17
45
  }
18
46
  return lines;
19
47
  }
@@ -21,8 +49,31 @@ function renderSection(title, entries, holds) {
21
49
  * Markdown. */
22
50
  export function renderBacklogMarkdown(payload) {
23
51
  const lines = ["# tumwater backlog", ""];
24
- lines.push(...renderSection("Planned features", payload.plans, payload.holds?.plans));
25
- lines.push(...renderSection("Open bugs", payload.bugs, payload.holds?.bugs));
52
+ lines.push(...renderPlannedSection(payload.plans, payload.holds?.plans, payload.inProgress?.plans));
53
+ lines.push(...renderSection("Open bugs", payload.bugs, payload.holds?.bugs, payload.inProgress?.bugs));
26
54
  lines.push(...renderSection("Open questions", payload.questions));
27
55
  return lines.join("\n").trimEnd();
28
56
  }
57
+ /** The view with only the plans and bugs no hold keeps from being picked: entry `i` stays when
58
+ * `holds.plans[i]` / `holds.bugs[i]` is null, and `holds` and `inProgress` are filtered by the
59
+ * same indexes so the arrays stay parallel. An entry a role is working on has no hold, so it
60
+ * stays with its in-progress mark. Questions carry no holds and pass through; a view without
61
+ * `holds` is returned unchanged. */
62
+ export function readyBacklog(view) {
63
+ const holds = view.holds;
64
+ if (!holds)
65
+ return view;
66
+ const keep = (hs, n) => Array.from({ length: n }, (_, i) => i).filter((i) => (hs[i] ?? null) === null);
67
+ const plan = keep(holds.plans, view.plans.length);
68
+ const bug = keep(holds.bugs, view.bugs.length);
69
+ const pick = (xs, idx) => idx.map((i) => xs?.[i]);
70
+ return {
71
+ ...view,
72
+ plans: pick(view.plans, plan),
73
+ bugs: pick(view.bugs, bug),
74
+ holds: { plans: pick(holds.plans, plan), bugs: pick(holds.bugs, bug) },
75
+ ...(view.inProgress
76
+ ? { inProgress: { plans: pick(view.inProgress.plans, plan), bugs: pick(view.inProgress.bugs, bug) } }
77
+ : {}),
78
+ };
79
+ }
@@ -1,15 +1,20 @@
1
+ // vocabulary: plain
1
2
  import path from "node:path";
2
3
  import { increment } from "../collections.js";
3
4
  import { readTextOrNull } from "../files/files.js";
4
5
  import { cachedTextByStat } from "../files/stat-cache.js";
5
- import { BACKLOG_FILES, fenceTracker, headingMetadata, parseEntryDetails, sectionBodyLines, fenceAwareHeadingLines, headingTitle, } from "./backlog-md.js";
6
+ import { BACKLOG_FILES, FINISHED_ARCHIVES, fenceTracker, headingMetadata, parseEntryDetails, sectionBodyLines, fenceAwareHeadingLines, headingTitle, } from "./backlog-md.js";
6
7
  import { changeBaseRev, fileContentAt } from "../git/git.js";
8
+ import { allLiveClaims } from "../loop/loop-state.js";
7
9
  import { collapseWhitespace, truncate } from "../text/text.js";
8
- import { entryHoldAgainst, holdMark, plannedPartKeys } from "./backlog-eligibility.js";
10
+ import { entryHoldAgainst, holdMark, plannedPartKeys, seriesPart, } from "./backlog-eligibility.js";
9
11
  import { plannedPlanEntries } from "./backlog.js";
12
+ /** The archive files of PLANS.md and BUGS.md (FINISHED_ARCHIVES). */
13
+ const PLANS_ARCHIVE = FINISHED_ARCHIVES[0].archive;
14
+ const BUGS_ARCHIVE = FINISHED_ARCHIVES[1].archive;
10
15
  /** The stamp verbs a backlog heading's trailing `(planned 2026-09-29)`-style parenthetical
11
16
  * opens with — the same list the backlog writers stamp and the dashboards parse. One home
12
- * for the verb list: the TUI's fleet alerts strip the suffix (stripEntryStamp) and the
17
+ * for the verb list: the TUI's team alerts strip the suffix (stripEntryStamp) and the
13
18
  * dashboard's browser view model splits it off as metadata (splitTitle), and a new stamp
14
19
  * verb must reach both renders.
15
20
  * Kept as pattern SOURCE rather than a RegExp so the browser twin (a String.raw template
@@ -20,16 +25,44 @@ export const ENTRY_STAMP_META_SOURCE = String.raw `\s*\(((?:planned|reported|fou
20
25
  const ENTRY_STAMP_META_RE = new RegExp(ENTRY_STAMP_META_SOURCE, "i");
21
26
  /** A heading or title with its trailing `(planned 2026-09-29)`-style stamp suffix removed,
22
27
  * whitespace otherwise untouched — the shared span of this module's indexTitle, eligibility's
23
- * entryKey and seriesPart, and the TUI fleet alerts, which all read a heading stamp-free. */
28
+ * entryKey and seriesPart, and the TUI team alerts, which all read a heading stamp-free. */
24
29
  export function stripEntryStamp(title) {
25
30
  return title.replace(ENTRY_STAMP_META_RE, "");
26
31
  }
32
+ /** The heading of a `## Done`/`## Fixed` entry with its completion stamp removed. Cuts at the
33
+ * first `dateRe` match, drops a parenthetical the cut left open (`(found by … 2026-10-01; `,
34
+ * `(planned …; `), ignores a stray `)` (`… now under ## Fixed); fixed 2026-10-04 …)`), trims
35
+ * trailing separators, then strips a closed `(planned …)`/`(found …)` stamp suffix. `dateRe`
36
+ * matches nothing in the result. */
37
+ export function finishedTitle(title, dateRe) {
38
+ const match = dateRe.exec(title);
39
+ let t = match ? title.slice(0, match.index) : title;
40
+ const open = [];
41
+ for (let i = 0; i < t.length; i++) {
42
+ if (t[i] === "(")
43
+ open.push(i);
44
+ else if (t[i] === ")" && open.length)
45
+ open.pop();
46
+ }
47
+ if (open.length)
48
+ t = t.slice(0, open[0]);
49
+ t = t.replace(/[\s,;:—–-]+$/, "");
50
+ return stripEntryStamp(t).trimEnd();
51
+ }
27
52
  /** A heading's parenthetical dates: `(planned YYYY-MM-DD` opens a plan entry; `done
28
53
  * YYYY-MM-DD` records its completion. Matched against the JOINED heading metadata
29
54
  * (headingMetadata), never the bare first line — many headings wrap their dates onto a
30
55
  * second line (`(planned 2026-09-02, done` / `2026-09-03)`). */
31
56
  const PLANNED_DATE = /\(planned \d{4}-\d{2}-\d{2}/;
32
57
  const DONE_DATE = /\bdone \d{4}-\d{2}-\d{2}/;
58
+ /** A completion stamp: `done YYYY-MM-DD` introduced by a structural delimiter (`— done`,
59
+ * `, done`, `; done`, `(done`) rather than by an English subject in prose. Every entry-own done
60
+ * record in PLANS.md is delimiter-introduced (`…planned …, done …`, `…planned …; done …`,
61
+ * `…) — done …`, `…) (done …)`); a "done DATE" that merely names PRIOR merged work is a
62
+ * participle ("…part 2/2 … both done 2026-10-16, …") whose `done` follows a word, so this
63
+ * narrower regex does not match it and a mention inside a heading's own `(planned …)` prose
64
+ * cannot strand the entry. */
65
+ const DONE_STAMP = /[—,(;]\s*done \d{4}-\d{2}-\d{2}/;
33
66
  /** The `### ` headings of `md`'s PLANS.md that sit under the wrong section, in file order:
34
67
  * (a) entries under `## Done` whose heading carries a `(planned YYYY-MM-DD…)` parenthetical
35
68
  * but no `done YYYY-MM-DD` — written as plans, never stamped done, and invisible to every
@@ -58,7 +91,16 @@ export function strandedPlanEntries(md) {
58
91
  if (!line.startsWith("### "))
59
92
  continue;
60
93
  const { text } = headingMetadata(lines, i);
61
- if (section === "Done" ? PLANNED_DATE.test(text) && !DONE_DATE.test(text) : DONE_DATE.test(text))
94
+ // A `## Done` entry is stranded when it carries the `(planned …)` opener but no done
95
+ // stamp of its own. An entry still under `## Planned` is stranded only when its heading
96
+ // carries a completion stamp — a `done YYYY-MM-DD` introduced by a structural delimiter
97
+ // (`— done`, `, done`, `; done`, `(done`), the repo's completion shapes. A "done DATE" that
98
+ // merely describes PRIOR merged work ("…both done 2026-10-16, …") follows an English word,
99
+ // not a delimiter, so DONE_STAMP does not match it and the entry is not stranded.
100
+ const misplaced = section === "Done"
101
+ ? PLANNED_DATE.test(text) && !DONE_DATE.test(text)
102
+ : DONE_STAMP.test(text);
103
+ if (misplaced)
62
104
  stranded.push({ title: headingTitle(line, "### "), section });
63
105
  }
64
106
  }
@@ -97,8 +139,8 @@ function sectionTitleCounts(md) {
97
139
  return counts;
98
140
  }
99
141
  /** The `## ` titles of `md` that appear more than once, in first-appearance order — the
100
- * duplicate-heading signal `checkBacklogHeadings` reports for main and
101
- * `backlogStructureReason` checks on a tree being landed. */
142
+ * duplicate-heading signal `checkBacklogHeadings` reports for main and `backlogStructureReason`
143
+ * checks on a tree being merged. */
102
144
  export function duplicateHeadings(md) {
103
145
  return [...sectionTitleCounts(md)].filter(([, n]) => n > 1).map(([title]) => title);
104
146
  }
@@ -129,30 +171,29 @@ export function doubleBlockFixedEntries(md) {
129
171
  }
130
172
  return found;
131
173
  }
132
- /** The first structural fault a diff landing on `mainBranch` leaves in a backlog file, or
133
- * undefined when none: for each touched backlog file it compares the `## ` heading set of the
134
- * tree being landed (`wt`) against the diff's merge-base — the same base falseFixReason
135
- * measures against, so a stacked batch is judged change by change — and rejects when
136
- * (a) the head carries MORE of a title than the base carried (a duplicate `## Done` added, or
137
- * a second one where the base had none), or (b) a title present on the base is gone from the
138
- * head (a whole section dropped). The rule is deliberately about heading sets, not section
139
- * names: a project whose BUGS.md adds `## Verified` (this repo's does) or a fresh repo seeded
140
- * from src/init/init.ts's templates passes unchanged. A base that already carries a duplicate never
141
- * blocks unrelated edits — rule (a) fires only when the head's count EXCEEDS the base's, so a
142
- * change that removes a duplicate always passes. For PLANS.md there is a third rule
143
- * (part 4/4): a head that ADDS a `### ` entry directly under `## Done` whose joined heading
144
- * metadata carries `(planned YYYY-MM-DD` but no `done YYYY-MM-DD`, and whose key the base's
145
- * PLANS.md never carried in ANY section, is a plan filed into the wrong section — rejected with
146
- * a reason naming the entry and the one-line fix. Judged against the merge-base (the same base
147
- * the heading rules use), so a Planned → Done move of an entry the base already had passes, a
148
- * stacked batch is measured change by change, and a pre-existing stranded entry (whose key the
149
- * base has) never blocks unrelated landings — the clean loop's repair (part 3/4) owns those.
150
- * BUGS.md has one more: a head `## Fixed` entry that holds more than one `**Symptom:**` or
151
- * `**Fix:**` block (doubleBlockFixedEntries) absorbed a headingless record into its body; a
152
- * base body that already had the shape is never re-flagged, so only a newly merged record is
153
- * rejected (BUGS.md 2026-10-08). Detection is deterministic markdown reading —
154
- * no pi — so both the review gate (exempt and code diffs alike) and the in-lock landing
155
- * re-check can afford it on every landing (plans: "Backlog structure check", part 2/4). */
174
+ /** The first structural fault a diff merging into `mainBranch` leaves in a backlog file, or
175
+ * undefined when none: for each touched backlog file it compares the `## ` heading set of the tree
176
+ * being merged (`wt`) against the diff's merge-base — the same base falseFixReason measures
177
+ * against, so a stacked batch is judged change by change — and rejects when (a) the head carries
178
+ * MORE of a title than the base carried (a duplicate `## Done` added, or a second one where the
179
+ * base had none), or (b) a title present on the base is gone from the head (a whole section
180
+ * dropped). The rule is deliberately about heading sets, not section names: a project whose BUGS.md
181
+ * adds `## Verified` (this repo's does) or a fresh repo seeded from src/init/init.ts's templates
182
+ * passes unchanged. A base that already carries a duplicate never blocks unrelated edits — rule (a)
183
+ * fires only when the head's count EXCEEDS the base's, so a change that removes a duplicate always
184
+ * passes. For PLANS.md there is a third rule (part 4/4): a head that ADDS a `### ` entry directly
185
+ * under `## Done` whose joined heading metadata carries `(planned YYYY-MM-DD` but no `done
186
+ * YYYY-MM-DD`, and whose key the base's PLANS.md never carried in ANY section, is a plan filed into
187
+ * the wrong section — rejected with a reason naming the entry and the one-line fix. Judged against
188
+ * the merge-base (the same base the heading rules use), so a Planned → Done move of an entry the
189
+ * base already had passes, a stacked batch is measured change by change, and a pre-existing
190
+ * stranded entry (whose key the base has) never blocks unrelated merges — the clean loop's repair
191
+ * (part 3/4) owns those. BUGS.md has one more: a head `## Fixed` entry that holds more than one
192
+ * `**Symptom:**` or `**Fix:**` block (doubleBlockFixedEntries) absorbed a headingless record into
193
+ * its body; a base body that already had the shape is never re-flagged, so only a newly merged
194
+ * record is rejected (BUGS.md 2026-10-08). Detection is deterministic markdown reading — no pi — so
195
+ * both the review gate (exempt and code diffs alike) and the in-lock merge re-check can afford it
196
+ * on every merge (plans: "Backlog structure check", part 2/4). */
156
197
  export async function backlogStructureReason(wt, mainBranch, files) {
157
198
  const touched = files.filter((f) => BACKLOG_FILES.has(f));
158
199
  if (touched.length === 0)
@@ -172,45 +213,52 @@ export async function backlogStructureReason(wt, mainBranch, files) {
172
213
  if (!headCounts.has(title))
173
214
  return (`${file} drops the "## ${title}" section the base had — entries filed under it would ` +
174
215
  `be invisible to every section reader; restore "## ${title}"`);
175
- // Part 4/4's rule, PLANS.md only (see this function's doc comment): a NEW plan filed
176
- // directly under ## Done with no done date. strandedPlanEntries already does the
177
- // fence-aware, joined-metadata reading the first two conditions need; the base's key set
178
- // supplies the third.
179
- if (file === "PLANS.md") {
180
- const baseKeys = planHeadingKeys(base);
216
+ // Part 4/4's rule, PLANS.md and its archive (see this function's doc comment): a NEW plan
217
+ // filed directly under ## Done with no done date. strandedPlanEntries already does the
218
+ // fence-aware, joined-metadata reading the first two conditions need; the base's key set —
219
+ // over both files, since tumwater moves a finished plan from one to the other
220
+ // (plans/backlog-archive.md) — supplies the third.
221
+ if (file === "PLANS.md" || file === PLANS_ARCHIVE) {
222
+ const baseKeys = new Set([
223
+ ...planHeadingKeys(file === "PLANS.md" ? base : await fileContentAt(wt, baseRev, "PLANS.md")),
224
+ ...planHeadingKeys(file === PLANS_ARCHIVE ? base : await fileContentAt(wt, baseRev, PLANS_ARCHIVE)),
225
+ ]);
181
226
  const newcomer = strandedPlanEntries(head).find((e) => e.section === "Done" && !baseKeys.has(planHeadingKey(e.title)));
182
227
  if (newcomer)
183
- return (`PLANS.md files "${newcomer.title}" directly under "## Done" with no done date — a plan ` +
228
+ return (`${file} files "${newcomer.title}" directly under "## Done" with no done date — a plan ` +
184
229
  `written into the done section is invisible to every Planned reader; file it under ` +
185
- `"## Planned" instead`);
230
+ `"## Planned"${file === "PLANS.md" ? "" : " in PLANS.md"} instead`);
186
231
  }
187
232
  // A new headingless record cannot hide in a Fixed entry's body: reject a head whose
188
233
  // multi-block entries the base did not carry (an existing one never blocks unrelated
189
234
  // edits, matching the heading-set rules above). The Fix paragraph is the guard's unit, so
190
235
  // a merged second block would otherwise sit inside the neighbor it was symbol-checked
191
- // against.
192
- if (file === "BUGS.md") {
236
+ // against. BUGS-FIXED.md holds the same entries once archived.
237
+ if (file === "BUGS.md" || file === BUGS_ARCHIVE) {
193
238
  const baseTitles = new Set(doubleBlockFixedEntries(base));
194
239
  const newcomer = doubleBlockFixedEntries(head).find((title) => !baseTitles.has(title));
195
240
  if (newcomer)
196
- return (`BUGS.md's Fixed entry "${newcomer}" holds more than one **Symptom:**/**Fix:** block — ` +
241
+ return (`${file}'s Fixed entry "${newcomer}" holds more than one **Symptom:**/**Fix:** block — ` +
197
242
  `a record with no "### " heading has merged into it, so the hidden record is invisible ` +
198
243
  `to every section reader; give it its own "### " heading`);
199
244
  }
200
245
  }
201
246
  return undefined;
202
247
  }
203
- /** Per-file cache of PLANS.md's stranded entries (strandedPlanEntriesFor). */
248
+ /** Per-file cache of PLANS.md's and PLANS-DONE.md's stranded entries (strandedPlanEntriesFor). */
204
249
  const strandedPlanCache = new Map();
205
- /** `strandedPlanEntries` for the primary checkout's PLANS.md, stat-cached per file path
206
- * (stat-cache.cachedTextByStat): the clean loop asks for it every tick and `tumwater role`
207
- * previews the same block, and PLANS.md's `## Done` history grows without bound, so an
250
+ /** `strandedPlanEntries` for the primary checkout's PLANS.md and PLANS-DONE.md (where finished
251
+ * plans are archived, plans/backlog-archive.md), each entry tagged with its file, stat-cached per
252
+ * file path (stat-cache.cachedTextByStat): the clean loop asks for it every tick, `tumwater role`
253
+ * previews the same block and doctor reports it, and the archive grows without bound, so an
208
254
  * unchanged file costs one stat instead of a full read and section walk. A missing or
209
255
  * unreadable file reads as no entries. The result is copied out, so each caller owns its
210
256
  * data. */
211
- function strandedPlanEntriesFor(root) {
212
- const full = path.join(root, "PLANS.md");
213
- return (cachedTextByStat(strandedPlanCache, full, full, (md) => strandedPlanEntries(md), (entries) => entries.slice()) ?? []);
257
+ export function strandedPlanEntriesFor(root) {
258
+ return ["PLANS.md", PLANS_ARCHIVE].flatMap((file) => {
259
+ const full = path.join(root, file);
260
+ return (cachedTextByStat(strandedPlanCache, full, full, (md) => strandedPlanEntries(md).map((e) => ({ ...e, file })), (entries) => entries.slice()) ?? []);
261
+ });
214
262
  }
215
263
  /** The clean loop's `<backlog-structure>` prompt block for the primary checkout at `root`
216
264
  * (the same root the telemetry digest and qa coverage blocks read): a rendered block listing
@@ -223,12 +271,12 @@ export function renderBacklogStructureBlock(root) {
223
271
  if (stranded.length === 0)
224
272
  return undefined;
225
273
  const listed = stranded
226
- .map((e) => `- (now under ## ${e.section}) ${e.title}`)
274
+ .map((e) => `- (now under ${e.file !== undefined && e.file !== "PLANS.md" ? `${e.file} ` : ""}## ${e.section}) ${e.title}`)
227
275
  .join("\n");
228
276
  // The task wording lives in the clean role's find text (src/roles/role-catalog.ts); the block
229
277
  // only carries the evidence, like the digest and coverage blocks do.
230
- return `Plan headings filed under the wrong section of PLANS.md — invisible to the readers
231
- that scan one section, so no loop sees them as backlog work:
278
+ return `Plan headings filed under the wrong section of PLANS.md or PLANS-DONE.md — invisible to
279
+ the readers that scan one section, so no role sees them as backlog work:
232
280
  <backlog-structure>
233
281
  ${listed}
234
282
  </backlog-structure>`;
@@ -301,8 +349,128 @@ const INDEX_SECTIONS = [
301
349
  { file: "BUGS.md", section: "Open" },
302
350
  { file: "QUESTIONS.md", section: "Open" },
303
351
  ];
352
+ /** How many closed entries per closed section the index lists. */
353
+ const RECENT_CLOSED_MAX = 5;
354
+ /** The closed sections whose newest entries the index also lists, each read from the live file
355
+ * and then its archive (FINISHED_ARCHIVES, plans/backlog-archive.md); every one keeps its entries
356
+ * newest first, so the first entries in file order are the recent ones. */
357
+ const CLOSED_SECTIONS = FINISHED_ARCHIVES.map((a) => ({
358
+ files: [a.file, a.archive],
359
+ section: a.section,
360
+ }));
304
361
  /** Per-file cache of the parsed actionable entries (indexSectionCache). */
305
362
  const indexSectionCache = new Map();
363
+ /** Per-file cache of the newest closed entries' ranges; separate from indexSectionCache, which is
364
+ * keyed by the file path alone and PLANS.md would collide. */
365
+ const closedSectionCache = new Map();
366
+ /** One `IndexRow` as an ordinary index line: range, truncated stamp-free title, hold mark, and
367
+ * the in-flight suffix when a live claim names it. The collapsed-series line is built by
368
+ * collapseBlockedSeries, returned alongside these. */
369
+ function renderIndexRow(row) {
370
+ return `- ${row.start}-${row.end}: ${indexTitle(row.title)}${row.mark}${row.claimer ? ` [in flight by ${row.claimer}]` : ""}`;
371
+ }
372
+ /** True when `row` is a candidate for series-collapsing: it is a series part held only by an
373
+ * unmerged prerequisite (`{ blockedBy }`, not a Refused/needs-review/needs-replan note) and no live
374
+ * claim names it. Returns the row's series part when it collapses, else null. */
375
+ function collapsibleSeriesPart(row) {
376
+ if (row.claimer !== undefined)
377
+ return null;
378
+ if (typeof row.hold !== "object" || row.hold === null)
379
+ return null;
380
+ return seriesPart(row.title);
381
+ }
382
+ /** The collapsed part list for a series' blocked parts: each part as `i/n`, with a run of
383
+ * consecutive numeric parts (same total, adjacent values) joined into one en-dash range
384
+ * (`2/5–5/5`) and each lettered part (`2a`) listed singly. */
385
+ export function partsLabel(rows) {
386
+ const refs = rows.map((r) => seriesPart(r.title));
387
+ const pieces = [];
388
+ let start = null;
389
+ let prev = null;
390
+ const numeric = (p) => /^\d+$/.test(p);
391
+ const flush = () => {
392
+ if (start === null || prev === null)
393
+ return;
394
+ pieces.push(start.part === prev.part
395
+ ? `${start.part}/${start.of}`
396
+ : `${start.part}/${start.of}–${prev.part}/${prev.of}`);
397
+ start = null;
398
+ prev = null;
399
+ };
400
+ for (const ref of refs) {
401
+ if (prev !== null &&
402
+ numeric(ref.part) &&
403
+ numeric(prev.part) &&
404
+ Number(ref.part) === Number(prev.part) + 1 &&
405
+ ref.of === prev.of) {
406
+ prev = ref;
407
+ continue;
408
+ }
409
+ flush();
410
+ start = ref;
411
+ prev = ref;
412
+ }
413
+ flush();
414
+ return pieces.join(", ");
415
+ }
416
+ /** Renders `rows` to index lines, collapsing a multi-part series' blocked parts to one line per
417
+ * series (this plan, "Compact backlog index, part 1/1"): a long series' chain of held parts would
418
+ * otherwise fill every loop prompt with lines no role can act on. An entry collapses when it is a
419
+ * series part held only by an unmerged prerequisite and no live claim names it; a series with two
420
+ * or more such parts renders as one line at the position of its first collapsed part, spanning from
421
+ * that part's start to the last's end, with the part list (`2/4–4/4`) and a `[blocked: each waits
422
+ * on an earlier part]` mark. A series with a single blocked part, an eligible part, a note-held
423
+ * (Refused/needs-review/needs-replan) part and a claimed part keep their ordinary lines, so the
424
+ * eligibility rules the feature role's charter teaches are unchanged; the collapsed mark keeps the
425
+ * `[blocked: …]` prefix it already skips. */
426
+ /** The grouping half of collapseBlockedSeries, on any rows carrying a title, hold and claimer:
427
+ * each series with two or more collapsible blocked parts becomes one `series` group at its first
428
+ * part's place; every other row stays a `row` group in order. */
429
+ export function groupBlockedSeries(rows) {
430
+ // Group the collapsible rows by series, keyed on the lowercased series name.
431
+ const bySeries = new Map();
432
+ for (const row of rows) {
433
+ const part = collapsibleSeriesPart(row);
434
+ if (part === null)
435
+ continue;
436
+ const key = part.series.toLowerCase();
437
+ const list = bySeries.get(key) ?? [];
438
+ list.push(row);
439
+ bySeries.set(key, list);
440
+ }
441
+ // Only a series with two or more blocked parts collapses; a lone blocked part keeps its
442
+ // exact `requires` line.
443
+ const collapse = new Set();
444
+ for (const [key, list] of bySeries)
445
+ if (list.length >= 2)
446
+ collapse.add(key);
447
+ const groups = [];
448
+ const skipped = new Set();
449
+ for (const row of rows) {
450
+ if (skipped.has(row))
451
+ continue;
452
+ const part = collapsibleSeriesPart(row);
453
+ if (part !== null && collapse.has(part.series.toLowerCase())) {
454
+ const group = bySeries.get(part.series.toLowerCase());
455
+ groups.push({ kind: "series", series: part.series, rows: group });
456
+ for (const g of group)
457
+ skipped.add(g);
458
+ continue;
459
+ }
460
+ groups.push({ kind: "row", row });
461
+ }
462
+ return groups;
463
+ }
464
+ function collapseBlockedSeries(rows) {
465
+ return groupBlockedSeries(rows).map((g) => {
466
+ if (g.kind === "row")
467
+ return renderIndexRow(g.row);
468
+ const first = g.rows[0];
469
+ const last = g.rows[g.rows.length - 1];
470
+ return (`- ${first.start}-${last.end}: ${indexTitle(g.series)}, ${g.rows.length} blocked parts ` +
471
+ `(${partsLabel(g.rows)}) [blocked: each waits on an earlier part]`);
472
+ });
473
+ }
306
474
  /** The `<backlog-index>` prompt block: each actionable entry (PLANS.md ## Planned, BUGS.md
307
475
  * ## Open, QUESTIONS.md ## Open) with its 1-based line range, rendered from the primary
308
476
  * checkout `root`. Replaces the per-tick `grep -n '^##'` heading map, whose pattern also matched
@@ -319,6 +487,12 @@ export function renderBacklogIndexBlock(root) {
319
487
  // PLANS.md costs one stat per call.
320
488
  const planned = plannedPlanEntries(root);
321
489
  const plannedParts = plannedPartKeys(planned);
490
+ // The cross-role in-flight hold (BUGS.md 2026-10-09): every role's prompts share this one
491
+ // section-agnostic index, and the role it is assembled for is not available here, so an entry
492
+ // claimed by ANY role is marked in flight — the owner's own index marks it too, which is harmless
493
+ // (its assignment note is the instruction), and every other role reads it as taken instead of
494
+ // re-implementing a change someone's merge is already carrying to main.
495
+ const claimed = allLiveClaims(root);
322
496
  const listed = [];
323
497
  for (const { file, section } of INDEX_SECTIONS) {
324
498
  const full = path.join(root, file);
@@ -339,16 +513,45 @@ export function renderBacklogIndexBlock(root) {
339
513
  if (entries === null || entries.length === 0)
340
514
  continue;
341
515
  listed.push(`${file} ## ${section}`);
342
- for (const e of entries) {
343
- const mark = holdMark(entryHoldAgainst({ title: e.title, body: e.body }, plannedParts));
344
- listed.push(`- ${e.start}-${e.end}: ${indexTitle(e.title)}${mark}`);
516
+ // Build the rows once per section, then collapse a PLANS.md series' blocked parts (this
517
+ // plan) — BUGS.md and QUESTIONS.md sections keep every entry on its own line.
518
+ const rows = entries.map((e) => {
519
+ const hold = entryHoldAgainst({ title: e.title, body: e.body }, plannedParts);
520
+ const key = collapseWhitespace(stripEntryStamp(e.title)).toLowerCase();
521
+ return {
522
+ start: e.start,
523
+ end: e.end,
524
+ title: e.title,
525
+ hold,
526
+ mark: holdMark(hold),
527
+ claimer: claimed.get(key),
528
+ };
529
+ });
530
+ const isPlans = file === "PLANS.md" && section === "Planned";
531
+ listed.push(...(isPlans ? collapseBlockedSeries(rows) : rows.map(renderIndexRow)));
532
+ }
533
+ for (const { files, section } of CLOSED_SECTIONS) {
534
+ // The newest RECENT_CLOSED_MAX across the live section (normally empty) and the archive.
535
+ let room = RECENT_CLOSED_MAX;
536
+ for (const file of files) {
537
+ if (room <= 0)
538
+ break;
539
+ const full = path.join(root, file);
540
+ const entries = cachedTextByStat(closedSectionCache, full, full, (md) => actionableEntryRanges(md, section).slice(0, RECENT_CLOSED_MAX), (v) => v.slice())?.slice(0, room);
541
+ if (entries === undefined || entries.length === 0)
542
+ continue;
543
+ room -= entries.length;
544
+ listed.push(`${file} ## ${section} (newest ${entries.length})`);
545
+ for (const e of entries)
546
+ listed.push(`- ${e.start}-${e.end}: ${indexTitle(e.title)}`);
345
547
  }
346
548
  }
347
549
  if (listed.length === 0)
348
550
  return undefined;
349
- return `Actionable backlog — each entry with the 1-based line range it occupies in its file.
350
- Read the entries you need by those ranges; the files' closed sections make a full heading map
351
- grow with every tick.
551
+ return `Backlog index — each actionable entry, then the newest closed ones, with the 1-based line range it occupies in its file.
552
+ Read the entries you need by those ranges; the archive files (PLANS-DONE.md, BUGS-FIXED.md) grow
553
+ with every merge, so search them rather than reading them. Blocked parts of a multi-part series are
554
+ shown as one line per series.
352
555
  <backlog-index>
353
556
  ${listed.join("\n")}
354
557
  </backlog-index>`;