tumwater 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -1,20 +1,18 @@
1
- import { andMore, readBacklogChecked, FIX_CLAIMS_CHECKED, FIX_CLAIM_HEADING_MAX, REFUSAL_NOTE_MAX, STALE_QUESTION_DAYS, STALE_BUG_DAYS, STALE_WAIT_DAYS } from "./doctor-backlog.js";
1
+ import { andMore, quoteHeading, readBacklogChecked, FIX_CLAIMS_CHECKED, REFUSAL_NOTE_MAX, STALE_QUESTION_DAYS, STALE_BUG_DAYS } from "./doctor-backlog.js";
2
2
  import { truncate } from "../text/text.js";
3
+ import { plural, agree } from "../text/phrases.js";
3
4
  import { ageInDays, filingDate, fixedBugEntries, openBugEntries, openQuestionEntries, plannedPlanEntries } from "../backlog/backlog.js";
4
- import { stripEntryStamp } from "../backlog/backlog-structure.js";
5
5
  import { questionFilingDate } from "../backlog/question-answer.js";
6
- import { refusalNote, waitingNote } from "../backlog/backlog-eligibility.js";
6
+ import { needsReviewNote, refusalNote } from "../backlog/backlog-eligibility.js";
7
7
  import { plannedPartKeys, plansWaitingOn, prerequisitesMerged } from "../backlog/backlog-parts.js";
8
8
  import { lastDate } from "../backlog/backlog-md.js";
9
9
  import { formatTimestamp } from "../text/datetime.js";
10
10
  import { stringList } from "../files/json-object.js";
11
- import { byDaysThenIndex } from "../collections.js";
12
11
  import { readEventsSince } from "../events/event-window.js";
13
12
  import { okOutcome, warnOutcome } from "./outcome.js";
14
13
  /** The doctor's open-bug and question checks, split out of doctor-backlog.ts: questions that
15
14
  * have waited too long for an answer (checkStaleQuestions), bugs left unfixed (checkStaleBugs),
16
- * bugs held for their next failure (checkWaitingBugs, with its quotedTestName / waitingNoteDate /
17
- * failureMentions helpers), fixed records whose quoted test regressed (checkRegressedFixes), and
15
+ * fixed records whose quoted test regressed (checkRegressedFixes), and
18
16
  * entries parked by a Refused note (checkRefusedEntries). They import the shared helpers
19
17
  * (readBacklogChecked, andMore) from doctor-backlog.ts, plus the age/trim bounds, and
20
18
  * all warn instead of failing: a stale or
@@ -44,21 +42,22 @@ export function checkStaleQuestions(root) {
44
42
  const [first, ...rest] = stale;
45
43
  if (!first)
46
44
  return okOutcome("no open question has waited more than 7 days for an answer");
47
- const more = andMore(rest, (q) => `"${truncate(q.title, FIX_CLAIM_HEADING_MAX)}"`);
48
- return warnOutcome(`QUESTIONS.md question "${truncate(first.title, FIX_CLAIM_HEADING_MAX)}" has waited ${first.days} d for an answer${more} — ` +
45
+ const more = andMore(rest, (q) => quoteHeading(q.title));
46
+ return warnOutcome(`QUESTIONS.md question ${quoteHeading(first.title)} has waited ${first.days} d for an answer${more} — ` +
49
47
  `a question that waits too long for a human answer is worth your attention`);
50
48
  }
51
- /** Stale bugs — unrefused open BUGS.md bugs whose `(found by … YYYY-MM-DD)` or `(reported …)`
52
- * stamp is at least 3 days old. The bugfix role fixes a bug within hours, so one that sits means
53
- * no role is fixing it. Positions are the 1-based `bug --list` numbers; a refused bug is skipped
54
- * (checkRefusedEntries names it), a waiting bug is skipped (checkWaitingBugs names it) and so is
55
- * an undated heading. A warn, never a fail. */
49
+ /** Stale bugs — unrefused, unheld open BUGS.md bugs whose `(found by … YYYY-MM-DD)` or
50
+ * `(reported …)` stamp is at least 3 days old. The bugfix role fixes a bug within hours, so one
51
+ * that sits means no role is fixing it. Positions are the 1-based `bug --list` numbers; a refused
52
+ * bug is skipped (checkRefusedEntries names it), a bug parked by a Needs-review note is skipped
53
+ * (the note names what would make it fail, so it is not unfixed), and so is an undated heading.
54
+ * A warn, never a fail. */
56
55
  export function checkStaleBugs(root) {
57
56
  const doc = readBacklogChecked(root, "BUGS.md");
58
57
  if (typeof doc !== "string")
59
58
  return doc;
60
59
  const stale = openBugEntries(root).flatMap((entry, index) => {
61
- if (refusalNote(entry.body) !== null || waitingNote(entry.body) !== null)
60
+ if (refusalNote(entry.body) !== null || needsReviewNote(entry.body) !== null)
62
61
  return [];
63
62
  const stamp = filingDate(entry.title);
64
63
  if (stamp === null)
@@ -69,8 +68,8 @@ export function checkStaleBugs(root) {
69
68
  const [first, ...rest] = stale;
70
69
  if (!first)
71
70
  return okOutcome("no open bug has waited more than 3 days for a fix");
72
- const more = andMore(rest, (b) => `#${b.position} "${truncate(b.title, FIX_CLAIM_HEADING_MAX)}"`);
73
- return warnOutcome(`BUGS.md bug #${first.position} "${truncate(first.title, FIX_CLAIM_HEADING_MAX)}" has waited ${first.days} d for a fix${more} — ` +
71
+ const more = andMore(rest, (b) => `#${b.position} ${quoteHeading(b.title)}`);
72
+ return warnOutcome(`BUGS.md bug #${first.position} ${quoteHeading(first.title)} has waited ${first.days} d for a fix${more} — ` +
74
73
  `no role is fixing it: check that the bugfix role is enabled and not failing, or retract it with \`tumwater bug --cancel ${first.position}\``);
75
74
  }
76
75
  /** The first span between straight double quotes in a bug heading that is at least 12
@@ -85,10 +84,6 @@ export function quotedTestName(title) {
85
84
  function dayAfterMs(date) {
86
85
  return new Date(Number(date.slice(0, 4)), Number(date.slice(5, 7)) - 1, Number(date.slice(8, 10)) + 1).getTime();
87
86
  }
88
- /** The first `YYYY-MM-DD` in a Waiting for the next failure note's text, or null. */
89
- export function waitingNoteDate(note) {
90
- return /\d{4}-\d{2}-\d{2}/.exec(note)?.[0] ?? null;
91
- }
92
87
  /** True when `text` is a check-failure report that names `name` as a test that failed — the
93
88
  * doctors' recurrence/regression evidence. The recurrence scans must not count any line that
94
89
  * merely *contains* the quoted test name as the flake's return, or a warning that only quotes
@@ -127,8 +122,8 @@ export function failureMentions(events, name, sinceMs) {
127
122
  }
128
123
  /** The newest failure report at or after `since` naming `name` — `failureMentions`'s last
129
124
  * mention — or undefined when none. The single home of the "did this flake come back after
130
- * its boundary?" step, shared by the waiting-bug recurrence scan (checkWaitingBugs) and the
131
- * regressed-fix match (matchedRegressedFixes), so both keep the same last-mention rule. */
125
+ * its boundary?" step, shared by the regressed-fix match (matchedRegressedFixes), so both
126
+ * keep the same last-mention rule. */
132
127
  function lastFailureMention(events, name, sinceMs) {
133
128
  const mentions = failureMentions(events, name, sinceMs);
134
129
  return mentions[mentions.length - 1];
@@ -136,104 +131,11 @@ function lastFailureMention(events, name, sinceMs) {
136
131
  /** The read-events window (ms) that reaches the oldest of `sinces` — `Date.now()` minus the
137
132
  * oldest timestamp, floored at 0 — so a recurrence check reads events past every candidate's
138
133
  * `since`. The single home of the "oldest of a set of since-times" step, shared by
139
- * checkWaitingBugs and checkRegressedFixes, so those sites cannot drift on the clamp or the
134
+ * checkRegressedFixes, so those sites cannot drift on the clamp or the
140
135
  * time base. */
141
136
  function sinceWindowMs(sinces) {
142
137
  return Math.max(0, Date.now() - Math.min(...sinces));
143
138
  }
144
- /** Open BUGS.md bugs held by a **Waiting for the next failure …** note, in file order: the
145
- * doctor's `checkWaitingBugs` selection and anchor, factored out so the always-on stale-wait
146
- * feed (staleWaitingBugRows) reads the same held set and cannot drift from the doctor's rule.
147
- * A bug that is also Refused is left to checkRefusedEntries. `since` is the day-after the
148
- * note's date — the recurrence scan's boundary; `date` the note's own `YYYY-MM-DD` or null. */
149
- function heldWaitingBugs(root) {
150
- return openBugEntries(root).flatMap((entry, index) => {
151
- const note = waitingNote(entry.body);
152
- if (note === null || refusalNote(entry.body) !== null)
153
- return [];
154
- const name = quotedTestName(entry.title);
155
- const date = waitingNoteDate(note);
156
- const since = name !== null && date !== null ? dayAfterMs(date) : null;
157
- return [{ index, position: index + 1, title: entry.title, note, name, since, date }];
158
- });
159
- }
160
- /** Waiting bugs — open BUGS.md bugs held by a **Waiting for the next failure …** note, which
161
- * parks a bug until it fails again. Positions are the 1-based `bug --list` numbers; a bug that
162
- * is also Refused is left to checkRefusedEntries. Ok when the hold is undisturbed — doctor
163
- * then only names the bugs and how to release one — and a warn when a held bug's quoted test
164
- * has failed in a gate or merge check since the day after its note (the note carries only a
165
- * date, so a same-day failure cannot be told from the one it was written about). A warn is
166
- * never a fail, and doctor writes nothing to BUGS.md. */
167
- export function checkWaitingBugs(root) {
168
- const doc = readBacklogChecked(root, "BUGS.md");
169
- if (typeof doc !== "string")
170
- return doc;
171
- const held = heldWaitingBugs(root);
172
- const [first, ...rest] = held;
173
- if (!first)
174
- return okOutcome("no open bug is waiting for its next failure");
175
- const sinces = held.flatMap((b) => (b.since !== null && !Number.isNaN(b.since) ? [b.since] : []));
176
- if (sinces.length > 0) {
177
- const { events } = readEventsSince(root, sinceWindowMs(sinces));
178
- const recurred = held.flatMap((b) => {
179
- if (b.name === null || b.since === null)
180
- return [];
181
- const newest = lastFailureMention(events, b.name, b.since);
182
- return newest ? [{ ...b, newest }] : [];
183
- });
184
- const [again, ...others] = recurred;
185
- if (again) {
186
- const more = andMore(others, (b) => `#${b.position} "${truncate(b.title, FIX_CLAIM_HEADING_MAX)}"`);
187
- return warnOutcome(`BUGS.md bug #${again.position} "${truncate(again.title, FIX_CLAIM_HEADING_MAX)}" failed again since its Waiting for the next failure note ` +
188
- `(${formatTimestamp(again.newest.ts)}: ${truncate(again.newest.text, REFUSAL_NOTE_MAX)})${more} — ` +
189
- `record the occurrence under the entry and release it with \`tumwater bug --release ${again.position}\``);
190
- }
191
- }
192
- /* A Waiting hold has no expiry of its own, so a bug whose flake never comes back parks
193
- * indefinitely; a hold that has outlived STALE_WAIT_DAYS without a recurrence is escalated
194
- * from the ok below to a warn so the operator is nudged to release or re-hold it. */
195
- const lapsed = held.flatMap((b) => {
196
- /* An undated note (no YYYY-MM-DD in its text) has no hold-start marker, and checkStaleBugs
197
- * skips any bug carrying a waiting note, so without a fallback it would park indefinitely
198
- * too; the filing stamp anchors it — the hold cannot be younger than the bug it dates from. */
199
- const anchor = b.date ?? filingDate(b.title);
200
- if (anchor === null)
201
- return [];
202
- const days = ageInDays(anchor);
203
- return days >= STALE_WAIT_DAYS ? [{ ...b, days }] : [];
204
- });
205
- const [firstLapsed, ...restLapsed] = lapsed;
206
- if (firstLapsed) {
207
- const more = andMore(restLapsed, (b) => `#${b.position} "${truncate(b.title, FIX_CLAIM_HEADING_MAX)}"`);
208
- return warnOutcome(`BUGS.md bug #${firstLapsed.position} "${truncate(firstLapsed.title, FIX_CLAIM_HEADING_MAX)}" has waited ${firstLapsed.days} d for its next failure without one ` +
209
- `— its flake likely never comes back, so it parks indefinitely; release it with \`tumwater bug --release ${firstLapsed.position}\`${more}`);
210
- }
211
- const more = andMore(rest, (b) => `#${b.position} "${truncate(b.title, FIX_CLAIM_HEADING_MAX)}"`);
212
- return okOutcome(`BUGS.md bug #${first.position} "${truncate(first.title, FIX_CLAIM_HEADING_MAX)}" is held until it fails again ` +
213
- `(${truncate(first.note, REFUSAL_NOTE_MAX)})${more} — selfcheck lifts the note when it records a new occurrence; ` +
214
- `release it now with \`tumwater bug --release ${first.position}\``);
215
- }
216
- /** The always-on rows behind the dashboard/TUI/`tumwater status` waiting-bug alert: open BUGS.md
217
- * bugs held by a **Waiting for the next failure …** note whose anchor (the note's `YYYY-MM-DD`,
218
- * else the heading's filing stamp) is at least STALE_WAIT_DAYS old. Shares the doctor's
219
- * STALE_WAIT_DAYS threshold and the heldWaitingBugs selection/anchor, so the alert and the
220
- * doctor's `waiting bugs` row cannot drift on the rule; an undated note-and-heading is skipped.
221
- * The returned objects carry the 0-based index (drawer address `bugs:<index>`), the 1-based
222
- * position (`bug --release <position>`), the stamp-stripped title, and the whole-day age.
223
- * Returned most-days-waited-first, ties in file order, like staleOpenBugs. This deliberately
224
- * omits `checkWaitingBugs`'s recurrence scan, because that scan reads events back to the oldest
225
- * hold and is too costly on the status poll path; the doctor still reports a recurrence first. */
226
- export function staleWaitingBugRows(root, now = Date.now()) {
227
- return heldWaitingBugs(root)
228
- .flatMap((b) => {
229
- const anchor = b.date ?? filingDate(b.title);
230
- if (anchor === null)
231
- return [];
232
- const days = ageInDays(anchor, now);
233
- return days >= STALE_WAIT_DAYS ? [{ index: b.index, position: b.position, title: stripEntryStamp(b.title), days }] : [];
234
- })
235
- .sort(byDaysThenIndex);
236
- }
237
139
  /** The regression candidates for the `regressed fixes` feed: a newest-FIX_CLAIMS_CHECKED BUGS.md
238
140
  * Fixed record whose heading quotes a test name and carries a `YYYY-MM-DD` date, unless an Open
239
141
  * bug already quotes the same test (which means the regression is filed). Shared by the doctor's
@@ -268,8 +170,8 @@ export function regressedFixesPhrase(regressed) {
268
170
  const [first, ...rest] = regressed;
269
171
  if (!first)
270
172
  return null;
271
- const more = andMore(rest, (c) => `"${truncate(c.title, FIX_CLAIM_HEADING_MAX)}"`);
272
- return `BUGS.md Fixed record "${truncate(first.title, FIX_CLAIM_HEADING_MAX)}" quotes a test that failed again after its ${first.date} fix ` +
173
+ const more = andMore(rest, (c) => quoteHeading(c.title));
174
+ return `BUGS.md Fixed record ${quoteHeading(first.title)} quotes a test that failed again after its ${first.date} fix ` +
273
175
  `(${formatTimestamp(first.newest.ts)}: ${truncate(first.newest.text, REFUSAL_NOTE_MAX)})${more} — ` +
274
176
  "file it as a new bug with `tumwater bug`, quoting the test name and citing the Fixed record";
275
177
  }
@@ -314,14 +216,14 @@ export function checkRefusedEntries(root) {
314
216
  const [first, ...rest] = held;
315
217
  if (!first)
316
218
  return okOutcome("no planned plan or open bug is held by a Refused note");
317
- const more = andMore(rest, (e) => `${e.file} ${e.command} #${e.position} "${truncate(e.title, FIX_CLAIM_HEADING_MAX)}"` +
219
+ const more = andMore(rest, (e) => `${e.file} ${e.command} #${e.position} ${quoteHeading(e.title)}` +
318
220
  (e.mergeDone.length > 0 ? " [prerequisites no longer planned]" : "") +
319
- (e.waiting > 0 ? ` [${e.waiting} part(s) wait on it]` : ""));
221
+ (e.waiting > 0 ? ` [${plural(e.waiting, "part waits on it", "parts wait on it")}]` : ""));
320
222
  const stale = first.mergeDone.length > 0
321
- ? `; its prerequisite(s) ${first.mergeDone.map((r) => `${r.series} ${r.part}/${r.of}`).join(", ")} are no longer planned, so the refusal may be stale`
223
+ ? `; its ${agree(first.mergeDone.length, "prerequisite", "prerequisites")} ${first.mergeDone.map((r) => `${r.series} ${r.part}/${r.of}`).join(", ")} ${agree(first.mergeDone.length, "is", "are")} no longer planned, so the refusal may be stale`
322
224
  : "";
323
225
  const waiting = first.waiting > 0
324
- ? `; ${first.waiting} planned part(s) wait on it, directly or through an earlier part`
226
+ ? `; ${plural(first.waiting, "planned part waits on it", "planned parts wait on it")}, directly or through an earlier part`
325
227
  : "";
326
228
  return warnOutcome(`${first.file} ${first.command} #${first.position} "${first.title}" is held by a Refused note ` +
327
229
  `(${truncate(first.note, REFUSAL_NOTE_MAX)})${stale}${waiting}${more} — no role takes a refused entry until it is edited: ` +
@@ -1,4 +1,4 @@
1
- import { andMore, readDoc, readBacklogChecked, FIX_CLAIMS_CHECKED, FIX_CLAIM_HEADING_MAX, REFUSAL_NOTE_MAX } from "./doctor-backlog.js";
1
+ import { andMore, quoteHeading, readDoc, readBacklogChecked, FIX_CLAIMS_CHECKED, REFUSAL_NOTE_MAX } from "./doctor-backlog.js";
2
2
  import { cachedByStat } from "../files/stat-cache.js";
3
3
  import { VALIDATION_GAP_TAGS } from "../roles/role-guidance.js";
4
4
  import path from "node:path";
@@ -9,9 +9,8 @@ import { planDependencyProblems } from "../backlog/backlog-parts.js";
9
9
  import { BACKLOG_FILES, FINISHED_ARCHIVES, validationGapTag } from "../backlog/backlog-md.js";
10
10
  import { statOrNull } from "../files/files.js";
11
11
  import { duplicateHeadings, strandedPlanEntriesFor } from "../backlog/backlog-structure.js";
12
- import { liveFinishedHistory } from "../backlog/backlog-archive.js";
12
+ import { liveFinishedHistory, liveFinishedRowList } from "../backlog/backlog-archive.js";
13
13
  import { fixSymbols, missingSymbolNames, cachedSourceHaystack, unbackedSymbols } from "../verdict/fix-claim.js";
14
- import { plural } from "../text/phrases.js";
15
14
  import { okOutcome, warnOutcome } from "./outcome.js";
16
15
  /** The doctor's PLANS.md-structure and fix-claim checks, split out of doctor-backlog.ts: the
17
16
  * checks that re-verify BUGS.md's newest Fixed records against the tree (checkFixClaims,
@@ -45,7 +44,7 @@ export function checkFixClaims(root) {
45
44
  const { heading, missing } = first;
46
45
  const names = missingSymbolNames(missing);
47
46
  // Every other suspect is still named, shortened: a doctor check is one line.
48
- const more = andMore(rest, (p) => `"${truncate(p.heading, FIX_CLAIM_HEADING_MAX)}"`, "record(s)");
47
+ const more = andMore(rest, (p) => quoteHeading(p.heading), "record(s)");
49
48
  return warnOutcome(`BUGS.md records "${heading}" as Fixed, but none of the symbols its Fix paragraph names ` +
50
49
  `exist on this tree: ${names}${more} — land the fix or keep the bug Open / refresh a stale record`);
51
50
  }
@@ -148,14 +147,14 @@ export function checkValidationGaps(root) {
148
147
  if (!first) {
149
148
  if (unpinned.length > 0) {
150
149
  const up = unpinned[0];
151
- const more = andMore(unpinned.slice(1), (s) => `"${truncate(s.title, FIX_CLAIM_HEADING_MAX)}"`, "record(s)");
150
+ const more = andMore(unpinned.slice(1), (s) => quoteHeading(s.title), "record(s)");
152
151
  return warnOutcome(`BUGS.md record "${up.title}" is an \`unclear-invariant\` fix but names no ` +
153
152
  `docs/invariants.md section — record the reconstructed guarantee there (a "Pinned by" line) in the ` +
154
153
  `same commit so the next bug in that area does not reconstruct it again${more}`);
155
154
  }
156
155
  return okOutcome(`newest ${validationGapRecords(root).length} Fixed record(s) carry a known Validation gap tag`);
157
156
  }
158
- const more = andMore(rest, (s) => `"${truncate(s.title, FIX_CLAIM_HEADING_MAX)}"`, "record(s)");
157
+ const more = andMore(rest, (s) => quoteHeading(s.title), "record(s)");
159
158
  const what = first.kind === "missing"
160
159
  ? `BUGS.md record "${first.title}" has no **Validation gap:** line`
161
160
  : `BUGS.md record "${first.title}" names unrecognized Validation gap tag "${first.tag}"`;
@@ -183,7 +182,7 @@ export function checkStrandedPlans(root) {
183
182
  const [first, ...rest] = stranded;
184
183
  if (!first)
185
184
  return okOutcome(clean);
186
- const more = andMore(rest, (e) => `"${truncate(e.title, FIX_CLAIM_HEADING_MAX)}"`);
185
+ const more = andMore(rest, (e) => quoteHeading(e.title));
187
186
  const file = first.file ?? "PLANS.md";
188
187
  return warnOutcome(`${file} has a plan stranded under ## ${first.section}: "${first.title}"${more} — ` +
189
188
  `move it under ## ${first.section === "Done" ? "Planned" : "Done"}${file === "PLANS.md" ? "" : " in PLANS.md"} ` +
@@ -199,8 +198,7 @@ export function checkLiveFinished(root) {
199
198
  const live = liveFinishedHistory(root);
200
199
  if (live.length === 0)
201
200
  return okOutcome("PLANS.md and BUGS.md hold no finished entries — they are archived");
202
- const parts = live.map((l) => `${l.file} ## ${l.section} holds ${plural(l.entries, "finished entry", "finished entries")} (${Math.round(l.bytes / 1024)} KB)`);
203
- return warnOutcome(`${parts.join("; ")} — every run that reads the file pays for them; ` +
201
+ return warnOutcome(`${liveFinishedRowList(live)} — every run that reads the file pays for them; ` +
204
202
  "`tumwater archive` moves them to the archive files and commits them (a starting team does too)");
205
203
  }
206
204
  /** The always-on `backlog-headings` alert polls this on every observer surface and every
@@ -245,8 +243,8 @@ function scanBacklogHeadings(root) {
245
243
  * always-on alert so the poll path never re-reads an unchanged backlog or archive file. */
246
244
  const backlogHeadingFileCache = new Map();
247
245
  /** The duplicated `## ` headings already on main, exported so the always-on `backlog-headings`
248
- * team alert (src/ui/team-alerts.ts) renders the same set the doctor's `backlog headings` row
249
- * warns about. */
246
+ * team alert (src/ui/team-alert-backlog.ts) renders the same set the doctor's `backlog
247
+ * headings` row warns about. */
250
248
  export function duplicatedBacklogHeadings(root) {
251
249
  return scanBacklogHeadings(root).duplicates;
252
250
  }
@@ -258,9 +256,10 @@ export function duplicatedBacklogHeadings(root) {
258
256
  * stat-keyed cache entry can serve it. */
259
257
  const liveFinishedCache = new Map();
260
258
  /** The finished-history rows already on main, exported so the always-on `backlog-archive` team
261
- * alert (src/ui/team-alerts.ts) renders the same set the doctor's `backlog archive` row warns
262
- * about — stat-gated so a steady-state poll pays four stats, not a full re-parse. The cache key
263
- * is the stat identity (`dev:ino:mtimeMs:size`, `absent` when the stat is null) of every
259
+ * alert (src/ui/team-alert-backlog.ts) renders the same set the doctor's `backlog archive`
260
+ * row warns about — stat-gated so a steady-state poll pays four stats, not a full re-parse.
261
+ * The cache key is the stat identity (`dev:ino:mtimeMs:size`, `absent` when the stat is
262
+ * null) of every
264
263
  * FINISHED_ARCHIVES live file and archive under `root`, so any write that changes finished
265
264
  * history or the archive refreshes the rows on the next call without a process restart. Returns
266
265
  * a copy on a hit; on a miss it recomputes via liveFinishedHistory, and returns `[]` (uncached)
@@ -310,24 +309,24 @@ export function checkBacklogHeadings(root) {
310
309
  `later entries are invisible; keep one heading per name (the gate blocks only a merge ` +
311
310
  `that adds another copy — remove the duplicate in a deliberate edit)`);
312
311
  }
313
- /** Stalled entries — plans or bugs held by a **Needs review …** or **Needs replan …** note,
312
+ /** Stalled entries — plans or bugs held by a **Needs review …** note,
314
313
  * parked until the plan role splits or replans them, plus the held-for-operator entries
315
314
  * parked until the operator releases them. No role takes a held entry (the claim gate refuses
316
315
  * one) and nothing clears the note on its own, so the state is visible only as the
317
- * `[needs review]`/`[needs replan]` marks in the backlog listings; this names them so an
316
+ * `[needs review]` marks in the backlog listings; this names them so an
318
317
  * operator gets a single-line health signal from `tumwater doctor`. Reads the tree at `root`
319
318
  * — the primary checkout IS main's tree — like checkRefusedEntries, which is also where
320
319
  * refusal notes (a different hold) are surfaced. entryHold (src/backlog/backlog-eligibility.ts)
321
320
  * already classifies the notes, so this check adds no parsing: only the entries whose hold is
322
- * exactly needs-review, needs-replan, or held-for-operator are collected, from both PLANS.md
321
+ * exactly needs-review or held-for-operator are collected, from both PLANS.md
323
322
  * and BUGS.md. A warn, never a fail — the checkRefusedEntries precedent: a parked entry is
324
- * operator signal, not a broken environment; the plan role resolves the first two itself, and
323
+ * operator signal, not a broken environment; the plan role resolves the first itself, and
325
324
  * a held-for-operator entry is the operator's to release (`tumwater plan|bug --release`). */
326
325
  export function checkStalledEntries(root) {
327
326
  const planned = plannedPlanEntries(root);
328
327
  const collect = (entry, file) => {
329
328
  const hold = entryHold(entry, planned);
330
- return hold === "needs-review" || hold === "needs-replan" || hold === "held-for-operator" // vocabulary-ok: the held-for-operator hold's key
329
+ return hold === "needs-review" || hold === "held-for-operator" // vocabulary-ok: the held-for-operator hold's key
331
330
  ? { file, entry }
332
331
  : null;
333
332
  };
@@ -337,16 +336,16 @@ export function checkStalledEntries(root) {
337
336
  ].filter((x) => x !== null);
338
337
  const [first, ...rest] = stalled;
339
338
  if (!first)
340
- return okOutcome("no planned plan or open bug is held by a Needs-review, Needs-replan, or held-for-operator note"); // vocabulary-ok: names the held-for-operator hold
341
- const more = andMore(rest, (s) => `"${truncate(s.entry.title, FIX_CLAIM_HEADING_MAX)}"`);
339
+ return okOutcome("no planned plan or open bug is held by a Needs-review or held-for-operator note"); // vocabulary-ok: names the held-for-operator hold
340
+ const more = andMore(rest, (s) => quoteHeading(s.entry.title));
342
341
  const kind = entryKindLabel(first.file); // "Bug" | "Plan"
343
342
  const head = `${first.file === "bugs" ? "BUGS.md" : "PLANS.md"} ${kind.toLowerCase()}`;
344
343
  const hold = entryHold(first.entry, planned);
345
- const note = (first.entry.body.split(/\n/).find((line) => line.trimStart().startsWith("**Needs review ") || line.trimStart().startsWith("**Needs replan ")) ?? "").replace(/^\s*\*\*/, "").replace("**", "");
344
+ const note = (first.entry.body.split(/\n/).find((line) => line.trimStart().startsWith("**Needs review ")) ?? "").replace(/^\s*\*\*/, "").replace("**", "");
346
345
  if (hold === "held-for-operator") { // vocabulary-ok: the held-for-operator hold's key
347
- return warnOutcome(`${head} "${truncate(first.entry.title, FIX_CLAIM_HEADING_MAX)}" is held for the operator ${heldCount(first.entry.body)} times${more} — release it with tumwater ${kind.toLowerCase()} --release`); // vocabulary-ok: a held-for-operator entry is the operator's to release
346
+ return warnOutcome(`${head} ${quoteHeading(first.entry.title)} is held for the operator ${heldCount(first.entry.body)} times${more} — release it with tumwater ${kind.toLowerCase()} --release`); // vocabulary-ok: a held-for-operator entry is the operator's to release
348
347
  }
349
- return warnOutcome(`${head} "${truncate(first.entry.title, FIX_CLAIM_HEADING_MAX)}" is held by a ${hold === "needs-review" ? "Needs review" : "Needs replan"} note ` +
348
+ return warnOutcome(`${head} ${quoteHeading(first.entry.title)} is held by a Needs review note ` +
350
349
  `(${truncate(note, REFUSAL_NOTE_MAX)})${more} — the plan role resolves it by splitting or replanning`);
351
350
  }
352
351
  /** Plan dependencies — mistakes in the planned plans' `requires … landed` clauses that the claim
@@ -361,7 +360,7 @@ export function planDependencyProblem(root) {
361
360
  const [firstDangling, ...restDangling] = dangling;
362
361
  const [firstUnparsed, ...restUnparsed] = unparsed;
363
362
  const [firstMisworded, ...restMisworded] = misworded;
364
- const quote = (t) => `"${truncate(t, FIX_CLAIM_HEADING_MAX)}"`;
363
+ const quote = quoteHeading;
365
364
  const others = (parts) => parts.some(([n]) => n > 0) ? ` (also ${parts.map(([n, noun]) => `${n} ${noun}`).join(", ")})` : "";
366
365
  const danglingMsg = (d) => `PLANS.md plan ${quote(d.title)} requires ${d.ref}, which no PLANS.md heading carries — the hold is void, ` +
367
366
  "so the plan is claimable now; fix the series name or part number";
@@ -37,9 +37,9 @@ import { okOutcome, warnOutcome } from "./outcome.js";
37
37
  * and pre-convention Fixed entries predate the validation-gap line — so a whole-section scan
38
38
  * would warn forever on records nobody should rewrite. */
39
39
  export const FIX_CLAIMS_CHECKED = 10;
40
- /** A second or later suspect record's heading is trimmed to this many characters in the warn —
41
- * the first is named in full, the rest only enough to find them in BUGS.md. */
42
- export const FIX_CLAIM_HEADING_MAX = 60;
40
+ /** A cited record's heading is trimmed to this many characters in a doctor warn — enough to
41
+ * find it back in its file; quoteHeading applies it to every citation. */
42
+ const FIX_CLAIM_HEADING_MAX = 60;
43
43
  /** A refused entry's note is trimmed to this many characters in the refusals warn. */
44
44
  export const REFUSAL_NOTE_MAX = 120;
45
45
  /** The whole days an open question may wait before the stale-questions check warns about it:
@@ -50,13 +50,6 @@ export const STALE_QUESTION_DAYS = 7;
50
50
  * opinionated default with no knob, like STALE_QUESTION_DAYS. Three days is long enough that a
51
51
  * bug filed late one evening and fixed the next day never warns. */
52
52
  export const STALE_BUG_DAYS = 3;
53
- /** The whole days a bug may stay held by a Waiting for the next failure note before the
54
- * waiting-bugs check warns that its flake has not come back. A note parks a bug until it fails
55
- * again, but the failure may never come, so a hold has to lapse or the entry parks indefinitely;
56
- * one week without a recurrence is long enough that a recurring flake would almost surely have
57
- * shown up, and short enough that a bug which never returns stops squatting in the backlog as
58
- * an ok. An opinionated default with no knob, like STALE_BUG_DAYS. */
59
- export const STALE_WAIT_DAYS = 7;
60
53
  /** The `(and N more …)` suffix three one-line doctor details append after naming their first
61
54
  * suspect (fix claims, stranded plans, duplicated headings): the rest stay visible but
62
55
  * shortened, since a doctor check renders one line. `render` makes each extra item's short
@@ -69,6 +62,12 @@ export function andMore(rest, render, noun = "") {
69
62
  ? ` (and ${rest.length} more${noun ? ` ${noun}` : ""}: ${rest.map(render).join(", ")})`
70
63
  : "";
71
64
  }
65
+ /** Quote a heading/summary in a doctor sentence, truncated to FIX_CLAIM_HEADING_MAX so a check
66
+ * stays one line: `"…"`. The doctor's citations all quote-then-truncate the same way; this is
67
+ * the one home for that shape. */
68
+ export function quoteHeading(title) {
69
+ return `"${truncate(title, FIX_CLAIM_HEADING_MAX)}"`;
70
+ }
72
71
  /** Read one backlog file for a check, or the failure message explaining why it could not be
73
72
  * read (text.ts's errorMessage). Callers shape the failure themselves — a returned warn for a
74
73
  * single-file check, an "(unreadable)" list entry for the multi-file heading sweep. */
@@ -94,7 +93,7 @@ export function readDocChecked(p, file, missingDetail) {
94
93
  }
95
94
  /** The standard single-file check's read prologue for a backlog document at `root`: the
96
95
  * readDocChecked call every per-file doctor check (checkFixClaims, checkValidationGaps,
97
- * checkStrandedPlans, checkStaleQuestions, and the stale/waiting bug checks) repeats, with the
96
+ * checkStrandedPlans, checkStaleQuestions, and the stale bug checks) repeats, with the
98
97
  * `no <file> — nothing to verify` missing-detail derived from the file name so the file triple
99
98
  * (path, display name, message) and the message template cannot drift across the seven call
100
99
  * sites. Returns the document, or the outcome the caller should return as-is. */
@@ -105,11 +104,9 @@ export function readBacklogChecked(root, file) {
105
104
  * warns: 6 hours, fixed. A verify plus a merge completes in minutes (plans/merge-queue.md), so a
106
105
  * full six-hour silence while the team is healthy — not on a hold, which doctor already reports
107
106
  * separately — is never a merely busy pipeline: it reads as a drain that has stalled. The one
108
- * opinionated default, like STALE_QUESTION_DAYS, so no knob. The always-on `stale-merges` team
109
- * alert (src/ui/status-payload.ts staleMerges) reuses this same window through
110
- * staleQueuedMergeRows, so the doctor's row and the alert cannot drift on the threshold (the
107
+ * opinionated default, like STALE_QUESTION_DAYS, so no knob (the dead-alert feed is gone; the
111
108
  * STALE_PROMPT_MS/STALE_BUG_DAYS precedent). */
112
- export const MERGE_QUEUE_STALE_MS = 6 * HOUR_MS;
109
+ const MERGE_QUEUE_STALE_MS = 6 * HOUR_MS;
113
110
  /** The window of the merge-wait phrase on the stale merge-queue row. */
114
111
  const MERGE_WAIT_WINDOW_MS = DAY_MS;
115
112
  /** Stale queued merges — a change that has sat in the durable merge queue too long without starting
@@ -141,19 +138,18 @@ export function checkStaleQueuedMerges(root, now) {
141
138
  const [first, ...rest] = stale;
142
139
  if (!first)
143
140
  return withWait(okOutcome(`no merge-queue entry has waited more than ${humanSeconds(MERGE_QUEUE_STALE_MS / 1000)} to start`));
144
- const more = andMore(rest, (e) => `${e.role} — "${truncate(e.summary, FIX_CLAIM_HEADING_MAX)}"`);
145
- return withWait(warnOutcome(`merge-queue entry of ${roleLabel(first.role)} — "${truncate(first.summary, FIX_CLAIM_HEADING_MAX)}" — has waited ` +
141
+ const more = andMore(rest, (e) => `${e.role} — ${quoteHeading(e.summary)}`);
142
+ return withWait(warnOutcome(`merge-queue entry of ${roleLabel(first.role)} — ${quoteHeading(first.summary)} — has waited ` +
146
143
  `${first.age} to start${more}; a queued change the pipeline neither approves nor flags reads as healthy, so the merge queue has stopped moving`));
147
144
  }
148
- /** The one traversal shared by the doctor's `stale merge-queue entries` row
149
- * (checkStaleQueuedMerges) and the always-on `stale-merges` team alert
150
- * (src/ui/status-payload.ts staleMerges): every queued merge whose role is not in the in-flight
145
+ /** The one traversal for the doctor's `stale merge-queue entries` row
146
+ * (checkStaleQueuedMerges): every queued merge whose role is not in the in-flight
151
147
  * marker and whose wait strictly exceeds MERGE_QUEUE_STALE_MS (6 h), in queue-file order (oldest
152
148
  * first). The in-flight set comes from readMergeMarker via mergeChanges (an absent or
153
149
  * never-throwing unreadable marker contributes the empty set) — a slow review is not a stalled
154
- * drain. Each row carries { role, summary, waitedMs }; the doctor's row adds its age phrase and
155
- * the alert names the wait, so the rule and its threshold live in one place and cannot drift. */
156
- export function staleQueuedMergeRows(root, now) {
150
+ * drain. Each row carries { role, summary, waitedMs }, which the row renders with its age
151
+ * phrase, so the rule and its threshold live in one place and cannot drift. */
152
+ function staleQueuedMergeRows(root, now) {
157
153
  const marker = readMergeMarker(root);
158
154
  const inFlight = new Set(marker ? mergeChanges(marker).map((c) => c.role) : []);
159
155
  return queuedMerges(root)
@@ -161,9 +157,8 @@ export function staleQueuedMergeRows(root, now) {
161
157
  .map((e) => ({ role: e.role, summary: e.summary, waitedMs: now - e.enqueuedAt }));
162
158
  }
163
159
  /** The merge-queue files `readMergeQueue` skips — torn, foreign, or vanished mid-listing — as
164
- * checkout-relative paths. One traversal shared by the doctor's `checkMergeQueueFiles` row and
165
- * the always-on `skipped-merges` team alert (src/ui/status-payload.ts skippedMergeFiles), so
166
- * the two surfaces select the same files and name the same paths and cannot drift. */
160
+ * checkout-relative paths. The one traversal behind the doctor's `checkMergeQueueFiles` row, so
161
+ * the row names the same paths the reader actually skips and cannot drift. */
167
162
  export function skippedMergeQueueFiles(root) {
168
163
  return unreadableMergeQueueFiles(root).map((f) => path.relative(root, f));
169
164
  }
@@ -197,9 +192,8 @@ export const POST_REVIEW_AT_RISK_FAILURES = POST_REVIEW_FAILURE_LIMIT - 1;
197
192
  * is not waiting on a verdict and must not read as one failed run from being dropped.
198
193
  * A review that has already landed its fix (`fixed` set) is left out too — it is a settled
199
194
  * record naming both shas, not a verdict-less entry the next run could drop.
200
- * One traversal shared by the doctor's `post-merge reviews` row (checkPostReviews) and the
201
- * always-on `post-merge-risk` team alert (src/ui/status-payload.ts postReviewRiskAlert), so the
202
- * two surfaces select the same entries and cannot drift. Reads .tumwater/state/post-review.json
195
+ * One traversal used by the doctor's `post-merge reviews` row (checkPostReviews), so the row's
196
+ * selection lives in one place. Reads .tumwater/state/post-review.json
203
197
  * through readPostReviews, which degrades a missing or torn file to the well-formed subset or
204
198
  * `[]` and never throws. */
205
199
  export function atRiskPostReviews(root) {
@@ -209,22 +203,22 @@ export function atRiskPostReviews(root) {
209
203
  }
210
204
  /** The doctor's one sentence naming the post-merge reviews one failed run from being dropped
211
205
  * (the checkStaleQueuedMerges phrase precedent): the first in full — role, short commit, summary
212
- * and the failures count — the rest folded through andMore, and the one sentence every surface
213
- * shows, so the doctor row and the alert cannot drift on the wording. null for an empty list. */
206
+ * and the failures count — the rest folded through andMore, the one sentence the row shows, so
207
+ * the wording lives in one place. null for an empty list. */
214
208
  export function atRiskPostReviewsPhrase(rows) {
215
209
  const [first, ...rest] = rows;
216
210
  if (!first)
217
211
  return null;
218
212
  const more = andMore(rest, (r) => `${roleLabel(r.role)} ${shortSha(r.commit)}`);
219
- return `the post-merge review of ${roleLabel(first.role)} ${shortSha(first.commit)} — "${truncate(first.summary, FIX_CLAIM_HEADING_MAX)}" — has ended without a verdict ${first.failures} times${more}; ` +
213
+ return `the post-merge review of ${roleLabel(first.role)} ${shortSha(first.commit)} — ${quoteHeading(first.summary)} — has ended without a verdict ${first.failures} times${more}; ` +
220
214
  `one more failed run drops it and the commit stays on main unreviewed — fix the reviewer backend or accept the commit unreviewed deliberately`;
221
215
  }
222
216
  /** A post-merge review one failed run from being dropped (src/review/post-merge-review.ts, the
223
217
  * drain drops an entry at POST_REVIEW_FAILURE_LIMIT failed runs) — the last moment an operator
224
218
  * can fix the reviewer backend or deliberately accept the commit unreviewed before the drain
225
219
  * drops it from the queue and it stays on main unreviewed. Reads .tumwater/state/post-review.json
226
- * through the shared atRiskPostReviews traversal the always-on post-merge-risk team alert
227
- * (src/ui/status-payload.ts postReviewRiskAlert) uses, so the two surfaces cannot drift. A warn,
220
+ * through the shared atRiskPostReviews traversal, so the selection and wording live in one
221
+ * place. A warn,
228
222
  * never a fail — the checkStaleQueuedMerges precedent: a failing reviewer is operator signal,
229
223
  * not a broken environment; readPostReviews never throws (a missing or torn file degrades to the
230
224
  * well-formed subset or `[]`), so the check needs no read-error arm. */
@@ -236,16 +230,14 @@ export function checkPostReviews(root) {
236
230
  /** How long a queued prompt may wait for its role to dequeue it before the stale-prompt check
237
231
  * warns: a day, fixed. An opinionated default with no knob, like MERGE_QUEUE_STALE_MS: a day is
238
232
  * long enough that a healthy role's next run would have dequeued the prompt, and short enough
239
- * that a paused or disabled role's stranded prompt shows up. The always-on stale-prompt team
240
- * alert (src/ui/status-payload.ts stalePrompts) reuses this same threshold, so it cannot drift
241
- * from the doctor's check — the STALE_BUG_DAYS precedent. */
242
- export const STALE_PROMPT_MS = DAY_MS;
233
+ * that a paused or disabled role's stranded prompt shows up. The stale-prompt check reads this
234
+ * same threshold, so it cannot drift — the STALE_BUG_DAYS precedent. */
235
+ const STALE_PROMPT_MS = DAY_MS;
243
236
  /** The queued inbox prompts the doctor's stale-prompt rule calls stale — deliverable now (a
244
237
  * `--at` prompt still counting down is never stale) and queued more than STALE_PROMPT_MS,
245
238
  * oldest-first, `queuedAtMs` narrowed to a number. One traversal shared by the doctor's
246
- * `checkStalePrompts` check and the always-on `stalePrompts` team alert (src/ui/status-payload.ts),
247
- * so the two surfaces read the same rule at the same threshold and cannot drift. */
248
- export function staleQueuedPromptRows(root, now) {
239
+ * `checkStalePrompts` check, so the rule lives in one place at one threshold. */
240
+ function staleQueuedPromptRows(root, now) {
249
241
  return queuedListRows(root, null, listWideScope(root))
250
242
  .filter((p) => p.queuedAtMs !== null && deliverableAt(p.notBeforeMs, now) && now - p.queuedAtMs > STALE_PROMPT_MS)
251
243
  .sort((a, b) => a.queuedAtMs - b.queuedAtMs)
@@ -258,7 +250,7 @@ export function staleQueuedPromptRows(root, now) {
258
250
  * views (`tumwater prompt --list`, the dashboard Inbox tab, the TUI Inbox pane) show its age —
259
251
  * and only when the operator looks. This names the oldest due-but-undelivered prompt, its role,
260
252
  * and the deliver-or-drop commands. Reads the queue through the shared `staleQueuedPromptRows`
261
- * traversal the always-on stale-prompts alert uses (no new reader in src/inbox/), keeping only
253
+ * traversal (no new reader in src/inbox/), keeping only
262
254
  * rows that are deliverable now — a `--at 7d` prompt still counting down is never stale — and
263
255
  * stamped more than STALE_PROMPT_MS ago. A warn, never a fail — the checkStaleQueuedMerges
264
256
  * precedent: a stranded prompt is operator signal, not a broken environment. */
@@ -12,6 +12,7 @@ export const DOCTOR_CHECK_NAMES = [
12
12
  "init",
13
13
  "config key names",
14
14
  "brief",
15
+ "principles",
15
16
  "bootstrap",
16
17
  "pause",
17
18
  "quiet hours",
@@ -28,7 +29,6 @@ export const DOCTOR_CHECK_NAMES = [
28
29
  "over-cap model",
29
30
  "fallback chain",
30
31
  "models for each tier",
31
- "image input",
32
32
  "claude auth",
33
33
  "claude api key",
34
34
  "claude version",
@@ -59,6 +59,7 @@ export const DOCTOR_CHECK_NAMES = [
59
59
  "check",
60
60
  "ci parity",
61
61
  "qa coverage",
62
+ "role notes",
62
63
  "root install",
63
64
  "flaky checks",
64
65
  "post-merge churn",
@@ -72,11 +73,12 @@ export const DOCTOR_CHECK_NAMES = [
72
73
  "plan dependencies",
73
74
  "stale questions",
74
75
  "stale bugs",
75
- "waiting bugs",
76
76
  "regressed fixes",
77
77
  "stale merge-queue entries",
78
78
  "merge-queue files",
79
79
  "post-merge reviews",
80
+ "main diff rules",
81
+ "dropped post-merge reviews",
80
82
  "stale prompts",
81
83
  "uncommitted backlog",
82
84
  "update",