tumwater 0.2.0 → 0.3.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 (446) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +42 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +4 -3
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +402 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +246 -55
  41. package/dist/src/backlog/backlog-cli-view.js +280 -0
  42. package/dist/src/backlog/backlog-cli.js +85 -274
  43. package/dist/src/backlog/backlog-commit.js +4 -5
  44. package/dist/src/backlog/backlog-eligibility.js +175 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +254 -0
  48. package/dist/src/backlog/backlog-release.js +117 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-culprit.js +94 -0
  56. package/dist/src/baseline/main-guardrails.js +209 -0
  57. package/dist/src/baseline/main-red.js +139 -32
  58. package/dist/src/baseline/main-revert.js +133 -0
  59. package/dist/src/baseline/main-watch.js +184 -0
  60. package/dist/src/brief/brief-edit.js +9 -11
  61. package/dist/src/brief/brief.js +31 -51
  62. package/dist/src/budget/budget.js +30 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +1 -2
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +118 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +150 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +308 -590
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-role-command.js +420 -0
  84. package/dist/src/cli/cli-start-summary.js +160 -0
  85. package/dist/src/cli/{cli-run.js → cli-start.js} +165 -237
  86. package/dist/src/cli/completion.js +149 -22
  87. package/dist/src/cli/config-commands.js +41 -18
  88. package/dist/src/cli/follow-view.js +75 -0
  89. package/dist/src/cli/help.js +189 -73
  90. package/dist/src/cli/log-commands.js +148 -82
  91. package/dist/src/cli/question-commands.js +205 -10
  92. package/dist/src/cli.js +106 -43
  93. package/dist/src/collections.js +26 -10
  94. package/dist/src/concurrency/check-permit.js +20 -3
  95. package/dist/src/concurrency/lock.js +17 -13
  96. package/dist/src/concurrency/provider-lease.js +44 -8
  97. package/dist/src/concurrency/semaphore.js +102 -26
  98. package/dist/src/config/config-aliases.js +0 -1
  99. package/dist/src/config/config-editable-keys.js +13 -1
  100. package/dist/src/config/config-fallbacks.js +93 -56
  101. package/dist/src/config/config-field-checks.js +40 -10
  102. package/dist/src/config/config-keys.js +30 -8
  103. package/dist/src/config/config-live.js +63 -24
  104. package/dist/src/config/config-request.js +137 -0
  105. package/dist/src/config/config-schema.js +31 -24
  106. package/dist/src/config/config-selector-checks.js +123 -0
  107. package/dist/src/config/config-validation-agents.js +80 -0
  108. package/dist/src/config/config-validation-model.js +116 -0
  109. package/dist/src/config/config-validation.js +141 -199
  110. package/dist/src/config/config-views.js +127 -53
  111. package/dist/src/config/config-write-core.js +135 -0
  112. package/dist/src/config/config-write.js +131 -264
  113. package/dist/src/config/config.js +48 -38
  114. package/dist/src/config/model-selector.js +34 -34
  115. package/dist/src/doctor/doctor-backlog-bugs.js +287 -0
  116. package/dist/src/doctor/doctor-backlog-plans.js +263 -0
  117. package/dist/src/doctor/doctor-backlog.js +100 -496
  118. package/dist/src/doctor/doctor-check-names.js +81 -0
  119. package/dist/src/doctor/doctor-checks.js +52 -579
  120. package/dist/src/doctor/doctor-claude-checks.js +145 -0
  121. package/dist/src/doctor/doctor-codex-checks.js +279 -0
  122. package/dist/src/doctor/doctor-contention-checks.js +380 -0
  123. package/dist/src/doctor/doctor-holds.js +82 -66
  124. package/dist/src/doctor/doctor-install.js +2 -1
  125. package/dist/src/doctor/doctor-model-checks.js +236 -402
  126. package/dist/src/doctor/doctor-notify.js +3 -4
  127. package/dist/src/doctor/doctor-opencode-checks.js +162 -0
  128. package/dist/src/doctor/doctor-orphans.js +182 -63
  129. package/dist/src/doctor/doctor-project-checks.js +303 -0
  130. package/dist/src/doctor/doctor.js +121 -77
  131. package/dist/src/doctor/image-probe.js +21 -8
  132. package/dist/src/doctor/outcome.js +3 -2
  133. package/dist/src/doctor/probe.js +7 -11
  134. package/dist/src/events/event-format.js +181 -86
  135. package/dist/src/events/event-labels.js +12 -5
  136. package/dist/src/events/event-legacy.js +39 -15
  137. package/dist/src/events/event-read.js +39 -39
  138. package/dist/src/events/event-tally.js +16 -0
  139. package/dist/src/events/event-window.js +60 -24
  140. package/dist/src/events/events.js +30 -13
  141. package/dist/src/events/notify.js +7 -7
  142. package/dist/src/failure/failure-cluster.js +12 -12
  143. package/dist/src/failure/failure-data.js +56 -56
  144. package/dist/src/failure/failure-render.js +31 -23
  145. package/dist/src/failure/failure-state-change.js +17 -12
  146. package/dist/src/failure/time-spend.js +99 -35
  147. package/dist/src/files/files.js +111 -35
  148. package/dist/src/files/json-files.js +3 -3
  149. package/dist/src/files/json-object.js +28 -7
  150. package/dist/src/files/stat-cache.js +2 -2
  151. package/dist/src/files/tail.js +1 -1
  152. package/dist/src/gates/bootstrap-gates.js +4 -5
  153. package/dist/src/gates/budget-gates.js +177 -84
  154. package/dist/src/gates/disk-gate.js +1 -1
  155. package/dist/src/gates/gate-polls.js +108 -58
  156. package/dist/src/gates/maintenance-quota.js +10 -13
  157. package/dist/src/gates/pause-gates.js +28 -10
  158. package/dist/src/gates/readiness.js +7 -3
  159. package/dist/src/gates/role-cap-gates.js +8 -8
  160. package/dist/src/gates/startup-gate.js +94 -32
  161. package/dist/src/gates/streak-gate.js +54 -30
  162. package/dist/src/git/commit-message.js +77 -46
  163. package/dist/src/git/git-diff.js +16 -10
  164. package/dist/src/git/git-run.js +79 -38
  165. package/dist/src/git/git.js +36 -34
  166. package/dist/src/git/slot-salvage.js +253 -0
  167. package/dist/src/git/slots-state.js +6 -6
  168. package/dist/src/git/worktree-pool.js +132 -38
  169. package/dist/src/git/worktree-use.js +4 -11
  170. package/dist/src/git/worktree.js +94 -37
  171. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  172. package/dist/src/gui/gui-args.js +79 -8
  173. package/dist/src/gui/gui-backlog-commands.js +42 -18
  174. package/dist/src/gui/gui-backlog-endpoints.js +155 -0
  175. package/dist/src/gui/gui-command.js +8 -13
  176. package/dist/src/gui/gui-config-commands.js +94 -72
  177. package/dist/src/gui/gui-endpoint-commands.js +4 -3
  178. package/dist/src/gui/gui-endpoint-common.js +4 -4
  179. package/dist/src/gui/gui-endpoints.js +11 -380
  180. package/dist/src/gui/gui-history-endpoints.js +301 -0
  181. package/dist/src/gui/gui-operator-commands.js +108 -13
  182. package/dist/src/gui/gui-prompt-commands.js +11 -14
  183. package/dist/src/gui/gui-report-endpoints.js +90 -0
  184. package/dist/src/gui/gui-server.js +11 -4
  185. package/dist/src/gui/gui-team-commands.js +7 -7
  186. package/dist/src/gui/http-body.js +1 -1
  187. package/dist/src/history/history-data.js +122 -234
  188. package/dist/src/history/history-queued.js +147 -0
  189. package/dist/src/history/history-render.js +42 -22
  190. package/dist/src/history/history.js +193 -49
  191. package/dist/src/inbox/inbox-attachments.js +29 -30
  192. package/dist/src/inbox/inbox-cancel.js +10 -11
  193. package/dist/src/inbox/inbox-edit.js +9 -9
  194. package/dist/src/inbox/inbox-submit.js +18 -18
  195. package/dist/src/inbox/inbox-top.js +25 -9
  196. package/dist/src/inbox/inbox.js +42 -43
  197. package/dist/src/inbox/pending-prompt.js +26 -26
  198. package/dist/src/inbox/prompt-commands.js +62 -49
  199. package/dist/src/inbox/prompt-not-before.js +2 -2
  200. package/dist/src/init/init-templates.js +1 -2
  201. package/dist/src/init/init.js +28 -23
  202. package/dist/src/merge/backlog-conflicts.js +215 -35
  203. package/dist/src/merge/merge-batch.js +14 -8
  204. package/dist/src/merge/merge-cancel.js +53 -0
  205. package/dist/src/merge/merge-check-failures.js +44 -22
  206. package/dist/src/merge/merge-core.js +81 -32
  207. package/dist/src/merge/merge-diff.js +5 -4
  208. package/dist/src/merge/merge-drain.js +150 -11
  209. package/dist/src/merge/merge-git.js +177 -38
  210. package/dist/src/merge/merge-pipeline.js +15 -3
  211. package/dist/src/merge/merge-post-review.js +90 -0
  212. package/dist/src/merge/merge-queue-render.js +49 -11
  213. package/dist/src/merge/merge-queue.js +31 -15
  214. package/dist/src/merge/merge-requeue.js +102 -0
  215. package/dist/src/merge/merge-slot.js +61 -16
  216. package/dist/src/merge/merge-stack.js +33 -11
  217. package/dist/src/merge/merge-to-main.js +86 -27
  218. package/dist/src/merge/merge-top.js +69 -0
  219. package/dist/src/merge/merge-verify.js +12 -10
  220. package/dist/src/notes-cli.js +30 -15
  221. package/dist/src/operator/operator-commands.js +192 -81
  222. package/dist/src/operator/operator-intent.js +69 -42
  223. package/dist/src/operator/operator-requests.js +35 -40
  224. package/dist/src/operator/retire.js +5 -6
  225. package/dist/src/operator/revert.js +142 -34
  226. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  227. package/dist/src/orchestrator/orchestrator-poll.js +320 -0
  228. package/dist/src/orchestrator/orchestrator-scheduling.js +68 -369
  229. package/dist/src/orchestrator/orchestrator.js +128 -369
  230. package/dist/src/orchestrator/retention.js +42 -8
  231. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  232. package/dist/src/orchestrator/scheduling-scope.js +201 -0
  233. package/dist/src/orchestrator/scheduling-skip.js +185 -0
  234. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  235. package/dist/src/orchestrator/startup-migrations.js +11 -7
  236. package/dist/src/paths.js +116 -49
  237. package/dist/src/pi/command-shape.js +12 -1
  238. package/dist/src/pi/pi-args.js +6 -4
  239. package/dist/src/pi/pi-backend.js +6 -6
  240. package/dist/src/pi/pi-bin.js +54 -63
  241. package/dist/src/pi/pi-listed-models.js +53 -0
  242. package/dist/src/pi/pi-models.js +63 -30
  243. package/dist/src/pi/pi-run-result.js +1 -1
  244. package/dist/src/pi/pi-settings.js +1 -1
  245. package/dist/src/pi/pi-stream.js +6 -6
  246. package/dist/src/pi/pi-watchdogs.js +18 -19
  247. package/dist/src/pi-extension/bounded-output.js +5 -3
  248. package/dist/src/pi-extension/context-budget.js +2 -2
  249. package/dist/src/pi-extension/context-shake.js +1 -1
  250. package/dist/src/pi-extension/context-window.js +0 -1
  251. package/dist/src/pi-extension/full-output.js +3 -10
  252. package/dist/src/pi-extension/strip-thinking.js +0 -1
  253. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  254. package/dist/src/pi-extension/write-guard.js +37 -0
  255. package/dist/src/process/launch-services.js +1 -2
  256. package/dist/src/process/node-bin.js +16 -0
  257. package/dist/src/process/process-group.js +8 -18
  258. package/dist/src/process/process.js +41 -2
  259. package/dist/src/process/run-marker.js +18 -6
  260. package/dist/src/process/supervisor.js +22 -21
  261. package/dist/src/prompt/dialect.js +17 -2
  262. package/dist/src/prompt/gate-notes.js +203 -0
  263. package/dist/src/prompt/gate-prompts.js +121 -211
  264. package/dist/src/prompt/notes-backlog.js +181 -0
  265. package/dist/src/prompt/principles.js +1 -2
  266. package/dist/src/prompt/prompt-followup.js +19 -14
  267. package/dist/src/prompt/prompt.js +129 -204
  268. package/dist/src/redeploy/redeploy-policy.js +24 -19
  269. package/dist/src/redeploy/redeploy-probes.js +1 -1
  270. package/dist/src/redeploy/redeploy.js +2 -3
  271. package/dist/src/redeploy/redeployer.js +20 -20
  272. package/dist/src/redeploy/self-reload.js +2 -1
  273. package/dist/src/report/report-data.js +161 -77
  274. package/dist/src/report/report-fold.js +129 -52
  275. package/dist/src/report/report-main-red.js +55 -0
  276. package/dist/src/report/report-render.js +133 -64
  277. package/dist/src/report/report-types.js +1 -0
  278. package/dist/src/report/report.js +26 -9
  279. package/dist/src/request-timeouts.js +2 -2
  280. package/dist/src/review/known-flakes.js +100 -1
  281. package/dist/src/review/post-merge-review.js +106 -0
  282. package/dist/src/review/protected-edits.js +4 -5
  283. package/dist/src/review/review-followup.js +8 -8
  284. package/dist/src/review/review-precheck.js +66 -23
  285. package/dist/src/review/review-run.js +395 -0
  286. package/dist/src/review/review-verdict.js +2 -2
  287. package/dist/src/review/review.js +155 -360
  288. package/dist/src/review/secret-leaks.js +36 -16
  289. package/dist/src/review/suite-rerun.js +0 -1
  290. package/dist/src/roles/legacy-role-migration.js +80 -7
  291. package/dist/src/roles/role-catalog.js +69 -12
  292. package/dist/src/roles/role-guidance.js +55 -20
  293. package/dist/src/roles/role-labels.js +1 -2
  294. package/dist/src/roles/role-render.js +67 -50
  295. package/dist/src/roles/role-view.js +59 -47
  296. package/dist/src/roles/roles.js +31 -31
  297. package/dist/src/roles/runner-ids.js +97 -0
  298. package/dist/src/run/acceptance-greps.js +119 -0
  299. package/dist/src/run/feedback-filings.js +51 -0
  300. package/dist/src/{tick → run}/qa-coverage.js +9 -27
  301. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  302. package/dist/src/run/run-detail-data.js +179 -0
  303. package/dist/src/run/run-detail-render.js +98 -0
  304. package/dist/src/run/run-detail.js +168 -0
  305. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +72 -60
  306. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  307. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +108 -73
  308. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  309. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +108 -50
  310. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  311. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  312. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +91 -56
  313. package/dist/src/run/selfcheck-digest.js +40 -0
  314. package/dist/src/run/stage-backlog-checks.js +249 -0
  315. package/dist/src/run/stage-check.js +172 -0
  316. package/dist/src/run/stage-path-checks.js +730 -0
  317. package/dist/src/{loop → runner}/leftover.js +80 -25
  318. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  319. package/dist/src/runner/revision.js +202 -0
  320. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  321. package/dist/src/runner/runner-outcome.js +107 -0
  322. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  323. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +21 -15
  324. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +218 -240
  325. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  326. package/dist/src/runner/runner-state.js +102 -0
  327. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  328. package/dist/src/scheduling/backoff.js +38 -38
  329. package/dist/src/scheduling/claims.js +35 -22
  330. package/dist/src/scheduling/once-round.js +22 -22
  331. package/dist/src/scheduling/quiet-hours.js +5 -6
  332. package/dist/src/scheduling/scheduling.js +74 -64
  333. package/dist/src/scheduling/work-merged-cache.js +2 -2
  334. package/dist/src/status/status-budget.js +55 -47
  335. package/dist/src/status/status-data.js +43 -16
  336. package/dist/src/status/status-inbox.js +2 -2
  337. package/dist/src/status/status-install.js +0 -1
  338. package/dist/src/status/status-polls.js +28 -28
  339. package/dist/src/status/status-roles.js +39 -13
  340. package/dist/src/team/error-storm.js +8 -8
  341. package/dist/src/team/failure-spread.js +2 -2
  342. package/dist/src/team/orchestrator-info.js +15 -9
  343. package/dist/src/team/reclaim.js +5 -6
  344. package/dist/src/team/team-hold.js +7 -7
  345. package/dist/src/team/team-polls.js +91 -50
  346. package/dist/src/team/team-state.js +8 -2
  347. package/dist/src/text/datetime.js +63 -17
  348. package/dist/src/text/format.js +48 -11
  349. package/dist/src/text/markdown.js +8 -5
  350. package/dist/src/text/phrases.js +57 -23
  351. package/dist/src/text/suggest.js +1 -1
  352. package/dist/src/text/text.js +67 -13
  353. package/dist/src/ui/badges.js +65 -37
  354. package/dist/src/ui/gui/gui-client-backlog.js +292 -0
  355. package/dist/src/ui/gui/gui-client-boot.js +32 -26
  356. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  357. package/dist/src/ui/gui/gui-client-drawer-config.js +212 -0
  358. package/dist/src/ui/gui/gui-client-drawer.js +126 -285
  359. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  360. package/dist/src/ui/gui/gui-client-history.js +434 -67
  361. package/dist/src/ui/gui/gui-client-labels.js +1 -2
  362. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  363. package/dist/src/ui/gui/gui-client-model.js +27 -36
  364. package/dist/src/ui/gui/gui-client-operator.js +55 -51
  365. package/dist/src/ui/gui/gui-client-pending.js +227 -23
  366. package/dist/src/ui/gui/gui-client-report-chart.js +464 -0
  367. package/dist/src/ui/gui/gui-client-report.js +87 -427
  368. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  369. package/dist/src/ui/gui/gui-client-settings.js +56 -4
  370. package/dist/src/ui/gui/gui-client-team.js +25 -465
  371. package/dist/src/ui/gui/gui-client.js +27 -25
  372. package/dist/src/ui/gui/gui-icons.js +0 -2
  373. package/dist/src/ui/gui/gui-page.js +9 -7
  374. package/dist/src/ui/gui/gui-styles.js +34 -34
  375. package/dist/src/ui/progress-data.js +9 -11
  376. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +31 -31
  377. package/dist/src/ui/status-model.js +133 -125
  378. package/dist/src/ui/status-payload.js +136 -49
  379. package/dist/src/ui/status-render.js +101 -82
  380. package/dist/src/ui/team-alerts.js +248 -57
  381. package/dist/src/ui/tone.js +5 -6
  382. package/dist/src/ui/transcript-run.js +58 -0
  383. package/dist/src/ui/transcript-tail.js +28 -20
  384. package/dist/src/ui/transcript.js +63 -23
  385. package/dist/src/ui/tui/tui-app.js +0 -1
  386. package/dist/src/ui/tui/tui-backlog.js +51 -14
  387. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  388. package/dist/src/ui/tui/tui-editor-attach.js +3 -5
  389. package/dist/src/ui/tui/tui-editor-backends.js +132 -0
  390. package/dist/src/ui/tui/tui-editor-brief.js +10 -12
  391. package/dist/src/ui/tui/tui-editor-budget.js +35 -31
  392. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  393. package/dist/src/ui/tui/tui-editor-concurrency.js +24 -0
  394. package/dist/src/ui/tui/tui-editor-config.js +86 -0
  395. package/dist/src/ui/tui/tui-editor-entry.js +36 -47
  396. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  397. package/dist/src/ui/tui/tui-editor-instances.js +37 -0
  398. package/dist/src/ui/tui/tui-editor-instructions.js +136 -0
  399. package/dist/src/ui/tui/tui-editor-interval.js +11 -9
  400. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  401. package/dist/src/ui/tui/tui-editor-notes.js +3 -5
  402. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  403. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  404. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  405. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  406. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  407. package/dist/src/ui/tui/tui-editor-quiet.js +99 -35
  408. package/dist/src/ui/tui/tui-editor-retire.js +4 -6
  409. package/dist/src/ui/tui/tui-editor-role-key.js +76 -0
  410. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  411. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  412. package/dist/src/ui/tui/tui-editor-session.js +60 -34
  413. package/dist/src/ui/tui/tui-editor-slots.js +23 -0
  414. package/dist/src/ui/tui/tui-editors.js +35 -2
  415. package/dist/src/ui/tui/tui-frame.js +56 -11
  416. package/dist/src/ui/tui/tui-history.js +8 -9
  417. package/dist/src/ui/tui/tui-input.js +13 -11
  418. package/dist/src/ui/tui/tui-keymap.js +0 -1
  419. package/dist/src/ui/tui/tui-keys.js +24 -9
  420. package/dist/src/ui/tui/tui-pane-activity.js +14 -11
  421. package/dist/src/ui/tui/tui-pane-backlog.js +171 -126
  422. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  423. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  424. package/dist/src/ui/tui/tui-pane-health.js +58 -8
  425. package/dist/src/ui/tui/tui-pane-history.js +402 -82
  426. package/dist/src/ui/tui/tui-pane-pending.js +308 -13
  427. package/dist/src/ui/tui/tui-pane-queued.js +71 -55
  428. package/dist/src/ui/tui/tui-pane-report.js +46 -14
  429. package/dist/src/ui/tui/tui-pane-session.js +25 -6
  430. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  431. package/dist/src/ui/tui/tui-pane.js +80 -23
  432. package/dist/src/ui/tui/tui-panes.js +100 -30
  433. package/dist/src/ui/tui/tui.js +14 -14
  434. package/dist/src/verdict/fix-claim.js +82 -32
  435. package/dist/src/verdict/no-change.js +4 -4
  436. package/dist/src/verdict/refusal.js +40 -16
  437. package/dist/src/verdict/reply-contract.js +42 -31
  438. package/dist/src/version.js +0 -1
  439. package/package.json +2 -1
  440. package/dist/src/loop/revision.js +0 -70
  441. package/dist/src/roles/loop-ids.js +0 -77
  442. package/dist/src/tick/stage-check.js +0 -419
  443. package/dist/src/tick/telemetry-digest.js +0 -25
  444. package/dist/src/tick/tick-detail-data.js +0 -108
  445. package/dist/src/tick/tick-detail.js +0 -163
  446. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,6 +1,8 @@
1
1
  /** The dashboard HTTP server: routing, the shared-token gate, the static page, /api/status, and the
2
2
  * self-reload watch. Every /api endpoint's handler lives in gui/gui-endpoints.ts (the GET data
3
- * endpoints) and gui/gui-endpoint-commands.ts (the POST operator endpoints) — this module owns
3
+ * endpoints, barrelled there from one module per family — gui-history-endpoints,
4
+ * gui-backlog-endpoints, gui-report-endpoints, gui-admin-endpoints) and
5
+ * gui/gui-endpoint-commands.ts (the POST operator endpoints) — this module owns
4
6
  * the socket, not the data. The `tumwater gui` CLI entry (cmdGui) lives in gui/gui-command.ts,
5
7
  * which imports startGui from here, so the server and the command that boots it stay adjacent. */
6
8
  import crypto from "node:crypto";
@@ -10,8 +12,8 @@ import { statusPayload } from "../ui/status-payload.js";
10
12
  import { captureStartupBuild, createReloadWatch, reexecSelf, watchReloadSupervisor, } from "../redeploy/self-reload.js";
11
13
  import { warnEvent, TUMWATER_SOURCE } from "../events/events.js";
12
14
  import { errorMessage } from "../text/text.js";
13
- import { handleBacklog, handleBrief, handleConfig, handleDiff, handleFailures, handleReport, handleHistory, handleLogs, handlePromptText, handleTick, handleRole, handleTranscript, handleDoctor, handleRetireList, } from "./gui-endpoints.js";
14
- import { handleAbort, handleBriefEdit, handleBudget, handleConfigSet, handleConfigUnset, handleRoleCap, handleRoleEnable, handleRoleDisable, handleRoleInstances, handleRoleInterval, handleRoleInstructions, handleRoleModel, handleRoleQuietHours, handleNotifyTest, handlePause, handlePauseRole, handlePrompt, handleBacklogFile, handleBacklogCancel, handleBacklogRelease, handleBacklogTop, handleBacklogEdit, handlePromptCancel, handlePromptEdit, handlePromptRole, handlePromptTop, handleQuestionAnswer, handleReclaim, handleRestart, handleStopSchedule, handleResetCounters, handleRoleNotes, handleRetire, handleWake, handleWakeCancel, } from "./gui-endpoint-commands.js";
15
+ import { handleBacklog, handleBacklogMatch, handleBrief, handleConfig, handleDiff, handleFailures, handleReport, handleHistory, handleLogs, handlePromptText, handleRun, handleRole, handleTranscript, handleDoctor, handleRetireList, } from "./gui-endpoints.js";
16
+ import { handleAbort, handleBriefEdit, handleBudget, handleConfigSet, handleConfigUnset, handleRoleCap, handleRoleEnable, handleRoleDisable, handleRoleInstances, handleRoleInterval, handleRoleInstructions, handleRoleModel, handleRoleQuietHours, handleNotifyTest, handlePause, handlePauseRole, handlePrompt, handleBacklogFile, handleBacklogCancel, handleBacklogRelease, handleBacklogTop, handleBacklogEdit, handlePromptCancel, handlePromptEdit, handlePromptRole, handlePromptTop, handleQuestionAnswer, handleReclaim, handleKillOrphans, handleDoctorReprobe, handleRestart, handleStopSchedule, handleResetCounters, handleRoleNotes, handleRetire, handleRevert, handleQueueCancel, handleWake, handleWakeCancel, } from "./gui-endpoint-commands.js";
15
17
  import { sendJson } from "./http-body.js";
16
18
  /** Constant-time credential comparison for the shared-token gate: `timingSafeEqual` throws
17
19
  * on unequal lengths, so the length equality is the guard. A naive `===` string compare
@@ -148,10 +150,11 @@ export function startGui(root, port, allInterfaces = false, token = "", watch =
148
150
  "GET /api/logs": (_req, res, target) => handleLogs(target.searchParams, res, root),
149
151
  "GET /api/transcript": (_req, res, target) => handleTranscript(target.searchParams, res, root),
150
152
  "GET /api/prompt-text": (_req, res, target) => handlePromptText(target.searchParams, res, root),
151
- "GET /api/run": (_req, res, target) => handleTick(target.searchParams, res, root),
153
+ "GET /api/run": (_req, res, target) => handleRun(target.searchParams, res, root),
152
154
  "GET /api/role": (_req, res, target) => handleRole(target.searchParams, res, root),
153
155
  "GET /api/diff": (_req, res, target) => handleDiff(target.searchParams, res, root),
154
156
  "GET /api/backlog": (_req, res, target) => handleBacklog(target.searchParams, res, root),
157
+ "GET /api/backlog-match": (_req, res, target) => handleBacklogMatch(target.searchParams, res, root),
155
158
  "GET /api/config": (_req, res) => handleConfig(res, root),
156
159
  "GET /api/brief": (_req, res) => handleBrief(res, root),
157
160
  "GET /api/doctor": (_req, res) => handleDoctor(res, root),
@@ -186,9 +189,13 @@ export function startGui(root, port, allInterfaces = false, token = "", watch =
186
189
  "POST /api/reset-counters": (req, res) => handleResetCounters(req, res, root),
187
190
  "POST /api/role-notes": (req, res) => handleRoleNotes(req, res, root),
188
191
  "POST /api/retire": (req, res) => handleRetire(req, res, root),
192
+ "POST /api/revert": (req, res) => handleRevert(req, res, root),
193
+ "POST /api/queue-cancel": (req, res) => handleQueueCancel(req, res, root),
189
194
  "POST /api/restart": (req, res) => handleRestart(req, res, root),
190
195
  "POST /api/stop-schedule": (req, res) => handleStopSchedule(req, res, root),
191
196
  "POST /api/reclaim": (req, res) => handleReclaim(req, res, root),
197
+ "POST /api/kill-orphans": (req, res) => handleKillOrphans(req, res, root),
198
+ "POST /api/doctor-reprobe": (req, res) => handleDoctorReprobe(req, res, root),
192
199
  "POST /api/abort": (req, res) => handleAbort(req, res, root),
193
200
  "POST /api/pause-role": (req, res) => handlePauseRole(req, res, root),
194
201
  };
@@ -5,7 +5,7 @@
5
5
  * `wakeInSecondsMs` body rules they share. Each handler adapts HTTP onto the domain work one
6
6
  * layer down (team/team-state, operator-intent) and touches no socket beyond its own `res`.
7
7
  */
8
- import { baseRoleOf, INSTANCE_ROLES } from "../roles/loop-ids.js";
8
+ import { baseRoleOf, INSTANCE_ROLES } from "../roles/runner-ids.js";
9
9
  import { normalizePauseReason, pauseTeam, pauseRole, resumeTeam, resumeRole } from "../team/team-state.js";
10
10
  import { cancelWakes, PAUSE_FOR_MAX_MS, requestWake } from "../operator/operator-intent.js";
11
11
  import { requirePausedFlag, validRoleIds } from "./gui-args.js";
@@ -127,13 +127,13 @@ export async function handleWakeCancel(req, res, root) {
127
127
  const body = await readRoleBody(req, res, '{"role": "feature"}', root, true);
128
128
  if (!body)
129
129
  return;
130
- sendJson(res, 200, { ok: true, message: cancelWakes(root, body.role) });
130
+ sendJson(res, 200, { ok: true, message: cancelWakes(root, body.role === undefined ? undefined : [body.role]) });
131
131
  }
132
- /** Resume a row: lift every marker key that pauses this loop id (its own, its base role's, and
133
- * instance 1's `<role>-1`), since the row shows Resume whenever `loopPaused` holds and the key
132
+ /** Resume a row: lift every marker key that pauses this role id (its own, its base role's, and
133
+ * instance 1's `<role>-1`), since the row shows Resume whenever `runnerPaused` holds and the key
134
134
  * actually in the marker may differ from the row's id. Reports whether any key was lifted. */
135
- function resumeLoopKeys(root, loopId) {
136
- const keys = new Set([loopId, baseRoleOf(loopId), ...(INSTANCE_ROLES.has(loopId) ? [`${loopId}-1`] : [])]);
135
+ function resumeRunnerKeys(root, runnerId) {
136
+ const keys = new Set([runnerId, baseRoleOf(runnerId), ...(INSTANCE_ROLES.has(runnerId) ? [`${runnerId}-1`] : [])]);
137
137
  let changed = false;
138
138
  for (const key of keys)
139
139
  changed = resumeRole(root, key) || changed;
@@ -159,6 +159,6 @@ export async function handlePauseRole(req, res, root) {
159
159
  return;
160
160
  const changed = value
161
161
  ? pauseRole(root, body.role, untilMs)
162
- : resumeLoopKeys(root, body.role);
162
+ : resumeRunnerKeys(root, body.role);
163
163
  sendJson(res, 200, { ok: true, changed, paused: value, ...(untilMs === undefined ? {} : { until: untilMs }) });
164
164
  }
@@ -13,7 +13,7 @@ export function badRequest(res, message) {
13
13
  sendJson(res, 400, { error: message });
14
14
  }
15
15
  /** Send 200 with the {"ok":true,"status":"gone"} body every file-addressed handler that answers
16
- * for a vanished entry (the loop already dequeued it) replies with — data for the UI's flash
16
+ * for a vanished entry (the role already dequeued it) replies with — data for the UI's flash
17
17
  * line, not a server fault — so the status and the `{ ok, status }` wrapper live here instead
18
18
  * of being open-coded at each call site. */
19
19
  export function okGone(res) {
@@ -1,29 +1,29 @@
1
- // vocabulary: plain
2
1
  /** Collection half of `tumwater history`: read the event log and distill the last N completed
3
- * ticks into TickRow — one row per tick, newest first, with the paired duration and the raw
2
+ * runs into RunRow — one row per run, newest first, with the paired duration and the raw
4
3
  * usage numbers. The Markdown/table rendering of this data lives in history.ts, a pure
5
4
  * function of it; the split mirrors the usage report's (src/report/report-data.ts /
6
- * src/report/report-render.ts) and keeps "what happened" (tick pairing, role filtering,
5
+ * src/report/report-render.ts) and keeps "what happened" (run pairing, role filtering,
7
6
  * scan windows) apart from "how it prints" (column widths, terminal display padding),
8
7
  * which change for different reasons — and keeps core data collection out of the
9
8
  * presentation layer, so a core consumer (as the GUI's /api/history already is) never
10
9
  * forces a core→ui import. */
11
- import { eventRole, eventTick, eventUsage, readEvents, tickSpanMs, tickStartMap, tickStartModel, tickStartModelMap } from "../events/event-read.js";
10
+ import { eventRole, eventRun, eventUsage, readEvents, runSpanMs, runStartMap, runStartModel, runStartModelMap } from "../events/event-read.js";
12
11
  import { readEventsSinceJoined } from "../events/event-window.js";
13
- import { formatTimestamp } from "../text/datetime.js";
12
+ import { formatTimestamp, HOUR_MS } from "../text/datetime.js";
14
13
  import { eventTypeLabel } from "../events/event-labels.js";
15
14
  import { roleLabel } from "../roles/role-labels.js";
16
- import { squash, textOr } from "../text/text.js";
15
+ import { asList, squash, textOr } from "../text/text.js";
17
16
  import { shortSpanPhrase } from "../text/phrases.js";
18
17
  import { outcomeText, usageText } from "../events/event-format.js";
19
- import { compareCodeUnits, groupBy } from "../collections.js";
18
+ import { compareCodeUnits } from "../collections.js";
19
+ import { bucketMergeEvents, resolveQueuedResult } from "./history-queued.js";
20
20
  /** `history`'s default row count and ceiling. The default shows a working hour of a quiet
21
21
  * team; the ceiling bounds how much log one ask re-reads — more rows only re-read more log
22
22
  * with no added signal, so an explicit flag above it fails fast (the report's --days pattern). */
23
- export const HISTORY_DEFAULT_TICKS = 20;
24
- export const HISTORY_MAX_TICKS = 200;
23
+ export const HISTORY_DEFAULT_RUNS = 20;
24
+ export const HISTORY_MAX_RUNS = 200;
25
25
  /** The outcome classes `tumwater history --result` and the dashboard's History filter both
26
- * speak: Changes (the tick merged or queued work), No change (it did nothing on purpose, or
26
+ * speak: Changes (the run merged or queued work), No change (it did nothing on purpose, or
27
27
  * the operator aborted it), Refused (a run that deliberately declined its entry), Problems
28
28
  * (everything else, e.g. error/rejected). The one canonical
29
29
  * rule; the browser's copy in gui-client-history.ts is pinned to it by
@@ -35,7 +35,7 @@ export const HISTORY_RESULT_FLAG_NAMES = { changes: "merged", none: "none", refu
35
35
  export function parseHistoryResultClass(raw) {
36
36
  return HISTORY_RESULT_CLASSES.find((c) => raw === c || raw === HISTORY_RESULT_FLAG_NAMES[c]) ?? null;
37
37
  }
38
- /** Which outcome class a tick result falls in (the HISTORY_RESULT_CLASSES rule): `changed` and
38
+ /** Which outcome class a run result falls in (the HISTORY_RESULT_CLASSES rule): `changed` and
39
39
  * `queued` are `changes`; `no_change`, `skipped`, and `user_aborted` are `none`; `refused` is
40
40
  * `refused`; every other result is `problems`. Pure; the canonical home the dashboard's
41
41
  * client-side filter mirrors. */
@@ -48,16 +48,18 @@ export function historyClass(result) {
48
48
  return "refused";
49
49
  return "problems";
50
50
  }
51
- /** Tally rows per loop by historyClass, each row counted once. Loops come back in plain string
52
- * order (not localeCompare) so the order is deterministic; `total` sums every loop. Pure. */
53
- export function summarizeTickRows(rows) {
51
+ /** Tally rows into groups by `keyOf`, each row counted once; the key lands in the group row's
52
+ * `role` field. Groups come back in plain string order (not localeCompare) so the order is
53
+ * deterministic; `total` sums every row. Pure. */
54
+ function tallyRows(rows, keyOf) {
54
55
  const blank = (role) => ({ role, runs: 0, changes: 0, none: 0, refused: 0, problems: 0, tokens: 0, costUsd: 0, lastChangeTs: null });
55
- const byLoop = new Map();
56
+ const byKey = new Map();
56
57
  const total = blank("total");
57
58
  for (const row of rows) {
58
- let entry = byLoop.get(row.role);
59
+ const key = keyOf(row);
60
+ let entry = byKey.get(key);
59
61
  if (!entry)
60
- byLoop.set(row.role, (entry = blank(row.role)));
62
+ byKey.set(key, (entry = blank(key)));
61
63
  const cls = historyClass(row.result);
62
64
  for (const t of [entry, total]) {
63
65
  t.runs += 1;
@@ -70,52 +72,70 @@ export function summarizeTickRows(rows) {
70
72
  t.lastChangeTs = Math.max(t.lastChangeTs ?? row.ts, row.ts);
71
73
  }
72
74
  }
73
- const roles = [...byLoop.values()].sort((a, b) => compareCodeUnits(a.role, b.role));
74
- return { roles, total };
75
+ const groups = [...byKey.values()].sort((a, b) => compareCodeUnits(a.role, b.role));
76
+ return { groups, total };
75
77
  }
76
- /** How much of a tick's summary or error a row keeps. Long enough to name the work, short
78
+ /** Tally rows per role by historyClass, each row counted once. Roles come back in plain string
79
+ * order (not localeCompare) so the order is deterministic; `total` sums every role. Pure. */
80
+ export function summarizeRunRows(rows) {
81
+ const { groups, total } = tallyRows(rows, (r) => r.role);
82
+ return { roles: groups, total };
83
+ }
84
+ /** What `history --summary --by` accepts: the default per-role grouping or per-model. */
85
+ export const HISTORY_SUMMARY_GROUPS = ["role", "model"];
86
+ /** Sentinel group key for rows whose model is null; no model selector can equal it. */
87
+ const NO_MODEL_KEY = "\u0000";
88
+ /** summarizeRunRows grouped by the model each run started on instead of by role. Models come back
89
+ * in plain string order with the null bucket last; `total` equals summarizeRunRows's. Pure. */
90
+ export function summarizeRunRowsByModel(rows) {
91
+ const { groups, total } = tallyRows(rows, (r) => r.model ?? NO_MODEL_KEY);
92
+ const models = groups.map(({ role, ...counts }) => ({ model: role === NO_MODEL_KEY ? null : role, ...counts }));
93
+ models.sort((a, b) => (a.model === null ? 1 : b.model === null ? -1 : compareCodeUnits(a.model, b.model)));
94
+ return { models, total };
95
+ }
96
+ /** How much of a run's summary or error a row keeps. Long enough to name the work, short
77
97
  * enough that the row stays one terminal line next to its other columns. */
78
98
  const DETAIL_MAX = 72;
79
99
  /** The hard ceiling on one ask's scan window (events, not rows): the growth loop below
80
100
  * re-reads with a larger window only while the filtered rows fall short and the log may hold
81
101
  * more, and this bounds the worst case — readTailText reads bytes proportional to the limit's
82
102
  * line count, so the cap is also the largest byte read one command can cost. Comfortably
83
- * above the dilution a whole role catalog can impose on HISTORY_MAX_TICKS rows. Exported as
84
- * the one ceiling every read-only view's single ask scans under: tick-detail-data.ts's
85
- * readTickDetail reads the same bound, so no ask can re-read more log than any other and the
103
+ * above the dilution a whole role catalog can impose on HISTORY_MAX_RUNS rows. Exported as
104
+ * the one ceiling every read-only view's single ask scans under: run-detail-data.ts's
105
+ * readRunDetail reads the same bound, so no ask can re-read more log than any other and the
86
106
  * cap cannot drift per consumer. */
87
107
  export const HISTORY_SCAN_MAX_EVENTS = 20_000;
88
108
  /** How far behind a since-shaped scan's cutoff the queued-row join reads its evidence: the
89
- * row set is ts-filtered to the cutoff, but a queued tick whose tick SPANS the cutoff logged
90
- * its `merge_queued` pin before it (the pin is logged during the tick, the run_end after it)
109
+ * row set is ts-filtered to the cutoff, but a queued run whose run SPANS the cutoff logged
110
+ * its `merge_queued` pin before it (the pin is logged during the run, the run_end after it)
91
111
  * — so the join evidence needs its own read reaching further back. Six hours outlasts any
92
- * tick the harness runs (every stage inside a tick is timeout-bounded); a tick that somehow
112
+ * run the harness runs (every stage inside a run is timeout-bounded); a run that somehow
93
113
  * queued later than that keeps the raw label, the conservative fallback. The wider read shares
94
114
  * one bounded backwards scan with the rows' own window (readEventsSinceJoined), and its
95
115
  * day-keyed scan never reaches past its own window's first day. */
96
- const TICK_ROW_JOIN_BACKSLACK_MS = 6 * 60 * 60 * 1000;
97
- export function tickRowCells(row) {
116
+ const RUN_ROW_JOIN_BACKSLACK_MS = 6 * HOUR_MS;
117
+ export function runRowCells(row) {
98
118
  return {
99
- loop: roleLabel(row.role),
100
- tick: String(row.run),
119
+ role: roleLabel(row.role),
120
+ run: String(row.run),
101
121
  result: eventTypeLabel(row.result),
102
122
  model: row.model ?? "",
103
123
  duration: row.durationMs === null ? "—" : shortSpanPhrase(row.durationMs),
104
124
  usage: row.usage,
105
125
  };
106
126
  }
107
- /** One tick row's line: its timestamp, the six tickRowCells values (the model cell between
127
+ /** One run row's line: its timestamp, the six runRowCells values (the model cell between
108
128
  * the result and the duration), then the free-text detail, joined by two spaces and stripped
109
129
  * of trailing padding. This is the one home for the row's column order and separator;
110
130
  * renderRow (history-render.ts) passes a per-column padding function to align the CLI table,
111
- * while tickRowGrepHaystack below passes none. The tick cell's "#" prefix rides outside the
131
+ * while runRowGrepHaystack below passes none. The run cell's "#" prefix rides outside the
112
132
  * padding, so its column width measures the number alone. */
113
- export function tickRowLine(row, pad = (_c, v) => v) {
114
- const c = tickRowCells(row);
133
+ export function runRowLine(row, pad = (_c, v) => v) {
134
+ const c = runRowCells(row);
115
135
  return [
116
136
  row.time,
117
- pad("loop", c.loop), // vocabulary-ok: column key, not shown
118
- `#${pad("tick", c.tick)}`, // vocabulary-ok: column key, not shown
137
+ pad("role", c.role), // vocabulary-ok: column key, not shown
138
+ `#${pad("run", c.run)}`,
119
139
  pad("result", c.result),
120
140
  pad("model", c.model),
121
141
  pad("duration", c.duration),
@@ -125,193 +145,58 @@ export function tickRowLine(row, pad = (_c, v) => v) {
125
145
  .join(" ")
126
146
  .trimEnd();
127
147
  }
128
- /** The `--grep` haystack for one rendered history row: tickRowLine's canonical unpadded row
148
+ /** The `--grep` haystack for one rendered history row: runRowLine's canonical unpadded row
129
149
  * line prefixed with the raw event type so stable ids (`run_end`) are greppable the way logs
130
150
  * --grep's haystack carries `e.type`, and followed by the raw role id and result code, which
131
151
  * the row shows as `feature #2` and `no change`, so a script's grep on the code still
132
152
  * matches. Built per row because the filter runs before the widths of the rows actually shown
133
153
  * are known, and shared by `tumwater history --grep` and the dashboard's
134
154
  * /api/history?grep so the two cannot disagree on the rule. The row's cells and column order
135
- * come from tickRowCells and tickRowLine, the one home for each. */
136
- export function tickRowGrepHaystack(row) {
137
- return `run_end ${tickRowLine(row)} ${row.role} ${row.result}`;
155
+ * come from runRowCells and runRowLine, the one home for each. */
156
+ export function runRowGrepHaystack(row) {
157
+ return `run_end ${runRowLine(row)} ${row.role} ${row.result}`;
138
158
  }
139
- /** The shared tick-grep rule: a case-insensitive substring match over tickRowGrepHaystack.
159
+ /** The shared run-grep rule: a case-insensitive substring match over runRowGrepHaystack.
140
160
  * Four call sites apply it — `tumwater history --grep`, /api/history's window and count
141
161
  * shapes, and the TUI History search — so the three surfaces cannot disagree on what a match
142
162
  * means. The pattern is lowercased once here, not per row. */
143
- export function tickRowsMatchingGrep(rows, pattern) {
163
+ export function runRowsMatchingGrep(rows, pattern) {
144
164
  const needle = pattern.toLowerCase();
145
- return rows.filter((r) => tickRowGrepHaystack(r).toLowerCase().includes(needle));
146
- }
147
- /** The merge bookkeeping resolveQueuedResult joins over: per loop, the `merge_queued` pins
148
- * and the `merge_done`/`merge_failed` outcomes, each list oldest-first (the scan order). Exported
149
- * as one helper with exactly two call sites — readTickRows' history-row join and the failure
150
- * digest's time-and-spend fold (time-spend.ts) — so the two collectors cannot disagree about
151
- * which events feed the join or in what order they sit: a third merge-bookkeeping event
152
- * type, or a changed order rule, is edited once here. Callers bucket only the events they
153
- * need; the digest folds its `review_rejected` reasons with its own pass.
154
- * Roles or cutoffs are the caller's concern: pass a role-scoped or wider-read event list and
155
- * the maps carry exactly that list's evidence. */
156
- export function bucketMergeEvents(events) {
157
- const mergeQueuedByLoop = groupBy(events.filter((e) => e.type === "merge_queued"), (e) => e.source);
158
- const outcomeByLoop = groupBy(events.filter((e) => e.type === "merge_done" || e.type === "merge_failed"), (e) => e.source);
159
- return { mergeQueuedByLoop, outcomeByLoop };
160
- }
161
- /** Resolve one queued tick row's eventual merge verdict: a "queued" run_end carries no
162
- * result until its merge batch merges or fails, so the row joins its `end` run_end to the
163
- * tick's merge_queued pin and that pin's merge_done/merge_failed outcome (both from the
164
- * bucketMergeEvents maps, oldest-first per loop). The claim cursor `claimTop` (mutated in
165
- * place) walks the pin list newest-first and hands each queued tick the newest unclaimed pin
166
- * at or before its end, so several queued ticks cannot claim one pin.
167
- *
168
- * One change can take several merge attempts: a retriable result (RETRIABLE_MERGE_RESULTS:
169
- * review_error, merge_conflict, merge_blocked) keeps the pin, and the role's next run — a
170
- * leftover-recovery run that does no work of its own and ends in about a second — re-enqueues
171
- * it (isRecoveryPin). The authoring run and the recovery runs resolve by different rules, so
172
- * every attempt's verdict shows on exactly one row:
173
- * - The run that authored the change (its pin is not a re-enqueue of a kept attempt) takes
174
- * the change's FINAL verdict, not its first attempt's: a retriable failure that recovery
175
- * later merges reads "changed" (BUGS.md 2026-10-10). While a re-enqueued attempt is still
176
- * pending the row keeps the raw "queued" label — the change is waiting to merge again, and
177
- * the failure before it already shows on the recovery run's row.
178
- * - A recovery run takes the verdict of the attempt it re-enqueued — the retriable failure
179
- * that kept the pin and made the run happen (recoveredAttemptVerdict). So a change that
180
- * timed out in review seven times and was then rejected reads "rejected" once (the
181
- * authoring row) and "review failed" seven times (the authoring attempt's and six
182
- * recoveries' timeouts, each on the recovery row that followed it), not "rejected" on all
183
- * eight rows.
184
- * When main moved before a retry the verification's syncPinToMain rebased the ref
185
- * (merge-core.ts), so the recovery pin carries the rebased sha while each attempt's own
186
- * merge_done/merge_failed logs its entry's sha (merge-slot.ts's entry.sha): the attempts' shas
187
- * differ, so the authoring row's walk follows the pin sequence (isRecoveryPin), not sha
188
- * equality alone. It stops at a pin that is not a recovery re-enqueue — a fresh change after a
189
- * conflict-cap discard — so the row never credits a different change's verdict (BUGS.md
190
- * 2026-10-10). A recovery pin whose previous attempt shows no retriable verdict (a crash
191
- * left the commit unpinned or the merge unfinished, or that attempt sits outside the read) is
192
- * the change's first visible attempt, so its row takes the authoring rule.
193
- *
194
- * `joinStarts` maps loop#tick to its run_start's ts; when `floorTs` (a since-window's cutoff,
195
- * null in the count view) sits after that start, the row's join evidence is not whole in the
196
- * windowed read and the row keeps its raw "queued" label (null). A MISSING start needs no
197
- * guard — it joins, because rotation drops a prefix, so every pin older than the lost start is
198
- * lost with it and nothing can be mis-claimed (and a skipped tick has no start at all but is
199
- * never queued). A merge with no outcome event in the join set also keeps the raw label: the
200
- * conservative fallback for a verdict simply not visible here. Returns the verdict's result
201
- * text plus the pin and outcome of the attempt that produced it (and that pin's sha), or null
202
- * to keep the raw label. Pure apart from the cursor it owns. */
203
- export function resolveQueuedResult(end, mergeQueuedByLoop, outcomeByLoop, claimTop, joinStarts, floorTs) {
204
- if (floorTs !== null) {
205
- const startTs = joinStarts.get(`${end.source}#${end.run}`);
206
- if (startTs !== undefined && startTs < floorTs)
207
- return null;
208
- }
209
- const queuedList = mergeQueuedByLoop.get(end.source);
210
- if (!queuedList || queuedList.length === 0)
211
- return null;
212
- let top = claimTop.get(end.source) ?? queuedList.length - 1;
213
- while (top >= 0 && queuedList[top].ts > end.ts)
214
- top--;
215
- if (top < 0)
216
- return null;
217
- claimTop.set(end.source, top - 1);
218
- const outcomes = outcomeByLoop.get(end.source) ?? [];
219
- // A recovery run's row: the attempt it re-enqueued, not the change's final verdict, so the
220
- // final verdict shows once (on the authoring row) and each retriable failure once (here).
221
- const recovered = recoveredAttemptVerdict(queuedList, top, outcomes);
222
- if (recovered !== null)
223
- return recovered;
224
- // The authoring row: walk forward while the next pin is a recovery re-enqueue and resolve to
225
- // the last attempt's verdict. `findLast` picks the newest same-sha outcome so an un-rebased
226
- // retry (recovery re-enqueues the identical sha, logged by both pins) does not re-match the
227
- // first attempt's outcome inside the recovery pin's walk. The returned pin/outcome ride the
228
- // result so a caller correlating sibling evidence (the failure digest's review-failure
229
- // message) can key on THIS attempt's position, not a sha a pre-review rebase may have moved
230
- // (BUGS.md 2026-10-10).
231
- let idx = top;
232
- let resolved = null;
233
- for (;;) {
234
- const pin = queuedList[idx];
235
- const outcome = outcomes.findLast((o) => o.ts >= pin.ts && textOr(o.commit, "") === textOr(pin.commit, ""));
236
- // No outcome on the tick's own pin, or on a recovery pin the walk reached: that attempt is
237
- // still pending, so the change is waiting to merge — keep the raw label. The previous
238
- // attempt's retriable verdict already shows on the recovery run's row.
239
- if (!outcome)
240
- return null;
241
- const verdict = attemptVerdict(pin, outcome);
242
- if (verdict === null)
243
- return resolved;
244
- // A merge or a rejection ends the change (the pin is discarded, so no recovery pin can
245
- // follow); any other verdict kept the pin, and the walk continues only if the next pin is
246
- // the recovery re-enqueue that kept it. Otherwise this attempt's verdict is the final one
247
- // visible.
248
- if (endsChange(verdict.result) || !isRecoveryPin(queuedList[idx + 1]))
249
- return verdict;
250
- resolved = verdict;
251
- idx += 1;
252
- }
165
+ return rows.filter((r) => runRowGrepHaystack(r).toLowerCase().includes(needle));
253
166
  }
254
- /** The verdict one attempt's outcome carries, or null for a torn outcome with no result (a
255
- * merge_failed whose result is missing or not text). A merge_done with no result is a merge. */
256
- function attemptVerdict(pin, outcome) {
257
- const result = textOr(outcome.result, outcome.type === "merge_done" ? "changed" : "");
258
- return result ? { result, sha: textOr(pin.commit, ""), pin, outcome } : null;
167
+ /** The shared model filter: keeps the rows whose run_start named exactly one of `models`
168
+ * (case-sensitive, the strings `history --summary --by model` prints); a null-model row never
169
+ * matches. Four call sites apply it — `tumwater history --model`, /api/history?model, the TUI
170
+ * History model selector, and newestRunRow's `--last` model filter — so the surfaces cannot
171
+ * disagree on what a model selector matches. */
172
+ export function runRowsOnModels(rows, models) {
173
+ return rows.filter((r) => r.model !== null && models.includes(r.model));
259
174
  }
260
- /** True when a verdict ends its change: a merge or a rejection discards the pin, so no recovery
261
- * re-enqueue can follow it. Every other verdict (the retriable ones) kept the pin. The one rule
262
- * both directions of resolveQueuedResult's chain read — the authoring row's walk forward and a
263
- * recovery row's step back — so the two cannot disagree on where a change ends. */
264
- function endsChange(result) {
265
- return result === "changed" || result === "rejected";
266
- }
267
- /** A recovery run's own row verdict: when the pin at `idx` is a recovery re-enqueue and the
268
- * previous pin's attempt ended in a retriable verdict (the kept pin this run re-enqueued), that
269
- * verdict, carrying the previous attempt's pin and outcome. The outcome is the newest one for
270
- * the previous pin's sha logged between the two pins: recovery runs only after the previous
271
- * attempt's outcome is written, and bounding at this pin keeps an un-rebased retry (both pins
272
- * share a sha) from matching a LATER attempt's outcome. Null when the pin is not a recovery
273
- * re-enqueue, or is one with no retriable attempt visible before it — a crash's unpinned or
274
- * unfinished commit, or a previous pin outside the read — so the row takes the authoring rule. */
275
- function recoveredAttemptVerdict(queuedList, idx, outcomes) {
276
- const pin = queuedList[idx];
277
- const prev = queuedList[idx - 1];
278
- if (prev === undefined || !isRecoveryPin(pin))
279
- return null;
280
- const outcome = outcomes.findLast((o) => o.ts >= prev.ts && o.ts <= pin.ts && textOr(o.commit, "") === textOr(prev.commit, ""));
281
- const verdict = outcome === undefined ? null : attemptVerdict(prev, outcome);
282
- return verdict === null || endsChange(verdict.result) ? null : verdict;
283
- }
284
- /** True when a pin re-enqueues the previous attempt's kept pin rather than starting a fresh
285
- * change. `recoverLeftover` (src/loop/leftover.ts) is the one `merge_queued` producer whose
286
- * summary starts with this label, so a fresh change's pin after a conflict-cap discard ends
287
- * `resolveQueuedResult`'s walk. */
288
- function isRecoveryPin(pin) {
289
- return pin !== undefined && textOr(pin.summary, "").startsWith("recovered leftover work from ");
290
- }
291
- /** The tick rows for the last `limit` completed ticks in `events` (oldest-first, as
175
+ /** The run rows for the last `limit` completed runs in `events` (oldest-first, as
292
176
  * readEvents returns them), filtered to `role` when given, newest first. Pure: the CLI, the
293
177
  * GUI, and the tests share this collector, and none of them writes anything. */
294
- export function tickRows(events, limit, role, join) {
295
- const scoped = role === null ? events : events.filter((e) => e.source === role);
178
+ export function runRows(events, limit, role, join) {
179
+ const wanted = role === null ? null : new Set(asList(role));
180
+ const scoped = wanted === null ? events : events.filter((e) => wanted.has(e.source));
296
181
  // The start pairing is the shared helper (events.ts), so history's dash-on-unpaired rule
297
- // and the digest's fold cannot drift into different notions of a tick's span.
298
- const starts = tickStartMap(scoped);
299
- const models = tickStartModelMap(scoped);
300
- // The merge bookkeeping resolveQueuedResult joins over: per loop, the merge_queued pins
182
+ // and the digest's fold cannot drift into different notions of a run's span.
183
+ const starts = runStartMap(scoped);
184
+ const models = runStartModelMap(scoped);
185
+ // The merge bookkeeping resolveQueuedResult joins over: per role, the merge_queued pins
301
186
  // (oldest first) and the merge_done/merge_failed outcomes (oldest first). Both ride the role
302
- // filter with their tick, so the scoped events hold them exactly when they hold the row.
303
- // A since-shaped scan hands a WIDER join read instead (readTickRowsSince): its cutoff cut
304
- // the pins of ticks that span it, so the maps read the unfiltered-by-cutoff evidence and
187
+ // filter with their run, so the scoped events hold them exactly when they hold the row.
188
+ // A since-shaped scan hands a WIDER join read instead (readRunRowsSince): its cutoff cut
189
+ // the pins of runs that span it, so the maps read the unfiltered-by-cutoff evidence and
305
190
  // resolveQueuedResult guards each claim on the row's run_start being whole in that read.
306
191
  const joinScoped = join
307
- ? role === null
192
+ ? wanted === null
308
193
  ? join.events
309
- : join.events.filter((e) => e.source === role)
194
+ : join.events.filter((e) => wanted.has(e.source))
310
195
  : scoped;
311
- const joinStarts = join ? tickStartMap(joinScoped) : starts;
312
- const joinModels = join ? tickStartModelMap(joinScoped) : models;
196
+ const joinStarts = join ? runStartMap(joinScoped) : starts;
197
+ const joinModels = join ? runStartModelMap(joinScoped) : models;
313
198
  const floorTs = join ? join.floorTs : null;
314
- const { mergeQueuedByLoop, outcomeByLoop } = bucketMergeEvents(joinScoped);
199
+ const { mergeQueuedByRunner, outcomeByRunner } = bucketMergeEvents(joinScoped);
315
200
  const claimTop = new Map();
316
201
  const rows = [];
317
202
  for (let i = scoped.length - 1; i >= 0 && rows.length < limit; i--) {
@@ -320,7 +205,7 @@ export function tickRows(events, limit, role, join) {
320
205
  continue;
321
206
  const rawResult = outcomeText(e);
322
207
  const result = rawResult === "queued"
323
- ? (resolveQueuedResult(e, mergeQueuedByLoop, outcomeByLoop, claimTop, joinStarts, floorTs)
208
+ ? (resolveQueuedResult(e, mergeQueuedByRunner, outcomeByRunner, claimTop, joinStarts, floorTs)
324
209
  ?.result ?? rawResult)
325
210
  : rawResult;
326
211
  const usage = eventUsage(e);
@@ -330,38 +215,38 @@ export function tickRows(events, limit, role, join) {
330
215
  tokens: usage.tokens,
331
216
  costUsd: usage.costUsd,
332
217
  role: eventRole(e),
333
- run: eventTick(e),
218
+ run: eventRun(e),
334
219
  result,
335
- model: tickStartModel(e, join ? joinModels : models),
336
- durationMs: tickSpanMs(e, starts),
220
+ model: runStartModel(e, join ? joinModels : models),
221
+ durationMs: runSpanMs(e, starts),
337
222
  usage: usageText(e),
338
223
  detail: squash(textOr(e.summary, textOr(e.error, "")), DETAIL_MAX),
339
224
  });
340
225
  }
341
226
  return rows;
342
227
  }
343
- /** The last `limit` completed ticks read from root's event log, filtered to `role` when
228
+ /** The last `limit` completed runs read from root's event log, filtered to `role` when
344
229
  * given (newest first) — the scan cmdHistory and the GUI's handleHistory share, so the two
345
230
  * surfaces cannot drift. A window twice the ask plus slack: run_start lines and unrelated
346
- * events interleave with the run_end rows scanned for, and a skipped tick's end rides a start
231
+ * events interleave with the run_end rows scanned for, and a skipped run's end rides a start
347
232
  * that may sit outside any smaller window. Two shortfalls grow the window (and re-read) while
348
233
  * the scan filled it — `events.length >= window` means the log may hold older events — under
349
234
  * the same HISTORY_SCAN_MAX_EVENTS bound so one ask cannot scan without end:
350
- * - Rows fall short. A role filter dilutes the window (every other loop's events occupy it
351
- * too), so a quiet role in a busy team must still get its last `limit` ticks whenever the
352
- * retained log holds them — never a bare "no ticks yet" for a role whose ticks sit just
235
+ * - Rows fall short. A role filter dilutes the window (every other role's events occupy it
236
+ * too), so a quiet role in a busy team must still get its last `limit` runs whenever the
237
+ * retained log holds them — never a bare "no runs yet" for a role whose runs sit just
353
238
  * past the first window.
354
- * - A row's run_start went unpaired. The window boundary can cut inside a tick's own event
239
+ * - A row's run_start went unpaired. The window boundary can cut inside a run's own event
355
240
  * block: every run_end of the ask sits within the window but the oldest one's run_start
356
241
  * sits just before it, so the row renders a dash though the log holds the start. Every
357
- * run_end the harness logs follows its run_start (tick() logs the start before runTick,
242
+ * run_end the harness logs follows its run_start (run() logs the start before runOnce,
358
243
  * skipped results included), so an unpaired start is either just past the window or lost
359
244
  * to rotation — growing re-pairs the former, and the latter only costs the bounded scan.
360
245
  * (A queued row's merge verdict needs no growth: the merge_done/merge_failed event sits
361
246
  * after its run_end, so any tail window holding the row holds the verdict too — or there
362
247
  * is none yet, the genuinely-pending case that keeps the raw label.) */
363
- export function readTickRows(root, limit, role) {
364
- const key = `${root}\u0000${role ?? ""}\u0000${limit}`;
248
+ export function readRunRows(root, limit, role) {
249
+ const key = `${root}\u0000${typeof role === "string" || role === null ? role ?? "" : role.join(",")}\u0000${limit}`;
365
250
  // A repeated ask's steady state starts where the last one ended, not back at the base window: the
366
251
  // GUI drawer's history panel re-issues this ask every second while events flow, and each poll's
367
252
  // ladder re-parsed every intermediate window (base → ×4 → …) before the one that satisfied
@@ -371,47 +256,50 @@ export function readTickRows(root, limit, role) {
371
256
  // (events only accumulate), and if it no longer does — a rotation shrank the live log, or the
372
257
  // ask itself changed — the loop below keeps growing exactly as before. In-memory only; a stale
373
258
  // note costs one larger first read, never a wrong row.
374
- let window = tickRowWindowMemo.get(key) ?? limit * 2 + 50;
259
+ let window = runRowWindowMemo.get(key) ?? limit * 2 + 50;
375
260
  let events = readEvents(root, window);
376
- let rows = tickRows(events, limit, role);
261
+ let rows = runRows(events, limit, role);
377
262
  while ((rows.length < limit || rows.some((r) => r.durationMs === null)) &&
378
263
  events.length >= window &&
379
264
  window < HISTORY_SCAN_MAX_EVENTS) {
380
265
  window = Math.min(window * 4, HISTORY_SCAN_MAX_EVENTS);
381
266
  events = readEvents(root, window);
382
- rows = tickRows(events, limit, role);
267
+ rows = runRows(events, limit, role);
383
268
  }
384
- if (tickRowWindowMemo.size >= TICK_ROW_WINDOW_MEMO_MAX)
385
- tickRowWindowMemo.clear();
386
- tickRowWindowMemo.set(key, window);
269
+ if (runRowWindowMemo.size >= RUN_ROW_WINDOW_MEMO_MAX)
270
+ runRowWindowMemo.clear();
271
+ runRowWindowMemo.set(key, window);
387
272
  return rows;
388
273
  }
389
- /** Per (root, role, limit): the window readTickRows' last ask ended on — the first window that
274
+ /** Per (root, role, limit): the window readRunRows' last ask ended on — the first window that
390
275
  * satisfied it, or the cap when the ladder ran out (the next poll then skips the ladder's
391
276
  * proven-too-small windows, the same steady-state note mainCheckForPoll's grewFull carries).
392
277
  * Bounded so many short-lived roots in tests cannot grow it unbounded. */
393
- const tickRowWindowMemo = new Map();
394
- const TICK_ROW_WINDOW_MEMO_MAX = 256;
395
- /** The window-shaped sibling of readTickRows: the completed ticks of the last `sinceMs`, not
278
+ const runRowWindowMemo = new Map();
279
+ const RUN_ROW_WINDOW_MEMO_MAX = 256;
280
+ /** The window-shaped sibling of readRunRows: the completed runs of the last `sinceMs`, not
396
281
  * the last N — the collector behind `history --since`. The window read itself (cutoff, the
397
282
  * rotation-spanning day-keyed read with its at-most-one-day over-read, the ts filter, and the
398
283
  * exact coverage predicate cmdLogs consults) is event-window.ts's readEventsSinceJoined — one
399
284
  * backwards scan serves both this view and the wider join read, with the narrow view's events
400
285
  * and coverage derived from the wide scan, so neither windowed surface can claim coverage the
401
286
  * other would hedge or key its read differently. The rows read the cutoff-filtered events, but
402
- * their queued-row join reads WIDER evidence (TICK_ROW_JOIN_BACKSLACK_MS): a queued tick whose
403
- * tick spans the cutoff logged its merge_queued pin before the cutoff, and without the wider
287
+ * their queued-row join reads WIDER evidence (RUN_ROW_JOIN_BACKSLACK_MS): a queued run whose
288
+ * run spans the cutoff logged its merge_queued pin before the cutoff, and without the wider
404
289
  * read the row would either keep "Queued to land" after its change merged or misclaim an older
405
- * tick's pin (BUGS.md 2026-09-30). The rows reuse the pure tickRows with limit =
290
+ * run's pin (BUGS.md 2026-09-30). The rows reuse the pure runRows with limit =
406
291
  * events.length, so pairing, role filtering, and the dash-on-unpaired rule stay exactly the
407
- * count view's. Pure over root: reads the event log, writes nothing. */
408
- export function readTickRowsSince(root, sinceMs, role) {
292
+ * count view's. `untilMs`, when given, ends the window that long ago. Pure over root: reads the
293
+ * event log, writes nothing. */
294
+ export function readRunRowsSince(root, sinceMs, role, untilMs = null) {
409
295
  // One backwards scan serves both the rows' window and the wider join read (see
410
296
  // readEventsSinceJoined): two readEventsSince calls re-scanned and re-parsed the same
411
297
  // log bytes — a whole duplicate window scan per `history --since`.
412
- const { events, join, covered } = readEventsSinceJoined(root, sinceMs, TICK_ROW_JOIN_BACKSLACK_MS);
413
- return {
414
- rows: tickRows(events, events.length, role, { events: join.events, floorTs: join.cutoff }),
415
- covered,
416
- };
298
+ const { cutoff, events, join, covered } = readEventsSinceJoined(root, sinceMs, RUN_ROW_JOIN_BACKSLACK_MS);
299
+ const rows = runRows(events, events.length, role, { events: join.events, floorTs: join.cutoff });
300
+ // `untilMs` ends the window that long ago, on the same `now` as the lower edge (cutoff +
301
+ // sinceMs - untilMs). It filters rows, not events, so the queued-row join and the run_start
302
+ // pairing read the same evidence as without it; `covered` is the far edge's and unchanged.
303
+ const upperTs = untilMs === null ? null : cutoff + sinceMs - untilMs;
304
+ return { rows: upperTs === null ? rows : rows.filter((r) => r.ts < upperTs), covered };
417
305
  }