tumwater 0.4.1 → 0.4.3

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 (338) hide show
  1. package/README.md +3 -5
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +35 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +70 -37
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +24 -24
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +6 -14
  8. package/dist/src/agent/backend-traits.js +16 -2
  9. package/dist/src/agent/claude/claude-account.js +1 -8
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +131 -78
  13. package/dist/src/agent/codex/codex-account.js +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +120 -69
  18. package/dist/src/agent/codex/codex-stream.js +72 -39
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +21 -33
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +52 -29
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +51 -29
  27. package/dist/src/agent/session-ledger.js +16 -0
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-archive.js +64 -165
  30. package/dist/src/backlog/backlog-cli-view.js +81 -41
  31. package/dist/src/backlog/backlog-cli.js +57 -26
  32. package/dist/src/backlog/backlog-eligibility.js +84 -136
  33. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  34. package/dist/src/backlog/backlog-index-data.js +2 -2
  35. package/dist/src/backlog/backlog-md.js +42 -31
  36. package/dist/src/backlog/backlog-parts.js +65 -27
  37. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +30 -14
  38. package/dist/src/backlog/backlog-release.js +15 -23
  39. package/dist/src/backlog/backlog-structure.js +128 -62
  40. package/dist/src/backlog/backlog-write.js +1 -1
  41. package/dist/src/backlog/backlog.js +5 -4
  42. package/dist/src/baseline/main-baseline.js +178 -104
  43. package/dist/src/baseline/main-guardrails.js +54 -17
  44. package/dist/src/baseline/main-watch.js +1 -1
  45. package/dist/src/budget/budget-fallback-view.js +100 -243
  46. package/dist/src/budget/budget-wiring.js +24 -65
  47. package/dist/src/budget/budget.js +2 -1
  48. package/dist/src/budget/fallback-breaker.js +2 -2
  49. package/dist/src/build/build-check-detect.js +43 -14
  50. package/dist/src/build/build-check-events.js +2 -2
  51. package/dist/src/build/build-check-scoped.js +103 -50
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/change/change-data.js +4 -2
  54. package/dist/src/cli/cli-command-args.js +275 -137
  55. package/dist/src/cli/cli-flag-specs.js +43 -26
  56. package/dist/src/cli/cli-init.js +34 -12
  57. package/dist/src/cli/cli-query-commands.js +1 -1
  58. package/dist/src/cli/cli-repo-gate.js +2 -2
  59. package/dist/src/cli/cli-review-commands.js +2 -1
  60. package/dist/src/cli/cli-role-command.js +27 -50
  61. package/dist/src/cli/cli-start.js +150 -67
  62. package/dist/src/cli/completion.js +82 -46
  63. package/dist/src/cli/config-commands.js +82 -71
  64. package/dist/src/cli/follow-view.js +3 -1
  65. package/dist/src/cli/help.js +6 -9
  66. package/dist/src/cli/log-commands.js +46 -24
  67. package/dist/src/cli/notify-command.js +2 -2
  68. package/dist/src/cli/question-commands.js +86 -60
  69. package/dist/src/cli.js +183 -141
  70. package/dist/src/collections.js +52 -23
  71. package/dist/src/concurrency/check-slots.js +70 -0
  72. package/dist/src/concurrency/lock.js +22 -0
  73. package/dist/src/concurrency/run-permit.js +49 -0
  74. package/dist/src/concurrency/semaphore.js +46 -250
  75. package/dist/src/config/config-editable-keys.js +4 -7
  76. package/dist/src/config/config-fallbacks.js +79 -103
  77. package/dist/src/config/config-field-checks.js +66 -37
  78. package/dist/src/config/config-keys.js +5 -20
  79. package/dist/src/config/config-live.js +161 -153
  80. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  81. package/dist/src/config/config-request.js +153 -102
  82. package/dist/src/config/config-schema.js +9 -11
  83. package/dist/src/config/config-selector-checks.js +1 -1
  84. package/dist/src/config/config-validation-agents.js +2 -2
  85. package/dist/src/config/config-validation-roles.js +4 -52
  86. package/dist/src/config/config-validation.js +4 -38
  87. package/dist/src/config/config-views.js +4 -34
  88. package/dist/src/config/config-write.js +22 -46
  89. package/dist/src/config/config.js +4 -5
  90. package/dist/src/doctor/doctor-backlog-bugs.js +24 -121
  91. package/dist/src/doctor/doctor-backlog-plans.js +27 -27
  92. package/dist/src/doctor/doctor-backlog.js +29 -41
  93. package/dist/src/doctor/doctor-check-names.js +4 -7
  94. package/dist/src/doctor/doctor-checks.js +5 -5
  95. package/dist/src/doctor/doctor-claude-checks.js +46 -30
  96. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  97. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  98. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  99. package/dist/src/doctor/doctor-holds.js +19 -56
  100. package/dist/src/doctor/doctor-install.js +1 -1
  101. package/dist/src/doctor/doctor-launch-services.js +1 -1
  102. package/dist/src/doctor/doctor-model-checks.js +181 -273
  103. package/dist/src/doctor/doctor-notify.js +4 -7
  104. package/dist/src/doctor/doctor-opencode-checks.js +90 -52
  105. package/dist/src/doctor/doctor-orphans.js +120 -20
  106. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  107. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  108. package/dist/src/doctor/doctor-principles.js +3 -3
  109. package/dist/src/doctor/{probe.js → doctor-probe.js} +1 -1
  110. package/dist/src/doctor/doctor-project-checks.js +69 -20
  111. package/dist/src/doctor/doctor-role-notes.js +7 -25
  112. package/dist/src/doctor/doctor.js +12 -15
  113. package/dist/src/events/event-format.js +19 -53
  114. package/dist/src/events/event-labels.js +0 -5
  115. package/dist/src/events/event-legacy.js +0 -2
  116. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  117. package/dist/src/events/event-read.js +1 -1
  118. package/dist/src/events/event-window.js +1 -1
  119. package/dist/src/events/events.js +15 -2
  120. package/dist/src/failure/failure-cluster.js +6 -6
  121. package/dist/src/failure/failure-data.js +8 -4
  122. package/dist/src/failure/failure-render.js +44 -34
  123. package/dist/src/failure/failure-state-change.js +177 -92
  124. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  125. package/dist/src/files/files.js +30 -0
  126. package/dist/src/files/json-object.js +21 -8
  127. package/dist/src/files/stat-cache.js +12 -8
  128. package/dist/src/gates/budget-gates.js +85 -84
  129. package/dist/src/gates/gate-polls.js +27 -68
  130. package/dist/src/gates/pause-gates.js +16 -2
  131. package/dist/src/gates/readiness.js +1 -1
  132. package/dist/src/gates/role-cap-gates.js +44 -35
  133. package/dist/src/gates/{startup-gate.js → startup-gates.js} +34 -34
  134. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  135. package/dist/src/git/git.js +75 -45
  136. package/dist/src/git/slot-salvage.js +5 -4
  137. package/dist/src/git/worktree-pool.js +3 -6
  138. package/dist/src/gui/gui-admin-endpoints.js +5 -6
  139. package/dist/src/gui/gui-backlog-commands.js +4 -4
  140. package/dist/src/gui/gui-backlog-endpoints.js +62 -48
  141. package/dist/src/gui/gui-command.js +43 -24
  142. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  143. package/dist/src/gui/gui-history-endpoints.js +100 -52
  144. package/dist/src/gui/gui-operator-commands.js +22 -35
  145. package/dist/src/gui/gui-prompt-commands.js +48 -42
  146. package/dist/src/gui/gui-report-endpoints.js +3 -2
  147. package/dist/src/gui/gui-server.js +1 -2
  148. package/dist/src/history/history-data.js +71 -44
  149. package/dist/src/history/history-queued.js +49 -27
  150. package/dist/src/history/history.js +138 -85
  151. package/dist/src/inbox/inbox-submit.js +1 -5
  152. package/dist/src/inbox/prompt-commands.js +82 -60
  153. package/dist/src/init/init.js +156 -55
  154. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  155. package/dist/src/merge/merge-core.js +2 -2
  156. package/dist/src/merge/merge-drain.js +8 -11
  157. package/dist/src/merge/merge-pipeline.js +6 -7
  158. package/dist/src/merge/merge-post-review-drain.js +11 -14
  159. package/dist/src/merge/merge-requeue.js +1 -1
  160. package/dist/src/merge/merge-slot.js +0 -8
  161. package/dist/src/merge/merge-verify.js +8 -18
  162. package/dist/src/notes-cli.js +52 -42
  163. package/dist/src/operator/operator-commands.js +143 -90
  164. package/dist/src/operator/operator-intent.js +3 -3
  165. package/dist/src/operator/{retire.js → operator-retire.js} +2 -1
  166. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  167. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  168. package/dist/src/orchestrator/orchestrator.js +8 -21
  169. package/dist/src/orchestrator/retention.js +6 -5
  170. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  171. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  172. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  173. package/dist/src/orchestrator/startup-migrations.js +1 -1
  174. package/dist/src/paths.js +8 -13
  175. package/dist/src/pi/pi-args.js +5 -2
  176. package/dist/src/pi/pi-backend.js +2 -2
  177. package/dist/src/pi/pi-bin.js +3 -3
  178. package/dist/src/pi/pi-event-line.js +1 -1
  179. package/dist/src/pi/pi-models.js +43 -29
  180. package/dist/src/pi/pi-settings.js +43 -20
  181. package/dist/src/pi/pi-stream.js +32 -6
  182. package/dist/src/pi/pi-watchdogs.js +29 -3
  183. package/dist/src/pi/pi.js +2 -2
  184. package/dist/src/pi-extension/context-shake.js +87 -51
  185. package/dist/src/pi-extension/kill-guard.js +69 -0
  186. package/dist/src/process/process-group.js +49 -5
  187. package/dist/src/process/process.js +31 -8
  188. package/dist/src/process/run-marker.js +31 -13
  189. package/dist/src/process/supervisor.js +57 -29
  190. package/dist/src/prompt/dialect.js +1 -1
  191. package/dist/src/prompt/gate-notes.js +11 -11
  192. package/dist/src/prompt/notes-backlog.js +73 -57
  193. package/dist/src/prompt/principles.js +6 -4
  194. package/dist/src/prompt/prompt.js +92 -43
  195. package/dist/src/report/report-data.js +61 -260
  196. package/dist/src/report/report-fold.js +34 -302
  197. package/dist/src/report/report-main-red.js +45 -42
  198. package/dist/src/report/report-render.js +37 -183
  199. package/dist/src/report/report-since-data.js +219 -0
  200. package/dist/src/report/report-since-render.js +173 -0
  201. package/dist/src/report/report-usage-fold.js +288 -0
  202. package/dist/src/report/report.js +48 -23
  203. package/dist/src/review/known-flakes.js +3 -3
  204. package/dist/src/review/post-merge-review-accept.js +3 -2
  205. package/dist/src/review/post-merge-review-render.js +9 -3
  206. package/dist/src/review/post-merge-review.js +10 -10
  207. package/dist/src/review/quarantine-render.js +2 -1
  208. package/dist/src/review/review-precheck.js +11 -1
  209. package/dist/src/review/review.js +40 -153
  210. package/dist/src/review/secret-leaks.js +26 -16
  211. package/dist/src/roles/role-catalog.js +28 -32
  212. package/dist/src/roles/role-guidance.js +25 -46
  213. package/dist/src/roles/role-render.js +1 -14
  214. package/dist/src/roles/role-view.js +10 -23
  215. package/dist/src/run/acceptance-greps.js +88 -45
  216. package/dist/src/run/ci-parity.js +5 -6
  217. package/dist/src/run/qa-coverage.js +4 -4
  218. package/dist/src/run/run-apply.js +12 -6
  219. package/dist/src/run/run-detail-data.js +137 -68
  220. package/dist/src/run/run-detail-render.js +97 -34
  221. package/dist/src/run/run-detail.js +106 -53
  222. package/dist/src/run/run-dismiss.js +43 -0
  223. package/dist/src/run/run-prompt.js +150 -93
  224. package/dist/src/run/run-resume.js +85 -37
  225. package/dist/src/run/run-stage.js +139 -105
  226. package/dist/src/run/run-timing.js +26 -6
  227. package/dist/src/run/run-usage.js +31 -1
  228. package/dist/src/run/run-verdict.js +71 -67
  229. package/dist/src/run/stage-backlog-checks.js +2 -29
  230. package/dist/src/run/stage-check.js +52 -43
  231. package/dist/src/run/stage-checks-shared.js +28 -0
  232. package/dist/src/run/stage-name-checks.js +111 -92
  233. package/dist/src/run/stage-path-checks.js +69 -42
  234. package/dist/src/run/stage-prose-checks.js +55 -40
  235. package/dist/src/runner/runner-outcome.js +2 -2
  236. package/dist/src/runner/runner-pi.js +3 -10
  237. package/dist/src/runner/runner-recovery.js +1 -1
  238. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  239. package/dist/src/runner/runner-run.js +23 -38
  240. package/dist/src/runner/runner-state-load.js +2 -9
  241. package/dist/src/runner/runner.js +21 -36
  242. package/dist/src/scheduling/claims.js +3 -14
  243. package/dist/src/scheduling/scheduling.js +53 -66
  244. package/dist/src/status/status-data.js +7 -11
  245. package/dist/src/status/status-role-notes.js +20 -16
  246. package/dist/src/status/status-roles.js +11 -52
  247. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  248. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  249. package/dist/src/team/team-hold.js +98 -65
  250. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  251. package/dist/src/team/team-polls.js +28 -126
  252. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  253. package/dist/src/team/team-state.js +4 -13
  254. package/dist/src/text/datetime.js +8 -1
  255. package/dist/src/text/markdown.js +1 -1
  256. package/dist/src/text/phrases.js +20 -15
  257. package/dist/src/text/text.js +15 -3
  258. package/dist/src/ui/alert-feeds.js +52 -281
  259. package/dist/src/ui/badges.js +3 -14
  260. package/dist/src/ui/gui/gui-client-boot.js +21 -6
  261. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  262. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  263. package/dist/src/ui/gui/gui-client-drawer.js +2 -2
  264. package/dist/src/ui/gui/gui-client-history.js +28 -14
  265. package/dist/src/ui/gui/gui-client-model.js +0 -12
  266. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  267. package/dist/src/ui/gui/gui-client-report-chart.js +12 -10
  268. package/dist/src/ui/gui/gui-client-report.js +1 -2
  269. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  270. package/dist/src/ui/gui/gui-client-settings.js +43 -107
  271. package/dist/src/ui/gui/gui-client-team.js +30 -4
  272. package/dist/src/ui/gui/gui-client.js +7 -1
  273. package/dist/src/ui/gui/gui-icons.js +7 -1
  274. package/dist/src/ui/progress-data.js +53 -40
  275. package/dist/src/ui/run-progress-model.js +9 -2
  276. package/dist/src/ui/status-model.js +4 -26
  277. package/dist/src/ui/status-payload.js +4 -8
  278. package/dist/src/ui/status-render.js +6 -10
  279. package/dist/src/ui/team-alert-backlog.js +29 -60
  280. package/dist/src/ui/team-alert-shared.js +23 -10
  281. package/dist/src/ui/team-alert-status.js +17 -173
  282. package/dist/src/ui/team-alert-tail.js +19 -228
  283. package/dist/src/ui/team-alerts.js +12 -24
  284. package/dist/src/ui/transcript-run.js +27 -16
  285. package/dist/src/ui/transcript-tail.js +137 -94
  286. package/dist/src/ui/tui/tui-editor-answer.js +66 -54
  287. package/dist/src/ui/tui/tui-editor-attach.js +77 -65
  288. package/dist/src/ui/tui/tui-editor-backends.js +65 -58
  289. package/dist/src/ui/tui/tui-editor-brief.js +64 -57
  290. package/dist/src/ui/tui/tui-editor-budget.js +109 -96
  291. package/dist/src/ui/tui/tui-editor-config.js +81 -71
  292. package/dist/src/ui/tui/tui-editor-entry.js +167 -157
  293. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  294. package/dist/src/ui/tui/tui-editor-pause.js +208 -188
  295. package/dist/src/ui/tui/tui-editor-prompt.js +50 -44
  296. package/dist/src/ui/tui/tui-editor-queued.js +74 -67
  297. package/dist/src/ui/tui/tui-editor-quiet.js +124 -110
  298. package/dist/src/ui/tui/tui-editor-release.js +53 -44
  299. package/dist/src/ui/tui/tui-editor-retire.js +60 -49
  300. package/dist/src/ui/tui/tui-editor-role-enabled.js +93 -82
  301. package/dist/src/ui/tui/tui-editor-role-file.js +61 -49
  302. package/dist/src/ui/tui/tui-editor-role-key.js +52 -44
  303. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  304. package/dist/src/ui/tui/tui-editor-search.js +73 -60
  305. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  306. package/dist/src/ui/tui/tui-editors.js +1 -7
  307. package/dist/src/ui/tui/tui-frame.js +182 -134
  308. package/dist/src/ui/tui/tui-guard.js +20 -0
  309. package/dist/src/ui/tui/tui-input.js +159 -92
  310. package/dist/src/ui/tui/tui-keymap.js +51 -29
  311. package/dist/src/ui/tui/tui-keys.js +2 -2
  312. package/dist/src/ui/tui/tui-pane-activity.js +65 -53
  313. package/dist/src/ui/tui/tui-pane-backlog.js +78 -40
  314. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  315. package/dist/src/ui/tui/tui-pane-health.js +254 -251
  316. package/dist/src/ui/tui/tui-pane-history.js +81 -94
  317. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  318. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  319. package/dist/src/ui/tui/tui-pane-report.js +76 -58
  320. package/dist/src/ui/tui/tui-pane-session.js +14 -9
  321. package/dist/src/ui/tui/tui-pane-transcript.js +152 -141
  322. package/dist/src/ui/tui/tui-pane.js +205 -175
  323. package/dist/src/ui/tui/tui-panes.js +31 -35
  324. package/dist/src/ui/tui/tui.js +4 -5
  325. package/dist/src/verdict/fix-claim.js +87 -53
  326. package/dist/src/verdict/refusal.js +31 -7
  327. package/package.json +5 -5
  328. package/dist/src/concurrency/provider-lease.js +0 -105
  329. package/dist/src/doctor/image-probe.js +0 -257
  330. package/dist/src/runner/model-fallback.js +0 -76
  331. package/dist/src/runner/runner-fallback.js +0 -66
  332. package/dist/src/ui/team-alert-backlog-feed.js +0 -181
  333. package/dist/src/ui/team-alert-merge.js +0 -183
  334. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  335. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  336. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  337. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  338. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -1,12 +1,12 @@
1
1
  import { addTo, rankCountEntries, sortByValueDesc } from "../collections.js";
2
+ import { foldBaseRoles } from "./report-fold.js";
2
3
  import { compactTokens, usd } from "../text/format.js";
3
4
  import { durationLabel } from "../cli/cli-args.js";
4
- import { boundedWindowLine, monthDay, reportWindow, sinceWindowLine } from "../text/datetime.js";
5
+ import { monthDay, reportWindow } from "../text/datetime.js";
5
6
  import { eventsRotationLabel } from "../events/events.js";
6
7
  import { SPARSE_WINDOW_NOTE } from "../events/event-window.js";
7
8
  import { shortSpanPhrase } from "../text/phrases.js";
8
9
  import { markdownTable } from "../text/markdown.js";
9
- import { baseRoleOf } from "../roles/runner-ids.js";
10
10
  import { backendDisplayName } from "../agent/backend-traits.js";
11
11
  /** Bar width for one day: up to 20 blocks scaled to the window's max tokensOut —
12
12
  * round(20·v/max), min 1 when v > 0. */
@@ -19,7 +19,7 @@ function barWidth(v, max) {
19
19
  * collections.ts's rank rule, so the report cannot drift from the other counters that rank the
20
20
  * same way.
21
21
  * Callers filter/format the ranked pairs. */
22
- function rankedRoleMap(totals) {
22
+ export function rankedRoleMap(totals) {
23
23
  return rankCountEntries(Object.entries(totals));
24
24
  }
25
25
  /** Window totals per role: fold the per-day role maps (any `Record<string, number>` day field,
@@ -35,7 +35,7 @@ function rankedRoleTotals(series, pick) {
35
35
  }
36
36
  /** One "**Label:** role — n · role — n" Markdown line from already-ranked pairs; an empty
37
37
  * pair list (an all-zero window) renders "-". fmt formats each role's number. */
38
- function rankedRoleLine(label, pairs, fmt) {
38
+ export function rankedRoleLine(label, pairs, fmt) {
39
39
  return `**${label}:** ${pairs.length === 0 ? "-" : pairs.map(([r, n]) => `${r} — ${fmt(n)}`).join(" · ")}`;
40
40
  }
41
41
  /** The "**Prompt tokens by role:**" line — each role's prompt-token total and, when its
@@ -43,7 +43,7 @@ function rankedRoleLine(label, pairs, fmt) {
43
43
  * — with zero-total roles omitted (the same zero-means-absent rule the sibling lines apply)
44
44
  * and "-" when no role sent a prompt. `prompt` arrives already ranked (prompt desc then
45
45
  * name asc). The one home of the share format, so the day and `--since` lines cannot drift. */
46
- function promptTokensLine(prompt, cache) {
46
+ export function promptTokensLine(prompt, cache) {
47
47
  const cacheByRole = new Map(cache);
48
48
  const cells = prompt
49
49
  .filter(([, n]) => n > 0)
@@ -58,9 +58,21 @@ function promptTokensLine(prompt, cache) {
58
58
  * unfiltered lines. fmt formats each role's number (default String for count lines; pass
59
59
  * usd/compactTokens for spend/token lines). All the "X by role" lines share this filter-rule
60
60
  * home so the zero-means-render-dash guard cannot drift between the --since and day renders. */
61
- function pushRankedRoleLine(lines, label, pairs, fmt = String) {
61
+ export function pushRankedRoleLine(lines, label, pairs, fmt = String) {
62
62
  lines.push(rankedRoleLine(label, pairs.filter(([, n]) => n > 0), fmt));
63
63
  }
64
+ /** Push a "**Label:** …" ranked-role line only when some role carries a positive value — the
65
+ * zero-means-absent idiom: a window or day with no evidence prints no line at all, unlike
66
+ * pushRankedRoleLine's always-printed "-", because "no model ran" is an absence, not a zero
67
+ * (the same reason pushProviderCostLines and pushDayCameoLine stay silent when empty). fmt
68
+ * formats each role's number (default String for count lines). Shared by the since and day
69
+ * renders so the filter-and-if-present guard cannot drift between them — a field that flipped
70
+ * one render to always-print would show an absent "-" line in one report but not the other. */
71
+ export function pushPresentRankedLine(lines, label, pairs, fmt = String) {
72
+ const present = pairs.filter(([, n]) => n > 0);
73
+ if (present.length > 0)
74
+ lines.push(rankedRoleLine(label, present, fmt));
75
+ }
64
76
  /** The per-day cameo line of the team day report — "MMM D <cell>" cells for each day that
65
77
  * carries a value, joined under a bold title, or undefined when no day has one (zero means
66
78
  * absent). The one home of that cameo rendering, shared by the per-day medians, the red-check
@@ -90,7 +102,7 @@ function pushDayCameoLine(lines, series, title, pick, fmt = shortSpanPhrase) {
90
102
  * renders. Zero-means-absent: a provider with no spend prints no line, and both lanes format
91
103
  * with usd so the day and --since windows cannot drift (the --since path ranks via rankedRoleMap,
92
104
  * the day path via rankedRoleTotals). */
93
- function pushProviderCostLines(lines, rankCost, rankNotional) {
105
+ export function pushProviderCostLines(lines, rankCost, rankNotional) {
94
106
  const providers = rankCost().filter(([, c]) => c > 0);
95
107
  if (providers.length > 0)
96
108
  lines.push(rankedRoleLine("Cost by provider", providers, usd));
@@ -159,7 +171,7 @@ const ROLE_SCOPE_NOTE = "role scope: merge runs and backlog tallies (features do
159
171
  * nonzero (zero means absent, so an API-only or merge-free window renders as before), and the
160
172
  * role-scope note when the report is scoped. One home so the day report and the --since window
161
173
  * cannot drift on the order, the omission predicates, or the scope note. */
162
- function pushReportSummary(lines, t, tail = "", scoped = false) {
174
+ export function pushReportSummary(lines, t, tail = "", scoped = false) {
163
175
  lines.push(totalsLine(t, tail));
164
176
  if (t.mergeRuns > 0)
165
177
  lines.push(mergeShareLine(t));
@@ -168,160 +180,13 @@ function pushReportSummary(lines, t, tail = "", scoped = false) {
168
180
  if (scoped)
169
181
  lines.push(ROLE_SCOPE_NOTE);
170
182
  }
171
- /** Render a trailing-window totals report as Markdown — the aggregate sibling of the
172
- * `logs --since` raw stream ("how much" here, "what exactly" there). Pure function of
173
- * SinceReport: no I/O, no clock reads (the window line's local time is rendered from the
174
- * collector's captured cutoff, so the printed window matches the aggregated one). */
175
- export function renderSinceReportMarkdown(data) {
176
- const lines = [];
177
- lines.push("# tumwater usage report");
178
- lines.push("");
179
- lines.push(data.untilMs !== undefined && data.toIso !== undefined
180
- ? boundedWindowLine(durationLabel(data.sinceMs), durationLabel(data.untilMs), data.fromIso, data.toIso)
181
- : sinceWindowLine(durationLabel(data.sinceMs), data.fromIso));
182
- if (data.role !== undefined)
183
- lines.push(`role: ${data.role}`);
184
- lines.push("");
185
- const t = data.totals;
186
- // Same totals voice as the day report, minus the features/bugs and filing cells: those tallies
187
- // come from day-granular backlog-file dates that cannot subdivide a sub-day window, so the
188
- // final line below states the omission instead of showing a day-rounded number.
189
- pushReportSummary(lines, t, "", data.role !== undefined);
190
- lines.push("");
191
- lines.push(rankedRoleLine("Runs by role", rankedRoleMap(data.runsByRole), String));
192
- // Same breakdown for token spend: ranked by tokens desc then name asc, zero-token roles
193
- // omitted — an all-zero window renders "-" like the other lines. Output tokens are the
194
- // resource the budget charges, so this is the line an operator reads to find which role's
195
- // prompt loop burned the window's tokens.
196
- pushRankedRoleLine(lines, "Tokens by role", rankedRoleMap(data.tokensByRole), compactTokens);
197
- // The prompt side of the same breakdown, straight after the output-token line: how many
198
- // prompt tokens each role sent and the share of them its backend served from cache — the
199
- // input side of the bill an operator cannot see from Tokens by role. Zero-prompt roles are
200
- // omitted, and an all-zero window renders "-" like the sibling lines.
201
- lines.push(promptTokensLine(rankedRoleMap(data.promptTokensByRole ?? {}), rankedRoleMap(data.cacheReadTokensByRole ?? {})));
202
- // Same breakdown for spend: ranked by spend desc then name asc, zero-spend roles omitted —
203
- // an all-zero window renders "-" like the runs line.
204
- pushRankedRoleLine(lines, "Cost by role", rankedRoleMap(data.costByRole), usd);
205
- // The same breakdown per PROVIDER (plans: per-provider spend), gated to the team report
206
- // like its day sibling. SinceReport.costByProvider is always present (empty when none), so
207
- // the zero-means-absent gate prints no line for a window with no provider spend.
208
- if (data.role === undefined) {
209
- pushProviderCostLines(lines, () => rankedRoleMap(data.costByProvider), () => rankedRoleMap(data.notionalByProvider));
210
- // The same breakdown per MODEL (each run_start's agent-qualified selector — the model a
211
- // team mixing providers, per-role overrides and fallbacks actually ran on), gated to the
212
- // team report like the provider lines: a model is not a role, so a role-scoped window
213
- // omits it. SinceReport.runsByModel is always present, so a window with no modelled
214
- // start prints no line (keeping a model-less day's output unchanged), exactly as the
215
- // provider lines stay absent when no provider spent.
216
- const modelPairs = rankedRoleMap(data.runsByModel).filter(([, n]) => n > 0);
217
- if (modelPairs.length > 0)
218
- lines.push(rankedRoleLine("Runs by model", modelPairs, String));
219
- }
220
- // Main's red wall-clock over the window (main's own, not any role's — a role-scoped
221
- // collector carries 0, so this never prints for a scoped window). Zero-absent: no line for
222
- // a window main was never red.
223
- if (data.mainRedMs > 0)
224
- lines.push(`**Main red:** ${durationLabel(data.mainRedMs)}`);
225
- // The same "what kept main red" detail named by failing test — which tests main's own red
226
- // checks named over the window. Team-only like main's red time, so a role-scoped window
227
- // omits it; zero-absent: no line when the window's red checks named no test.
228
- if (data.role === undefined) {
229
- const mainRedByTest = rankedRoleMap(data.mainRedByTest ?? {}).filter(([, n]) => n > 0);
230
- if (mainRedByTest.length > 0)
231
- lines.push(rankedRoleLine("Main red by test", mainRedByTest, String));
232
- // The same "what kept main red" detail named by merge — the landed merges the watch's
233
- // own main_culprit events blamed, mirroring the failing-test line right above. Team-only
234
- // like main's red time and zero-absent: no line when the window blamed no merge.
235
- const mainRedByCulprit = rankedRoleMap(data.mainRedByCulprit ?? {}).filter(([, n]) => n > 0);
236
- if (mainRedByCulprit.length > 0)
237
- lines.push(rankedRoleLine("Main red by merge", mainRedByCulprit, String));
238
- }
239
- // The same breakdown for review friction: rejections per role from review_rejected events;
240
- // a window with none renders "-" like the other lines. The post-merge line is the
241
- // afterMerge: true subset — rejections that reached an already-merged commit.
242
- pushRankedRoleLine(lines, "Rejections by role", rankedRoleMap(data.rejectionsByRole));
243
- pushRankedRoleLine(lines, "Post-merge rejections by role", rankedRoleMap(data.postMergeRejectionsByRole));
244
- pushRankedRoleLine(lines, "Revisions by role", rankedRoleMap(data.revisionsByRole));
245
- pushRankedRoleLine(lines, "Refusals by role", rankedRoleMap(data.refusalsByRole));
246
- // The same breakdown for outright failures: error-class run_ends per role under the
247
- // digest's OUTCOME_CLASS — the runs that burned agent time and merged nothing without
248
- // going through review, which rejections/revisions would miss entirely. A window with
249
- // none renders "-" like the other lines.
250
- pushRankedRoleLine(lines, "Failed runs by role", rankedRoleMap(data.failedRunsByRole));
251
- pushRankedRoleLine(lines, "Fallbacks by role", rankedRoleMap(data.fallbacksByRole));
252
- pushRankedRoleLine(lines, "Image reroutes by role", rankedRoleMap(data.imageReroutesByRole));
253
- // Deferred maintenance runs per reason code, the --since twin of the day render's line:
254
- // how many due maintenance runs stood down and why (backlog / no-work / plans-full).
255
- // Gated to the team report — a reason is not a role, so a role-scoped window omits it.
256
- // Zero-means-absent: a window with no deferral prints no line.
257
- if (data.role === undefined) {
258
- const deferred = rankedRoleMap(data.deferredByReason ?? {}).filter(([, n]) => n > 0);
259
- if (deferred.length > 0)
260
- lines.push(rankedRoleLine("Deferred runs by reason", deferred, String));
261
- }
262
- // The merged-commits split by base role, matching the day render's unfiltered line: the
263
- // window's totals.commits carries the count; this line says which role's commits it was.
264
- lines.push(rankedRoleLine("Commits by role", rankedRoleMap(data.commitsByRole), String));
265
- // The merged-outcomes split by base role, mirroring the day render's line: which roles'
266
- // changes reach main and which bounce off the gate (merge_failed). Gated to the team
267
- // report — a role-scoped window omits merge (ROLE_SCOPE_NOTE), so the per-role
268
- // attribution would contradict it. Zero-means-absent: the failures line prints only when
269
- // some role has one; a merge-free window renders `-` like the other lines.
270
- if (data.role === undefined) {
271
- lines.push(rankedRoleLine("Merged by role", rankedRoleMap(data.mergedByRole), String));
272
- const mergeFailures = rankedRoleMap(data.mergeFailedByRole).filter(([, n]) => n > 0);
273
- if (mergeFailures.length > 0)
274
- lines.push(rankedRoleLine("Merge failures by role", mergeFailures, String));
275
- // WHY the window's merges failed, from each merge_failed's result code — the day
276
- // report's reason twin of the role line above, so an operator can tell a review
277
- // rejection (fix the change) from a conflict/block (rebase) from a shutdown abort
278
- // (capacity). Ranked the same way (count desc then code asc), reason-free only.
279
- const mergeFailureReasons = rankedRoleMap(data.mergeFailedByReason ?? {}).filter(([, n]) => n > 0);
280
- if (mergeFailureReasons.length > 0)
281
- lines.push(rankedRoleLine("Merge failures by reason", mergeFailureReasons, String));
282
- }
283
- // Spend per merged commit, the --since twin of the day render's `Cost per commit:` line:
284
- // costByRole is keyed by role id (instances not folded, so `feature-2` joins `feature`),
285
- // commitsByRole is already base-role keyed. Prints for a spend-free window as `-` and for a
286
- // role-scoped window from its scoped maps, matching the day render.
287
- lines.push(costPerCommitLine("Cost per commit", costPerCommitPairsFrom(foldBaseRoles(data.costByRole), data.commitsByRole)));
288
- // Subscription spend is notional: its own line, only when the window has any — the same
289
- // conditional/filtered `usd` form the day render's "Estimated by role" uses.
290
- if (data.notionalByRole !== undefined) {
291
- pushRankedRoleLine(lines, "Estimated by role", rankedRoleMap(data.notionalByRole), usd);
292
- // The day render's `Estimated per commit:` twin, after `Estimated by role`, from the same
293
- // notionalByRole source folded to base roles and the base-keyed commitsByRole.
294
- lines.push(costPerCommitLine("Estimated per commit", costPerCommitPairsFrom(foldBaseRoles(data.notionalByRole), data.commitsByRole)));
295
- }
296
- lines.push("");
297
- // Exactly one final line, chosen by the collector's coverage proof: either a hedged note
298
- // that stays true whenever it prints (the oldest retained event lies inside the window —
299
- // whether from rotation or a log born inside the window), or the one-time statement of the
300
- // backlog-tally omission. A flat "rotated out" claim would be false for a log that never
301
- // had the older events, so the wording names only what is provably the case. A role-scoped
302
- // report already states the omission in ROLE_SCOPE_NOTE, so it does not repeat the --days
303
- // pointer, which would imply a scoped day report shows the tallies it too omits.
304
- if (!data.coversFullWindow)
305
- lines.push(SPARSE_WINDOW_NOTE);
306
- else if (data.role === undefined)
307
- lines.push("backlog tallies (features done / bugs fixed / plans added / bugs filed) need the day report (--days)");
308
- return lines.join("\n");
309
- }
310
- /** Fold every key of a per-role-id map through its base role, so `feature-2` joins `feature`.
311
- * The same fold the day caller does per day, applied to an already-aggregated window map. */
312
- function foldBaseRoles(map) {
313
- const folded = {};
314
- for (const [id, c] of Object.entries(map))
315
- addTo(folded, baseRoleOf(id), c);
316
- return folded;
317
- }
318
183
  /** Rank one cost map against a base-keyed commits map into cost-per-commit output: the shared
319
184
  * core of the day and --since cost-per-commit lines. `cost` is expected base-role keyed (the
320
185
  * day caller folds per day, the --since caller via `foldBaseRoles`); commits are base-role
321
186
  * keyed. `merged` is [role, cost per commit, commits] for roles with both, ranked by cost per
322
187
  * commit desc then name asc; `idle` is roles that spent and merged nothing, ranked by spend.
323
188
  * A role with commits but no spend appears in neither list. */
324
- function costPerCommitPairsFrom(cost, commits) {
189
+ export function costPerCommitPairsFrom(cost, commits) {
325
190
  const mergeDone = [];
326
191
  const idleCost = [];
327
192
  for (const [role, c] of Object.entries(cost)) {
@@ -336,24 +201,26 @@ function costPerCommitPairsFrom(cost, commits) {
336
201
  sortByValueDesc(mergeDone);
337
202
  return { mergeDone, idle: rankCountEntries(idleCost) };
338
203
  }
339
- /** The day caller of the cost-per-commit core: fold each day's role-id-keyed spend map through
340
- * its base role (so `feature-2` joins `feature`) and aggregate the base-keyed commitsByRole,
341
- * then hand both to `costPerCommitPairsFrom`. `pick` selects the per-role-id spend map; merge
342
- * spend (the reviewer's) and notional subscription spend stay out by default, matching
343
- * `**Cost by role:**`. */
204
+ /** The day caller of the cost-per-commit core: aggregate each day's role-id-keyed spend map
205
+ * under its raw role id, then fold the whole map through its base role (so `feature-2` joins
206
+ * `feature`) once via the shared foldBaseRoles — addition is associative and the fold is
207
+ * deterministic, so this is the same total the --since caller's single fold reaches — and
208
+ * aggregate the base-keyed commitsByRole, then hand both to `costPerCommitPairsFrom`. `pick`
209
+ * selects the per-role-id spend map; merge spend (the reviewer's) and notional subscription
210
+ * spend stay out by default, matching `**Cost by role:**`. */
344
211
  function costPerCommitPairs(series, pick = (d) => d.costByRole) {
345
212
  const cost = {};
346
213
  const commits = {};
347
214
  for (const d of series) {
348
215
  for (const [id, c] of Object.entries(pick(d)))
349
- addTo(cost, baseRoleOf(id), c);
216
+ addTo(cost, id, c);
350
217
  for (const [role, n] of Object.entries(d.commitsByRole ?? {}))
351
218
  addTo(commits, role, n);
352
219
  }
353
- return costPerCommitPairsFrom(cost, commits);
220
+ return costPerCommitPairsFrom(foldBaseRoles(cost), commits);
354
221
  }
355
222
  /** One `**<label>:** …` line from costPerCommitPairs output; `-` when there is nothing. */
356
- function costPerCommitLine(label, pairs) {
223
+ export function costPerCommitLine(label, pairs) {
357
224
  const parts = [
358
225
  ...pairs.mergeDone.map(([r, per, n]) => `${r} — ${usd(per)} (${n})`),
359
226
  ...pairs.idle.map(([r, c]) => `${r} — ${usd(c)}, no commits`),
@@ -424,9 +291,7 @@ function pushReportProviderLines(lines, data) {
424
291
  // omits it (ROLE_SCOPE_NOTE covers the omission), and a window with no modelled start
425
292
  // prints no line (keeping a model-less day's output unchanged), exactly as the provider
426
293
  // lines stay absent when no provider spent. Ranked and formatted like Cost by role.
427
- const modelPairs = rankedRoleTotals(data.series, (d) => d.runsByModel ?? {}).filter(([, n]) => n > 0);
428
- if (modelPairs.length > 0)
429
- lines.push(rankedRoleLine("Runs by model", modelPairs, String));
294
+ pushPresentRankedLine(lines, "Runs by model", rankedRoleTotals(data.series, (d) => d.runsByModel ?? {}));
430
295
  }
431
296
  }
432
297
  /** The per-role review-friction and outright-failure lines. */
@@ -443,7 +308,6 @@ function pushReportFrictionLines(lines, data) {
443
308
  // through review, which rejections/revisions would miss entirely. Zero-means-absent, so a
444
309
  // failure-free window renders no line and an all-zero window renders "-" like the others.
445
310
  pushRankedRoleLine(lines, "Failed runs by role", rankedRoleTotals(data.series, (d) => d.failedRunsByRole ?? {}));
446
- pushRankedRoleLine(lines, "Fallbacks by role", rankedRoleTotals(data.series, (d) => d.fallbacksByRole ?? {}));
447
311
  pushRankedRoleLine(lines, "Image reroutes by role", rankedRoleTotals(data.series, (d) => d.imageReroutesByRole ?? {}));
448
312
  }
449
313
  /** The team-only deferred-runs-by-reason line. */
@@ -454,9 +318,7 @@ function pushReportDeferralLines(lines, data) {
454
318
  // reason is not a role (ROLE_SCOPE_NOTE covers the omission). Zero-means-absent: a window
455
319
  // with no deferral prints no line, so a deferral-free day's output is unchanged.
456
320
  if (data.role === undefined) {
457
- const deferred = rankedRoleTotals(data.series, (d) => d.deferredByReason ?? {}).filter(([, n]) => n > 0);
458
- if (deferred.length > 0)
459
- lines.push(rankedRoleLine("Deferred runs by reason", deferred, String));
321
+ pushPresentRankedLine(lines, "Deferred runs by reason", rankedRoleTotals(data.series, (d) => d.deferredByReason ?? {}));
460
322
  }
461
323
  }
462
324
  /** The Commits-by-role line plus the team-only merge/main-red/cameo block. */
@@ -474,14 +336,10 @@ function pushReportMergeLines(lines, data) {
474
336
  if (data.role === undefined) {
475
337
  const mergeDone = rankedRoleTotals(data.series, (d) => d.mergedByRole ?? {});
476
338
  lines.push(rankedRoleLine("Merged by role", mergeDone, String));
477
- const mergeFailures = rankedRoleTotals(data.series, (d) => d.mergeFailedByRole ?? {}).filter(([, n]) => n > 0);
478
- if (mergeFailures.length > 0)
479
- lines.push(rankedRoleLine("Merge failures by role", mergeFailures, String));
339
+ pushPresentRankedLine(lines, "Merge failures by role", rankedRoleTotals(data.series, (d) => d.mergeFailedByRole ?? {}));
480
340
  // The --since reason twin, folded across the series the same way; a reason-free window
481
341
  // prints no line, so its output is byte-for-byte unchanged.
482
- const mergeFailureReasons = rankedRoleTotals(data.series, (d) => d.mergeFailedByReason ?? {}).filter(([, n]) => n > 0);
483
- if (mergeFailureReasons.length > 0)
484
- lines.push(rankedRoleLine("Merge failures by reason", mergeFailureReasons, String));
342
+ pushPresentRankedLine(lines, "Merge failures by reason", rankedRoleTotals(data.series, (d) => d.mergeFailedByReason ?? {}));
485
343
  // Per-day median passing check time, so a suite slowing over days shows; days without a
486
344
  // check are left out, and no line prints when no day has one (zero means absent).
487
345
  pushDayCameoLine(lines, data.series, "Merge check median", (d) => d.checkMedianMs);
@@ -495,15 +353,11 @@ function pushReportMergeLines(lines, data) {
495
353
  // The day twin of the --since window's totals line: the same tests ranked across the
496
354
  // series' per-day main-red maps (Main red by test). Zero-absent: no line when no day
497
355
  // named a failing test.
498
- const mainRedByTest = rankedRoleTotals(data.series, (d) => d.mainRedByTest ?? {}).filter(([, n]) => n > 0);
499
- if (mainRedByTest.length > 0)
500
- lines.push(rankedRoleLine("Main red by test", mainRedByTest, String));
356
+ pushPresentRankedLine(lines, "Main red by test", rankedRoleTotals(data.series, (d) => d.mainRedByTest ?? {}));
501
357
  // The day twin of the --since window's merges line: the same culprits ranked across the
502
358
  // series' per-day main-red maps (Main red by merge). Zero-absent: no line when no day
503
359
  // blamed a merge.
504
- const mainRedByCulprit = rankedRoleTotals(data.series, (d) => d.mainRedByCulprit ?? {}).filter(([, n]) => n > 0);
505
- if (mainRedByCulprit.length > 0)
506
- lines.push(rankedRoleLine("Main red by merge", mainRedByCulprit, String));
360
+ pushPresentRankedLine(lines, "Main red by merge", rankedRoleTotals(data.series, (d) => d.mainRedByCulprit ?? {}));
507
361
  pushDayCameoLine(lines, data.series, "Merge wait median", (d) => d.mergeWaitMedianMs);
508
362
  // How long the merge slot itself held each change it landed, beside the merge-wait median:
509
363
  // the queue backlog (merge wait minus slot time) apart from the slot's own review/check/
@@ -0,0 +1,219 @@
1
+ /** The trailing-window half of the usage report's collection: read the harness's event log
2
+ * over the `sinceMs` window and distill it into a `SinceReport` — run/token/commit/cost
3
+ * totals plus features-done/cost maps and main-red tallies. Split out of
4
+ * src/report/report-data.ts (which keeps the per-day `ReportData` series collector) so each
5
+ * window collector owns its own helpers and its own shape: the day collector's zero-filled
6
+ * series and backlog-file reads have no place in a window totals view, and the two change
7
+ * for different reasons. The event-log folding both collectors share lives in
8
+ * src/report/report-usage-fold.ts (foldUsageEvent) and src/report/report-fold.ts (the windowed
9
+ * cache and scope helpers), the shapes they produce in src/report/report-types.ts, and the
10
+ * Markdown rendering in src/report/report-since-render.ts (which imports the shared formatting
11
+ * voice from src/report/report-render.ts). */
12
+ import { assertReportSinceMs, eventWindowCovers, readWindowEvents, sinceWindowTiming } from "../events/event-window.js";
13
+ import { dayKey } from "../text/datetime.js";
14
+ import { addTo } from "../collections.js";
15
+ import { nonemptyObj, roleSum, scopeBases, scopedByRole, zeroCommitCounts, zeroMergeCounts, } from "./report-fold.js";
16
+ import { foldUsageEvent } from "./report-usage-fold.js";
17
+ import { mainRedCulpritsByDay, mainRedFailingTestsByDay, mainRedMinutesByDay } from "./report-main-red.js";
18
+ /** Aggregate team usage over the trailing `sinceMs` window ending now (or `untilMs` ago, which
19
+ * drops events at or after that end; `coversFullWindow` still judges the far edge
20
+ * only), from the event log only (run_end/merged — see SinceReport for why the backlog files
21
+ * are not read). Kept a
22
+ * separate function rather than a `collectReport` variant flag: the day collector's
23
+ * zero-filled series and backlog-file reads have no place in a window totals view. */
24
+ export function collectReportSince(root, sinceMs, role, untilMs) {
25
+ assertReportSinceMs(sinceMs);
26
+ const { now, cutoff, end, slice } = sinceWindowTiming(sinceMs, untilMs);
27
+ const { raw, acc } = foldSinceWindow(root, cutoff, now, end, untilMs);
28
+ // The empty-window branch of the shared proof matters here: with no retained events nothing
29
+ // can have rotated away, and printing a rotation note over a fresh install would claim a
30
+ // history that never existed. When neither proof holds, the oldest retained event lies inside
31
+ // the window and the render notes the possible truncation.
32
+ const coversFullWindow = eventWindowCovers(raw, cutoff);
33
+ // Role scope: every map and total below derives from the fold's per-role maps for the named
34
+ // base roles alone — merge usage (the merge slot's, not the role's) and the team-only
35
+ // fields stay out, so a scoped --since report sums exactly the same events the scoped day
36
+ // report would. The scope is the base roles, so instances (`feature-2`) aggregate into their
37
+ // group as the cap does.
38
+ const bases = scopeBases(role);
39
+ return {
40
+ ...slice,
41
+ ...(bases === undefined ? {} : { role: bases.join(",") }),
42
+ totals: sinceScopedTotals(acc, bases),
43
+ ...sinceRoleMaps(acc, bases),
44
+ // Main's red time is main's own (G1's rule), not any role's, so a role-scoped window
45
+ // carries 0 — the type's always-present default for the team-only field.
46
+ mainRedMs: bases === undefined
47
+ ? [...mainRedMinutesByDay(raw.events, cutoff, end).values()].reduce((a, b) => a + b, 0)
48
+ : 0,
49
+ ...nonemptyObj("mainRedByTest", sinceMainRedByTest(raw, cutoff, end, bases)),
50
+ ...nonemptyObj("mainRedByCulprit", sinceMainRedByCulprit(raw, cutoff, end, bases)),
51
+ coversFullWindow,
52
+ };
53
+ }
54
+ /** The window's event read and fold, one function so the read happens exactly once per window
55
+ * and every downstream builder — the coverage proof and the main-red by-test/by-culprit maps
56
+ * — consumes the same `raw`. `raw` is the one day-keyed log read the whole report is built on
57
+ * (its `events` feed both the `acc` fold below and the red maps), so its retention-backed
58
+ * coverage and its event set cannot disagree across the builders. */
59
+ function foldSinceWindow(root, cutoff, now, end, untilMs) {
60
+ // The window key is the cutoff's local calendar day, from the shared dayKey helper
61
+ // eventDayKey buckets events with, so the read's day keys cannot disagree with the ts
62
+ // filter below; the day-keyed read may include earlier hours of that day, which the ts
63
+ // filter removes (over-read is at most one day's events). Future-dated events are dropped
64
+ // here too, matching collectReport (its day map only holds the window's days).
65
+ const fromKey = dayKey(cutoff);
66
+ const raw = readWindowEvents(root, fromKey);
67
+ const acc = {
68
+ runs: 0,
69
+ runsByRole: {},
70
+ rejectionsByRole: {},
71
+ postMergeRejectionsByRole: {},
72
+ revisionsByRole: {},
73
+ refusalsByRole: {},
74
+ checkDurationsMs: [],
75
+ checkFailures: 0,
76
+ mergeWaitsMs: [],
77
+ mergeDurationsMs: [],
78
+ runDurationsMs: [],
79
+ providerWaitsMs: [],
80
+ slotWaitsMs: [],
81
+ checkWaitsMs: [],
82
+ reviewDurationsMs: [],
83
+ failedRunsByRole: {},
84
+ imageReroutesByRole: {},
85
+ tokensByRole: {},
86
+ promptTokensByRole: {},
87
+ cacheReadTokensByRole: {},
88
+ costByRole: {},
89
+ tokensOut: 0,
90
+ ...zeroCommitCounts(),
91
+ costUsd: 0,
92
+ notionalCostUsd: 0,
93
+ notionalByRole: {},
94
+ ...zeroMergeCounts(),
95
+ mergedByRole: {},
96
+ mergeFailedByRole: {},
97
+ mergeFailedByReason: {},
98
+ costByProvider: {},
99
+ notionalByProvider: {},
100
+ notionalByBackend: {},
101
+ runsByModel: {},
102
+ deferredByReason: {},
103
+ };
104
+ for (const ev of raw.events) {
105
+ if (typeof ev.ts !== "number" || ev.ts < cutoff || ev.ts > now || (untilMs !== undefined && ev.ts >= end))
106
+ continue;
107
+ foldUsageEvent(acc, ev);
108
+ }
109
+ return { raw, acc };
110
+ }
111
+ /** The window's `totals` group under both scope rules, named so the team-vs-role branch reads
112
+ * as one function instead of a ternary buried in the return: the team figure sums the merge-slot
113
+ * fields in (the merge slot's dash tokens/cost are the same team spend the budget charges), while
114
+ * the role branch sums just the base roles through `roleSum` and zeroes the merge counts — a
115
+ * role's merge usage is the merge slot's, not the role's. */
116
+ function sinceScopedTotals(acc, bases) {
117
+ return bases === undefined
118
+ ? {
119
+ tokensOut: acc.tokensOut + (acc.mergeTokens ?? 0),
120
+ runs: acc.runs ?? 0,
121
+ commits: acc.commits,
122
+ costUsd: acc.costUsd + (acc.mergeCostUsd ?? 0),
123
+ ...(acc.notionalCostUsd > 0 ? { notionalCostUsd: acc.notionalCostUsd } : {}),
124
+ ...nonemptyObj("notionalByBackend", acc.notionalByBackend),
125
+ mergeRuns: acc.mergeRuns ?? 0,
126
+ mergeTokens: acc.mergeTokens ?? 0,
127
+ mergeCostUsd: acc.mergeCostUsd ?? 0,
128
+ ...((acc.mergeFailed ?? 0) > 0 ? { mergeFailed: acc.mergeFailed } : {}),
129
+ }
130
+ : (() => {
131
+ // The role's own notional spend, from its run_end events; the merge slot's
132
+ // notional is team-wide and stays out like its cost.
133
+ const notional = roleSum(acc.notionalByRole, bases);
134
+ return {
135
+ tokensOut: roleSum(acc.tokensByRole, bases),
136
+ runs: roleSum(acc.runsByRole, bases),
137
+ commits: roleSum(acc.commitsByRole, bases),
138
+ costUsd: roleSum(acc.costByRole, bases),
139
+ ...(notional > 0 ? { notionalCostUsd: notional } : {}),
140
+ ...zeroMergeCounts(),
141
+ };
142
+ })();
143
+ }
144
+ /** Flatten a per-day main-red record map (day → label → count) into one flat label→count map,
145
+ * the shared body of sinceMainRedByTest and sinceMainRedByCulprit. Main's red state is main's
146
+ * own, not any role's, so a role-scoped window carries none — the empty map `nonemptyObj` then
147
+ * omits from the JSON, keeping a red-free --since byte-identical to today. */
148
+ function flattenMainRedByDay(perDay, bases) {
149
+ if (bases !== undefined)
150
+ return {};
151
+ const flat = {};
152
+ for (const record of perDay.values()) {
153
+ for (const [label, n] of Object.entries(record))
154
+ addTo(flat, label, n);
155
+ }
156
+ return flat;
157
+ }
158
+ /** Main's red failing tests over the window, flattened across the per-day records of the same
159
+ * red `main_check` events the red time uses (G1's rule) into one map. Main's failing tests are
160
+ * main's own, not any role's, so a role-scoped window carries none (see flattenMainRedByDay). */
161
+ function sinceMainRedByTest(raw, cutoff, end, bases) {
162
+ return flattenMainRedByDay(mainRedFailingTestsByDay(raw.events, cutoff, end), bases);
163
+ }
164
+ /** Main's red culprits over the window, flattened across the per-day records of the same red
165
+ * `main_culprit` events the live `mainred` alert's `red.culprit` reads (the historical twin)
166
+ * into one map. Main's own blame, not any role's, so a role-scoped window carries none (see
167
+ * flattenMainRedByDay). */
168
+ function sinceMainRedByCulprit(raw, cutoff, end, bases) {
169
+ return flattenMainRedByDay(mainRedCulpritsByDay(raw.events, cutoff, end), bases);
170
+ }
171
+ /** The `SinceReport` return's role-scoped map group — every field a reader names when asking
172
+ * "which fields are team-only?": each `scopedByRole` call applies the base-role scope (the
173
+ * rule `collectReportSince`'s `bases` comment spells), so a role-scoped window shows exactly
174
+ * the maps the scoped day report would, and the team-only fields (providers, backends, models,
175
+ * deferred reasons) carry full maps for the team or empty maps for a scoped role. */
176
+ function sinceRoleMaps(acc, bases) {
177
+ // The --since render/JSON's Notional by role line is conditional on the window's own
178
+ // notional spend, scoped to base when a role is given — the same conditional the day
179
+ // collector applies to ReportDay.notionalByRole (line ~573), so a role-scoped window
180
+ // whose role spent no notional carries no notionalByRole instead of an empty map.
181
+ const notionalByRole = scopedByRole(acc.notionalByRole, bases);
182
+ // Deferred runs are bucketed by a reason code (not a role), so a role-scoped window
183
+ // carries no entries — the same team-only rule runsByModel follows. Only set when the
184
+ // window actually deferred (zero means absent), so a deferral-free --since JSON omits it.
185
+ const deferred = scopedByRole(acc.deferredByReason, bases);
186
+ return {
187
+ runsByRole: scopedByRole(acc.runsByRole, bases),
188
+ costByRole: scopedByRole(acc.costByRole, bases),
189
+ rejectionsByRole: scopedByRole(acc.rejectionsByRole, bases),
190
+ postMergeRejectionsByRole: scopedByRole(acc.postMergeRejectionsByRole, bases),
191
+ revisionsByRole: scopedByRole(acc.revisionsByRole, bases),
192
+ refusalsByRole: scopedByRole(acc.refusalsByRole, bases),
193
+ failedRunsByRole: scopedByRole(acc.failedRunsByRole, bases),
194
+ imageReroutesByRole: scopedByRole(acc.imageReroutesByRole, bases),
195
+ tokensByRole: scopedByRole(acc.tokensByRole, bases),
196
+ ...nonemptyObj("promptTokensByRole", scopedByRole(acc.promptTokensByRole, bases)),
197
+ ...nonemptyObj("cacheReadTokensByRole", scopedByRole(acc.cacheReadTokensByRole, bases)),
198
+ commitsByRole: scopedByRole(acc.commitsByRole, bases),
199
+ mergedByRole: scopedByRole(acc.mergedByRole, bases),
200
+ mergeFailedByRole: scopedByRole(acc.mergeFailedByRole, bases),
201
+ // A result code is not a role, so a role-scoped window carries an empty map (a code
202
+ // never baseRoleOf to the role) — the render gates the reason line to the team report.
203
+ mergeFailedByReason: scopedByRole(acc.mergeFailedByReason, bases),
204
+ ...nonemptyObj("notionalByRole", notionalByRole),
205
+ // Provider spend is a team attribution (a provider is not a role), so a role-scoped
206
+ // window carries empty maps — providers never baseRoleOf to the role — and the render
207
+ // gates the provider lines to the team report anyway. Always present, empty when none.
208
+ costByProvider: scopedByRole(acc.costByProvider, bases),
209
+ notionalByProvider: scopedByRole(acc.notionalByProvider, bases),
210
+ // Backend spend is a team attribution (a backend is not a role), so a role-scoped window
211
+ // omits it — the same absent-when-empty rule the day collector applies.
212
+ ...nonemptyObj("notionalByBackend", scopedByRole(acc.notionalByBackend, bases)),
213
+ // Model attribution is team-only (a model is not a role, so no model folds to the
214
+ // scoped role), the same full-map-for-team / empty-for-scoped rule costByProvider
215
+ // follows. Always present so the render can gate on the window's own events.
216
+ runsByModel: scopedByRole(acc.runsByModel, bases),
217
+ ...nonemptyObj("deferredByReason", deferred),
218
+ };
219
+ }