tumwater 0.4.0 → 0.4.2

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 (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -1,6 +1,6 @@
1
1
  /** Backlog eligibility: which PLANS.md / BUGS.md entries a work role may take now (plans,
2
- * parallel-work-instances, part 2/7). An entry is held when its body carries a **Refused …**,
3
- * **Needs review …**, **Needs replan …** or **Waiting for the next failure …** note, or when
2
+ * parallel-work-instances, part 2/7). An entry is held when its body carries a **Refused …**
3
+ * or **Needs review …** note, or when
4
4
  * its heading's trailing parenthetical
5
5
  * (or a parenthetical left open by a wrapped heading) names a prerequisite (`requires parts
6
6
  * 1/5–3/5 landed`) that is itself still listed under `## Planned`. The parsers are pure over
@@ -11,24 +11,26 @@ import path from "node:path";
11
11
  import { statOrNull } from "../files/files.js";
12
12
  import { allLiveClaims } from "../runner/runner-state-load.js";
13
13
  import { escapeRegExp, firstTrimmedLineStartingWith } from "../text/text.js";
14
- import { HELD_TAG_PREFIX, NEEDS_REPLAN_PREFIX, NEEDS_REVIEW_PREFIX, PRIOR_HELD_PREFIX, WAITING_FOR_FAILURE_PREFIX } from "../roles/role-guidance.js";
14
+ import { HELD_TAG_PREFIX, NEEDS_REVIEW_PREFIX, PRIOR_HELD_PREFIX } from "../roles/role-guidance.js";
15
15
  import { baseRoleOf } from "../roles/runner-ids.js";
16
16
  import { roleLabel } from "../roles/role-labels.js";
17
- import { actionableEntryRangesFor } from "./backlog-structure.js";
17
+ import { actionableEntryRangesFor } from "./backlog-index-data.js";
18
18
  import { entryKey, partKey, plannedPartKeys, plansWaitingOn, requiredParts, seriesPart } from "./backlog-parts.js";
19
19
  import { finishedFiles, openBugEntries, plannedPlanEntries } from "./backlog.js";
20
- /** The number of holds an entry's count must reach before a Needs-review/Needs-replan note
20
+ /** The number of holds an entry's count must reach before a Needs-review note
21
21
  * becomes a `held-for-operator` hold (the operator's decision, surfaced by parts 2/3 and 3/3). */
22
22
  export const OPERATOR_HOLD_THRESHOLD = 3;
23
- /** The held count a body records: the integer in the first Needs-review or Needs-replan note
24
- * line's `(held N)` tag, else the integer in a `**Previously held N**` line, else 0. A present
25
- * but non-numeric `(held …)` tag is 0 (and stops the scan), so a malformed tag never throws.
26
- * Pure over the body text — the data-layer read half of the convergence backstop. */
23
+ /** The held count a body records: the integer in the first Needs-review note
24
+ * line's `(held N)` tag, else 0. A present but non-numeric `(held …)` tag is 0 (and stops the
25
+ * scan), so a malformed tag never throws. Pure over the body text — the data-layer read half of
26
+ * the convergence backstop. No writer emits a `(held N)` tag anymore and the `**Previously held
27
+ * N**` markers left on disk are never read, so a freshly written flat note always reads 0 and a
28
+ * new hold never escalates to the operator's desk. */
27
29
  export function heldCount(body) {
28
30
  const tagRe = new RegExp(`${escapeRegExp(HELD_TAG_PREFIX)}(\\d+)\\)`);
29
31
  for (const raw of body.split("\n")) {
30
32
  const line = raw.trimStart();
31
- const note = line.startsWith(NEEDS_REVIEW_PREFIX) || line.startsWith(NEEDS_REPLAN_PREFIX);
33
+ const note = line.startsWith(NEEDS_REVIEW_PREFIX);
32
34
  if (!note)
33
35
  continue;
34
36
  const tag = tagRe.exec(line);
@@ -37,29 +39,22 @@ export function heldCount(body) {
37
39
  if (line.includes(HELD_TAG_PREFIX))
38
40
  return 0;
39
41
  }
40
- for (const raw of body.split("\n")) {
41
- const line = raw.trimStart();
42
- if (!line.startsWith(PRIOR_HELD_PREFIX))
43
- continue;
44
- const count = /\d+/.exec(line.slice(PRIOR_HELD_PREFIX.length));
45
- return count === null ? 0 : Number(count[0]);
46
- }
47
42
  return 0;
48
43
  }
49
- /** The `(held N)` count on a single Needs-review or Needs-replan note line, or undefined when the
44
+ /** The `(held N)` count on a single Needs-review note line, or undefined when the
50
45
  * line is not such a note or carries no numeric tag. The per-line form of heldCount, so a writer
51
46
  * that drops notes (withoutHeldForOperatorNotes) can gate on the same threshold without scanning
52
47
  * the whole body. */
53
48
  function lineHeldCount(line) {
54
49
  const trimmed = line.trimStart();
55
- const isNote = trimmed.startsWith(NEEDS_REVIEW_PREFIX) || trimmed.startsWith(NEEDS_REPLAN_PREFIX);
50
+ const isNote = trimmed.startsWith(NEEDS_REVIEW_PREFIX);
56
51
  if (!isNote)
57
52
  return undefined;
58
53
  const tag = new RegExp(`${escapeRegExp(HELD_TAG_PREFIX)}(\\d+)\\)`).exec(trimmed);
59
54
  return tag === null ? undefined : Number(tag[1]);
60
55
  }
61
56
  /** The `**Refused …` note prefix a refusing run writes. Every hold note — this one and the
62
- * imported Needs-review and Needs-replan prefixes — is matched as a line prefix (hasNoteLine)
57
+ * imported Needs-review prefix — is matched as a line prefix (hasNoteLine)
63
58
  * so a mention in prose never holds an entry. */
64
59
  const REFUSED_PREFIX = "**Refused ";
65
60
  /** True when `body` carries a line (ignoring leading indentation) that begins with `prefix`.
@@ -79,9 +74,11 @@ function noteText(body, prefix) {
79
74
  export function refusalNote(body) {
80
75
  return noteText(body, REFUSED_PREFIX);
81
76
  }
82
- /** The text of `body`'s Waiting-for-the-next-failure note, or null when it has none. */
83
- export function waitingNote(body) {
84
- return noteText(body, WAITING_FOR_FAILURE_PREFIX);
77
+ /** The text of `body`'s Needs-review note, or null when it has none (entryHold's
78
+ * "needs-review" hold). A needs-review note parks the entry (an unreproducible bug, a plan
79
+ * judged too large, a rejected plan), so the stale-bug checks skip it like a refused one. */
80
+ export function needsReviewNote(body) {
81
+ return noteText(body, NEEDS_REVIEW_PREFIX);
85
82
  }
86
83
  /** `lines` without any line starting with `prefix` (the same line-prefix rule hasNoteLine
87
84
  * applies). When a dropped note sat between two blank lines, the blank line after it goes too,
@@ -99,17 +96,12 @@ function withoutNoteLines(lines, prefix) {
99
96
  }
100
97
  return out;
101
98
  }
102
- /** `lines` without any Refused note line. Every other line, Needs-review, needs-replan and
103
- * waiting notes included, is kept, so refusal lifting never lifts another hold. */
99
+ /** `lines` without any Refused note line. Every other line, Needs-review notes included, is
100
+ * kept, so refusal lifting never lifts another hold. */
104
101
  export function withoutRefusalNotes(lines) {
105
102
  return withoutNoteLines(lines, REFUSED_PREFIX);
106
103
  }
107
- /** `lines` without any Waiting-for-the-next-failure note line, collapsing blank lines as
108
- * withoutRefusalNotes does. */
109
- export function withoutWaitingNotes(lines) {
110
- return withoutNoteLines(lines, WAITING_FOR_FAILURE_PREFIX);
111
- }
112
- /** `lines` with every Needs-review/Needs-replan note whose `(held N)` count reached
104
+ /** `lines` with every Needs-review note whose `(held N)` count reached
113
105
  * OPERATOR_HOLD_THRESHOLD removed, together with its `**Previously held N−1**` line (the prior
114
106
  * count the increment rule recorded beside the live `(held N)`), so a `--release` lift leaves
115
107
  * neither behind and the entry becomes eligible. Drops nothing when no note is at or past the
@@ -203,9 +195,9 @@ export function waitingPartRanges(root, title) {
203
195
  return out;
204
196
  }
205
197
  /** Why `entry` is held against the still-`planned` entries, or null when it may be taken: a
206
- * `**Refused …` line (refused), a Needs-review line (needs-review), a Needs-replan line
207
- * (needs-replan — the plan role owns it), or a prerequisite `(series, part)` still among
208
- * `planned` ({ blockedBy }). Series compare case-insensitively; part tokens compare verbatim.
198
+ * `**Refused …` line (refused), a Needs-review line (needs-review), or a prerequisite
199
+ * `(series, part)` still among `planned` ({ blockedBy }). Series compare case-insensitively;
200
+ * part tokens compare verbatim.
209
201
  * Bodies mentioning "requires" are never consulted — only the heading's trailing or
210
202
  * still-open parenthetical. */
211
203
  export function entryHold(entry, planned) {
@@ -217,17 +209,13 @@ export function entryHold(entry, planned) {
217
209
  export function entryHoldAgainst(entry, plannedParts) {
218
210
  if (hasNoteLine(entry.body, REFUSED_PREFIX))
219
211
  return "refused";
220
- if (hasNoteLine(entry.body, NEEDS_REVIEW_PREFIX) || hasNoteLine(entry.body, NEEDS_REPLAN_PREFIX)) {
212
+ if (hasNoteLine(entry.body, NEEDS_REVIEW_PREFIX)) {
221
213
  // A note whose held count reached OPERATOR_HOLD_THRESHOLD stops out of the role loop:
222
- // neither the plan role (planBacklogFull tests only these two) nor this role takes it.
214
+ // neither the plan role (planBacklogFull) nor this role takes it.
223
215
  return heldCount(entry.body) >= OPERATOR_HOLD_THRESHOLD
224
216
  ? "held-for-operator" // vocabulary-ok: the operator-held string names the operator hold decision
225
- : hasNoteLine(entry.body, NEEDS_REVIEW_PREFIX)
226
- ? "needs-review"
227
- : "needs-replan";
217
+ : "needs-review";
228
218
  }
229
- if (hasNoteLine(entry.body, WAITING_FOR_FAILURE_PREFIX))
230
- return "waiting-for-failure";
231
219
  const refs = requiredParts(entry.title);
232
220
  if (refs.length === 0)
233
221
  return null;
@@ -247,12 +235,8 @@ export function holdMark(hold) {
247
235
  return " [blocked: refused]";
248
236
  if (hold === "needs-review")
249
237
  return " [blocked: needs review]";
250
- if (hold === "needs-replan")
251
- return " [blocked: needs replan]";
252
238
  if (hold === "held-for-operator")
253
239
  return " [blocked: held for operator]"; // vocabulary-ok: this hold names the operator-held wording for the operator decision
254
- if (hold === "waiting-for-failure")
255
- return " [blocked: waiting for the next failure]";
256
240
  return ` [blocked: requires ${hold.blockedBy.join(", ")}]`;
257
241
  }
258
242
  /** The line `plan|bug --release` and `--top` print when the entry they touched is still held:
@@ -359,13 +343,13 @@ export function operatorHeldEntries(root) {
359
343
  export function entryKindLabel(file) {
360
344
  return file === "bugs" ? "Bug" : "Plan";
361
345
  }
362
- /** The planned plans and open bugs whose hold is exactly `"needs-review"` or
363
- * `"needs-replan"`, in file order: the entries parked by a Needs-review/Needs-replan note
364
- * until the plan role splits or replans them (the `stalled-entries` team alert's input), each
365
- * carrying its backlog file, 0-based index and the hold. Same `backlogHolds(root)` + the
366
- * planned/`openBugEntries` readers `operatorHeldEntries` uses, so the alert and the doctor's
367
- * `stalled entries` row classify through the same `entryHold` and cannot disagree on which
368
- * holds are role-resolvable. A held-for-operator entry (a note at the held threshold) is
346
+ /** The planned plans and open bugs whose hold is exactly `"needs-review"`, in file order: the
347
+ * entries parked by a Needs-review note until the plan role splits or replans them (the
348
+ * `stalled-entries` team alert's input), each carrying its backlog file, 0-based index and the
349
+ * hold. Same `backlogHolds(root)` + the planned/`openBugEntries` readers `operatorHeldEntries`
350
+ * uses, so the alert and the doctor's `stalled entries` row classify through the same
351
+ * `entryHold` and cannot disagree on which holds are role-resolvable. A held-for-operator
352
+ * entry (a note at the held threshold) is
369
353
  * excluded — it feeds `operatorHeldEntries` and the `operator-held` alert instead. A missing
370
354
  * file yields `[]`. */
371
355
  export function stalledEntries(root) {
@@ -374,7 +358,7 @@ export function stalledEntries(root) {
374
358
  const entriesOf = (entries, held, file) => {
375
359
  entries.forEach((e, i) => {
376
360
  const hold = held[i];
377
- if (hold === "needs-review" || hold === "needs-replan")
361
+ if (hold === "needs-review")
378
362
  out.push({ file, index: i, title: e.title, hold });
379
363
  });
380
364
  };
@@ -426,17 +410,25 @@ export function cancelledClaimNote(runner) {
426
410
  ? ""
427
411
  : `${roleLabel(runner)} is still working on it — its run goes on; \`tumwater abort --role ${runner}\` stops it`;
428
412
  }
413
+ /** The backlog file and section a claim-taking role's entries live under: BUGS.md's `## Open`
414
+ * for bugfix, PLANS.md's `## Planned` for every other base. An instance id resolves through its
415
+ * base role. The one home of the role → (file, section) mapping, so eligibility (eligibleEntries
416
+ * below) and claim staging (scheduling/claims.ts) read a claim's line range from the right file
417
+ * and cannot drift on which section a role owns. */
418
+ export function roleSection(role) {
419
+ return baseRoleOf(role) === "bugfix"
420
+ ? { file: "BUGS.md", section: "Open" }
421
+ : { file: "PLANS.md", section: "Planned" };
422
+ }
429
423
  /** The entries `role` may take now, in file order: PLANS.md's `## Planned` entries for
430
424
  * feature, BUGS.md's `## Open` entries for bugfix, minus every entry entryHold holds, each with
431
425
  * its stamp-free key, verbatim title and 1-based line range. Reads the primary checkout through
432
426
  * the same stat-cached readers the index uses; a missing or unreadable file yields []. */
433
427
  export function eligibleEntries(root, role) {
434
- // An instance id (`bugfix-2`) resolves through its base role, exactly as claims.ts's
435
- // roleSection does, so a claim's line range is read from the right file.
436
- const bugfix = baseRoleOf(role) === "bugfix";
437
- const file = bugfix ? "BUGS.md" : "PLANS.md";
438
- const section = bugfix ? "Open" : "Planned";
439
- const entries = bugfix ? openBugEntries(root) : plannedPlanEntries(root);
428
+ // An instance id (`bugfix-2`) resolves through its base role, as roleSection does, so a
429
+ // claim's line range and the entry list are read from the right file.
430
+ const { file, section } = roleSection(role);
431
+ const entries = section === "Open" ? openBugEntries(root) : plannedPlanEntries(root);
440
432
  const ranges = actionableEntryRangesFor(root, file, section);
441
433
  const planned = plannedPlanEntries(root);
442
434
  const parts = plannedPartKeys(planned);
@@ -0,0 +1,133 @@
1
+ /** File brand-new BUGS.md `## Open` entries (operator bug reports, quarantined flaky tests) on
2
+ * the base branch as one `tumwater(operator):` commit, under the merge lock and the
3
+ * primary-worktree lock. Split out of backlog-archive.ts, whose name and doc cover archiving
4
+ * finished entries to the archive files, not appending new open entries. */
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { withLock, withPrimaryWorktreeLockAsync } from "../concurrency/lock.js";
8
+ import { readTextIfExists, writeTextAtomic } from "../files/files.js";
9
+ import { commitAuthorArgs, committerIdentArgs, git, gitTry } from "../git/git-run.js";
10
+ import { stampedSubject } from "../git/commit-message.js";
11
+ import { mergeLockDir } from "../paths.js";
12
+ import { plural } from "../text/phrases.js";
13
+ import { collapseWhitespace, errorFirstLine } from "../text/text.js";
14
+ import { formatDate } from "../text/datetime.js";
15
+ import { parseEntryDetails } from "./backlog-md.js";
16
+ import { appendBugEntryLocked } from "./backlog-write.js";
17
+ import { primaryOffBaseBranchReason } from "./backlog-archive.js";
18
+ /** The start of a quarantine entry's title for `test`, up to the date. appendBugEntryLocked
19
+ * collapses whitespace in the title, so the test part is collapsed the same way; collapsing the
20
+ * whole prefix would trim its trailing space. */
21
+ function quarantineTitlePrefix(test) {
22
+ return collapseWhitespace(`\`${test}\` failed, then passed on the same tree`) + " (quarantined by tumwater ";
23
+ }
24
+ /** Output fenced into a bug body: a fence of its own length cannot be closed by the quoted text. */
25
+ function fencedTail(tail) {
26
+ return ["~~~~", ...tail.replaceAll("~~~~", "~~~").split("\n").slice(-30), "~~~~"].join("\n");
27
+ }
28
+ /** File one BUGS.md `## Open` entry per given entry and commit BUGS.md on the base branch as one
29
+ * `tumwater(operator):` commit (the subject built from the number of entries actually appended is
30
+ * stamped under the operator role), under the merge lock and the primary-worktree lock exactly as
31
+ * archiveFinishedOnMain does. Skipped,
32
+ * writing nothing, when the primary checkout is off the base branch, BUGS.md has uncommitted
33
+ * changes, or BUGS.md exists but cannot be read; a failed append or commit restores BUGS.md.
34
+ * Every entry is appended verbatim as its own `## Open` heading — unless its `ifOpen` dedupe
35
+ * matches an existing heading, which is reused instead — and the returned headings are the
36
+ * entry headings in input order. */
37
+ export async function fileBugsOnMain(root, entries, subject, opts = {}) {
38
+ return withLock(mergeLockDir(root), () => withPrimaryWorktreeLockAsync(root, async () => {
39
+ const offBase = await primaryOffBaseBranchReason(root);
40
+ if (offBase !== null)
41
+ return { status: "skipped", reason: offBase };
42
+ const status = await gitTry(root, "status", "--porcelain", "--", "BUGS.md");
43
+ if (status === null)
44
+ return { status: "skipped", reason: "could not read git status" };
45
+ if (status.trim() !== "" && !status.startsWith("??")) {
46
+ return { status: "skipped", reason: "BUGS.md has uncommitted changes" };
47
+ }
48
+ const file = path.join(root, "BUGS.md");
49
+ let before;
50
+ try {
51
+ before = readTextIfExists(file);
52
+ }
53
+ catch (err) {
54
+ return { status: "skipped", reason: `BUGS.md could not be read: ${errorFirstLine(err)}` };
55
+ }
56
+ const open = before === null ? [] : parseEntryDetails(before, "Open").map((e) => e.title);
57
+ const headings = [];
58
+ let appended = 0;
59
+ const restoreBugs = async () => {
60
+ await gitTry(root, "reset", "-q", "--", "BUGS.md");
61
+ if (before === null)
62
+ fs.rmSync(file, { force: true });
63
+ else
64
+ writeTextAtomic(file, before);
65
+ };
66
+ try {
67
+ for (const entry of entries) {
68
+ if (entry.ifOpen !== undefined) {
69
+ const existing = entry.ifOpen(open);
70
+ if (existing !== undefined) {
71
+ headings.push(existing);
72
+ continue;
73
+ }
74
+ }
75
+ headings.push(appendBugEntryLocked(root, entry.title, entry.body));
76
+ appended++;
77
+ }
78
+ }
79
+ catch (err) {
80
+ await restoreBugs();
81
+ return { status: "skipped", reason: `a BUGS.md entry could not be written: ${errorFirstLine(err)}` };
82
+ }
83
+ if (appended === 0)
84
+ return { status: "filed", headings };
85
+ const commitSubject = stampedSubject("operator", // vocabulary-ok: the tumwater(operator): commit prefix is a machine string
86
+ subject(appended));
87
+ try {
88
+ await git(root, "add", "--", "BUGS.md");
89
+ await git(root, ...(await committerIdentArgs(root)), "commit", ...commitAuthorArgs(), "--only", "-m", commitSubject, "--", "BUGS.md");
90
+ }
91
+ catch (err) {
92
+ await restoreBugs();
93
+ return { status: "skipped", reason: `the commit failed: ${errorFirstLine(err)}` };
94
+ }
95
+ return { status: "filed", headings };
96
+ }), opts.lockTimeoutMs);
97
+ }
98
+ /** File one BUGS.md `## Open` entry per newly quarantined test and commit BUGS.md on the base
99
+ * branch as one `tumwater(operator):` commit, delegating the locked write/commit to
100
+ * fileBugsOnMain and preserving the existing subject. A test whose quarantine entry is already
101
+ * under `## Open` (a filing whose markQuarantineFiled failed after the commit) gets that heading
102
+ * back and is not filed again — the dedupe runs inside fileBugsOnMain under the merge lock (via
103
+ * each entry's `ifOpen`), so concurrent quarantine filings cannot both append a duplicate, and
104
+ * the real skip on a dirty or off-base BUGS.md is enforced under the same lock. */
105
+ export async function fileQuarantineBugsOnMain(root, tests, opts = {}) {
106
+ const date = formatDate(new Date());
107
+ const toFile = [];
108
+ for (const { test, tail } of tests) {
109
+ const prefix = quarantineTitlePrefix(test);
110
+ const body = [
111
+ `The check failed on this test and then passed on a re-run of the same tree, so tumwater quarantined it: it counts as a known failure until this entry leaves \`## Open\`. Find why it is unstable and fix it.`,
112
+ ...(tail ? ["", fencedTail(tail)] : []),
113
+ ].join("\n");
114
+ toFile.push({
115
+ title: `${prefix}${date})`,
116
+ body,
117
+ // Dedupe under the lock, against the headings fileBugsOnMain reads: the reused heading
118
+ // (not a duplicate) is returned in headings and zipped back to this test below.
119
+ ifOpen: (open) => open.find((t) => t.startsWith(prefix)),
120
+ });
121
+ }
122
+ const outcome = await fileBugsOnMain(root, toFile,
123
+ // Size the subject by what was actually appended: tests whose `## Open` entry was already
124
+ // present are reused, not filed, so a mixed batch keeps the original "File N …" wording
125
+ // with N the count of new entries only.
126
+ (appended) => `File ${plural(appended, "quarantined flaky test", "quarantined flaky tests")} in BUGS.md`, opts);
127
+ if (outcome.status === "skipped")
128
+ return { status: "skipped", reason: outcome.reason };
129
+ // fileBugsOnMain's headings align with `tests` in order (reused headings or appended titles).
130
+ const bugs = new Map();
131
+ outcome.headings.forEach((heading, i) => bugs.set(tests[i].test, heading));
132
+ return { status: "filed", bugs };
133
+ }
@@ -0,0 +1,159 @@
1
+ /** The data half of the `<backlog-index>` block every role prompt carries: the 1-based line
2
+ * ranges each actionable entry occupies in its file (actionableEntryRanges/
3
+ * actionableEntryRangesFor) and the collapsed blocked-series rows
4
+ * (groupBlockedSeries/partsLabel) that back it. Extracted from backlog-structure.ts
5
+ * (organized 2026-10-25) so that module keeps only the backlog's structural-correctness checks
6
+ * (stranded plans, duplicate and readded headings, the new-plan-under-Done gate) and this one
7
+ * holds what the multiple index readers share — the role prompt's index block
8
+ * (backlog-index.ts), the eligibility and claims readers, the archive, write and release
9
+ * paths, and the status/tui surfaces all ask for the same section walk of the same file, so
10
+ * the stat-cached range walk (actionableEntryRangesFor) is the one shared helper. The walk
11
+ * and series grouping are pure markdown reading — no pi, no git. Rendering of the block
12
+ * lives in backlog-index.ts; this module holds only the data the surfaces share. */
13
+ import path from "node:path";
14
+ import { cachedTextByStat } from "../files/stat-cache.js";
15
+ import { fenceTracker, headingTitle } from "./backlog-md.js";
16
+ import { seriesPart } from "./backlog-parts.js";
17
+ /** The entries under one `## <sectionTitle>` section with their 1-based line ranges, in file
18
+ * order: the fence-aware walk sectionLines and parseEntryDetails share, carrying the line
19
+ * numbers neither returns. An entry starts at a `### ` line and ends at the line before the
20
+ * next `### ` or `## ` line (or EOF) — the same boundary parseEntryDetails closes bodies on —
21
+ * so a role reading lines `start`..`end` sees exactly the entry's heading and body. A `## ` or
22
+ * `### ` line inside a fenced code block is body content, never a boundary, through
23
+ * backlog-md.ts's shared fenceTracker. */
24
+ export function actionableEntryRanges(md, sectionTitle) {
25
+ const lines = md.split("\n");
26
+ const fenced = fenceTracker();
27
+ const entries = [];
28
+ let inSection = false;
29
+ let open = null;
30
+ // `end` is the 1-based number of the entry's last body line, i.e. the 0-based index of the
31
+ // next heading; at EOF it is the line count (the last 1-based line number).
32
+ const close = (end) => {
33
+ if (open !== null)
34
+ entries.push({ title: open.title, start: open.start, end });
35
+ open = null;
36
+ };
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const line = lines[i] ?? "";
39
+ if (fenced.inside(line))
40
+ continue;
41
+ if (line.startsWith("## ")) {
42
+ close(i);
43
+ inSection = headingTitle(line, "## ") === sectionTitle;
44
+ }
45
+ else if (inSection && line.startsWith("### ")) {
46
+ close(i);
47
+ open = { title: headingTitle(line, "### "), start: i + 1 };
48
+ }
49
+ }
50
+ // A document ending in a newline splits to a trailing "" element; the last real line is one
51
+ // less, so an entry running to EOF ends there instead of at the phantom line.
52
+ close(md.endsWith("\n") ? lines.length - 1 : lines.length);
53
+ return entries;
54
+ }
55
+ /** Per-file cache of a section's actionable ranges (actionableEntryRangesFor). */
56
+ const actionableRangesCache = new Map();
57
+ /** `actionableEntryRanges` for one backlog file, stat-cached per (file path, section title):
58
+ * the per-poll and per-run readers (eligibility's eligibleEntries, claims' listedKeys) ask for
59
+ * the same section of the same file every couple of seconds, and BUGS.md's closed history —
60
+ * which the whole-file walk reads — grows without bound, so an unchanged file costs one stat
61
+ * instead of a full read, split, and section walk. Keyed by file path so distinct roots never
62
+ * collide, bounded inside cachedTextByStat. A missing or unreadable file reads as no ranges. The
63
+ * result is copied out, so each caller owns its data. */
64
+ export function actionableEntryRangesFor(root, file, sectionTitle) {
65
+ const full = path.join(root, file);
66
+ return (cachedTextByStat(actionableRangesCache, `${full}\u0000${sectionTitle}`, full, (md) => actionableEntryRanges(md, sectionTitle), (ranges) => ranges.slice()) ?? []);
67
+ }
68
+ /** True when `row` is a candidate for series-collapsing: it is a series part held only by an
69
+ * unmerged prerequisite (`{ blockedBy }`, not a Refused/needs-review note) and no live
70
+ * claim names it. Returns the row's series part when it collapses, else null. */
71
+ function collapsibleSeriesPart(row) {
72
+ if (row.claimer !== undefined)
73
+ return null;
74
+ if (typeof row.hold !== "object" || row.hold === null)
75
+ return null;
76
+ return seriesPart(row.title);
77
+ }
78
+ /** The collapsed part list for a series' blocked parts: each part as `i/n`, with a run of
79
+ * consecutive numeric parts (same total, adjacent values) joined into one en-dash range
80
+ * (`2/5–5/5`) and each lettered part (`2a`) listed singly. */
81
+ export function partsLabel(rows) {
82
+ const refs = rows.map((r) => seriesPart(r.title));
83
+ const pieces = [];
84
+ let start = null;
85
+ let prev = null;
86
+ const numeric = (p) => /^\d+$/.test(p);
87
+ const flush = () => {
88
+ if (start === null || prev === null)
89
+ return;
90
+ pieces.push(start.part === prev.part
91
+ ? `${start.part}/${start.of}`
92
+ : `${start.part}/${start.of}–${prev.part}/${prev.of}`);
93
+ start = null;
94
+ prev = null;
95
+ };
96
+ for (const ref of refs) {
97
+ if (prev !== null &&
98
+ numeric(ref.part) &&
99
+ numeric(prev.part) &&
100
+ Number(ref.part) === Number(prev.part) + 1 &&
101
+ ref.of === prev.of) {
102
+ prev = ref;
103
+ continue;
104
+ }
105
+ flush();
106
+ start = ref;
107
+ prev = ref;
108
+ }
109
+ flush();
110
+ return pieces.join(", ");
111
+ }
112
+ /** Renders `rows` to index lines, collapsing a multi-part series' blocked parts to one line per
113
+ * series (this plan, "Compact backlog index, part 1/1"): a long series' chain of held parts would
114
+ * otherwise fill every role prompt with lines no role can act on. An entry collapses when it is a
115
+ * series part held only by an unmerged prerequisite and no live claim names it; a series with two
116
+ * or more such parts renders as one line at the position of its first collapsed part, spanning from
117
+ * that part's start to the last's end, with the part list (`2/4–4/4`) and a `[blocked: each waits
118
+ * on an earlier part]` mark. A series with a single blocked part, an eligible part, a note-held
119
+ * (Refused/needs-review) part and a claimed part keep their ordinary lines, so the
120
+ * eligibility rules the feature role's charter teaches are unchanged; the collapsed mark keeps the
121
+ * `[blocked: …]` prefix it already skips. */
122
+ /** The grouping half of backlog-index's collapseBlockedSeries, on any rows carrying a title,
123
+ * hold and claimer: each series with two or more collapsible blocked parts becomes one `series`
124
+ * group at its first part's place; every other row stays a `row` group in order. */
125
+ export function groupBlockedSeries(rows) {
126
+ // Group the collapsible rows by series, keyed on the lowercased series name.
127
+ const bySeries = new Map();
128
+ for (const row of rows) {
129
+ const part = collapsibleSeriesPart(row);
130
+ if (part === null)
131
+ continue;
132
+ const key = part.series.toLowerCase();
133
+ const list = bySeries.get(key) ?? [];
134
+ list.push(row);
135
+ bySeries.set(key, list);
136
+ }
137
+ // Only a series with two or more blocked parts collapses; a lone blocked part keeps its
138
+ // exact `requires` line.
139
+ const collapse = new Set();
140
+ for (const [key, list] of bySeries)
141
+ if (list.length >= 2)
142
+ collapse.add(key);
143
+ const groups = [];
144
+ const skipped = new Set();
145
+ for (const row of rows) {
146
+ if (skipped.has(row))
147
+ continue;
148
+ const part = collapsibleSeriesPart(row);
149
+ if (part !== null && collapse.has(part.series.toLowerCase())) {
150
+ const group = bySeries.get(part.series.toLowerCase());
151
+ groups.push({ kind: "series", series: part.series, rows: group });
152
+ for (const g of group)
153
+ skipped.add(g);
154
+ continue;
155
+ }
156
+ groups.push({ kind: "row", row });
157
+ }
158
+ return groups;
159
+ }
@@ -2,16 +2,18 @@
2
2
  * entry (PLANS.md ## Planned, BUGS.md ## Open, QUESTIONS.md ## Open) plus the newest closed
3
3
  * ones, each with the 1-based line range it occupies in its file — the bounded replacement for
4
4
  * the per-run `grep -n '^##'` heading map. The entry-range parsing (actionableEntryRanges) and
5
- * series collapsing (groupBlockedSeries, partsLabel) it builds on live in backlog-structure.ts
6
- * alongside the stranded-plan checks; this module holds only the block's rendering and its
7
- * per-file stat caches. Extracted from backlog-structure.ts (organized 2026-10-19) so that
8
- * module no longer carries a second responsibility. */
5
+ * series collapsing (groupBlockedSeries, partsLabel) it builds on live in
6
+ * backlog-index-data.ts; this module holds only the block's rendering and its per-file stat
7
+ * caches. Extracted from backlog-structure.ts (organized 2026-10-19, the data half split to
8
+ * backlog-index-data.ts 2026-10-25) so that module keeps only the backlog's structural
9
+ * checks. */
9
10
  import path from "node:path";
10
11
  import { cachedTextByStat } from "../files/stat-cache.js";
11
12
  import { FINISHED_ARCHIVES } from "./backlog-md.js";
12
13
  import { allLiveClaims } from "../runner/runner-state-load.js";
13
14
  import { collapseWhitespace, truncate } from "../text/text.js";
14
- import { actionableEntryRanges, groupBlockedSeries, partsLabel, stripEntryStamp, } from "./backlog-structure.js";
15
+ import { actionableEntryRanges, groupBlockedSeries, partsLabel, } from "./backlog-index-data.js";
16
+ import { stripEntryStamp } from "./backlog-structure.js";
15
17
  import { entryHoldAgainst, holdMark } from "./backlog-eligibility.js";
16
18
  import { plannedPartKeys } from "./backlog-parts.js";
17
19
  import { plannedPlanEntries } from "./backlog.js";