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
@@ -84,7 +84,6 @@ export const EVENT_TYPE_LABELS = {
84
84
  model_fallback_ended: "fallback model ended",
85
85
  opencode_free_model_billed: "free model billed",
86
86
  image_blind: "image not seen",
87
- image_reroute: "image sent to another model",
88
87
  build_stale: "update available",
89
88
  restart_pending: "update waits for idle",
90
89
  restart: "restarted onto update",
@@ -34,7 +34,7 @@ const LEGACY_EVENT_TYPES = {
34
34
  /** The ordered field rewrites; later parts of the series append here, and they run in order. */
35
35
  const LEGACY_EVENT_REWRITES = [
36
36
  {
37
- // The run number rode `tick` on run_start, run_end, prompt_taken, image_blind, image_reroute.
37
+ // The run number rode `tick` on run_start, run_end, prompt_taken, image_blind.
38
38
  name: "tick-to-run", // retired-name-ok: legacy name
39
39
  apply(ev) {
40
40
  const e = ev;
@@ -349,7 +349,7 @@ export function eventsSince(root, sinceMs, now, keep = () => true) {
349
349
  return readEventsSince(root, sinceMs).events.filter((ev) => keep(ev) && ev.ts >= now - sinceMs);
350
350
  }
351
351
  /** The `--since` / `?sinceMs=` trailing window's bounds and identity slice, shared by the two
352
- * trailing-window collectors (`collectReportSince` in report-data.ts and
352
+ * trailing-window collectors (`collectReportSince` in report-since-data.ts and
353
353
  * `collectFailureReportSince` in failure-data.ts): an open-ended window (cutoff = now − sinceMs,
354
354
  * end = now) when `untilMs` is absent, else the bounded [now − sinceMs, now − untilMs). Both
355
355
  * collectors budget events with the same open/closed comparisons against `cutoff`/`end` (and
@@ -58,8 +58,21 @@ export function logEvent(root, event) {
58
58
  ensureParentDir(file);
59
59
  rotateIfLarge(file, EVENTS_MAX_BYTES, EVENTS_KEEP);
60
60
  appendEventLine(file, JSON.stringify(full) + "\n");
61
- for (const listener of listeners)
62
- listener(full);
61
+ // The event is committed to the log before dispatch, so a subscriber's own failure must not
62
+ // make the write look like it failed to the caller, starve the other subscribers of this or
63
+ // any later event, or crash an unwrapped writer (the orchestrator's poll-loop warn site)
64
+ // for one listener's bug. Each listener is isolated: its throw is surfaced loudly on stderr
65
+ // (a terminal boundary, so the text is sanitized) and the dispatch moves on. The set is
66
+ // snapshotted so a listener that unsubscribes itself mid-dispatch cannot skip or double-run
67
+ // a sibling.
68
+ for (const listener of [...listeners]) {
69
+ try {
70
+ listener(full);
71
+ }
72
+ catch (err) {
73
+ process.stderr.write(`tumwater: event subscriber failed: ${stripTerminalControls(errorMessage(err))}\n`);
74
+ }
75
+ }
63
76
  return full;
64
77
  }
65
78
  /** Log a warning event: the harness's "something is off but the loop continues" signal. The
@@ -31,7 +31,7 @@ export const NOTIFY_EVENT_TYPES = [
31
31
  "role_streak_paused", // the error-streak circuit breaker auto-paused a role
32
32
  "role_cap_paused", // the role's own daily cost cap is reached — it stops starting runs
33
33
  "disk_low", // free space on the worktrees volume dropped below diskHoldGB — new work is held
34
- "main_red_hold", // main's own check is red — every code role is held until a fix lands
34
+ "main_red_hold", // main's own check is red — roles keep authoring; the merge-side main_red pin queues a kept change until main moves past it, and the ci healer is handed the failing tests
35
35
  "merge_failed", // the merge slot finished without merging a change
36
36
  "restart_blocked", // the self-redeploy for main is blocked and latched — the team is stuck behind it
37
37
  "question_posted", // a merge posted a new QUESTIONS.md ## Open entry — only a human can answer it
@@ -4,7 +4,7 @@
4
4
  * run-timeout shapes and their pooling, and the grouping engine live here rather than
5
5
  * inside either consumer. Pure string
6
6
  * and grouping logic — no event reads, no clock. */
7
- import { rankByCount } from "../collections.js";
7
+ import { rankByCount, topAndHidden } from "../collections.js";
8
8
  import { cutSplitsSurrogatePair } from "../text/text.js";
9
9
  /** The verbatim example's trim bound, shared by the normalized key and each cluster's example.
10
10
  * The digest's other caps (top-N counts, summary width) live beside their consumers in
@@ -146,8 +146,9 @@ export function clusterMessages(messages, top, urgent) {
146
146
  // Clusters whose example is `urgent` rank ahead of every other, whatever their count, so a
147
147
  // once-per-window singleton survives the cut past recurring noise.
148
148
  const sorted = rankByCount(drafts.values(), (d) => d.count + (urgent?.(d.example) ? Number.MAX_SAFE_INTEGER / 2 : 0), (d) => d.key);
149
+ const { visible, hidden } = topAndHidden(sorted, top);
149
150
  return {
150
- clusters: sorted.slice(0, top).map((d) => ({
151
+ clusters: visible.map((d) => ({
151
152
  key: d.key,
152
153
  count: d.count,
153
154
  roles: sortedRoles(d.roles),
@@ -155,6 +156,6 @@ export function clusterMessages(messages, top, urgent) {
155
156
  lastSeen: d.lastSeen,
156
157
  example: d.example,
157
158
  })),
158
- hiddenClusters: Math.max(0, sorted.length - top),
159
+ hiddenClusters: hidden,
159
160
  };
160
161
  }
@@ -217,7 +217,66 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
217
217
  const allEvents = bases === undefined ? allEventsRaw : scopeToRole(allEventsRaw, bases);
218
218
  const oldestRetained = allEventsRaw.length > 0 ? eventDayKey(allEventsRaw[0]) : null;
219
219
  const runEvents = current.filter((ev) => ev.type === "run_end");
220
- // Outcome table: one row per role, counts per result that occurred.
220
+ const outcomes = outcomeRows(runEvents);
221
+ // Time and spend: the same runs priced by wall-clock span and cost, per role × outcome
222
+ // class, plus the loss ranking that weighs causes by agent-hours rather than run counts.
223
+ const { timeSpend, lossCauses, lossCausesHidden } = timeAndSpend(runEvents, allEvents);
224
+ // Prompt-token telemetry: per role, the median prompt tokens a run sent and how much of
225
+ // the prompt budget went out before the first edit. Only runs carrying the field count, so
226
+ // a pre-feature log leaves the section off entirely.
227
+ const promptStats = promptStatsFor(runEvents);
228
+ const deltas = deltaRows(current, prior);
229
+ const errors = errorSection(runEvents);
230
+ const warnings = warningSection(current);
231
+ const { rejections, postMergeRejections } = rejectionSection(current);
232
+ const mergeFailures = mergeFailureSection(current);
233
+ const reviewFailures = reviewFailureSection(current);
234
+ const { merged, mergedTotal } = mergedCommits(current);
235
+ const { stateChanges, stateChangesTotal } = stateChangeRows(current);
236
+ // Judged on the two partitions, not the whole read: the since collector reads from priorFrom's
237
+ // local day key, which can reach events earlier that day but before priorFrom — outside both
238
+ // windows. Counting those as "events" would suppress the empty-window line with a window that
239
+ // in fact held none. The day collector's read is exactly current ∪ prior (whole local days), so
240
+ // this changes nothing there.
241
+ const hasEvents = current.length > 0 || prior.length > 0;
242
+ const oldestEventDate = hasEvents ? oldestRetained : null;
243
+ // Complete only when the reader proves the whole read window (priorFrom..to) is covered:
244
+ // coversFullWindow fires when the oldest retained line predates priorFrom, so both the delta
245
+ // baseline and the current window reach their starts. Every retained event's day is >= priorFrom
246
+ // (the reader filters on it), so an event-side comparison can never strengthen that proof — the
247
+ // former `oldestEventDate < from` arm declared the digest complete while the prior half was still
248
+ // truncated, leaving the delta table to present a skewed baseline as a real trend. Only a proven
249
+ // window can be trusted as idle rather than truncated by rotation.
250
+ const windowComplete = coversFullWindow;
251
+ return {
252
+ ...window,
253
+ ...(bases === undefined ? {} : { role: bases.join(",") }),
254
+ runs: runEvents.length,
255
+ hasEvents,
256
+ emptyLog: !hasEvents && !coversFullWindow,
257
+ logState,
258
+ partial: hasEvents && !windowComplete,
259
+ oldestEventDate,
260
+ outcomes,
261
+ deltas,
262
+ timeSpend,
263
+ lossCauses,
264
+ lossCausesHidden,
265
+ promptStats,
266
+ errors,
267
+ warnings,
268
+ reviewFailures,
269
+ rejections,
270
+ postMergeRejections,
271
+ mergeFailures,
272
+ merged,
273
+ mergedTotal,
274
+ stateChanges,
275
+ stateChangesTotal,
276
+ };
277
+ }
278
+ /** Outcome table: one row per role, counts per result that occurred. */
279
+ function outcomeRows(runEvents) {
221
280
  const outcomeMap = new Map();
222
281
  for (const ev of runEvents) {
223
282
  const role = eventRole(ev);
@@ -229,19 +288,14 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
229
288
  // sort-ascending-then-reverse idiom accidentally flipped equal-total ties into
230
289
  // reverse-alphabetical order; equal-total roles now follow the stated deterministic rule
231
290
  // (pinned by the tie test in test/failure-render.test.ts).
232
- const outcomes = rankByCount([...outcomeMap.entries()].map(([role, counts]) => ({ role, counts })), (row) => total(row.counts), (row) => row.role);
233
- // Time and spend: the same runs priced by wall-clock span and cost, per role × outcome
234
- // class, plus the loss ranking that weighs causes by agent-hours rather than run counts.
235
- const { timeSpend, lossCauses, lossCausesHidden } = timeAndSpend(runEvents, allEvents);
236
- // Prompt-token telemetry: per role, the median prompt tokens a run sent and how much of
237
- // the prompt budget went out before the first edit. Only runs carrying the field count, so
238
- // a pre-feature log leaves the section off entirely.
239
- const promptStats = promptStatsFor(runEvents);
240
- // Deltas: current vs preceding window, per role.
291
+ return rankByCount([...outcomeMap.entries()].map(([role, counts]) => ({ role, counts })), (row) => total(row.counts), (row) => row.role);
292
+ }
293
+ /** Deltas: current vs preceding window, per role, ordered by runs descending. */
294
+ function deltaRows(current, prior) {
241
295
  const curStats = roleStats(current);
242
296
  const prevStats = roleStats(prior);
243
297
  const roles = [...new Set([...curStats.keys(), ...prevStats.keys()])];
244
- const deltas = roles
298
+ return roles
245
299
  .map((role) => {
246
300
  const c = curStats.get(role) ?? { runs: 0, errors: 0, quietKills: 0, rejections: 0 };
247
301
  const p = prevStats.get(role) ?? { runs: 0, errors: 0, quietKills: 0, rejections: 0 };
@@ -260,51 +314,65 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
260
314
  .sort((a, b) => b.runs - a.runs ||
261
315
  b.prevRuns - a.prevRuns ||
262
316
  a.role.localeCompare(b.role));
263
- // Only runs that ENDED as errors carry a run error: `state.lastError` is shared state the
264
- // merge slot also writes, so a successful run's `run_end` can carry a leftover merge failure's
265
- // text (BUGS.md 2026-09-21). `main_red` joins them because its cause is written for that result
266
- // — the baseline gate's own text (src/baseline/main-red.ts), never a leftover — so the
267
- // section itemizes the outcome that blocks every merge instead of leaving it a bare count
268
- // (BUGS.md 2026-09-28). The total therefore equals the Outcome table's error column PLUS its
269
- // main_red cells; the render's section title says so, and merge failures still surface below.
270
- // The selection predicate is the Outcome table's own — raw `error`/`main_red` results, no
271
- // error-text test — so a malformed run_end that skipped the text can never be counted above
272
- // but itemized nowhere: those events cluster under a placeholder cause, keeping the section's
273
- // total equal to the tables it cross-checks against (BUGS.md 2026-09-30).
317
+ }
318
+ /** Only runs that ENDED as errors carry a run error: `state.lastError` is shared state the
319
+ * merge slot also writes, so a successful run's `run_end` can carry a leftover merge failure's
320
+ * text (BUGS.md 2026-09-21). `main_red` joins them because its cause is written for that result
321
+ * — the baseline gate's own text (src/baseline/main-red.ts), never a leftover — so the
322
+ * section itemizes the outcome that blocks every merge instead of leaving it a bare count
323
+ * (BUGS.md 2026-09-28). The total therefore equals the Outcome table's error column PLUS its
324
+ * main_red cells; the render's section title says so, and merge failures still surface below.
325
+ * The selection predicate is the Outcome table's own — raw `error`/`main_red` results, no
326
+ * error-text test — so a malformed run_end that skipped the text can never be counted above
327
+ * but itemized nowhere: those events cluster under a placeholder cause, keeping the section's
328
+ * total equal to the tables it cross-checks against (BUGS.md 2026-09-30). */
329
+ function errorSection(runEvents) {
274
330
  const errorEvents = runEvents.filter((ev) => ev.result === "error" || ev.result === "main_red");
275
- const errors = section(clusterMessages(errorEvents.map((ev) => ({
331
+ return section(clusterMessages(errorEvents.map((ev) => ({
276
332
  message: errorTextOrPlaceholder(ev.error),
277
333
  role: eventRole(ev),
278
334
  ts: ev.ts,
279
335
  })), ERROR_TOP), errorEvents.length);
280
- const warnings = messageSection(current.map((ev) => ev.type === "warning" && typeof ev.message === "string"
336
+ }
337
+ /** Warnings cluster on the merged, often-reworded message; the re-input rewording pools the
338
+ * pre-07b2664a wording with its current form so a window spanning the change counts one cause. */
339
+ function warningSection(current) {
340
+ return messageSection(current.map((ev) => ev.type === "warning" && typeof ev.message === "string"
281
341
  ? { ...ev, message: ev.message.replace(OLD_MERGE_CHECK_WORDING, "merge check failed (kept for one retry)") }
282
342
  : ev), "warning", WARNING_TOP, (m) => URGENT_WARNING.test(m));
283
- // Rejections cluster on (role, reasons[0]) — the field the event feed renders — so two
284
- // different rejection reasons from the same role stay separate rows, and an already-landed
285
- // one (ev.afterMerge === true) splits apart from a same-reason pre-merge one so the row names
286
- // the post-merge class whose rejection stands recorded on main. The section's total counts
287
- // every `review_rejected` in the window, reasons or not, so it equals the Deltas column and
288
- // the render's remainder line cross-checks against it.
343
+ }
344
+ /** Rejections cluster on (role, reasons[0]) — the field the event feed renders — so two
345
+ * different rejection reasons from the same role stay separate rows, and an already-landed
346
+ * one (ev.afterMerge === true) splits apart from a same-reason pre-merge one so the row names
347
+ * the post-merge class whose rejection stands recorded on main. The section's total counts
348
+ * every `review_rejected` in the window, reasons or not, so it equals the Deltas column and
349
+ * the render's remainder line cross-checks against it. The post-merge count rides along. */
350
+ function rejectionSection(current) {
289
351
  const rejectedEvents = current.filter((ev) => ev.type === "review_rejected");
290
352
  const postMergeRejections = rejectedEvents.filter((ev) => ev.afterMerge === true).length;
291
- const rejections = section(clusterMessages(rejectedEvents
292
- .filter((ev) => Array.isArray(ev.reasons))
293
- .map((ev) => {
294
- const reasons = ev.reasons;
295
- const message = ev.afterMerge === true ? `post-merge rejection: ${firstReason(reasons)}` : firstReason(reasons);
296
- return { message, role: eventRole(ev), ts: ev.ts, keyPrefix: `${eventRole(ev)}\u0000` };
297
- }), REJECTION_TOP), rejectedEvents.length);
298
- const mergeFailures = mergeFailureSection(current);
299
- // Merge review failures: a reviewer that could not return a parseable verdict (a dead
300
- // backend, a transport error) fails closed and keeps the commit, but its authoring run ends
301
- // `queued`/`no_change` — so the failure lives on the `review_failed` event, never on
302
- // `run_end.error`. Clustered on their own so the selfcheck role can see a gate that keeps
303
- // failing without misreading it as a run error (BUGS.md 2026-09-21).
304
- const reviewFailures = messageSection(current, "review_failed", REVIEW_FAILURE_TOP);
305
- // The newest MERGED_TOP merges, newest first. The pre-slice count rides along, as it does for
306
- // the state changes below, so the render can name what the cut left out — a busy window must
307
- // not read as a MERGED_TOP-merge day (BUGS.md 2026-09-23).
353
+ return {
354
+ rejections: section(clusterMessages(rejectedEvents
355
+ .filter((ev) => Array.isArray(ev.reasons))
356
+ .map((ev) => {
357
+ const reasons = ev.reasons;
358
+ const message = ev.afterMerge === true ? `post-merge rejection: ${firstReason(reasons)}` : firstReason(reasons);
359
+ return { message, role: eventRole(ev), ts: ev.ts, keyPrefix: `${eventRole(ev)}\u0000` };
360
+ }), REJECTION_TOP), rejectedEvents.length),
361
+ postMergeRejections,
362
+ };
363
+ }
364
+ /** Merge review failures: a reviewer that could not return a parseable verdict (a dead
365
+ * backend, a transport error) fails closed and keeps the commit, but its authoring run ends
366
+ * `queued`/`no_change` — so the failure lives on the `review_failed` event, never on
367
+ * `run_end.error`. Clustered on their own so the selfcheck role can see a gate that keeps
368
+ * failing without misreading it as a run error (BUGS.md 2026-09-21). */
369
+ function reviewFailureSection(current) {
370
+ return messageSection(current, "review_failed", REVIEW_FAILURE_TOP);
371
+ }
372
+ /** The newest MERGED_TOP merges, newest first. The pre-slice count rides along, as it does for
373
+ * the state changes below, so the render can name what the cut left out — a busy window must
374
+ * not read as a MERGED_TOP-merge day (BUGS.md 2026-09-23). */
375
+ function mergedCommits(current) {
308
376
  const allMerged = current
309
377
  .filter((ev) => ev.type === "merged")
310
378
  .map((ev) => ({
@@ -313,56 +381,17 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
313
381
  summary: typeof ev.summary === "string" ? truncateExample(ev.summary, SUMMARY_MAX) : "",
314
382
  }))
315
383
  .sort((a, b) => b.ts - a.ts);
316
- const merged = allMerged.slice(0, MERGED_TOP);
317
- // The harness's own decisions, newest STATE_CHANGE_TOP kept in chronological order. The
318
- // description is bounded at collection so render stays a pure function of this data. The
319
- // pre-slice count rides along so the render can say when it is showing a cut, not all of them.
384
+ return { merged: allMerged.slice(0, MERGED_TOP), mergedTotal: allMerged.length };
385
+ }
386
+ /** The harness's own decisions, newest STATE_CHANGE_TOP kept in chronological order. The
387
+ * description is bounded at collection so render stays a pure function of this data. The
388
+ * pre-slice count rides along so the render can say when it is showing a cut, not all of them. */
389
+ function stateChangeRows(current) {
320
390
  const allStateChanges = current
321
391
  .filter((ev) => STATE_CHANGE_TYPES.has(ev.type))
322
392
  .map((ev) => ({ ts: ev.ts, role: eventRole(ev), description: describeStateChange(ev) }))
323
393
  .sort((a, b) => a.ts - b.ts);
324
- const stateChanges = allStateChanges.slice(-STATE_CHANGE_TOP);
325
- // Judged on the two partitions, not the whole read: the since collector reads from priorFrom's
326
- // local day key, which can reach events earlier that day but before priorFrom — outside both
327
- // windows. Counting those as "events" would suppress the empty-window line with a window that
328
- // in fact held none. The day collector's read is exactly current ∪ prior (whole local days), so
329
- // this changes nothing there.
330
- const hasEvents = current.length > 0 || prior.length > 0;
331
- const oldestEventDate = hasEvents ? oldestRetained : null;
332
- // Complete only when the reader proves the whole read window (priorFrom..to) is covered:
333
- // coversFullWindow fires when the oldest retained line predates priorFrom, so both the delta
334
- // baseline and the current window reach their starts. Every retained event's day is >= priorFrom
335
- // (the reader filters on it), so an event-side comparison can never strengthen that proof — the
336
- // former `oldestEventDate < from` arm declared the digest complete while the prior half was still
337
- // truncated, leaving the delta table to present a skewed baseline as a real trend. Only a proven
338
- // window can be trusted as idle rather than truncated by rotation.
339
- const windowComplete = coversFullWindow;
340
- return {
341
- ...window,
342
- ...(bases === undefined ? {} : { role: bases.join(",") }),
343
- runs: runEvents.length,
344
- hasEvents,
345
- emptyLog: !hasEvents && !coversFullWindow,
346
- logState,
347
- partial: hasEvents && !windowComplete,
348
- oldestEventDate,
349
- outcomes,
350
- deltas,
351
- timeSpend,
352
- lossCauses,
353
- lossCausesHidden,
354
- promptStats,
355
- errors,
356
- warnings,
357
- reviewFailures,
358
- rejections,
359
- postMergeRejections,
360
- mergeFailures,
361
- merged,
362
- mergedTotal: allMerged.length,
363
- stateChanges,
364
- stateChangesTotal: allStateChanges.length,
365
- };
394
+ return { stateChanges: allStateChanges.slice(-STATE_CHANGE_TOP), stateChangesTotal: allStateChanges.length };
366
395
  }
367
396
  /** Median of a non-empty numeric list (the mean of the two middles for an even count). */
368
397
  function median(values) {
@@ -384,12 +413,14 @@ function promptStatsFor(runs) {
384
413
  if (prompt === 0 && preEdit === 0)
385
414
  continue;
386
415
  const role = eventRole(ev);
387
- const acc = getOrCreate(byRole, role, () => ({ prompts: [], promptSum: 0, preEditSum: 0 }));
416
+ const acc = getOrCreate(byRole, role, () => ({ prompts: [], promptSum: 0, preEditSum: 0, cacheReadSum: 0 }));
388
417
  if (prompt > 0) {
389
418
  acc.prompts.push(prompt);
390
419
  acc.promptSum += prompt;
391
420
  }
392
421
  acc.preEditSum += preEdit;
422
+ const cacheRead = nonNegativeNumber(ev.cacheReadTokens, 0);
423
+ acc.cacheReadSum += cacheRead;
393
424
  }
394
425
  return [...byRole.entries()]
395
426
  .filter(([, acc]) => acc.promptSum > 0)
@@ -398,6 +429,7 @@ function promptStatsFor(runs) {
398
429
  runs: acc.prompts.length,
399
430
  medianPromptTokens: median(acc.prompts),
400
431
  preEditShare: acc.preEditSum / acc.promptSum,
432
+ ...(acc.cacheReadSum > 0 ? { cacheReadShare: acc.cacheReadSum / acc.promptSum } : {}),
401
433
  }))
402
434
  .sort((a, b) => a.role.localeCompare(b.role));
403
435
  }
@@ -119,6 +119,19 @@ function hoursPhrase(ms) {
119
119
  * I/O, no clock reads. */
120
120
  export function renderFailureMarkdown(data) {
121
121
  const lines = [];
122
+ pushDigestHeader(lines, data);
123
+ pushStateChanges(lines, data);
124
+ pushOutcomeTable(lines, data);
125
+ pushTimeAndSpend(lines, data);
126
+ pushPromptStats(lines, data);
127
+ pushDeltas(lines, data);
128
+ pushClusterSections(lines, data);
129
+ pushMergedSection(lines, data);
130
+ return lines.join("\n");
131
+ }
132
+ /** The digest's title, blank line, window line (bounded/since/day) and log-state lines. Owns the
133
+ * local `bounded` const the empty branch reads, so the since-vs-day split stays in one place. */
134
+ function pushDigestHeader(lines, data) {
122
135
  lines.push("# tumwater failure digest");
123
136
  lines.push("");
124
137
  // The since shape prints the same sections as the day shape but labels its window as an
@@ -145,10 +158,12 @@ export function renderFailureMarkdown(data) {
145
158
  }
146
159
  else if (data.partial)
147
160
  lines.push(`partial: retained log starts ${data.oldestEventDate ?? "?"}`);
148
- // The causal frame before the counts: what the team DID in the window, so the reader can ask
149
- // whether the harness's response was right. Omitted entirely when the window held none. When
150
- // the newest-N cap cut the window's transitions, the remainder is named — a quiet window (no
151
- // line) and a truncated one must not look the same.
161
+ }
162
+ /** The causal frame before the counts: what the team DID in the window, so the reader can ask
163
+ * whether the harness's response was right. Omitted entirely when the window held none. When
164
+ * the newest-N cap cut the window's transitions, the remainder is named — a quiet window (no
165
+ * line) and a truncated one must not look the same. */
166
+ function pushStateChanges(lines, data) {
152
167
  if (data.stateChanges.length > 0) {
153
168
  lines.push("");
154
169
  // A scoped digest retitles the section: its transitions are the named role's, so the
@@ -161,6 +176,10 @@ export function renderFailureMarkdown(data) {
161
176
  if (hidden > 0)
162
177
  lines.push(`+${hidden} older transitions hidden`);
163
178
  }
179
+ }
180
+ /** The outcome table: the column set derived from the window's evidence, then the role × result
181
+ * table, or its placeholder when no run finished. */
182
+ function pushOutcomeTable(lines, data) {
164
183
  const cols = columns(data.outcomes);
165
184
  lines.push("");
166
185
  lines.push("## Outcome by role");
@@ -176,12 +195,16 @@ export function renderFailureMarkdown(data) {
176
195
  ...cols.map((c) => String(row.counts[c] ?? 0)),
177
196
  ]), ["left", ...cols.map(() => "right")]));
178
197
  }
179
- // The same runs priced by what they cost, so a 30-minute timeout outranks ten 200-ms
180
- // errors that the Outcome table above weighs equally. Two parts: the role × class table of
181
- // hours and dollars, then the top loss causes by time (an error cluster, a role's
182
- // no_change total — the quiet loss no cluster names, a review-rejected authoring span, or a
183
- // merge-conflict/merge-blocked authoring span). Grows only with the role catalog
184
- // (fixed by config) and the LOSS_TOP cut, so the byte bound argued above holds.
198
+ }
199
+ /** The same runs priced by what they cost, so a 30-minute timeout outranks ten 200-ms
200
+ * errors that the Outcome table above weighs equally. Two parts: the role × class table of
201
+ * hours and dollars, then the top loss causes by time (an error cluster, a role's
202
+ * no_change total — the quiet loss no cluster names, a review-rejected authoring span, a
203
+ * merge-conflict/merge-blocked authoring span, or a context cut-off (a run truncated at
204
+ * its model's ceiling, a cost of length the harness resumes rather than quiet idleness).
205
+ * Grows only with the role catalog (fixed by config) and the LOSS_TOP cut, so the byte
206
+ * bound argued above holds. */
207
+ function pushTimeAndSpend(lines, data) {
185
208
  lines.push("");
186
209
  lines.push("## Time and spend by outcome");
187
210
  if (data.timeSpend.length === 0) {
@@ -209,7 +232,9 @@ export function renderFailureMarkdown(data) {
209
232
  ? `refused on ${roleCell(c.roles[0] ?? "?")}${c.example ? ` — ${c.example}` : ""}`
210
233
  : c.kind === "merge-conflict" || c.kind === "merge-blocked"
211
234
  ? `${c.kind} authoring on ${roleCell(c.roles[0] ?? "?")}${c.example ? ` — ${c.example}` : ""}`
212
- : `${c.example} (${roleList(c.roles)})`;
235
+ : c.kind === "cutoff"
236
+ ? `context cut-off on ${roleCell(c.roles[0] ?? "?")}`
237
+ : `${c.example} (${roleList(c.roles)})`;
213
238
  lines.push(`- ${hoursPhrase(c.ms)} · ${usd(c.costUsd)} — ${plural(c.runs, "run")}: ${cause}`);
214
239
  }
215
240
  // Mark the top-5 cut the way the cluster sections mark theirs: a capped ranking that
@@ -220,18 +245,28 @@ export function renderFailureMarkdown(data) {
220
245
  }
221
246
  }
222
247
  }
223
- // Prompt-token telemetry: the per-role median prompt
224
- // send and how much of the prompt budget goes out before a run's first edit. Omitted
225
- // entirely when no run_end in the window carries the fields, so old logs and their golden
226
- // digests render unchanged.
248
+ }
249
+ /** Prompt-token telemetry: the per-role median prompt
250
+ * send and how much of the prompt budget goes out before a run's first edit, plus each
251
+ * role's cache-read share of its prompt bill when some run reported cache reads. Omitted
252
+ * entirely when no run_end in the window carries the fields, so old logs and their golden
253
+ * digests render unchanged. */
254
+ function pushPromptStats(lines, data) {
227
255
  if (data.promptStats.length > 0) {
228
256
  lines.push("");
229
257
  lines.push("## Prompt tokens by role");
230
258
  for (const row of data.promptStats) {
231
259
  const share = percent(row.preEditShare, 1);
232
- lines.push(`- ${roleCell(row.role)}: median ${row.medianPromptTokens} prompt tokens/run · ${share} before first edit`);
260
+ let line = `- ${roleCell(row.role)}: median ${row.medianPromptTokens} prompt tokens/run · ${share} before first edit`;
261
+ if (row.cacheReadShare !== undefined) {
262
+ line += ` · ${percent(row.cacheReadShare, 1)} cache reads`;
263
+ }
264
+ lines.push(line);
233
265
  }
234
266
  }
267
+ }
268
+ /** The deltas table: this window's runs/rates vs the preceding one, or its placeholder. */
269
+ function pushDeltas(lines, data) {
235
270
  lines.push("");
236
271
  lines.push(`## Deltas vs the preceding ${data.sinceMs !== undefined ? durationLabel(data.sinceMs - (data.untilMs ?? 0)) : dayLabel(data.days)}`);
237
272
  if (data.deltas.length === 0) {
@@ -246,6 +281,10 @@ export function renderFailureMarkdown(data) {
246
281
  rejectionCell(d.prevRuns, d.prevRejections, d.runs, d.rejections),
247
282
  ])));
248
283
  }
284
+ }
285
+ /** The five cluster sections in their current order, plus the post-merge-rejection line between
286
+ * the review/rejection clusters and the merge-failure section. */
287
+ function pushClusterSections(lines, data) {
249
288
  // The title restates the section's contract: its total now covers the Outcome table's error
250
289
  // column AND its main_red cells (the baseline gate's own cause rides the run, BUGS.md
251
290
  // 2026-09-28), so an operator cross-checking counts against the tables above is not misled
@@ -258,6 +297,9 @@ export function renderFailureMarkdown(data) {
258
297
  lines.push(`_${plural(data.postMergeRejections, "post-merge rejection", "post-merge rejections")} — the commit already reached main, so it stays there with the reviewer's recorded reasons (never re-reviewed — Forward, never back)_`);
259
298
  }
260
299
  renderClusters(lines, "Merge failures", data.mergeFailures, "merge failures");
300
+ }
301
+ /** The merged list, with its remainder line when the newest-N cut dropped anything. */
302
+ function pushMergedSection(lines, data) {
261
303
  lines.push("");
262
304
  lines.push("## Merged in the window");
263
305
  if (data.merged.length === 0) {
@@ -272,7 +314,6 @@ export function renderFailureMarkdown(data) {
272
314
  if (hidden > 0)
273
315
  lines.push(`+${hidden} older merges not listed`);
274
316
  }
275
- return lines.join("\n");
276
317
  }
277
318
  /** Push one "Top … clusters" section: each cluster as a `**N×** roles · first → last —
278
319
  * example` line (the collector supplies the order and the top-N cut), or a single `no <noun> in the