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,3 +1,4 @@
1
+ // vocabulary: plain
1
2
  /** Collection half of `tumwater history`: read the event log and distill the last N completed
2
3
  * ticks into TickRow — one row per tick, newest first, with the paired duration and the raw
3
4
  * usage numbers. The Markdown/table rendering of this data lives in history.ts, a pure
@@ -7,34 +8,71 @@
7
8
  * which change for different reasons — and keeps core data collection out of the
8
9
  * presentation layer, so a core consumer (as the GUI's /api/history already is) never
9
10
  * forces a core→ui import. */
10
- import { eventRole, eventTick, eventUsage, readEvents, tickSpanMs, tickStartMap } from "../events/event-read.js";
11
+ import { eventRole, eventTick, eventUsage, readEvents, tickSpanMs, tickStartMap, tickStartModel, tickStartModelMap } from "../events/event-read.js";
11
12
  import { readEventsSinceJoined } from "../events/event-window.js";
12
13
  import { formatTimestamp } from "../text/datetime.js";
14
+ import { eventTypeLabel } from "../events/event-labels.js";
15
+ import { roleLabel } from "../roles/role-labels.js";
13
16
  import { squash, textOr } from "../text/text.js";
14
17
  import { shortSpanPhrase } from "../text/phrases.js";
15
18
  import { outcomeText, usageText } from "../events/event-format.js";
16
- import { groupBy } from "../collections.js";
19
+ import { compareCodeUnits, groupBy } from "../collections.js";
17
20
  /** `history`'s default row count and ceiling. The default shows a working hour of a quiet
18
- * fleet; the ceiling bounds how much log one ask re-reads — more rows only re-read more log
21
+ * team; the ceiling bounds how much log one ask re-reads — more rows only re-read more log
19
22
  * with no added signal, so an explicit flag above it fails fast (the report's --days pattern). */
20
23
  export const HISTORY_DEFAULT_TICKS = 20;
21
24
  export const HISTORY_MAX_TICKS = 200;
22
25
  /** The outcome classes `tumwater history --result` and the dashboard's History filter both
23
- * speak: Changes (the tick landed or queued work), No change (it did nothing on purpose, or
24
- * the operator aborted it), Problems (everything else, e.g. error/rejected). The one canonical
26
+ * speak: Changes (the tick merged or queued work), No change (it did nothing on purpose, or
27
+ * the operator aborted it), Refused (a run that deliberately declined its entry), Problems
28
+ * (everything else, e.g. error/rejected). The one canonical
25
29
  * rule; the browser's copy in gui-client-history.ts is pinned to it by
26
30
  * test/gui-client-history.test.ts, so the two cannot drift. */
27
- export const HISTORY_RESULT_CLASSES = ["changes", "none", "problems"];
31
+ export const HISTORY_RESULT_CLASSES = ["changes", "none", "refused", "problems"];
32
+ /** The spelling each class has on `--result` and in the TUI filter: `changes` reads `merged`. */
33
+ export const HISTORY_RESULT_FLAG_NAMES = { changes: "merged", none: "none", refused: "refused", problems: "problems" };
34
+ /** Parse a `--result` value: a flag name, or a class name (so `changes` stays a hidden alias). */
35
+ export function parseHistoryResultClass(raw) {
36
+ return HISTORY_RESULT_CLASSES.find((c) => raw === c || raw === HISTORY_RESULT_FLAG_NAMES[c]) ?? null;
37
+ }
28
38
  /** Which outcome class a tick result falls in (the HISTORY_RESULT_CLASSES rule): `changed` and
29
- * `queued` are `changes`; `no_change`, `skipped`, and `user_aborted` are `none`; every other
30
- * result is `problems`. Pure; the canonical home the dashboard's client-side filter mirrors. */
39
+ * `queued` are `changes`; `no_change`, `skipped`, and `user_aborted` are `none`; `refused` is
40
+ * `refused`; every other result is `problems`. Pure; the canonical home the dashboard's
41
+ * client-side filter mirrors. */
31
42
  export function historyClass(result) {
32
43
  if (result === "changed" || result === "queued")
33
44
  return "changes";
34
45
  if (result === "no_change" || result === "skipped" || result === "user_aborted")
35
46
  return "none";
47
+ if (result === "refused")
48
+ return "refused";
36
49
  return "problems";
37
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) {
54
+ 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 total = blank("total");
57
+ for (const row of rows) {
58
+ let entry = byLoop.get(row.role);
59
+ if (!entry)
60
+ byLoop.set(row.role, (entry = blank(row.role)));
61
+ const cls = historyClass(row.result);
62
+ for (const t of [entry, total]) {
63
+ t.runs += 1;
64
+ t[cls] += 1;
65
+ t.tokens += row.tokens;
66
+ t.costUsd += row.costUsd;
67
+ if (row.durationMs !== null)
68
+ t.durationMs = (t.durationMs ?? 0) + row.durationMs;
69
+ if (cls === "changes")
70
+ t.lastChangeTs = Math.max(t.lastChangeTs ?? row.ts, row.ts);
71
+ }
72
+ }
73
+ const roles = [...byLoop.values()].sort((a, b) => compareCodeUnits(a.role, b.role));
74
+ return { roles, total };
75
+ }
38
76
  /** How much of a tick's summary or error a row keeps. Long enough to name the work, short
39
77
  * enough that the row stays one terminal line next to its other columns. */
40
78
  const DETAIL_MAX = 72;
@@ -49,7 +87,7 @@ const DETAIL_MAX = 72;
49
87
  export const HISTORY_SCAN_MAX_EVENTS = 20_000;
50
88
  /** How far behind a since-shaped scan's cutoff the queued-row join reads its evidence: the
51
89
  * row set is ts-filtered to the cutoff, but a queued tick whose tick SPANS the cutoff logged
52
- * its `land_queued` pin before it (the pin is logged during the tick, the tick_end after it)
90
+ * its `merge_queued` pin before it (the pin is logged during the tick, the run_end after it)
53
91
  * — so the join evidence needs its own read reaching further back. Six hours outlasts any
54
92
  * tick the harness runs (every stage inside a tick is timeout-bounded); a tick that somehow
55
93
  * queued later than that keeps the raw label, the conservative fallback. The wider read shares
@@ -58,25 +96,28 @@ export const HISTORY_SCAN_MAX_EVENTS = 20_000;
58
96
  const TICK_ROW_JOIN_BACKSLACK_MS = 6 * 60 * 60 * 1000;
59
97
  export function tickRowCells(row) {
60
98
  return {
61
- loop: row.loop,
62
- tick: String(row.tick),
63
- result: row.result,
99
+ loop: roleLabel(row.role),
100
+ tick: String(row.run),
101
+ result: eventTypeLabel(row.result),
102
+ model: row.model ?? "",
64
103
  duration: row.durationMs === null ? "—" : shortSpanPhrase(row.durationMs),
65
104
  usage: row.usage,
66
105
  };
67
106
  }
68
- /** One tick row's line: its timestamp, the five tickRowCells values, then the free-text detail,
69
- * joined by two spaces and stripped of trailing padding. This is the one home for the row's
70
- * column order and separator; renderRow (history.ts) passes a per-column padding function to
71
- * align the CLI table, while tickRowGrepHaystack below passes none. The tick cell's "#" prefix
72
- * rides outside the padding, so its column width measures the number alone. */
107
+ /** One tick row's line: its timestamp, the six tickRowCells values (the model cell between
108
+ * the result and the duration), then the free-text detail, joined by two spaces and stripped
109
+ * of trailing padding. This is the one home for the row's column order and separator;
110
+ * 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
112
+ * padding, so its column width measures the number alone. */
73
113
  export function tickRowLine(row, pad = (_c, v) => v) {
74
114
  const c = tickRowCells(row);
75
115
  return [
76
116
  row.time,
77
- pad("loop", c.loop),
78
- `#${pad("tick", c.tick)}`,
117
+ pad("loop", c.loop), // vocabulary-ok: column key, not shown
118
+ `#${pad("tick", c.tick)}`, // vocabulary-ok: column key, not shown
79
119
  pad("result", c.result),
120
+ pad("model", c.model),
80
121
  pad("duration", c.duration),
81
122
  pad("usage", c.usage),
82
123
  row.detail,
@@ -85,13 +126,15 @@ export function tickRowLine(row, pad = (_c, v) => v) {
85
126
  .trimEnd();
86
127
  }
87
128
  /** The `--grep` haystack for one rendered history row: tickRowLine's canonical unpadded row
88
- * line prefixed with the raw event type so stable ids (`tick_end`) are greppable the way logs
89
- * --grep's haystack carries `e.type`. Built per row because the filter runs before the widths of
90
- * the rows actually shown are known, and shared by `tumwater history --grep` and the dashboard's
129
+ * line prefixed with the raw event type so stable ids (`run_end`) are greppable the way logs
130
+ * --grep's haystack carries `e.type`, and followed by the raw role id and result code, which
131
+ * the row shows as `feature #2` and `no change`, so a script's grep on the code still
132
+ * matches. Built per row because the filter runs before the widths of the rows actually shown
133
+ * are known, and shared by `tumwater history --grep` and the dashboard's
91
134
  * /api/history?grep so the two cannot disagree on the rule. The row's cells and column order
92
135
  * come from tickRowCells and tickRowLine, the one home for each. */
93
136
  export function tickRowGrepHaystack(row) {
94
- return `tick_end ${tickRowLine(row)}`;
137
+ return `run_end ${tickRowLine(row)} ${row.role} ${row.result}`;
95
138
  }
96
139
  /** The shared tick-grep rule: a case-insensitive substring match over tickRowGrepHaystack.
97
140
  * Four call sites apply it — `tumwater history --grep`, /api/history's window and count
@@ -101,95 +144,145 @@ export function tickRowsMatchingGrep(rows, pattern) {
101
144
  const needle = pattern.toLowerCase();
102
145
  return rows.filter((r) => tickRowGrepHaystack(r).toLowerCase().includes(needle));
103
146
  }
104
- /** The landing bookkeeping resolveQueuedResult joins over: per loop, the `land_queued` pins
105
- * and the `landed`/`land_failed` outcomes, each list oldest-first (the scan order). Exported
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
106
149
  * as one helper with exactly two call sites — readTickRows' history-row join and the failure
107
150
  * digest's time-and-spend fold (time-spend.ts) — so the two collectors cannot disagree about
108
- * which events feed the join or in what order they sit: a third landing-bookkeeping event
151
+ * which events feed the join or in what order they sit: a third merge-bookkeeping event
109
152
  * type, or a changed order rule, is edited once here. Callers bucket only the events they
110
153
  * need; the digest folds its `review_rejected` reasons with its own pass.
111
154
  * Roles or cutoffs are the caller's concern: pass a role-scoped or wider-read event list and
112
155
  * the maps carry exactly that list's evidence. */
113
- export function bucketLandingEvents(events) {
114
- const landQueuedByLoop = groupBy(events.filter((e) => e.type === "land_queued"), (e) => e.loop);
115
- const outcomeByLoop = groupBy(events.filter((e) => e.type === "landed" || e.type === "land_failed"), (e) => e.loop);
116
- return { landQueuedByLoop, outcomeByLoop };
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 };
117
160
  }
118
- /** Resolve one queued tick row's eventual landing verdict: a "queued" tick_end carries no
119
- * result until its landing batch lands or fails, so the row joins its `end` tick_end to the
120
- * tick's land_queued pin and that pin's landed/land_failed outcome (both from the
121
- * bucketLandingEvents maps, oldest-first per loop). The claim cursor `claimTop` (mutated in
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
122
165
  * place) walks the pin list newest-first and hands each queued tick the newest unclaimed pin
123
- * at or before its end, so several queued ticks cannot claim one pin. Within that pin's
124
- * change the row takes the LAST landing attempt's verdict, not the first: a retriable result
125
- * (RETRIABLE_LANDING_RESULTS: review_error, merge_conflict, merge_blocked) keeps the pin and
126
- * leftover recovery re-enqueues it for another attempt, so a later recovery pin — and its
127
- * landed/land_failed — owns the real verdict. When main moved before the retry the vet's
128
- * syncPinToMain rebased the ref (landing-core.ts), so the recovery pin carries the rebased sha
129
- * while each attempt's own landed/land_failed logs its entry's authored sha (landing-slot.ts's
130
- * entry.sha): the attempts' shas differ, so the walk follows the pin sequence (isRecoveryPin),
131
- * not sha equality alone. It stops at a pin that is not a recovery re-enqueue — a fresh change
132
- * after a conflict-cap discard — so the row never credits a different change's verdict
133
- * (BUGS.md 2026-10-10). `joinStarts` maps loop#tick to its tick_start's ts; when `floorTs` (a
134
- * since-window's cutoff, null in the count view) sits after that start, the row's join
135
- * evidence is not whole in the windowed read and the row keeps its raw "queued" label (null).
136
- * A MISSING start needs no guard — it joins, because rotation drops a prefix, so every pin
137
- * older than the lost start is lost with it and nothing can be mis-claimed (and a skipped
138
- * tick has no start at all but is never queued). A landing with no outcome event in the join
139
- * set also keeps the raw label: the conservative fallback for a verdict simply not visible
140
- * here. Returns the outcome's result text and the landed sha, or null to keep the raw label.
141
- * Pure apart from the cursor it owns. */
142
- export function resolveQueuedResult(end, landQueuedByLoop, outcomeByLoop, claimTop, joinStarts, floorTs) {
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) {
143
204
  if (floorTs !== null) {
144
- const startTs = joinStarts.get(`${end.loop}#${end.tick}`);
205
+ const startTs = joinStarts.get(`${end.source}#${end.run}`);
145
206
  if (startTs !== undefined && startTs < floorTs)
146
207
  return null;
147
208
  }
148
- const queuedList = landQueuedByLoop.get(end.loop);
209
+ const queuedList = mergeQueuedByLoop.get(end.source);
149
210
  if (!queuedList || queuedList.length === 0)
150
211
  return null;
151
- let top = claimTop.get(end.loop) ?? queuedList.length - 1;
212
+ let top = claimTop.get(end.source) ?? queuedList.length - 1;
152
213
  while (top >= 0 && queuedList[top].ts > end.ts)
153
214
  top--;
154
215
  if (top < 0)
155
216
  return null;
156
- claimTop.set(end.loop, top - 1);
157
- const outcomes = outcomeByLoop.get(end.loop) ?? [];
158
- // One change can span several pins: a retriable landing failure keeps the pin and leftover
159
- // recovery re-enqueues the ref for another attempt, so walk forward while the next pin is a
160
- // recovery re-enqueue and resolve to the last attempt's verdict. `findLast` picks the newest
161
- // same-sha outcome so an un-rebased retry (recovery re-enqueues the identical sha, logged by
162
- // both pins) does not re-match the first attempt's outcome inside the recovery pin's walk.
163
- // The returned pin/outcome ride the result so a caller correlating sibling evidence (the
164
- // failure digest's review-failure message) can key on THIS attempt's position, not a sha a
165
- // pre-review rebase may have moved (BUGS.md 2026-10-10).
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).
166
231
  let idx = top;
167
232
  let resolved = null;
168
233
  for (;;) {
169
234
  const pin = queuedList[idx];
170
235
  const outcome = outcomes.findLast((o) => o.ts >= pin.ts && textOr(o.commit, "") === textOr(pin.commit, ""));
171
- // No outcome on the tick's own pin: nothing visible yet, so keep the raw label. No outcome
172
- // on a recovery pin the walk reached: the previous attempt's non-terminal verdict is still
173
- // the last one visible, so return it rather than the raw label.
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.
174
239
  if (!outcome)
240
+ return null;
241
+ const verdict = attemptVerdict(pin, outcome);
242
+ if (verdict === null)
175
243
  return resolved;
176
- const result = textOr(outcome.result, outcome.type === "landed" ? "changed" : "");
177
- if (!result)
178
- return resolved;
179
- const verdict = { result, sha: textOr(pin.commit, ""), pin, outcome };
180
- // A landing or a rejection ends the change (the pin is discarded, so no recovery pin can
244
+ // A merge or a rejection ends the change (the pin is discarded, so no recovery pin can
181
245
  // follow); any other verdict kept the pin, and the walk continues only if the next pin is
182
246
  // the recovery re-enqueue that kept it. Otherwise this attempt's verdict is the final one
183
247
  // visible.
184
- const terminal = result === "changed" || result === "rejected";
185
- if (terminal || !isRecoveryPin(queuedList[idx + 1]))
248
+ if (endsChange(verdict.result) || !isRecoveryPin(queuedList[idx + 1]))
186
249
  return verdict;
187
250
  resolved = verdict;
188
251
  idx += 1;
189
252
  }
190
253
  }
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;
259
+ }
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
+ }
191
284
  /** True when a pin re-enqueues the previous attempt's kept pin rather than starting a fresh
192
- * change. `recoverLeftover` (src/loop/leftover.ts) is the one `land_queued` producer whose
285
+ * change. `recoverLeftover` (src/loop/leftover.ts) is the one `merge_queued` producer whose
193
286
  * summary starts with this label, so a fresh change's pin after a conflict-cap discard ends
194
287
  * `resolveQueuedResult`'s walk. */
195
288
  function isRecoveryPin(pin) {
@@ -199,33 +292,35 @@ function isRecoveryPin(pin) {
199
292
  * readEvents returns them), filtered to `role` when given, newest first. Pure: the CLI, the
200
293
  * GUI, and the tests share this collector, and none of them writes anything. */
201
294
  export function tickRows(events, limit, role, join) {
202
- const scoped = role === null ? events : events.filter((e) => e.loop === role);
295
+ const scoped = role === null ? events : events.filter((e) => e.source === role);
203
296
  // The start pairing is the shared helper (events.ts), so history's dash-on-unpaired rule
204
297
  // and the digest's fold cannot drift into different notions of a tick's span.
205
298
  const starts = tickStartMap(scoped);
206
- // The landing bookkeeping resolveQueuedResult joins over: per loop, the land_queued pins
207
- // (oldest first) and the landed/land_failed outcomes (oldest first). Both ride the role
299
+ const models = tickStartModelMap(scoped);
300
+ // The merge bookkeeping resolveQueuedResult joins over: per loop, the merge_queued pins
301
+ // (oldest first) and the merge_done/merge_failed outcomes (oldest first). Both ride the role
208
302
  // filter with their tick, so the scoped events hold them exactly when they hold the row.
209
303
  // A since-shaped scan hands a WIDER join read instead (readTickRowsSince): its cutoff cut
210
304
  // the pins of ticks that span it, so the maps read the unfiltered-by-cutoff evidence and
211
- // resolveQueuedResult guards each claim on the row's tick_start being whole in that read.
305
+ // resolveQueuedResult guards each claim on the row's run_start being whole in that read.
212
306
  const joinScoped = join
213
307
  ? role === null
214
308
  ? join.events
215
- : join.events.filter((e) => e.loop === role)
309
+ : join.events.filter((e) => e.source === role)
216
310
  : scoped;
217
311
  const joinStarts = join ? tickStartMap(joinScoped) : starts;
312
+ const joinModels = join ? tickStartModelMap(joinScoped) : models;
218
313
  const floorTs = join ? join.floorTs : null;
219
- const { landQueuedByLoop, outcomeByLoop } = bucketLandingEvents(joinScoped);
314
+ const { mergeQueuedByLoop, outcomeByLoop } = bucketMergeEvents(joinScoped);
220
315
  const claimTop = new Map();
221
316
  const rows = [];
222
317
  for (let i = scoped.length - 1; i >= 0 && rows.length < limit; i--) {
223
318
  const e = scoped[i];
224
- if (e?.type !== "tick_end")
319
+ if (e?.type !== "run_end")
225
320
  continue;
226
321
  const rawResult = outcomeText(e);
227
322
  const result = rawResult === "queued"
228
- ? (resolveQueuedResult(e, landQueuedByLoop, outcomeByLoop, claimTop, joinStarts, floorTs)
323
+ ? (resolveQueuedResult(e, mergeQueuedByLoop, outcomeByLoop, claimTop, joinStarts, floorTs)
229
324
  ?.result ?? rawResult)
230
325
  : rawResult;
231
326
  const usage = eventUsage(e);
@@ -234,9 +329,10 @@ export function tickRows(events, limit, role, join) {
234
329
  time: formatTimestamp(e.ts),
235
330
  tokens: usage.tokens,
236
331
  costUsd: usage.costUsd,
237
- loop: eventRole(e),
238
- tick: eventTick(e),
332
+ role: eventRole(e),
333
+ run: eventTick(e),
239
334
  result,
335
+ model: tickStartModel(e, join ? joinModels : models),
240
336
  durationMs: tickSpanMs(e, starts),
241
337
  usage: usageText(e),
242
338
  detail: squash(textOr(e.summary, textOr(e.error, "")), DETAIL_MAX),
@@ -246,23 +342,23 @@ export function tickRows(events, limit, role, join) {
246
342
  }
247
343
  /** The last `limit` completed ticks read from root's event log, filtered to `role` when
248
344
  * given (newest first) — the scan cmdHistory and the GUI's handleHistory share, so the two
249
- * surfaces cannot drift. A window twice the ask plus slack: tick_start lines and unrelated
250
- * events interleave with the tick_end rows scanned for, and a skipped tick's end rides a start
345
+ * 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
251
347
  * that may sit outside any smaller window. Two shortfalls grow the window (and re-read) while
252
348
  * the scan filled it — `events.length >= window` means the log may hold older events — under
253
349
  * the same HISTORY_SCAN_MAX_EVENTS bound so one ask cannot scan without end:
254
350
  * - Rows fall short. A role filter dilutes the window (every other loop's events occupy it
255
- * too), so a quiet role in a busy fleet must still get its last `limit` ticks whenever the
351
+ * too), so a quiet role in a busy team must still get its last `limit` ticks whenever the
256
352
  * retained log holds them — never a bare "no ticks yet" for a role whose ticks sit just
257
353
  * past the first window.
258
- * - A row's tick_start went unpaired. The window boundary can cut inside a tick's own event
259
- * block: every tick_end of the ask sits within the window but the oldest one's tick_start
354
+ * - A row's run_start went unpaired. The window boundary can cut inside a tick's own event
355
+ * block: every run_end of the ask sits within the window but the oldest one's run_start
260
356
  * sits just before it, so the row renders a dash though the log holds the start. Every
261
- * tick_end the harness logs follows its tick_start (tick() logs the start before runTick,
357
+ * run_end the harness logs follows its run_start (tick() logs the start before runTick,
262
358
  * skipped results included), so an unpaired start is either just past the window or lost
263
359
  * to rotation — growing re-pairs the former, and the latter only costs the bounded scan.
264
- * (A queued row's landing verdict needs no growth: the landed/land_failed event sits
265
- * after its tick_end, so any tail window holding the row holds the verdict too — or there
360
+ * (A queued row's merge verdict needs no growth: the merge_done/merge_failed event sits
361
+ * after its run_end, so any tail window holding the row holds the verdict too — or there
266
362
  * is none yet, the genuinely-pending case that keeps the raw label.) */
267
363
  export function readTickRows(root, limit, role) {
268
364
  const key = `${root}\u0000${role ?? ""}\u0000${limit}`;
@@ -304,8 +400,8 @@ const TICK_ROW_WINDOW_MEMO_MAX = 256;
304
400
  * and coverage derived from the wide scan, so neither windowed surface can claim coverage the
305
401
  * other would hedge or key its read differently. The rows read the cutoff-filtered events, but
306
402
  * their queued-row join reads WIDER evidence (TICK_ROW_JOIN_BACKSLACK_MS): a queued tick whose
307
- * tick spans the cutoff logged its land_queued pin before the cutoff, and without the wider
308
- * read the row would either keep "Queued to land" after its change landed or misclaim an older
403
+ * tick spans the cutoff logged its merge_queued pin before the cutoff, and without the wider
404
+ * read the row would either keep "Queued to land" after its change merged or misclaim an older
309
405
  * tick's pin (BUGS.md 2026-09-30). The rows reuse the pure tickRows with limit =
310
406
  * events.length, so pairing, role filtering, and the dash-on-unpaired rule stay exactly the
311
407
  * count view's. Pure over root: reads the event log, writes nothing. */
@@ -0,0 +1,76 @@
1
+ // vocabulary: plain
2
+ /** Rendering half of `tumwater history`: format a `TickRow[]`/summary — collected by
3
+ * history-data.ts — into the aligned table or `--summary` text `cmdHistory` (history.ts)
4
+ * prints. The sibling observing view report does the same (src/report/report.ts prints
5
+ * src/report/report-render.ts's Markdown), and this split keeps the same separation
6
+ * "what happened / how it prints" that report and failure draw: the command owns argument
7
+ * parsing and stdout, this module owns column widths and cell wording. The rows themselves
8
+ * live in history-data.ts because the grep haystack shares the row line. */
9
+ import { formatTimestamp, humanSeconds } from "../text/datetime.js";
10
+ import { roleLabel } from "../roles/role-labels.js";
11
+ import { displayWidth, padToWidth } from "../text/text-width.js";
12
+ import { compactTokens, usd } from "../text/format.js";
13
+ import { tickRowCells, tickRowLine } from "./history-data.js";
14
+ /** Each column's width from its unpadded cell. Text cells measure terminal display columns
15
+ * (displayWidth), not UTF-16 code units: a loop name holding CJK or emoji renders two columns
16
+ * per code point, and a code-unit padEnd lets that row's later columns drift right of its
17
+ * ASCII neighbors. The tick cell measures before its "#" prefix is added (the prefix rides
18
+ * outside the pad), and duration is ASCII, so both take plain length. */
19
+ function widthsOf(cells) {
20
+ return {
21
+ loop: displayWidth(cells.loop),
22
+ tick: cells.tick.length,
23
+ result: displayWidth(cells.result),
24
+ model: displayWidth(cells.model),
25
+ duration: cells.duration.length,
26
+ usage: displayWidth(cells.usage),
27
+ };
28
+ }
29
+ /** The per-column maxima over every row the table shows — the widths derive from the rows
30
+ * actually shown, so a single-row table has no padding gap. */
31
+ export function widestWidths(rows) {
32
+ const widths = { loop: 0, tick: 0, result: 0, model: 0, duration: 0, usage: 0 };
33
+ for (const row of rows) {
34
+ const w = widthsOf(tickRowCells(row));
35
+ widths.loop = Math.max(widths.loop, w.loop);
36
+ widths.tick = Math.max(widths.tick, w.tick);
37
+ widths.result = Math.max(widths.result, w.result);
38
+ widths.model = Math.max(widths.model, w.model);
39
+ widths.duration = Math.max(widths.duration, w.duration);
40
+ widths.usage = Math.max(widths.usage, w.usage);
41
+ }
42
+ return widths;
43
+ }
44
+ /** One aligned table line: fixed-width columns over the row set, then the free-text detail.
45
+ * Every cell is padded to its column's width even when empty — usage is the one cell that can
46
+ * be empty (a tick with neither tokens nor cost), and dropping it would pull the detail left,
47
+ * misaligning that row against its neighbors; tickRowLine strips only the trailing pad. The
48
+ * row's column order rides history-data.ts's tickRowLine, shared with the grep haystack so the
49
+ * two cannot disagree about which field sits where. */
50
+ export function renderRow(row, widths) {
51
+ return tickRowLine(row, (column, value) => padToWidth(value, widths[column]));
52
+ }
53
+ /** The `--summary` text: a header, one line per loop, then the total line. The loop column pads to
54
+ * display width; each number column right-aligns under its header; the `time` column is the sum of
55
+ * each role's run spans, `—` when none of the role's rows carried a span; the last column,
56
+ * `last change`, is the newest change's timestamp (`—` when none) and is left-aligned, unpadded. */
57
+ export function renderSummary(summary) {
58
+ const all = [...summary.roles, summary.total];
59
+ const loopWidth = Math.max(...["role", ...all.map((r) => roleLabel(r.role))].map(displayWidth));
60
+ const cols = ["runs", "changes", "none", "refused", "problems", "tokens", "cost", "time"];
61
+ const cell = (r, c) => c === "tokens"
62
+ ? compactTokens(r.tokens)
63
+ : c === "cost"
64
+ ? usd(r.costUsd)
65
+ : c === "time"
66
+ ? r.durationMs === undefined
67
+ ? "—"
68
+ : humanSeconds(r.durationMs / 1000)
69
+ : String(c === "runs" ? r.runs : r[c]);
70
+ const widths = cols.map((c) => Math.max(c.length, ...all.map((r) => cell(r, c).length)));
71
+ const line = (loop, cells, lastChange) => [padToWidth(loop, loopWidth), ...cells.map((c, i) => c.padStart(widths[i] ?? 0)), lastChange].join(" ").trimEnd();
72
+ return [
73
+ line("role", [...cols], "last change"),
74
+ ...all.map((r) => line(roleLabel(r.role), cols.map((c) => cell(r, c)), r.lastChangeTs === null ? "—" : formatTimestamp(r.lastChangeTs))),
75
+ ].join("\n");
76
+ }