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
@@ -23,7 +23,7 @@ export function finishedArchiveOf(file) {
23
23
  }
24
24
  /** The repo-root backlog markdown files shared by every layer that knows them by name: the
25
25
  * tracked files roles edit and readers parse by `## ` section — read by backlog-structure.ts's
26
- * structural checks, merge/backlog-conflicts.ts's mechanical insert-conflict resolver, and
26
+ * structural checks, merge/merge-backlog-conflicts.ts's mechanical insert-conflict resolver, and
27
27
  * doctor-backlog-plans.ts's duplicate-heading check. One definition, so the three cannot disagree
28
28
  * about which files count. The archive files count too: merges insert at their top, and their
29
29
  * `## ` heading is as load-bearing as the live files'. */
@@ -134,7 +134,7 @@ export function nextSectionHeading(lines, from, fenced) {
134
134
  /** The body lines of one `## <sectionTitle>` section with fenced content stripped: the
135
135
  * sectionLines walk, then a fresh fenceTracker's filter — the shape every reader that
136
136
  * classifies a section's lines as markdown structure (entry headings, bullets, dates) walks.
137
- * Exactly three call sites today: entryDates (below), backlog-structure.ts's
137
+ * Exactly four call sites today: entryDates and entryLags (both below), backlog-structure.ts's
138
138
  * strandedPlanEntries, and stage-backlog-checks.ts's placeholderSections. The fresh
139
139
  * tracker is in sync with the document here: sectionLines
140
140
  * only recognizes a `## ` boundary outside a fence, so the section's heading line is
@@ -248,30 +248,44 @@ export function entryDates(md, sectionTitle, dateRe, first = false) {
248
248
  const dates = [];
249
249
  const pick = first ? firstDate : lastDate;
250
250
  const lines = sectionBodyLines(md, sectionTitle);
251
- for (let i = 0; i < lines.length; i++) {
252
- const line = lines[i] ?? "";
253
- if (!line.startsWith("### ") && !line.startsWith("- "))
254
- continue;
255
- const meta = headingMetadata(lines, i);
251
+ eachEntryStart(lines, ({ isBullet, meta, tail }) => {
256
252
  let date = null;
257
- if (line.startsWith("- ")) {
253
+ if (isBullet) {
258
254
  // Epitaph guard (see the doc comment): the date must live in the line's trailing
259
255
  // parenthetical beside a commit reference, or the bullet is body text, not an entry.
260
256
  // Within the parenthetical the completion is the LAST dated verb, matching the heading
261
257
  // branch: a decomposition cross-reference ("decomposed from the sibling bug fixed
262
258
  // <date>, fixed <date>") precedes the entry's own completion record.
263
- const tail = trailingParenthetical(line);
264
- if (/\bcommits?\b/.test(tail))
265
- date = pick(tail, dateRe);
259
+ const bulletTail = tail ?? "";
260
+ if (/\bcommits?\b/.test(bulletTail))
261
+ date = pick(bulletTail, dateRe);
266
262
  }
267
263
  else {
268
264
  date = pick(meta.text, dateRe);
269
265
  }
270
266
  if (date)
271
267
  dates.push(date);
268
+ });
269
+ return dates;
270
+ }
271
+ /** One walk over the entry starts inside a `## ` section's body lines, visiting each `### `
272
+ * heading or `- ` bullet line in order. An entry start is a line that begins `### ` or `- `
273
+ * (any other line is body content, skipped); `meta` is headingMetadata's join of the entry's
274
+ * metadata, `tail` is a bullet's trailing `(…)` parenthetical (null for a heading), and
275
+ * `isBullet` tells which shape it is. Advances past each entry's metadata by
276
+ * headingMetadata.next, so a later entry cannot scan the earlier one's continuation as body.
277
+ * Shared by entryDates and entryLags, whose date readings branch on the same `### `/`- `
278
+ * shape, so the two walks stay in lockstep. */
279
+ function eachEntryStart(lines, visit) {
280
+ for (let i = 0; i < lines.length; i++) {
281
+ const line = lines[i] ?? "";
282
+ if (!line.startsWith("### ") && !line.startsWith("- "))
283
+ continue;
284
+ const meta = headingMetadata(lines, i);
285
+ const isBullet = line.startsWith("- ");
286
+ visit({ isBullet, meta, tail: isBullet ? trailingParenthetical(line) : null });
272
287
  i = meta.next - 1; // The loop's ++ resumes at the first line not consumed as metadata.
273
288
  }
274
- return dates;
275
289
  }
276
290
  /** The filing-to-done span of each finished entry inside one `## <sectionTitle>` section,
277
291
  * paired with the day it completed on: `{ done, lagDays }` for every entry `entryDates` counts
@@ -287,22 +301,18 @@ export function entryDates(md, sectionTitle, dateRe, first = false) {
287
301
  export function entryLags(md, sectionTitle, dateRe, filingRe) {
288
302
  const lags = [];
289
303
  const lines = sectionBodyLines(md, sectionTitle);
290
- for (let i = 0; i < lines.length; i++) {
291
- const line = lines[i] ?? "";
292
- if (!line.startsWith("### ") && !line.startsWith("- "))
293
- continue;
294
- const meta = headingMetadata(lines, i);
304
+ eachEntryStart(lines, ({ isBullet, meta, tail }) => {
295
305
  let done = null;
296
306
  let filed = null;
297
- if (line.startsWith("- ")) {
307
+ if (isBullet) {
298
308
  // Same epitaph guard as entryDates: the completion must live in the trailing
299
309
  // parenthetical beside a commit reference, and the filing read follows the same
300
310
  // rule — sectionFilingDates matches the first stamp inside that same parenthetical
301
311
  // for a `- ` bullet, so a description-date mention before it must not pair.
302
- const tail = trailingParenthetical(line);
303
- if (/\bcommits?\b/.test(tail)) {
304
- done = lastDate(tail, dateRe);
305
- filed = firstDate(tail, filingRe);
312
+ const bulletTail = tail ?? "";
313
+ if (/\bcommits?\b/.test(bulletTail)) {
314
+ done = lastDate(bulletTail, dateRe);
315
+ filed = firstDate(bulletTail, filingRe);
306
316
  }
307
317
  }
308
318
  else {
@@ -314,8 +324,7 @@ export function entryLags(md, sectionTitle, dateRe, filingRe) {
314
324
  if (lagDays >= 0)
315
325
  lags.push({ done, lagDays });
316
326
  }
317
- i = meta.next - 1; // The loop's ++ resumes at the first line not consumed as metadata.
318
- }
327
+ });
319
328
  return lags;
320
329
  }
321
330
  /** A `### `/`- ` entry start's METADATA, joined for matching: the start line plus, for `### `
@@ -407,10 +416,12 @@ export function lastDate(text, dateRe) {
407
416
  * the filing onto a later day. This is the same first-match the canonical `filingDate`
408
417
  * (backlog.ts) applies to a heading line, so the filing read and `plan|bug --list`'s age agree
409
418
  * about when an entry opened. dateRe is cloned with the g flag so matchAll sees every date; a
410
- * text with no date yields null. Module-local: entryDates (its only caller) selects it with
411
- * `first`, so the walker stays single-homed like lastDate's completion rule (entryLags reads
412
- * it directly for an entry's filing stamp). */
413
- function firstDate(text, dateRe) {
419
+ * text with no date yields null. Exported so the heading stamp parsers that apply the same
420
+ * filing rule to a single title line — `filingDate` (backlog.ts) and `questionFilingDate`
421
+ * (question-answer.ts) — share the one first-match mechanism instead of re-stating it;
422
+ * entryDates selects it with `first`, so the walker stays single-homed like lastDate's
423
+ * completion rule. */
424
+ export function firstDate(text, dateRe) {
414
425
  const global = new RegExp(dateRe.source, dateRe.flags.includes("g") ? dateRe.flags : `${dateRe.flags}g`);
415
426
  const match = global.exec(text);
416
427
  return match?.[1] ?? null;
@@ -1,13 +1,12 @@
1
1
  /** The hold-lifting and reordering writes of `tumwater bug|plan --release <n>` and `--top <n>`:
2
- * lift an entry's Refused, Waiting-for-the-next-failure, or held-for-operator note, or move
2
+ * lift an entry's Refused or held-for-operator note, or move
3
3
  * it to the head of its open
4
4
  * section. Each comes as an exiting writer (the CLI) and a non-exiting `try…` core that refuses as
5
5
  * data (the TUI and dashboard); entry lookup and the section's file target are
6
6
  * backlog-write.ts's. */
7
- import path from "node:path";
8
- import { backlogTarget, findEntry, readEntryLines } from "./backlog-write.js";
9
- import { withoutHeldForOperatorNotes, withoutRefusalNotes, withoutWaitingNotes } from "./backlog-eligibility.js";
10
- import { readTextIfExists, writeTextAtomic } from "../files/files.js";
7
+ import { backlogTarget, findEntry, locateDashboardEntry, readEntryLines } from "./backlog-write.js";
8
+ import { withoutHeldForOperatorNotes, withoutRefusalNotes } from "./backlog-eligibility.js";
9
+ import { writeTextAtomic } from "../files/files.js";
11
10
  import { fail } from "../cli/cli-output.js";
12
11
  import { trimTrailingBlankLines } from "../text/text.js";
13
12
  import { withPrimaryWorktreeLock } from "../concurrency/lock.js";
@@ -16,29 +15,21 @@ import { withPrimaryWorktreeLock } from "../concurrency/lock.js";
16
15
  export function liftedNotePhrase(lifted) {
17
16
  return lifted === "refused"
18
17
  ? "the Refused note"
19
- : lifted === "waiting-for-failure"
20
- ? "the Waiting-for-the-next-failure note"
21
- : "the held-for-operator note"; // vocabulary-ok: the held-for-operator note's release wording
18
+ : "the held-for-operator note"; // vocabulary-ok: the held-for-operator note's release wording
22
19
  }
23
20
  /** Drop the Refused note from `entry`'s slice of `lines`, in place, and return its kind; an
24
- * entry with none loses its Waiting-for-the-next-failure note instead, and one with neither
25
- * loses its held-for-operator Needs-review/Needs-replan note (the `(held N)` count at/above
21
+ * entry with none loses its held-for-operator Needs-review note (the `(held N)` count at/above
26
22
  * threshold, plus its **Previously held N** line). Null (and `lines` untouched) when the entry
27
- * carries none of the three, shared by releaseEntry and tryReleaseEntry so they cannot drift. */
23
+ * carries neither, shared by releaseEntry and tryReleaseEntry so they cannot drift. */
28
24
  function liftRefusal(lines, entry) {
29
25
  const slice = lines.slice(entry.start - 1, entry.end);
30
26
  let kept = withoutRefusalNotes(slice);
31
27
  if (kept.length === slice.length) {
32
- kept = withoutWaitingNotes(slice);
33
- if (kept.length === slice.length) {
34
- kept = withoutHeldForOperatorNotes(slice);
35
- if (kept.length === slice.length)
36
- return null;
37
- lines.splice(entry.start - 1, slice.length, ...kept);
38
- return "held-for-operator"; // vocabulary-ok: the held-for-operator note's release wording
39
- }
28
+ kept = withoutHeldForOperatorNotes(slice);
29
+ if (kept.length === slice.length)
30
+ return null;
40
31
  lines.splice(entry.start - 1, slice.length, ...kept);
41
- return "waiting-for-failure";
32
+ return "held-for-operator"; // vocabulary-ok: the held-for-operator note's release wording
42
33
  }
43
34
  lines.splice(entry.start - 1, slice.length, ...kept);
44
35
  return "refused";
@@ -48,13 +39,10 @@ function liftRefusal(lines, entry) {
48
39
  export function tryReleaseEntry(root, kind, n, expectedTitle) {
49
40
  const { fileName, sectionTitle } = backlogTarget(kind);
50
41
  return withPrimaryWorktreeLock(root, () => {
51
- const file = path.join(root, fileName);
52
- const md = readTextIfExists(file) ?? "";
53
- const entry = findEntry(md, sectionTitle, n);
54
- if (entry === undefined)
55
- return { status: "gone" };
56
- if (entry.title !== expectedTitle)
57
- return { status: "moved", title: entry.title };
42
+ const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
43
+ if (located.status !== "ok")
44
+ return located;
45
+ const { file, md, entry } = located;
58
46
  const lines = md.split("\n");
59
47
  const lifted = liftRefusal(lines, entry);
60
48
  if (lifted === null)
@@ -63,7 +51,7 @@ export function tryReleaseEntry(root, kind, n, expectedTitle) {
63
51
  return { status: "released", file: fileName, title: entry.title, lifted };
64
52
  });
65
53
  }
66
- /** Lift the Refused, Waiting for the next failure, or held-for-operator note from the
54
+ /** Lift the Refused or held-for-operator note from the
67
55
  * `n`th entry (1-based, as `--list` numbers them) of the kind's open section and write the
68
56
  * file, under the same lock the cancel and edit writers hold, returning which hold it lifted.
69
57
  * An out-of-range position fails with locateEntry's wording; an entry with neither note
@@ -74,7 +62,7 @@ export function releaseEntry(root, kind, n) {
74
62
  const { file, lines, entry } = readEntryLines(root, fileName, sectionTitle, n);
75
63
  const lifted = liftRefusal(lines, entry);
76
64
  if (lifted === null)
77
- fail(`${kind} #${n} "${entry.title}" carries no Refused, Waiting for the next failure, or held-for-operator note`); // vocabulary-ok: the held-for-operator note's release wording
65
+ fail(`${kind} #${n} "${entry.title}" carries no Refused or held-for-operator note`); // vocabulary-ok: the held-for-operator note's release wording
78
66
  writeTextAtomic(file, lines.join("\n"));
79
67
  return { file: fileName, title: entry.title, lifted };
80
68
  });
@@ -117,13 +105,10 @@ function spliceEntryToTop(lines, sectionTitle, entry) {
117
105
  export function tryMoveEntryToTop(root, kind, n, expectedTitle) {
118
106
  const { fileName, sectionTitle } = backlogTarget(kind);
119
107
  return withPrimaryWorktreeLock(root, () => {
120
- const file = path.join(root, fileName);
121
- const md = readTextIfExists(file) ?? "";
122
- const entry = findEntry(md, sectionTitle, n);
123
- if (entry === undefined)
124
- return { status: "gone" };
125
- if (entry.title !== expectedTitle)
126
- return { status: "moved", title: entry.title };
108
+ const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
109
+ if (located.status !== "ok")
110
+ return located;
111
+ const { file, md, entry } = located;
127
112
  if (n === 1)
128
113
  return { status: "already_first", title: entry.title };
129
114
  const lines = md.split("\n");
@@ -1,5 +1,5 @@
1
1
  import { entryMatches } from "./backlog.js";
2
- import { groupBlockedSeries, partsLabel } from "./backlog-structure.js";
2
+ import { groupBlockedSeries, partsLabel } from "./backlog-index-data.js";
3
3
  import { holdMark, inProgressMark } from "./backlog-eligibility.js";
4
4
  /** One `## ` section: its entries verbatim, or the single `_(none)_` placeholder line. When
5
5
  * `holds` is given, entry `i`'s hold mark rides on its `### ` heading. */
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { increment } from "../collections.js";
3
4
  import { readTextOrNull } from "../files/files.js";
@@ -6,7 +7,6 @@ import { BACKLOG_FILES, FINISHED_ARCHIVES, fenceTracker, headingMetadata, parseE
6
7
  import { changeBaseRev, fileContentAt } from "../git/git.js";
7
8
  import { plural } from "../text/phrases.js";
8
9
  import { collapseWhitespace, truncate } from "../text/text.js";
9
- import { seriesPart } from "./backlog-parts.js";
10
10
  /** The archive files of PLANS.md and BUGS.md (FINISHED_ARCHIVES). */
11
11
  const PLANS_ARCHIVE = FINISHED_ARCHIVES[0].archive;
12
12
  const BUGS_ARCHIVE = FINISHED_ARCHIVES[1].archive;
@@ -252,7 +252,19 @@ export async function backlogStructureReason(wt, mainBranch, files) {
252
252
  const baseRev = await changeBaseRev(wt, mainBranch);
253
253
  for (const file of touched) {
254
254
  // A deleted file reads as empty: every heading the base had is gone — rule (b).
255
- const head = readTextOrNull(path.join(wt, file)) ?? "";
255
+ const full = path.join(wt, file);
256
+ // A symlink is never followed out of the worktree (it holds no heading of its own — the
257
+ // same lstat policy the backlog-conflict resolver and the false-fix guard pin, INV-G3), so
258
+ // it too reads as empty rather than bringing an arbitrary host file's headings into this
259
+ // gate's verdict.
260
+ let head = "";
261
+ try {
262
+ if (fs.lstatSync(full).isFile())
263
+ head = readTextOrNull(full) ?? "";
264
+ }
265
+ catch {
266
+ head = "";
267
+ }
256
268
  const base = await fileContentAt(wt, baseRev, file);
257
269
  const headCounts = sectionTitleCounts(head);
258
270
  const baseCounts = sectionTitleCounts(base);
@@ -356,146 +368,3 @@ the readers that scan one section, so no role sees them as backlog work:
356
368
  ${listed}
357
369
  </backlog-structure>`;
358
370
  }
359
- /** The entries under one `## <sectionTitle>` section with their 1-based line ranges, in file
360
- * order: the fence-aware walk sectionLines and parseEntryDetails share, carrying the line
361
- * numbers neither returns. An entry starts at a `### ` line and ends at the line before the
362
- * next `### ` or `## ` line (or EOF) — the same boundary parseEntryDetails closes bodies on —
363
- * so a role reading lines `start`..`end` sees exactly the entry's heading and body. A `## ` or
364
- * `### ` line inside a fenced code block is body content, never a boundary, through
365
- * backlog-md.ts's shared fenceTracker. */
366
- export function actionableEntryRanges(md, sectionTitle) {
367
- const lines = md.split("\n");
368
- const fenced = fenceTracker();
369
- const entries = [];
370
- let inSection = false;
371
- let open = null;
372
- // `end` is the 1-based number of the entry's last body line, i.e. the 0-based index of the
373
- // next heading; at EOF it is the line count (the last 1-based line number).
374
- const close = (end) => {
375
- if (open !== null)
376
- entries.push({ title: open.title, start: open.start, end });
377
- open = null;
378
- };
379
- for (let i = 0; i < lines.length; i++) {
380
- const line = lines[i] ?? "";
381
- if (fenced.inside(line))
382
- continue;
383
- if (line.startsWith("## ")) {
384
- close(i);
385
- inSection = headingTitle(line, "## ") === sectionTitle;
386
- }
387
- else if (inSection && line.startsWith("### ")) {
388
- close(i);
389
- open = { title: headingTitle(line, "### "), start: i + 1 };
390
- }
391
- }
392
- // A document ending in a newline splits to a trailing "" element; the last real line is one
393
- // less, so an entry running to EOF ends there instead of at the phantom line.
394
- close(md.endsWith("\n") ? lines.length - 1 : lines.length);
395
- return entries;
396
- }
397
- /** Per-file cache of a section's actionable ranges (actionableEntryRangesFor). */
398
- const actionableRangesCache = new Map();
399
- /** `actionableEntryRanges` for one backlog file, stat-cached per (file path, section title):
400
- * the per-poll and per-run readers (eligibility's eligibleEntries, claims' listedKeys) ask for
401
- * the same section of the same file every couple of seconds, and BUGS.md's closed history —
402
- * which the whole-file walk reads — grows without bound, so an unchanged file costs one stat
403
- * instead of a full read, split, and section walk. Keyed by file path so distinct roots never
404
- * collide, bounded inside cachedTextByStat. A missing or unreadable file reads as no ranges. The
405
- * result is copied out, so each caller owns its data. */
406
- export function actionableEntryRangesFor(root, file, sectionTitle) {
407
- const full = path.join(root, file);
408
- return (cachedTextByStat(actionableRangesCache, `${full}\u0000${sectionTitle}`, full, (md) => actionableEntryRanges(md, sectionTitle), (ranges) => ranges.slice()) ?? []);
409
- }
410
- /** True when `row` is a candidate for series-collapsing: it is a series part held only by an
411
- * unmerged prerequisite (`{ blockedBy }`, not a Refused/needs-review/needs-replan note) and no live
412
- * claim names it. Returns the row's series part when it collapses, else null. */
413
- function collapsibleSeriesPart(row) {
414
- if (row.claimer !== undefined)
415
- return null;
416
- if (typeof row.hold !== "object" || row.hold === null)
417
- return null;
418
- return seriesPart(row.title);
419
- }
420
- /** The collapsed part list for a series' blocked parts: each part as `i/n`, with a run of
421
- * consecutive numeric parts (same total, adjacent values) joined into one en-dash range
422
- * (`2/5–5/5`) and each lettered part (`2a`) listed singly. */
423
- export function partsLabel(rows) {
424
- const refs = rows.map((r) => seriesPart(r.title));
425
- const pieces = [];
426
- let start = null;
427
- let prev = null;
428
- const numeric = (p) => /^\d+$/.test(p);
429
- const flush = () => {
430
- if (start === null || prev === null)
431
- return;
432
- pieces.push(start.part === prev.part
433
- ? `${start.part}/${start.of}`
434
- : `${start.part}/${start.of}–${prev.part}/${prev.of}`);
435
- start = null;
436
- prev = null;
437
- };
438
- for (const ref of refs) {
439
- if (prev !== null &&
440
- numeric(ref.part) &&
441
- numeric(prev.part) &&
442
- Number(ref.part) === Number(prev.part) + 1 &&
443
- ref.of === prev.of) {
444
- prev = ref;
445
- continue;
446
- }
447
- flush();
448
- start = ref;
449
- prev = ref;
450
- }
451
- flush();
452
- return pieces.join(", ");
453
- }
454
- /** Renders `rows` to index lines, collapsing a multi-part series' blocked parts to one line per
455
- * series (this plan, "Compact backlog index, part 1/1"): a long series' chain of held parts would
456
- * otherwise fill every role prompt with lines no role can act on. An entry collapses when it is a
457
- * series part held only by an unmerged prerequisite and no live claim names it; a series with two
458
- * or more such parts renders as one line at the position of its first collapsed part, spanning from
459
- * that part's start to the last's end, with the part list (`2/4–4/4`) and a `[blocked: each waits
460
- * on an earlier part]` mark. A series with a single blocked part, an eligible part, a note-held
461
- * (Refused/needs-review/needs-replan) part and a claimed part keep their ordinary lines, so the
462
- * eligibility rules the feature role's charter teaches are unchanged; the collapsed mark keeps the
463
- * `[blocked: …]` prefix it already skips. */
464
- /** The grouping half of backlog-index's collapseBlockedSeries, on any rows carrying a title,
465
- * hold and claimer: each series with two or more collapsible blocked parts becomes one `series`
466
- * group at its first part's place; every other row stays a `row` group in order. */
467
- export function groupBlockedSeries(rows) {
468
- // Group the collapsible rows by series, keyed on the lowercased series name.
469
- const bySeries = new Map();
470
- for (const row of rows) {
471
- const part = collapsibleSeriesPart(row);
472
- if (part === null)
473
- continue;
474
- const key = part.series.toLowerCase();
475
- const list = bySeries.get(key) ?? [];
476
- list.push(row);
477
- bySeries.set(key, list);
478
- }
479
- // Only a series with two or more blocked parts collapses; a lone blocked part keeps its
480
- // exact `requires` line.
481
- const collapse = new Set();
482
- for (const [key, list] of bySeries)
483
- if (list.length >= 2)
484
- collapse.add(key);
485
- const groups = [];
486
- const skipped = new Set();
487
- for (const row of rows) {
488
- if (skipped.has(row))
489
- continue;
490
- const part = collapsibleSeriesPart(row);
491
- if (part !== null && collapse.has(part.series.toLowerCase())) {
492
- const group = bySeries.get(part.series.toLowerCase());
493
- groups.push({ kind: "series", series: part.series, rows: group });
494
- for (const g of group)
495
- skipped.add(g);
496
- continue;
497
- }
498
- groups.push({ kind: "row", row });
499
- }
500
- return groups;
501
- }
@@ -11,7 +11,8 @@
11
11
  * cli.ts beside the other command bodies. */
12
12
  import path from "node:path";
13
13
  import { fencedHeadingTitle, fenceTracker, headingMetadata, nextSectionHeading } from "./backlog-md.js";
14
- import { actionableEntryRanges, stripEntryStamp } from "./backlog-structure.js";
14
+ import { actionableEntryRanges } from "./backlog-index-data.js";
15
+ import { stripEntryStamp } from "./backlog-structure.js";
15
16
  import { readTextIfExists, writeTextAtomic } from "../files/files.js";
16
17
  import { formatDate } from "../text/datetime.js";
17
18
  import { fail } from "../cli/cli-output.js";
@@ -138,6 +139,24 @@ export function readEntryLines(root, fileName, sectionTitle, n) {
138
139
  const entry = locateEntry(md, fileName, sectionTitle, n);
139
140
  return { file, lines: md.split("\n"), entry };
140
141
  }
142
+ /** The title-checked entry lookup the four non-exiting dashboard writers share
143
+ * (tryCancelEntry, tryEditEntry here; tryReleaseEntry, tryMoveEntryToTop in backlog-release.ts):
144
+ * resolve `<fileName>`, read it — an absent file is an empty section, never a seed — locate the
145
+ * `n`th actionable entry, and compare its heading to `expectedTitle`. Returns `gone` when there
146
+ * is no entry at the position and `moved` when a different heading sits there, so the caller can
147
+ * refuse as data without writing; on success returns the `ok` file path, raw markdown and entry
148
+ * for the caller to splice and write its own outcome. A pure lookup, decided inside the caller's
149
+ * lock. */
150
+ export function locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle) {
151
+ const file = path.join(root, fileName);
152
+ const md = readTextIfExists(file) ?? "";
153
+ const entry = findEntry(md, sectionTitle, n);
154
+ if (entry === undefined)
155
+ return { status: "gone" };
156
+ if (entry.title !== expectedTitle)
157
+ return { status: "moved", title: entry.title };
158
+ return { status: "ok", file, md, entry };
159
+ }
141
160
  /** The section-remove entry point, the appendEntry analogue: the same cross-process lock, so a
142
161
  * cancel and a merge's working-tree fast-forward cannot each clobber the other's
143
162
  * read-modify-write. */
@@ -145,7 +164,7 @@ function removeEntry(root, fileName, sectionTitle, n) {
145
164
  return withPrimaryWorktreeLock(root, () => removeEntryLocked(root, fileName, sectionTitle, n));
146
165
  }
147
166
  /** Remove the `n`th entry (1-based, as `tumwater backlog` lists it) from `<root>/<fileName>`'s
148
- * `## <sectionTitle>` section. The ranges come from actionableEntryRanges (backlog-structure.ts),
167
+ * `## <sectionTitle>` section. The ranges come from actionableEntryRanges (backlog-index-data.ts),
149
168
  * whose walk is fence-aware, so a `## `/`### ` line quoted inside an entry's code fence is body
150
169
  * text: slicing the range removes the heading and its body whole without mistaking a quoted line
151
170
  * for the boundary. An out-of-range position (an empty section, or one holding only the
@@ -171,13 +190,10 @@ function spliceOutEntry(file, lines, entry) {
171
190
  export function tryCancelEntry(root, kind, n, expectedTitle) {
172
191
  const { fileName, sectionTitle } = backlogTarget(kind);
173
192
  return withPrimaryWorktreeLock(root, () => {
174
- const file = path.join(root, fileName);
175
- const md = readTextIfExists(file) ?? "";
176
- const entry = findEntry(md, sectionTitle, n);
177
- if (entry === undefined)
178
- return { status: "gone" };
179
- if (entry.title !== expectedTitle)
180
- return { status: "moved", title: entry.title };
193
+ const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
194
+ if (located.status !== "ok")
195
+ return located;
196
+ const { file, md, entry } = located;
181
197
  spliceOutEntry(file, md.split("\n"), entry);
182
198
  return { status: "cancelled", file: fileName, title: entry.title };
183
199
  });
@@ -191,7 +207,7 @@ function editEntry(root, fileName, sectionTitle, n, title, body) {
191
207
  /** Replace the `n`th entry (1-based, as `tumwater backlog` lists it) of `<root>/<fileName>`'s
192
208
  * `## <sectionTitle>` section with `### <title><stamp>` plus `body`, in place: the entry keeps
193
209
  * its position and its original trailing stamp parenthetical. The ranges come from
194
- * actionableEntryRanges (backlog-structure.ts), whose fence-aware walk means a `## `/`### `
210
+ * actionableEntryRanges (backlog-index-data.ts), whose fence-aware walk means a `## `/`### `
195
211
  * line quoted inside an entry's code fence is body text, so the spliced range covers exactly
196
212
  * the heading and body without mistaking a quoted line for the boundary. The stamp is the
197
213
  * trailing suffix of the JOINED heading metadata (headingMetadata folds a wrapped heading onto
@@ -249,13 +265,10 @@ export function tryEditEntry(root, kind, n, expectedTitle, rawTitle, rawBody) {
249
265
  const title = stripEntryStamp(collapseWhitespace(rawTitle));
250
266
  const body = rawBody.replace(/\r\n?/g, "\n").trim();
251
267
  return withPrimaryWorktreeLock(root, () => {
252
- const file = path.join(root, fileName);
253
- const md = readTextIfExists(file) ?? "";
254
- const entry = findEntry(md, sectionTitle, n);
255
- if (entry === undefined)
256
- return { status: "gone" };
257
- if (entry.title !== expectedTitle)
258
- return { status: "moved", title: entry.title };
268
+ const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
269
+ if (located.status !== "ok")
270
+ return located;
271
+ const { file, md, entry } = located;
259
272
  const wrote = spliceInEditedEntry(file, md.split("\n"), entry, title, body);
260
273
  return { status: wrote ? "edited" : "unchanged", file: fileName, title };
261
274
  });
@@ -348,7 +361,7 @@ function appendOperatorEntry(root, fileName, template, sectionTitle, title, body
348
361
  }
349
362
  /** Append one tumwater-filed bug under BUGS.md's `## Open` with the heading as given (no operator
350
363
  * stamp), for a caller that already holds the primary-worktree lock and commits the file itself
351
- * (backlog-archive.ts's quarantine filing). Returns the heading line's title. */
364
+ * (backlog-file-bugs.ts's quarantine filing). Returns the heading line's title. */
352
365
  export function appendBugEntryLocked(root, title, body) {
353
366
  const t = collapseWhitespace(title);
354
367
  requireBodyStructurallySound(body, "filed");
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { cachedTextByStat } from "../files/stat-cache.js";
3
- import { entryDates, entryLags, finishedArchiveOf, lastDate, parseEntryDetails } from "./backlog-md.js";
3
+ import { entryDates, entryLags, finishedArchiveOf, firstDate, lastDate, parseEntryDetails } from "./backlog-md.js";
4
+ import { CHECK_DECLARATION_MARKER } from "../roles/role-guidance.js";
4
5
  /** The stat-cached file readers of the project backlog data shown on both dashboards: planned
5
6
  * features (PLANS.md), open bugs (BUGS.md), and open questions (QUESTIONS.md). These are tracked
6
7
  * markdown that roles edit, so readers must never show a stale entry — but the dashboards poll them
@@ -78,11 +79,12 @@ export function sectionCompletionLags(root, fileName, sectionTitle, dateRe) {
78
79
  * bugs with `(found by …)` or `(reported …)`), parsed so `plan|bug --list` shows how long an entry
79
80
  * has waited, without opening the file. Read only the title — the whole heading, never the body,
80
81
  * because the stamp is the entry's own and the body may name other dates.
81
- * null when the heading carries no such clause. */
82
+ * null when the heading carries no such clause. Routed through backlog-md.ts's firstDate -
83
+ * the one first-match mechanism - with this module's own stamp regex.
84
+ * questionFilingDate (question-answer.ts) is the same recipe for the `asked by` verb. */
82
85
  const STAMP_DATE_RE = /(?:planned|reported|found by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/;
83
86
  export function filingDate(title) {
84
- const m = STAMP_DATE_RE.exec(title);
85
- return m ? m[1] : null;
87
+ return firstDate(title, STAMP_DATE_RE);
86
88
  }
87
89
  /** The completion stamp of a `## Done` plan heading. */
88
90
  export const DONE_DATE_RE = /done (\d{4}-\d{2}-\d{2})/;
@@ -168,6 +170,14 @@ export function openQuestions(root) {
168
170
  export function plannedPlanEntries(root) {
169
171
  return sectionEntries(root, "PLANS.md", "Planned");
170
172
  }
173
+ /** The PLANS.md `## Planned` entry that declares this project's deterministic check — the one
174
+ * whose body carries CHECK_DECLARATION_MARKER (the plan role writes it under the no-check note's
175
+ * direction, src/prompt/gate-notes.ts buildNoCheckNote; the feature role reads it as its task).
176
+ * Null when no planned entry declares one, so a declaration already waiting is never
177
+ * double-planned and a feature run implements only a declaration the plan role actually wrote. */
178
+ export function declarationPlanEntry(root) {
179
+ return plannedPlanEntries(root).find((e) => e.body.includes(CHECK_DECLARATION_MARKER)) ?? null;
180
+ }
171
181
  /** Open bugs as full entries (title + body), in file order. Missing or unreadable → []. */
172
182
  export function openBugEntries(root) {
173
183
  return sectionEntries(root, "BUGS.md", "Open");