tumwater 0.1.2 → 0.2.0

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 (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
- import { subscribeEvents, warnEventBestEffort } from "./events.js";
2
+ import { loadConfigSafe } from "../config/config.js";
3
+ import { subscribeEvents, TUMWATER_SOURCE, warnEventBestEffort } from "./events.js";
3
4
  import { formatEvent } from "./event-format.js";
4
5
  import { errorMessage, stripTerminalControls } from "../text/text.js";
5
6
  import { childEnv } from "../process/process.js";
@@ -8,53 +9,150 @@ import { childEnv } from "../process/process.js";
8
9
  * push channel beside the pull-based dashboards and `tumwater logs`.
9
10
  *
10
11
  * Scope, stated plainly: the notifier subscribes via subscribeEvents (src/events/events.ts), which
11
- * only sees events THIS process logs. All nine allowlisted types are logged by the
12
- * orchestrator's own process (budget-gates.ts, gate-polls.ts → streak-gate.ts,
13
- * role-cap-gates.ts and disk-gate.ts, landing-slot.ts, landing-questions.ts,
14
- * src/redeploy/redeployer.ts), so nothing
15
- * notable is missed; events appended by
12
+ * only sees events THIS process logs. Every allowlisted type but `supervisor_exit` is logged by
13
+ * the orchestrator's own process (budget-gates.ts, gate-polls.ts → streak-gate.ts,
14
+ * role-cap-gates.ts and disk-gate.ts, src/baseline/main-red.ts, src/team/team-polls.ts,
15
+ * merge-slot.ts, merge-questions.ts,
16
+ * src/redeploy/redeployer.ts). `supervisor_exit` is logged by the supervisor after the
17
+ * orchestrator is gone, so src/cli/cli-run.ts pages it directly with notifyTeamDown; events
18
+ * appended by
16
19
  * operator CLI commands go straight to the file and are out of scope by design.
17
20
  *
18
21
  * One knob, an opinionated fixed allowlist, no per-event selection — a command that can post
19
22
  * anywhere (a desktop notification, a webhook, a phone push) composes it with the environment
20
- * the harness hands it: TUMWATER_EVENT_TYPE, TUMWATER_EVENT_LOOP, and TUMWATER_EVENT_MESSAGE
23
+ * the harness hands it: TUMWATER_EVENT_TYPE, TUMWATER_EVENT_SOURCE, and TUMWATER_EVENT_MESSAGE
21
24
  * (the exact line `tumwater logs` renders, via formatEvent). */
22
25
  export const NOTIFY_EVENT_TYPES = [
23
26
  "budget_warning", // daily spend crossed 80% of the cap, gate still open — time to raise it or fix the fallback before the page below
24
27
  "role_cap_warning", // one role's own daily spend crossed 80% of its cap, still ticking — time to raise it before the page below
25
28
  "budget_paused", // spend cap hit with no usable free fallback — role loops are stopped
29
+ "claude_window_warning", // the claude.ai subscription window first reported allowed_warning — subscription claude seams still tick; the heads-up before the hold below
30
+ "claude_window_limited", // a rejected subscription window holds every subscription claude seam until it resets — the subscription team's binding stop
26
31
  "role_streak_paused", // the error-streak circuit breaker auto-paused a role
27
32
  "role_cap_paused", // the role's own daily cost cap is reached — it stops starting ticks
28
33
  "disk_low", // free space on the worktrees volume dropped below diskHoldGB — new work is held
29
- "land_failed", // the landing slot finished without landing a change
30
- "restart_blocked", // the self-redeploy for main is blocked and latched — the fleet is stuck behind it
31
- "question_posted", // a landing posted a new QUESTIONS.md ## Open entry — only a human can answer it
34
+ "main_red_hold", // main's own check is red — every code loop is held until a fix lands
35
+ "merge_failed", // the merge slot finished without merging a change
36
+ "restart_blocked", // the self-redeploy for main is blocked and latched — the team is stuck behind it
37
+ "question_posted", // a merge posted a new QUESTIONS.md ## Open entry — only a human can answer it
38
+ "entry_refused", // a refusal note merged — the entry is held until a human or the director releases it
39
+ "supervisor_exit", // the supervisor gave up — the team is down; paged from the supervisor process by notifyTeamDown, since no orchestrator is left to hear it
32
40
  ];
33
- /** Same-type events within this gap spawn the command once, not once per event: a burst of
34
- * `land_failed` events pages once. Keyed per type, so one type's page never suppresses
41
+ /** Same-type events within this gap spawn the command at most once per gap: a burst of
42
+ * `merge_failed` events pages once at once and once more when the gap ends, carrying the newest
43
+ * held event and how many were held. Keyed per type, so one type's page never suppresses
35
44
  * another's. */
36
45
  export const NOTIFY_MIN_GAP_MS = 60_000;
37
46
  /** The child environment the notify hook runs with: the three TUMWATER_EVENT_* variables over
38
47
  * the harness's own environment. One home for the variable names, shared by the notifier's
39
48
  * spawn and the `tumwater notify --test` diagnostic (src/cli/notify-command.ts), so the two
40
49
  * cannot drift on what a hook sees. */
41
- export function notifyCommandEnv(type, loop, message) {
50
+ export function notifyCommandEnv(type, source, message) {
42
51
  return childEnv({
43
52
  TUMWATER_EVENT_TYPE: type,
44
- TUMWATER_EVENT_LOOP: loop,
53
+ TUMWATER_EVENT_SOURCE: source,
45
54
  TUMWATER_EVENT_MESSAGE: message,
46
55
  });
47
56
  }
57
+ /** What every notify-hook failure warning starts with. The writers below build their messages
58
+ * from it and `isNotifyFailure` recognizes them by it, so the doctor reader and the writer
59
+ * share one definition. */
60
+ const NOTIFY_FAILURE_PREFIX = "notify command ";
61
+ /** True for a warning that spawnNotifyCommand logged for a failed page. */
62
+ export function isNotifyFailure(ev) {
63
+ return ev.type === "warning" && ev.source === TUMWATER_SOURCE && typeof ev.message === "string" && ev.message.startsWith(NOTIFY_FAILURE_PREFIX);
64
+ }
65
+ /** The warning for a notify hook that did not exit cleanly, or undefined for exit code 0.
66
+ * Pure, so the wording is testable without a spawn. */
67
+ export function notifyExitWarning(type, code, signal) {
68
+ if (code === 0)
69
+ return undefined;
70
+ const hint = "check it with `tumwater notify --test`";
71
+ if (code === null)
72
+ return `${NOTIFY_FAILURE_PREFIX}was killed by ${signal} paging ${type} — ${hint}`;
73
+ return `${NOTIFY_FAILURE_PREFIX}exited with code ${code}${notifyExitGloss(code)} paging ${type} — ${hint}`;
74
+ }
75
+ /** The sh launch-failure gloss for a notify hook that could not start: 127 is sh's "command
76
+ * not found", 126 its "found but not executable" — most often the configured `notify` command
77
+ * is a typo or an uninstalled/non-executable binary, so a bare "code 127" hides the fix the
78
+ * gloss names. Shared by the notifier's warning (notifyExitWarning above) and `tumwater
79
+ * notify --test`'s diagnostic (src/cli/notify-command.ts) so both surfaces name the same fix
80
+ * for a hook that cannot launch. Any other code gets no gloss. */
81
+ export function notifyExitGloss(code) {
82
+ return code === 127 ? " (command not found)" : code === 126 ? " (found but not executable)" : "";
83
+ }
84
+ /** Spawn the notify command for one event: detached with stdio ignored, so it outlives the
85
+ * caller, and never awaited. A failed spawn or a nonzero exit is one warning, never a throw. */
86
+ function spawnNotifyCommand(root, command, event, suffix = "") {
87
+ try {
88
+ const child = spawn(command, [], {
89
+ shell: true,
90
+ env: notifyCommandEnv(event.type, event.source, stripTerminalControls(formatEvent(event)) + suffix),
91
+ stdio: "ignore",
92
+ detached: true,
93
+ });
94
+ child.unref();
95
+ // The shell itself could not start (spawn failed asynchronously — e.g. the command line
96
+ // exceeds the OS arg limit): one warning names it and the listener keeps working. A
97
+ // command that starts but exits nonzero is reported by the exit listener below.
98
+ child.on("error", (err) => {
99
+ warnEventBestEffort(root, TUMWATER_SOURCE, `${NOTIFY_FAILURE_PREFIX}could not start: ${err.message}`);
100
+ });
101
+ // After unref() this listener does not keep the process alive: a hook that outlives the
102
+ // caller logs nothing.
103
+ child.on("exit", (code, signal) => {
104
+ const message = notifyExitWarning(event.type, code, signal);
105
+ if (message)
106
+ warnEventBestEffort(root, TUMWATER_SOURCE, message);
107
+ });
108
+ }
109
+ catch (err) {
110
+ warnEventBestEffort(root, TUMWATER_SOURCE, `${NOTIFY_FAILURE_PREFIX}could not start: ${errorMessage(err)}`);
111
+ }
112
+ }
113
+ /** Page the configured notify command for the team going down. The supervisor process calls
114
+ * this directly, because the orchestrator generation that runs newNotifier is already gone.
115
+ * Reads the command fresh from tumwater.json; a missing or invalid file or an absent `notify`
116
+ * returns silently (the team-down cause may be that very file), and there is no throttle —
117
+ * the supervisor exits right after this one event. */
118
+ export function notifyTeamDown(root, event) {
119
+ const { config } = loadConfigSafe(root);
120
+ const command = config?.notify;
121
+ if (typeof command !== "string" || command === "")
122
+ return;
123
+ spawnNotifyCommand(root, command, event);
124
+ }
48
125
  /** Subscribe to this process's event feed and spawn the configured command for allowlisted
49
126
  * events. Fire-and-forget: the spawn is never awaited from the poll loop — the command runs
50
- * detached with stdio ignored, so it outlives whatever the orchestrator does next, and a
51
- * nonzero exit is the command's problem, not the fleet's. `"warning"` is deliberately not on
52
- * the allowlist, so a failed spawn's warning can never recurse into another spawn. */
127
+ * detached with stdio ignored, so it outlives whatever the orchestrator does next. A nonzero
128
+ * exit or kill is logged as one warning and the page is not retried. `"warning"` is
129
+ * deliberately not on the allowlist, so a failure warning can never recurse into another
130
+ * spawn. A same-type event inside the gap is held, not dropped: the newest one is paged when
131
+ * the gap ends (or at `dispose`), with a count of the others held with it. */
53
132
  export function newNotifier(root, minGapMs = NOTIFY_MIN_GAP_MS,
54
133
  /** The throttle's clock — a test seam; production reads the wall clock. */
55
- now = Date.now) {
134
+ now = Date.now,
135
+ /** Runs `fn` after `delayMs` and returns a cancel function — a test seam; production uses an
136
+ * unref'd timer so a held page never keeps the process alive. */
137
+ schedule = (fn, delayMs) => {
138
+ const timer = setTimeout(fn, delayMs);
139
+ timer.unref();
140
+ return () => clearTimeout(timer);
141
+ }) {
56
142
  let command;
57
143
  const lastSpawnAt = new Map();
144
+ const pending = new Map();
145
+ const flush = (type) => {
146
+ const entry = pending.get(type);
147
+ if (!entry)
148
+ return;
149
+ pending.delete(type);
150
+ if (typeof command !== "string" || command === "")
151
+ return;
152
+ lastSpawnAt.set(type, now());
153
+ const suffix = entry.held > 1 ? ` (and ${entry.held - 1} more ${type} event(s) in the same minute; \`tumwater logs\` lists them)` : "";
154
+ spawnNotifyCommand(root, command, entry.event, suffix);
155
+ };
58
156
  const unsubscribe = subscribeEvents((event) => {
59
157
  if (typeof command !== "string" || command === "")
60
158
  return;
@@ -63,32 +161,33 @@ now = Date.now) {
63
161
  const type = event.type;
64
162
  const at = now();
65
163
  const last = lastSpawnAt.get(type);
66
- if (last !== undefined && at - last < minGapMs)
164
+ if (last !== undefined && at - last < minGapMs) {
165
+ const held = pending.get(type);
166
+ if (held) {
167
+ held.event = event;
168
+ held.held += 1;
169
+ }
170
+ else {
171
+ pending.set(type, { event, held: 1, cancel: schedule(() => flush(type), last + minGapMs - at) });
172
+ }
67
173
  return;
68
- lastSpawnAt.set(type, at);
69
- try {
70
- const child = spawn(command, [], {
71
- shell: true,
72
- env: notifyCommandEnv(event.type, event.loop, stripTerminalControls(formatEvent(event))),
73
- stdio: "ignore",
74
- detached: true,
75
- });
76
- child.unref();
77
- // The shell itself could not start (spawn failed asynchronously — e.g. the command line
78
- // exceeds the OS arg limit): one warning names it and the listener keeps working. A
79
- // command that starts but exits nonzero is not a harness concern and is ignored.
80
- child.on("error", (err) => {
81
- warnEventBestEffort(root, "harness", `notify command could not start: ${err.message}`);
82
- });
83
- }
84
- catch (err) {
85
- warnEventBestEffort(root, "harness", `notify command could not start: ${errorMessage(err)}`);
86
174
  }
175
+ // Past the gap: this event pages now and supersedes anything still held for the type.
176
+ pending.get(type)?.cancel();
177
+ pending.delete(type);
178
+ lastSpawnAt.set(type, at);
179
+ spawnNotifyCommand(root, command, event);
87
180
  });
88
181
  return {
89
182
  update(config) {
90
183
  command = config.notify;
91
184
  },
92
- dispose: unsubscribe,
185
+ dispose() {
186
+ unsubscribe();
187
+ for (const [type, entry] of [...pending]) {
188
+ entry.cancel();
189
+ flush(type);
190
+ }
191
+ },
93
192
  };
94
193
  }
@@ -1,6 +1,6 @@
1
1
  /** The cluster-key rules shared by everything that groups failure messages into causes: the
2
2
  * failure digest's collection (failure-data.ts) and the error-storm reducer
3
- * (src/fleet/error-storm.ts) both count by these keys, so the normalization, the two
3
+ * (src/team/error-storm.ts) both count by these keys, so the normalization, the two
4
4
  * tick-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. */
@@ -12,7 +12,7 @@ import { cutSplitsSurrogatePair } from "../text/text.js";
12
12
  export const EXAMPLE_MAX = 120;
13
13
  /** The example and cluster key every message-less failure takes: errorTextOrPlaceholder
14
14
  * returns it for the digest's error clusters (failure-data.ts) and its loss ranking
15
- * (time-spend.ts), so a broken `tick_end` that skipped its error text reads the same on both
15
+ * (time-spend.ts), so a broken `run_end` that skipped its error text reads the same on both
16
16
  * surfaces. */
17
17
  const NO_ERROR_TEXT = "(no error text recorded)";
18
18
  /** The error text a failure event carries, or NO_ERROR_TEXT when it carries none — the field
@@ -49,7 +49,7 @@ export function truncateExample(message, max = EXAMPLE_MAX) {
49
49
  * array in ascending localeCompare order, so equal inputs always render identically no matter
50
50
  * the Set's insertion order. One home for the rule the failure digest's clusters
51
51
  * (clusterMessages) and loss causes (failure-data.ts) and the error-storm warning
52
- * (src/fleet/error-storm.ts) all share — an inline `.sort()` without the comparator would
52
+ * (src/team/error-storm.ts) all share — an inline `.sort()` without the comparator would
53
53
  * silently pick code-unit order instead and let the two rules drift. */
54
54
  export function sortedRoles(roles) {
55
55
  return [...roles].sort((a, b) => a.localeCompare(b));
@@ -78,7 +78,7 @@ export function normalizeClusterKey(message) {
78
78
  }
79
79
  /** normalizeClusterKey's rendering of the two tick-timeout errors src/agent/run-agent.ts emits —
80
80
  * the plain kill and the still-making-progress variant (session and worktree edits preserved
81
- * for resume, BUGS.md 2026-09-29). Both are one cause pointing at one knob (tickTimeoutSeconds),
81
+ * for resume, BUGS.md 2026-09-29). Both are one cause pointing at one knob (runTimeoutSeconds),
82
82
  * so every consumer that counts by cluster key pools them under the plain one. */
83
83
  export const TICK_TIMEOUT_KEY = "timed out after <dur>";
84
84
  const TICK_TIMEOUT_PROGRESSING_KEY = "timed out after <dur> while still making progress — session and worktree edits preserved for resume";
@@ -94,9 +94,9 @@ const TICK_TIMEOUT_PROGRESSING_REVIEW_KEY = "timed out after <dur> while still m
94
94
  export const TICK_TIMEOUT_PROGRESSING_REVIEW_RESUME_KEY = "timed out after <dur> while still making progress — the commit is kept; the next attempt continues this review";
95
95
  /** The known progressing-timeout wordings and the key each pools under. The two review
96
96
  * wordings name one cause on one knob (review.timeoutSeconds): a still-making-progress kill
97
- * pools into the plain tick-timeout row (a mixed fleet of plain and progressing kills is one
97
+ * pools into the plain tick-timeout row (a mixed team of plain and progressing kills is one
98
98
  * cause's agent-hours, not two half-size rows the top-N cut can drop — the same pooling the
99
- * error-storm reducer applies, src/fleet/error-storm.ts), and a resumed review's wording maps
99
+ * error-storm reducer applies, src/team/error-storm.ts), and a resumed review's wording maps
100
100
  * to itself, because continuing the timed-out session is a different cause from a from-scratch
101
101
  * re-review. Enumerated so a future wording edit lands in
102
102
  * this table rather than silently changing which row its timeouts count under. */
@@ -114,9 +114,10 @@ export function poolTimeoutKey(key) {
114
114
  * rides lastSeen: a cluster that outlived a config change labels itself with the message as
115
115
  * it happens now, not the retired value it first emitted (BUGS.md 2026-09-30). `keyPrefix`
116
116
  * scopes a cluster to something the message itself omits (rejections key on role too) without
117
- * polluting the verbatim example. Returns the top-N clusters plus how many fell past the cut,
117
+ * polluting the verbatim example. `urgent` marks messages that outrank the count.
118
+ * Returns the top-N clusters plus how many fell past the cut,
118
119
  * so the render can mark the truncation instead of presenting the survivors as the whole. */
119
- export function clusterMessages(messages, top) {
120
+ export function clusterMessages(messages, top, urgent) {
120
121
  const drafts = new Map();
121
122
  for (const { message, role, ts, keyPrefix } of messages) {
122
123
  const key = `${keyPrefix ?? ""}${poolTimeoutKey(normalizeClusterKey(message))}`;
@@ -142,7 +143,9 @@ export function clusterMessages(messages, top) {
142
143
  });
143
144
  }
144
145
  }
145
- const sorted = rankByCount(drafts.values(), (d) => d.count, (d) => d.key);
146
+ // Clusters whose example is `urgent` rank ahead of every other, whatever their count, so a
147
+ // once-per-window singleton survives the cut past recurring noise.
148
+ const sorted = rankByCount(drafts.values(), (d) => d.count + (urgent?.(d.example) ? Number.MAX_SAFE_INTEGER / 2 : 0), (d) => d.key);
146
149
  return {
147
150
  clusters: sorted.slice(0, top).map((d) => ({
148
151
  key: d.key,
@@ -5,19 +5,34 @@ import { timeAndSpend } from "./time-spend.js";
5
5
  import { dayAt, dayKey, dayWindow, formatDate } from "../text/datetime.js";
6
6
  import { describeStateChange, STATE_CHANGE_TOP, STATE_CHANGE_TYPES } from "./failure-state-change.js";
7
7
  import { clusterMessages, errorTextOrPlaceholder, truncateExample } from "./failure-cluster.js";
8
- import { addTo, getOrCreate, rankByCount } from "../collections.js";
8
+ import { addTo, getOrCreate, memoSetLru, rankByCount } from "../collections.js";
9
9
  import { firstReason } from "../text/phrases.js";
10
10
  import { sameStatIdentity, statOrNull } from "../files/files.js";
11
11
  import { nonNegativeNumber } from "../files/json-object.js";
12
12
  import { eventsLogPath } from "../paths.js";
13
13
  /** Caps that keep the digest bounded regardless of how bad the window was — the top-N
14
- * clusters, one trimmed example each, and the newest N landed commits. See the render-doc
14
+ * clusters, one trimmed example each, and the newest N merged commits. See the render-doc
15
15
  * byte bound below for what these buy. */
16
16
  const ERROR_TOP = 7;
17
17
  const WARNING_TOP = 7;
18
+ /** Warnings that name a failing check or a stalled tool call. Each is a once-per-window
19
+ * singleton, so ranking by count alone lets recurring housekeeping lines push it out of the
20
+ * digest; these rank first. */
21
+ const URGENT_WARNING = /^(?:landing check red|merge check failed|tool call stalled)/;
22
+ /** The pre-07b2664a wording of the merge-check warning, pooled under the current one so a
23
+ * window spanning the reword counts one cause, not two. */
24
+ const OLD_MERGE_CHECK_WORDING = /^landing check red \(kept for one re-land\)/;
18
25
  const REVIEW_FAILURE_TOP = 5;
19
26
  const REJECTION_TOP = 5;
20
- const LANDED_TOP = 10;
27
+ /** Blocked merges are rare and each one is a signal, so the section gets its own cut, roomier
28
+ * than the other cluster sections'; the digest stays bounded and the render marks any clusters
29
+ * the cut hides. */
30
+ const MERGE_FAILURE_TOP = 10;
31
+ /** The cause text for a blocked or conflicted merge whose role logged no warning since its
32
+ * previous merge outcome — a block with no stated reason is a signal in itself, so it clusters
33
+ * on its own instead of hiding. */
34
+ const NO_MERGE_REASON = "no reason recorded";
35
+ const MERGED_TOP = 10;
21
36
  const SUMMARY_MAX = 100;
22
37
  /** Keep only events that belong to one base role, so a scoped digest aggregates instances
23
38
  * (`feature-2`) into their group exactly as `report --role` and the per-role cap do. Loop
@@ -44,21 +59,56 @@ function section(clustered, total) {
44
59
  * section does not render through it — its text lives on `error`, not `message`, with a
45
60
  * fallback phrase — and rejections key on reasons[0] and prefix their cluster keys, so both
46
61
  * keep their own blocks. */
47
- function messageSection(events, type, top) {
62
+ function messageSection(events, type, top, urgent) {
48
63
  const selected = events.filter((ev) => ev.type === type && typeof ev.message === "string" && ev.message !== "");
49
- return section(clusterMessages(selected.map((ev) => ({ message: ev.message, role: eventRole(ev), ts: ev.ts })), top), selected.length);
64
+ return section(clusterMessages(selected.map((ev) => ({ message: ev.message, role: eventRole(ev), ts: ev.ts })), top, urgent), selected.length);
65
+ }
66
+ /** The merge-failure section: each blocked or conflicted `merge_failed` clusters on
67
+ * (role, result, reason).
68
+ * `merge_failed` carries no message, so the reason is the same role's nearest preceding
69
+ * `warning` (a red merge check, a structure or fix-claim block) logged after that role's
70
+ * previous merge outcome; with none, the placeholder. The total counts every blocked or
71
+ * conflicted `merge_failed` in the window. */
72
+ function mergeFailureSection(events) {
73
+ const ordered = [...events].sort((a, b) => a.ts - b.ts);
74
+ const lastWarning = new Map();
75
+ const messages = [];
76
+ let total = 0;
77
+ for (const ev of ordered) {
78
+ const role = eventRole(ev);
79
+ if (ev.type === "warning" && typeof ev.message === "string" && ev.message !== "") {
80
+ lastWarning.set(role, ev.message.replace(OLD_MERGE_CHECK_WORDING, "merge check failed (kept for one retry)"));
81
+ }
82
+ else if (ev.type === "merge_done") {
83
+ lastWarning.delete(role);
84
+ }
85
+ else if (ev.type === "merge_failed") {
86
+ const result = typeof ev.result === "string" ? ev.result : "";
87
+ // Rejected and review_error merges have their own sections; only a block or a conflict
88
+ // is unaccounted for here.
89
+ if (result !== "merge_blocked" && result !== "merge_conflict") {
90
+ lastWarning.delete(role);
91
+ continue;
92
+ }
93
+ total++;
94
+ const reason = lastWarning.get(role) ?? NO_MERGE_REASON;
95
+ lastWarning.delete(role);
96
+ messages.push({ message: `${result} — ${reason}`, role, ts: ev.ts, keyPrefix: `${role}\u0000` });
97
+ }
98
+ }
99
+ return section(clusterMessages(messages, MERGE_FAILURE_TOP), total);
50
100
  }
51
101
  function roleStats(events) {
52
102
  const byRole = new Map();
53
- const statsFor = (role) => getOrCreate(byRole, role, () => ({ ticks: 0, errors: 0, quietKills: 0, rejections: 0 }));
103
+ const statsFor = (role) => getOrCreate(byRole, role, () => ({ runs: 0, errors: 0, quietKills: 0, rejections: 0 }));
54
104
  for (const ev of events) {
55
- // Ticks are tick_end events; a rejection is now recorded by the landing slot, AFTER the
105
+ // Ticks are run_end events; a rejection is now recorded by the merge slot, AFTER the
56
106
  // authoring tick has already ended `queued` (plans/merge-queue.md 3/5). review_rejected is
57
107
  // the one event every reject path logs exactly once (src/review/review.ts), so it — not a
58
- // tick_end result no production path emits — is the rejection source.
59
- if (ev.type === "tick_end") {
108
+ // run_end result no production path emits — is the rejection source.
109
+ if (ev.type === "run_end") {
60
110
  const stats = statsFor(eventRole(ev));
61
- stats.ticks++;
111
+ stats.runs++;
62
112
  if (ev.result === "error")
63
113
  stats.errors++;
64
114
  else if (ev.result === "quiet_killed")
@@ -75,7 +125,7 @@ function roleStats(events) {
75
125
  * collector re-reads and re-parses up to 2× the window's log bytes each time (~4 MB on this
76
126
  * repo's own log, growing toward the rotation cap). An unchanged log and window cost one stat
77
127
  * and return the prior result; any change — an append, a rotation, or a new local day —
78
- * re-derives exactly as uncached. Mirrors report-data.ts's foldWindowEvents, which already
128
+ * re-derives exactly as uncached. Mirrors report-fold.ts's foldWindowEvents, which already
79
129
  * spares the usage report the same re-read. */
80
130
  const failureDigestCache = new Map();
81
131
  const FAILURE_DIGEST_CACHE_MAX = 8;
@@ -95,7 +145,7 @@ export function collectFailureReport(root, days, role) {
95
145
  const { from, to } = dayWindow(days, now);
96
146
  const priorFrom = formatDate(dayAt(days * 2 - 1, now));
97
147
  const base = role === undefined ? undefined : baseRoleOf(role);
98
- // The base role rides the key, so a scoped and a fleet digest of the same log cannot
148
+ // The base role rides the key, so a scoped and a team digest of the same log cannot
99
149
  // collide in the memo.
100
150
  const key = `${root}\u0000${days}\u0000${base ?? ""}`;
101
151
  const stat = digestStat(root);
@@ -104,8 +154,7 @@ export function collectFailureReport(root, days, role) {
104
154
  cached.to === to &&
105
155
  cached.priorFrom === priorFrom &&
106
156
  sameStatIdentity(cached.stat, stat)) {
107
- failureDigestCache.delete(key); // Refresh LRU position.
108
- failureDigestCache.set(key, cached);
157
+ memoSetLru(failureDigestCache, key, cached, FAILURE_DIGEST_CACHE_MAX);
109
158
  return cached.data;
110
159
  }
111
160
  const { events, coversFullWindow, logState } = readWindowEvents(root, priorFrom);
@@ -121,12 +170,7 @@ export function collectFailureReport(root, days, role) {
121
170
  prior.push(ev);
122
171
  }
123
172
  const data = assembleFailureReport(current, prior, events, coversFullWindow, logState, { days, from, to }, role);
124
- failureDigestCache.set(key, { from, to, priorFrom, stat, data });
125
- if (failureDigestCache.size > FAILURE_DIGEST_CACHE_MAX) {
126
- const oldest = failureDigestCache.keys().next().value;
127
- if (oldest !== undefined)
128
- failureDigestCache.delete(oldest);
129
- }
173
+ memoSetLru(failureDigestCache, key, { from, to, priorFrom, stat, data }, FAILURE_DIGEST_CACHE_MAX);
130
174
  return data;
131
175
  }
132
176
  /** Collect the digest over the trailing `sinceMs` window ending now, partitioning the read into
@@ -164,7 +208,7 @@ export function collectFailureReportSince(root, sinceMs, role) {
164
208
  * exist twice. `window` carries the labels the render branches on. */
165
209
  function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWindow, logState, window, role) {
166
210
  // Scope first when a role was named: every section below derives from these partitions, so
167
- // narrowing them at the source scopes outcomes, deltas, clusters, landings, and time/spend at
211
+ // narrowing them at the source scopes outcomes, deltas, clusters, merges, and time/spend at
168
212
  // once. The unfiltered read's first event is kept for the retention line, so a scoped partial
169
213
  // window still names when the log starts, not when the scoped role's own first tick does.
170
214
  const base = role === undefined ? undefined : baseRoleOf(role);
@@ -172,7 +216,7 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
172
216
  const prior = base === undefined ? priorRaw : scopeToRole(priorRaw, base);
173
217
  const allEvents = base === undefined ? allEventsRaw : scopeToRole(allEventsRaw, base);
174
218
  const oldestRetained = allEventsRaw.length > 0 ? eventDayKey(allEventsRaw[0]) : null;
175
- const tickEvents = current.filter((ev) => ev.type === "tick_end");
219
+ const tickEvents = current.filter((ev) => ev.type === "run_end");
176
220
  // Outcome table: one row per role, counts per result that occurred.
177
221
  const outcomeMap = new Map();
178
222
  for (const ev of tickEvents) {
@@ -186,11 +230,11 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
186
230
  // reverse-alphabetical order; equal-total roles now follow the stated deterministic rule
187
231
  // (pinned by the tie test in test/failure-render.test.ts).
188
232
  const outcomes = rankByCount([...outcomeMap.entries()].map(([role, counts]) => ({ role, counts })), (row) => total(row.counts), (row) => row.role);
189
- // Time and spend: the same ticks priced by wall-clock span and cost, per role × outcome
233
+ // Time and spend: the same runs priced by wall-clock span and cost, per role × outcome
190
234
  // class, plus the loss ranking that weighs causes by agent-hours rather than tick counts.
191
235
  const { timeSpend, lossCauses, lossCausesHidden } = timeAndSpend(tickEvents, allEvents);
192
236
  // Prompt-token telemetry: per role, the median prompt tokens a tick sent and how much of
193
- // the prompt budget went out before the first edit. Only ticks carrying the field count, so
237
+ // the prompt budget went out before the first edit. Only runs carrying the field count, so
194
238
  // a pre-feature log leaves the section off entirely.
195
239
  const promptStats = promptStatsFor(tickEvents);
196
240
  // Deltas: current vs preceding window, per role.
@@ -199,12 +243,12 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
199
243
  const roles = [...new Set([...curStats.keys(), ...prevStats.keys()])];
200
244
  const deltas = roles
201
245
  .map((role) => {
202
- const c = curStats.get(role) ?? { ticks: 0, errors: 0, quietKills: 0, rejections: 0 };
203
- const p = prevStats.get(role) ?? { ticks: 0, errors: 0, quietKills: 0, rejections: 0 };
246
+ const c = curStats.get(role) ?? { runs: 0, errors: 0, quietKills: 0, rejections: 0 };
247
+ const p = prevStats.get(role) ?? { runs: 0, errors: 0, quietKills: 0, rejections: 0 };
204
248
  return {
205
249
  role,
206
- prevTicks: p.ticks,
207
- ticks: c.ticks,
250
+ prevRuns: p.runs,
251
+ runs: c.runs,
208
252
  prevErrors: p.errors,
209
253
  errors: c.errors,
210
254
  prevQuietKills: p.quietKills,
@@ -213,18 +257,18 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
213
257
  rejections: c.rejections,
214
258
  };
215
259
  })
216
- .sort((a, b) => b.ticks - a.ticks ||
217
- b.prevTicks - a.prevTicks ||
260
+ .sort((a, b) => b.runs - a.runs ||
261
+ b.prevRuns - a.prevRuns ||
218
262
  a.role.localeCompare(b.role));
219
- // Only ticks that ENDED as errors carry a tick error: `state.lastError` is shared state the
220
- // lander also writes, so a successful tick's `tick_end` can carry a leftover landing failure's
263
+ // Only runs that ENDED as errors carry a tick error: `state.lastError` is shared state the
264
+ // merge slot also writes, so a successful tick's `run_end` can carry a leftover merge failure's
221
265
  // text (BUGS.md 2026-09-21). `main_red` joins them because its cause is written for that result
222
266
  // — the baseline gate's own text (src/baseline/main-red.ts), never a leftover — so the
223
267
  // section itemizes the outcome that blocks every merge instead of leaving it a bare count
224
268
  // (BUGS.md 2026-09-28). The total therefore equals the Outcome table's error column PLUS its
225
- // main_red cells; the render's section title says so, and landing failures still surface below.
269
+ // main_red cells; the render's section title says so, and merge failures still surface below.
226
270
  // The selection predicate is the Outcome table's own — raw `error`/`main_red` results, no
227
- // error-text test — so a malformed tick_end that skipped the text can never be counted above
271
+ // error-text test — so a malformed run_end that skipped the text can never be counted above
228
272
  // but itemized nowhere: those events cluster under a placeholder cause, keeping the section's
229
273
  // total equal to the tables it cross-checks against (BUGS.md 2026-09-30).
230
274
  const errorEvents = tickEvents.filter((ev) => ev.result === "error" || ev.result === "main_red");
@@ -233,7 +277,9 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
233
277
  role: eventRole(ev),
234
278
  ts: ev.ts,
235
279
  })), ERROR_TOP), errorEvents.length);
236
- const warnings = messageSection(current, "warning", WARNING_TOP);
280
+ const warnings = messageSection(current.map((ev) => ev.type === "warning" && typeof ev.message === "string"
281
+ ? { ...ev, message: ev.message.replace(OLD_MERGE_CHECK_WORDING, "merge check failed (kept for one retry)") }
282
+ : ev), "warning", WARNING_TOP, (m) => URGENT_WARNING.test(m));
237
283
  // Rejections cluster on (role, reasons[0]) — the field the event feed renders — so two
238
284
  // different rejection reasons from the same role stay separate rows. The section's total
239
285
  // counts every `review_rejected` in the window, reasons or not, so it equals the Deltas
@@ -245,16 +291,17 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
245
291
  const reasons = ev.reasons;
246
292
  return { message: firstReason(reasons), role: eventRole(ev), ts: ev.ts, keyPrefix: `${eventRole(ev)}\u0000` };
247
293
  }), REJECTION_TOP), rejectedEvents.length);
248
- // Landing review failures: a reviewer that could not return a parseable verdict (a dead
294
+ const mergeFailures = mergeFailureSection(current);
295
+ // Merge review failures: a reviewer that could not return a parseable verdict (a dead
249
296
  // backend, a transport error) fails closed and keeps the commit, but its authoring tick ends
250
297
  // `queued`/`no_change` — so the failure lives on the `review_failed` event, never on
251
- // `tick_end.error`. Clustered on their own so the telemetry role can see a gate that keeps
298
+ // `run_end.error`. Clustered on their own so the telemetry role can see a gate that keeps
252
299
  // failing without misreading it as a tick error (BUGS.md 2026-09-21).
253
300
  const reviewFailures = messageSection(current, "review_failed", REVIEW_FAILURE_TOP);
254
- // The newest LANDED_TOP merges, newest first. The pre-slice count rides along, as it does for
301
+ // The newest MERGED_TOP merges, newest first. The pre-slice count rides along, as it does for
255
302
  // the state changes below, so the render can name what the cut left out — a busy window must
256
- // not read as a LANDED_TOP-merge day (BUGS.md 2026-09-23).
257
- const allLanded = current
303
+ // not read as a MERGED_TOP-merge day (BUGS.md 2026-09-23).
304
+ const allMerged = current
258
305
  .filter((ev) => ev.type === "merged")
259
306
  .map((ev) => ({
260
307
  ts: ev.ts,
@@ -262,7 +309,7 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
262
309
  summary: typeof ev.summary === "string" ? truncateExample(ev.summary, SUMMARY_MAX) : "",
263
310
  }))
264
311
  .sort((a, b) => b.ts - a.ts);
265
- const landed = allLanded.slice(0, LANDED_TOP);
312
+ const merged = allMerged.slice(0, MERGED_TOP);
266
313
  // The harness's own decisions, newest STATE_CHANGE_TOP kept in chronological order. The
267
314
  // description is bounded at collection so render stays a pure function of this data. The
268
315
  // pre-slice count rides along so the render can say when it is showing a cut, not all of them.
@@ -289,7 +336,7 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
289
336
  return {
290
337
  ...window,
291
338
  ...(base === undefined ? {} : { role: base }),
292
- ticks: tickEvents.length,
339
+ runs: tickEvents.length,
293
340
  hasEvents,
294
341
  emptyLog: !hasEvents && !coversFullWindow,
295
342
  logState,
@@ -305,8 +352,9 @@ function assembleFailureReport(currentRaw, priorRaw, allEventsRaw, coversFullWin
305
352
  warnings,
306
353
  reviewFailures,
307
354
  rejections,
308
- landed,
309
- landedTotal: allLanded.length,
355
+ mergeFailures,
356
+ merged,
357
+ mergedTotal: allMerged.length,
310
358
  stateChanges,
311
359
  stateChangesTotal: allStateChanges.length,
312
360
  };
@@ -317,15 +365,15 @@ function median(values) {
317
365
  const mid = Math.floor(sorted.length / 2);
318
366
  return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
319
367
  }
320
- /** Per-role prompt-token stats from the window's tick_end events. Ticks without a
368
+ /** Per-role prompt-token stats from the window's run_end events. Ticks without a
321
369
  * `promptTokens` reading contribute nothing (and skip a role entirely), so the section only
322
370
  * appears once some tick ran on a build that records the field. Both reads go through
323
371
  * nonNegativeNumber like every other event-log number: a torn or hand-edited line whose
324
372
  * `promptTokens` is `1e999` (JSON parses to Infinity) would otherwise land in the median and
325
373
  * the `preEditSum / promptSum` ratio as a non-finite value no report can render. */
326
- function promptStatsFor(ticks) {
374
+ function promptStatsFor(runs) {
327
375
  const byRole = new Map();
328
- for (const ev of ticks) {
376
+ for (const ev of runs) {
329
377
  const prompt = nonNegativeNumber(ev.promptTokens, 0);
330
378
  const preEdit = nonNegativeNumber(ev.preEditPromptTokens, 0);
331
379
  if (prompt === 0 && preEdit === 0)
@@ -342,7 +390,7 @@ function promptStatsFor(ticks) {
342
390
  .filter(([, acc]) => acc.promptSum > 0)
343
391
  .map(([role, acc]) => ({
344
392
  role,
345
- ticks: acc.prompts.length,
393
+ runs: acc.prompts.length,
346
394
  medianPromptTokens: median(acc.prompts),
347
395
  preEditShare: acc.preEditSum / acc.promptSum,
348
396
  }))