tumwater 0.4.1 → 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 (186) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/backend-traits.js +14 -0
  4. package/dist/src/agent/claude/claude-stream.js +27 -31
  5. package/dist/src/agent/codex/codex-rollout.js +38 -24
  6. package/dist/src/agent/codex/codex-stream.js +71 -38
  7. package/dist/src/agent/image-input.js +18 -87
  8. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  9. package/dist/src/agent/opencode/opencode-stream.js +4 -4
  10. package/dist/src/agent/run-agent.js +11 -2
  11. package/dist/src/agent/run-settle.js +22 -13
  12. package/dist/src/agent/run-spawn.js +3 -12
  13. package/dist/src/agent/run-stream.js +2 -10
  14. package/dist/src/agent/session-ledger.js +16 -0
  15. package/dist/src/backlog/backlog-archive.js +64 -165
  16. package/dist/src/backlog/backlog-eligibility.js +50 -58
  17. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  18. package/dist/src/backlog/backlog-index-data.js +2 -2
  19. package/dist/src/backlog/backlog-md.js +38 -27
  20. package/dist/src/backlog/backlog-release.js +11 -19
  21. package/dist/src/backlog/backlog-structure.js +14 -1
  22. package/dist/src/backlog/backlog-write.js +1 -1
  23. package/dist/src/backlog/backlog.js +5 -4
  24. package/dist/src/backlog/question-answer.js +30 -14
  25. package/dist/src/baseline/main-baseline.js +30 -20
  26. package/dist/src/baseline/main-guardrails.js +54 -17
  27. package/dist/src/baseline/main-watch.js +1 -1
  28. package/dist/src/budget/budget-wiring.js +0 -5
  29. package/dist/src/budget/budget.js +2 -1
  30. package/dist/src/build/build-check-scoped.js +70 -41
  31. package/dist/src/change/change-data.js +4 -2
  32. package/dist/src/cli/cli-command-args.js +199 -98
  33. package/dist/src/cli/cli-flag-specs.js +1 -4
  34. package/dist/src/cli/cli-review-commands.js +2 -1
  35. package/dist/src/cli/cli-role-command.js +27 -50
  36. package/dist/src/cli/cli-start.js +62 -12
  37. package/dist/src/cli/follow-view.js +3 -1
  38. package/dist/src/cli/help.js +6 -7
  39. package/dist/src/cli/log-commands.js +3 -1
  40. package/dist/src/cli.js +3 -7
  41. package/dist/src/collections.js +21 -7
  42. package/dist/src/config/config-fallbacks.js +0 -4
  43. package/dist/src/config/config-live.js +179 -147
  44. package/dist/src/config/config-views.js +2 -7
  45. package/dist/src/config/config-write.js +19 -26
  46. package/dist/src/doctor/doctor-backlog-bugs.js +17 -115
  47. package/dist/src/doctor/doctor-backlog-plans.js +8 -8
  48. package/dist/src/doctor/doctor-backlog.js +22 -36
  49. package/dist/src/doctor/doctor-check-names.js +2 -2
  50. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  51. package/dist/src/doctor/doctor-contention-checks.js +1 -1
  52. package/dist/src/doctor/doctor-holds.js +13 -11
  53. package/dist/src/doctor/doctor-model-checks.js +8 -184
  54. package/dist/src/doctor/doctor-notify.js +2 -5
  55. package/dist/src/doctor/doctor-opencode-checks.js +85 -48
  56. package/dist/src/doctor/doctor-project-checks.js +66 -17
  57. package/dist/src/doctor/doctor-role-notes.js +6 -24
  58. package/dist/src/doctor/doctor.js +6 -6
  59. package/dist/src/doctor/probe.js +1 -1
  60. package/dist/src/events/event-format.js +5 -15
  61. package/dist/src/events/event-labels.js +0 -1
  62. package/dist/src/events/event-legacy.js +1 -1
  63. package/dist/src/events/event-window.js +1 -1
  64. package/dist/src/events/events.js +15 -2
  65. package/dist/src/failure/failure-state-change.js +183 -68
  66. package/dist/src/failure/time-spend.js +1 -1
  67. package/dist/src/files/json-object.js +15 -2
  68. package/dist/src/gates/startup-gate.js +28 -28
  69. package/dist/src/git/slot-salvage.js +5 -4
  70. package/dist/src/gui/gui-admin-endpoints.js +2 -3
  71. package/dist/src/gui/gui-backlog-commands.js +1 -1
  72. package/dist/src/gui/gui-backlog-endpoints.js +1 -7
  73. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  74. package/dist/src/gui/gui-operator-commands.js +0 -12
  75. package/dist/src/gui/gui-prompt-commands.js +26 -20
  76. package/dist/src/gui/gui-report-endpoints.js +2 -1
  77. package/dist/src/gui/gui-server.js +1 -2
  78. package/dist/src/history/history.js +102 -67
  79. package/dist/src/inbox/inbox-submit.js +1 -5
  80. package/dist/src/inbox/prompt-commands.js +24 -19
  81. package/dist/src/init/init.js +68 -15
  82. package/dist/src/merge/merge-core.js +1 -1
  83. package/dist/src/merge/merge-post-review-drain.js +1 -1
  84. package/dist/src/operator/operator-commands.js +68 -47
  85. package/dist/src/operator/retire.js +2 -1
  86. package/dist/src/orchestrator/retention.js +6 -5
  87. package/dist/src/paths.js +0 -6
  88. package/dist/src/pi/pi-watchdogs.js +20 -1
  89. package/dist/src/pi-extension/context-shake.js +55 -31
  90. package/dist/src/process/process.js +23 -0
  91. package/dist/src/process/supervisor.js +17 -1
  92. package/dist/src/prompt/gate-notes.js +9 -9
  93. package/dist/src/prompt/notes-backlog.js +11 -24
  94. package/dist/src/prompt/prompt-followup.js +4 -12
  95. package/dist/src/prompt/prompt.js +4 -5
  96. package/dist/src/report/report-data.js +10 -225
  97. package/dist/src/report/report-fold.js +20 -295
  98. package/dist/src/report/report-main-red.js +45 -42
  99. package/dist/src/report/report-render.js +66 -39
  100. package/dist/src/report/report-since-data.js +218 -0
  101. package/dist/src/report/report-usage-fold.js +288 -0
  102. package/dist/src/report/report.js +2 -1
  103. package/dist/src/review/known-flakes.js +3 -3
  104. package/dist/src/review/post-merge-review-accept.js +3 -2
  105. package/dist/src/review/post-merge-review.js +9 -9
  106. package/dist/src/review/quarantine-render.js +2 -1
  107. package/dist/src/roles/role-catalog.js +18 -30
  108. package/dist/src/roles/role-guidance.js +18 -43
  109. package/dist/src/roles/role-view.js +1 -2
  110. package/dist/src/run/ci-parity.js +5 -6
  111. package/dist/src/run/qa-coverage.js +4 -4
  112. package/dist/src/run/run-detail-data.js +23 -7
  113. package/dist/src/run/run-detail-render.js +6 -0
  114. package/dist/src/run/run-detail.js +44 -22
  115. package/dist/src/run/run-dismiss.js +43 -0
  116. package/dist/src/run/run-prompt.js +100 -56
  117. package/dist/src/run/run-resume.js +32 -31
  118. package/dist/src/run/run-stage.js +4 -21
  119. package/dist/src/run/run-timing.js +18 -4
  120. package/dist/src/run/run-verdict.js +8 -73
  121. package/dist/src/run/stage-backlog-checks.js +2 -29
  122. package/dist/src/run/stage-check.js +4 -12
  123. package/dist/src/runner/runner-outcome.js +0 -5
  124. package/dist/src/runner/runner-run.js +2 -79
  125. package/dist/src/runner/runner-state-load.js +0 -10
  126. package/dist/src/runner/runner.js +1 -32
  127. package/dist/src/scheduling/claims.js +3 -10
  128. package/dist/src/status/status-data.js +5 -9
  129. package/dist/src/status/status-role-notes.js +18 -14
  130. package/dist/src/status/status-roles.js +2 -35
  131. package/dist/src/text/datetime.js +8 -1
  132. package/dist/src/text/text.js +12 -0
  133. package/dist/src/ui/alert-feeds.js +7 -212
  134. package/dist/src/ui/badges.js +2 -3
  135. package/dist/src/ui/gui/gui-client-boot.js +19 -4
  136. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  137. package/dist/src/ui/gui/gui-client-report-chart.js +10 -8
  138. package/dist/src/ui/gui/gui-client-report.js +1 -2
  139. package/dist/src/ui/gui/gui-client-settings.js +0 -23
  140. package/dist/src/ui/gui/gui-client-team.js +26 -0
  141. package/dist/src/ui/gui/gui-icons.js +7 -1
  142. package/dist/src/ui/status-model.js +2 -2
  143. package/dist/src/ui/status-payload.js +3 -3
  144. package/dist/src/ui/status-render.js +3 -3
  145. package/dist/src/ui/team-alert-backlog.js +23 -54
  146. package/dist/src/ui/team-alert-shared.js +23 -10
  147. package/dist/src/ui/team-alert-status.js +8 -3
  148. package/dist/src/ui/team-alert-tail.js +1 -14
  149. package/dist/src/ui/team-alerts.js +6 -17
  150. package/dist/src/ui/transcript-tail.js +137 -94
  151. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  152. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  153. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  154. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  155. package/dist/src/ui/tui/tui-editor-budget.js +105 -93
  156. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  157. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  158. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  159. package/dist/src/ui/tui/tui-editor-pause.js +209 -183
  160. package/dist/src/ui/tui/tui-editor-prompt.js +44 -34
  161. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  162. package/dist/src/ui/tui/tui-editor-quiet.js +121 -108
  163. package/dist/src/ui/tui/tui-editor-release.js +50 -42
  164. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  165. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  166. package/dist/src/ui/tui/tui-editor-role-file.js +58 -46
  167. package/dist/src/ui/tui/tui-editor-role-key.js +49 -42
  168. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  169. package/dist/src/ui/tui/tui-frame.js +1 -1
  170. package/dist/src/ui/tui/tui-keys.js +1 -1
  171. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  172. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  173. package/dist/src/ui/tui/tui-pane-health.js +3 -24
  174. package/dist/src/ui/tui/tui-pane-history.js +20 -24
  175. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  176. package/dist/src/ui/tui/tui-pane-session.js +3 -2
  177. package/dist/src/ui/tui/tui-pane-transcript.js +151 -128
  178. package/dist/src/ui/tui/tui-pane.js +201 -174
  179. package/dist/src/ui/tui/tui-panes.js +2 -3
  180. package/dist/src/ui/tui/tui.js +3 -3
  181. package/dist/src/verdict/fix-claim.js +18 -6
  182. package/dist/src/verdict/refusal.js +30 -6
  183. package/package.json +1 -1
  184. package/dist/src/doctor/image-probe.js +0 -257
  185. package/dist/src/ui/team-alert-backlog-feed.js +0 -181
  186. package/dist/src/ui/team-alert-merge.js +0 -183
@@ -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
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
+ }
@@ -66,7 +66,7 @@ export function actionableEntryRangesFor(root, file, sectionTitle) {
66
66
  return (cachedTextByStat(actionableRangesCache, `${full}\u0000${sectionTitle}`, full, (md) => actionableEntryRanges(md, sectionTitle), (ranges) => ranges.slice()) ?? []);
67
67
  }
68
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/needs-replan note) and no live
69
+ * unmerged prerequisite (`{ blockedBy }`, not a Refused/needs-review note) and no live
70
70
  * claim names it. Returns the row's series part when it collapses, else null. */
71
71
  function collapsibleSeriesPart(row) {
72
72
  if (row.claimer !== undefined)
@@ -116,7 +116,7 @@ export function partsLabel(rows) {
116
116
  * or more such parts renders as one line at the position of its first collapsed part, spanning from
117
117
  * that part's start to the last's end, with the part list (`2/4–4/4`) and a `[blocked: each waits
118
118
  * on an earlier part]` mark. A series with a single blocked part, an eligible part, a note-held
119
- * (Refused/needs-review/needs-replan) part and a claimed part keep their ordinary lines, so the
119
+ * (Refused/needs-review) part and a claimed part keep their ordinary lines, so the
120
120
  * eligibility rules the feature role's charter teaches are unchanged; the collapsed mark keeps the
121
121
  * `[blocked: …]` prefix it already skips. */
122
122
  /** The grouping half of backlog-index's collapseBlockedSeries, on any rows carrying a title,
@@ -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,11 +1,11 @@
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
7
  import { backlogTarget, findEntry, locateDashboardEntry, readEntryLines } from "./backlog-write.js";
8
- import { withoutHeldForOperatorNotes, withoutRefusalNotes, withoutWaitingNotes } from "./backlog-eligibility.js";
8
+ import { withoutHeldForOperatorNotes, withoutRefusalNotes } from "./backlog-eligibility.js";
9
9
  import { writeTextAtomic } from "../files/files.js";
10
10
  import { fail } from "../cli/cli-output.js";
11
11
  import { trimTrailingBlankLines } from "../text/text.js";
@@ -15,29 +15,21 @@ import { withPrimaryWorktreeLock } from "../concurrency/lock.js";
15
15
  export function liftedNotePhrase(lifted) {
16
16
  return lifted === "refused"
17
17
  ? "the Refused note"
18
- : lifted === "waiting-for-failure"
19
- ? "the Waiting-for-the-next-failure note"
20
- : "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
21
19
  }
22
20
  /** Drop the Refused note from `entry`'s slice of `lines`, in place, and return its kind; an
23
- * entry with none loses its Waiting-for-the-next-failure note instead, and one with neither
24
- * 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
25
22
  * threshold, plus its **Previously held N** line). Null (and `lines` untouched) when the entry
26
- * 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. */
27
24
  function liftRefusal(lines, entry) {
28
25
  const slice = lines.slice(entry.start - 1, entry.end);
29
26
  let kept = withoutRefusalNotes(slice);
30
27
  if (kept.length === slice.length) {
31
- kept = withoutWaitingNotes(slice);
32
- if (kept.length === slice.length) {
33
- kept = withoutHeldForOperatorNotes(slice);
34
- if (kept.length === slice.length)
35
- return null;
36
- lines.splice(entry.start - 1, slice.length, ...kept);
37
- return "held-for-operator"; // vocabulary-ok: the held-for-operator note's release wording
38
- }
28
+ kept = withoutHeldForOperatorNotes(slice);
29
+ if (kept.length === slice.length)
30
+ return null;
39
31
  lines.splice(entry.start - 1, slice.length, ...kept);
40
- return "waiting-for-failure";
32
+ return "held-for-operator"; // vocabulary-ok: the held-for-operator note's release wording
41
33
  }
42
34
  lines.splice(entry.start - 1, slice.length, ...kept);
43
35
  return "refused";
@@ -59,7 +51,7 @@ export function tryReleaseEntry(root, kind, n, expectedTitle) {
59
51
  return { status: "released", file: fileName, title: entry.title, lifted };
60
52
  });
61
53
  }
62
- /** 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
63
55
  * `n`th entry (1-based, as `--list` numbers them) of the kind's open section and write the
64
56
  * file, under the same lock the cancel and edit writers hold, returning which hold it lifted.
65
57
  * An out-of-range position fails with locateEntry's wording; an entry with neither note
@@ -70,7 +62,7 @@ export function releaseEntry(root, kind, n) {
70
62
  const { file, lines, entry } = readEntryLines(root, fileName, sectionTitle, n);
71
63
  const lifted = liftRefusal(lines, entry);
72
64
  if (lifted === null)
73
- 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
74
66
  writeTextAtomic(file, lines.join("\n"));
75
67
  return { file: fileName, title: entry.title, lifted };
76
68
  });
@@ -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";
@@ -251,7 +252,19 @@ export async function backlogStructureReason(wt, mainBranch, files) {
251
252
  const baseRev = await changeBaseRev(wt, mainBranch);
252
253
  for (const file of touched) {
253
254
  // A deleted file reads as empty: every heading the base had is gone — rule (b).
254
- 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
+ }
255
268
  const base = await fileContentAt(wt, baseRev, file);
256
269
  const headCounts = sectionTitleCounts(head);
257
270
  const baseCounts = sectionTitleCounts(base);
@@ -361,7 +361,7 @@ function appendOperatorEntry(root, fileName, template, sectionTitle, title, body
361
361
  }
362
362
  /** Append one tumwater-filed bug under BUGS.md's `## Open` with the heading as given (no operator
363
363
  * stamp), for a caller that already holds the primary-worktree lock and commits the file itself
364
- * (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. */
365
365
  export function appendBugEntryLocked(root, title, body) {
366
366
  const t = collapseWhitespace(title);
367
367
  requireBodyStructurallySound(body, "filed");
@@ -1,6 +1,6 @@
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
4
  import { CHECK_DECLARATION_MARKER } from "../roles/role-guidance.js";
5
5
  /** The stat-cached file readers of the project backlog data shown on both dashboards: planned
6
6
  * features (PLANS.md), open bugs (BUGS.md), and open questions (QUESTIONS.md). These are tracked
@@ -79,11 +79,12 @@ export function sectionCompletionLags(root, fileName, sectionTitle, dateRe) {
79
79
  * bugs with `(found by …)` or `(reported …)`), parsed so `plan|bug --list` shows how long an entry
80
80
  * has waited, without opening the file. Read only the title — the whole heading, never the body,
81
81
  * because the stamp is the entry's own and the body may name other dates.
82
- * 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. */
83
85
  const STAMP_DATE_RE = /(?:planned|reported|found by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/;
84
86
  export function filingDate(title) {
85
- const m = STAMP_DATE_RE.exec(title);
86
- return m ? m[1] : null;
87
+ return firstDate(title, STAMP_DATE_RE);
87
88
  }
88
89
  /** The completion stamp of a `## Done` plan heading. */
89
90
  export const DONE_DATE_RE = /done (\d{4}-\d{2}-\d{2})/;