tumwater 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -38,6 +38,21 @@ function rankedRoleTotals(series, pick) {
38
38
  function rankedRoleLine(label, pairs, fmt) {
39
39
  return `**${label}:** ${pairs.length === 0 ? "-" : pairs.map(([r, n]) => `${r} — ${fmt(n)}`).join(" · ")}`;
40
40
  }
41
+ /** The "**Prompt tokens by role:**" line — each role's prompt-token total and, when its
42
+ * backend reported a cache read, the share of it served from cache (`12000 (80% cached)`)
43
+ * — with zero-total roles omitted (the same zero-means-absent rule the sibling lines apply)
44
+ * and "-" when no role sent a prompt. `prompt` arrives already ranked (prompt desc then
45
+ * name asc). The one home of the share format, so the day and `--since` lines cannot drift. */
46
+ function promptTokensLine(prompt, cache) {
47
+ const cacheByRole = new Map(cache);
48
+ const cells = prompt
49
+ .filter(([, n]) => n > 0)
50
+ .map(([role, n]) => {
51
+ const c = cacheByRole.get(role) ?? 0;
52
+ return `${role} — ${compactTokens(n)}${c > 0 ? ` (${Math.round((100 * c) / n)}% cached)` : ""}`;
53
+ });
54
+ return `**Prompt tokens by role:** ${cells.length === 0 ? "-" : cells.join(" · ")}`;
55
+ }
41
56
  /** Push one "**Label:** …" ranked-role line, dropping roles with a non-positive value (zero
42
57
  * count/spend) before rendering, so an all-zero window still renders "-" exactly like the
43
58
  * unfiltered lines. fmt formats each role's number (default String for count lines; pass
@@ -46,6 +61,18 @@ function rankedRoleLine(label, pairs, fmt) {
46
61
  function pushRankedRoleLine(lines, label, pairs, fmt = String) {
47
62
  lines.push(rankedRoleLine(label, pairs.filter(([, n]) => n > 0), fmt));
48
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
+ 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
+ }
49
76
  /** The per-day cameo line of the team day report — "MMM D <cell>" cells for each day that
50
77
  * carries a value, joined under a bold title, or undefined when no day has one (zero means
51
78
  * absent). The one home of that cameo rendering, shared by the per-day medians, the red-check
@@ -159,6 +186,20 @@ function pushReportSummary(lines, t, tail = "", scoped = false) {
159
186
  * collector's captured cutoff, so the printed window matches the aggregated one). */
160
187
  export function renderSinceReportMarkdown(data) {
161
188
  const lines = [];
189
+ pushSinceWindowHeader(lines, data);
190
+ pushSinceSummary(lines, data);
191
+ pushSinceRoleLines(lines, data);
192
+ pushSinceProviderModelLines(lines, data);
193
+ pushSinceMainRedLines(lines, data);
194
+ pushSinceFrictionLines(lines, data);
195
+ pushSinceDeferralLines(lines, data);
196
+ pushSinceCommitLines(lines, data);
197
+ pushSinceCostLines(lines, data);
198
+ pushSinceCoverageLines(lines, data);
199
+ return lines.join("\n");
200
+ }
201
+ /** The window header: the title, the bounded-or-since window line and the role scope line. */
202
+ function pushSinceWindowHeader(lines, data) {
162
203
  lines.push("# tumwater usage report");
163
204
  lines.push("");
164
205
  lines.push(data.untilMs !== undefined && data.toIso !== undefined
@@ -167,21 +208,36 @@ export function renderSinceReportMarkdown(data) {
167
208
  if (data.role !== undefined)
168
209
  lines.push(`role: ${data.role}`);
169
210
  lines.push("");
211
+ }
212
+ /** The shared totals voice (minus features/bugs/filing cells, stated at the tail), gated
213
+ * to the team report like the day sibling's. */
214
+ function pushSinceSummary(lines, data) {
170
215
  const t = data.totals;
171
216
  // Same totals voice as the day report, minus the features/bugs and filing cells: those tallies
172
217
  // come from day-granular backlog-file dates that cannot subdivide a sub-day window, so the
173
218
  // final line below states the omission instead of showing a day-rounded number.
174
219
  pushReportSummary(lines, t, "", data.role !== undefined);
175
220
  lines.push("");
221
+ }
222
+ /** Runs / tokens / prompt-tokens / cost breakdowns by role. */
223
+ function pushSinceRoleLines(lines, data) {
176
224
  lines.push(rankedRoleLine("Runs by role", rankedRoleMap(data.runsByRole), String));
177
225
  // Same breakdown for token spend: ranked by tokens desc then name asc, zero-token roles
178
226
  // omitted — an all-zero window renders "-" like the other lines. Output tokens are the
179
227
  // resource the budget charges, so this is the line an operator reads to find which role's
180
228
  // prompt loop burned the window's tokens.
181
229
  pushRankedRoleLine(lines, "Tokens by role", rankedRoleMap(data.tokensByRole), compactTokens);
230
+ // The prompt side of the same breakdown, straight after the output-token line: how many
231
+ // prompt tokens each role sent and the share of them its backend served from cache — the
232
+ // input side of the bill an operator cannot see from Tokens by role. Zero-prompt roles are
233
+ // omitted, and an all-zero window renders "-" like the sibling lines.
234
+ lines.push(promptTokensLine(rankedRoleMap(data.promptTokensByRole ?? {}), rankedRoleMap(data.cacheReadTokensByRole ?? {})));
182
235
  // Same breakdown for spend: ranked by spend desc then name asc, zero-spend roles omitted —
183
236
  // an all-zero window renders "-" like the runs line.
184
237
  pushRankedRoleLine(lines, "Cost by role", rankedRoleMap(data.costByRole), usd);
238
+ }
239
+ /** The provider cost lines and the per-model line, gated to the team report. */
240
+ function pushSinceProviderModelLines(lines, data) {
185
241
  // The same breakdown per PROVIDER (plans: per-provider spend), gated to the team report
186
242
  // like its day sibling. SinceReport.costByProvider is always present (empty when none), so
187
243
  // the zero-means-absent gate prints no line for a window with no provider spend.
@@ -193,15 +249,29 @@ export function renderSinceReportMarkdown(data) {
193
249
  // omits it. SinceReport.runsByModel is always present, so a window with no modelled
194
250
  // start prints no line (keeping a model-less day's output unchanged), exactly as the
195
251
  // provider lines stay absent when no provider spent.
196
- const modelPairs = rankedRoleMap(data.runsByModel).filter(([, n]) => n > 0);
197
- if (modelPairs.length > 0)
198
- lines.push(rankedRoleLine("Runs by model", modelPairs, String));
252
+ pushPresentRankedLine(lines, "Runs by model", rankedRoleMap(data.runsByModel));
199
253
  }
254
+ }
255
+ /** Main's red time and the test/merge culprits behind it, gated like the day sibling. */
256
+ function pushSinceMainRedLines(lines, data) {
200
257
  // Main's red wall-clock over the window (main's own, not any role's — a role-scoped
201
258
  // collector carries 0, so this never prints for a scoped window). Zero-absent: no line for
202
259
  // a window main was never red.
203
260
  if (data.mainRedMs > 0)
204
261
  lines.push(`**Main red:** ${durationLabel(data.mainRedMs)}`);
262
+ // The same "what kept main red" detail named by failing test — which tests main's own red
263
+ // checks named over the window. Team-only like main's red time, so a role-scoped window
264
+ // omits it; zero-absent: no line when the window's red checks named no test.
265
+ if (data.role === undefined) {
266
+ pushPresentRankedLine(lines, "Main red by test", rankedRoleMap(data.mainRedByTest ?? {}));
267
+ // The same "what kept main red" detail named by merge — the landed merges the watch's
268
+ // own main_culprit events blamed, mirroring the failing-test line right above. Team-only
269
+ // like main's red time and zero-absent: no line when the window blamed no merge.
270
+ pushPresentRankedLine(lines, "Main red by merge", rankedRoleMap(data.mainRedByCulprit ?? {}));
271
+ }
272
+ }
273
+ /** The review-friction and failed-run breakdowns by role. */
274
+ function pushSinceFrictionLines(lines, data) {
205
275
  // The same breakdown for review friction: rejections per role from review_rejected events;
206
276
  // a window with none renders "-" like the other lines. The post-merge line is the
207
277
  // afterMerge: true subset — rejections that reached an already-merged commit.
@@ -215,16 +285,19 @@ export function renderSinceReportMarkdown(data) {
215
285
  // none renders "-" like the other lines.
216
286
  pushRankedRoleLine(lines, "Failed runs by role", rankedRoleMap(data.failedRunsByRole));
217
287
  pushRankedRoleLine(lines, "Fallbacks by role", rankedRoleMap(data.fallbacksByRole));
218
- pushRankedRoleLine(lines, "Image reroutes by role", rankedRoleMap(data.imageReroutesByRole));
288
+ }
289
+ /** Deferred maintenance runs by reason code, gated to the team report. */
290
+ function pushSinceDeferralLines(lines, data) {
219
291
  // Deferred maintenance runs per reason code, the --since twin of the day render's line:
220
292
  // how many due maintenance runs stood down and why (backlog / no-work / plans-full).
221
293
  // Gated to the team report — a reason is not a role, so a role-scoped window omits it.
222
294
  // Zero-means-absent: a window with no deferral prints no line.
223
295
  if (data.role === undefined) {
224
- const deferred = rankedRoleMap(data.deferredByReason ?? {}).filter(([, n]) => n > 0);
225
- if (deferred.length > 0)
226
- lines.push(rankedRoleLine("Deferred runs by reason", deferred, String));
296
+ pushPresentRankedLine(lines, "Deferred runs by reason", rankedRoleMap(data.deferredByReason ?? {}));
227
297
  }
298
+ }
299
+ /** Commits by role and the merged/merge-failure split, gated to the team report. */
300
+ function pushSinceCommitLines(lines, data) {
228
301
  // The merged-commits split by base role, matching the day render's unfiltered line: the
229
302
  // window's totals.commits carries the count; this line says which role's commits it was.
230
303
  lines.push(rankedRoleLine("Commits by role", rankedRoleMap(data.commitsByRole), String));
@@ -235,22 +308,32 @@ export function renderSinceReportMarkdown(data) {
235
308
  // some role has one; a merge-free window renders `-` like the other lines.
236
309
  if (data.role === undefined) {
237
310
  lines.push(rankedRoleLine("Merged by role", rankedRoleMap(data.mergedByRole), String));
238
- const mergeFailures = rankedRoleMap(data.mergeFailedByRole).filter(([, n]) => n > 0);
239
- if (mergeFailures.length > 0)
240
- lines.push(rankedRoleLine("Merge failures by role", mergeFailures, String));
311
+ pushPresentRankedLine(lines, "Merge failures by role", rankedRoleMap(data.mergeFailedByRole));
241
312
  // WHY the window's merges failed, from each merge_failed's result code — the day
242
313
  // report's reason twin of the role line above, so an operator can tell a review
243
314
  // rejection (fix the change) from a conflict/block (rebase) from a shutdown abort
244
315
  // (capacity). Ranked the same way (count desc then code asc), reason-free only.
245
- const mergeFailureReasons = rankedRoleMap(data.mergeFailedByReason ?? {}).filter(([, n]) => n > 0);
246
- if (mergeFailureReasons.length > 0)
247
- lines.push(rankedRoleLine("Merge failures by reason", mergeFailureReasons, String));
316
+ pushPresentRankedLine(lines, "Merge failures by reason", rankedRoleMap(data.mergeFailedByReason ?? {}));
248
317
  }
318
+ }
319
+ /** Cost/estimated per commit and the notional Estimated by role lines. */
320
+ function pushSinceCostLines(lines, data) {
321
+ // Spend per merged commit, the --since twin of the day render's `Cost per commit:` line:
322
+ // costByRole is keyed by role id (instances not folded, so `feature-2` joins `feature`),
323
+ // commitsByRole is already base-role keyed. Prints for a spend-free window as `-` and for a
324
+ // role-scoped window from its scoped maps, matching the day render.
325
+ lines.push(costPerCommitLine("Cost per commit", costPerCommitPairsFrom(foldBaseRoles(data.costByRole), data.commitsByRole)));
249
326
  // Subscription spend is notional: its own line, only when the window has any — the same
250
327
  // conditional/filtered `usd` form the day render's "Estimated by role" uses.
251
328
  if (data.notionalByRole !== undefined) {
252
329
  pushRankedRoleLine(lines, "Estimated by role", rankedRoleMap(data.notionalByRole), usd);
330
+ // The day render's `Estimated per commit:` twin, after `Estimated by role`, from the same
331
+ // notionalByRole source folded to base roles and the base-keyed commitsByRole.
332
+ lines.push(costPerCommitLine("Estimated per commit", costPerCommitPairsFrom(foldBaseRoles(data.notionalByRole), data.commitsByRole)));
253
333
  }
334
+ }
335
+ /** The trailing blank and the exactly-one coverage/backlog-omission note. */
336
+ function pushSinceCoverageLines(lines, data) {
254
337
  lines.push("");
255
338
  // Exactly one final line, chosen by the collector's coverage proof: either a hedged note
256
339
  // that stays true whenever it prints (the oldest retained event lies inside the window —
@@ -263,24 +346,22 @@ export function renderSinceReportMarkdown(data) {
263
346
  lines.push(SPARSE_WINDOW_NOTE);
264
347
  else if (data.role === undefined)
265
348
  lines.push("backlog tallies (features done / bugs fixed / plans added / bugs filed) need the day report (--days)");
266
- return lines.join("\n");
267
349
  }
268
- /** Cost per merged commit by base role. Spend comes from costByRole (keyed by role id, folded to
269
- * its base role so `feature-2` joins `feature`), commits from commitsByRole (already base-role
270
- * keyed). `merged` is [role, cost per commit, commits] for roles with both, ranked by cost per
350
+ /** Fold every key of a per-role-id map through its base role, so `feature-2` joins `feature`.
351
+ * The same fold the day caller does per day, applied to an already-aggregated window map. */
352
+ function foldBaseRoles(map) {
353
+ const folded = {};
354
+ for (const [id, c] of Object.entries(map))
355
+ addTo(folded, baseRoleOf(id), c);
356
+ return folded;
357
+ }
358
+ /** Rank one cost map against a base-keyed commits map into cost-per-commit output: the shared
359
+ * core of the day and --since cost-per-commit lines. `cost` is expected base-role keyed (the
360
+ * day caller folds per day, the --since caller via `foldBaseRoles`); commits are base-role
361
+ * keyed. `merged` is [role, cost per commit, commits] for roles with both, ranked by cost per
271
362
  * commit desc then name asc; `idle` is roles that spent and merged nothing, ranked by spend.
272
- * Merge spend (the reviewer's) and notional subscription spend stay out by default, matching
273
- * `**Cost by role:**` (`pick` selects another per-role-id spend map, e.g. notionalByRole); a
274
- * role with commits but no spend in the picked map appears in neither list. */
275
- function costPerCommitPairs(series, pick = (d) => d.costByRole) {
276
- const cost = {};
277
- const commits = {};
278
- for (const d of series) {
279
- for (const [id, c] of Object.entries(pick(d)))
280
- addTo(cost, baseRoleOf(id), c);
281
- for (const [role, n] of Object.entries(d.commitsByRole ?? {}))
282
- addTo(commits, role, n);
283
- }
363
+ * A role with commits but no spend appears in neither list. */
364
+ function costPerCommitPairsFrom(cost, commits) {
284
365
  const mergeDone = [];
285
366
  const idleCost = [];
286
367
  for (const [role, c] of Object.entries(cost)) {
@@ -295,6 +376,22 @@ function costPerCommitPairs(series, pick = (d) => d.costByRole) {
295
376
  sortByValueDesc(mergeDone);
296
377
  return { mergeDone, idle: rankCountEntries(idleCost) };
297
378
  }
379
+ /** The day caller of the cost-per-commit core: fold each day's role-id-keyed spend map through
380
+ * its base role (so `feature-2` joins `feature`) and aggregate the base-keyed commitsByRole,
381
+ * then hand both to `costPerCommitPairsFrom`. `pick` selects the per-role-id spend map; merge
382
+ * spend (the reviewer's) and notional subscription spend stay out by default, matching
383
+ * `**Cost by role:**`. */
384
+ function costPerCommitPairs(series, pick = (d) => d.costByRole) {
385
+ const cost = {};
386
+ const commits = {};
387
+ for (const d of series) {
388
+ for (const [id, c] of Object.entries(pick(d)))
389
+ addTo(cost, baseRoleOf(id), c);
390
+ for (const [role, n] of Object.entries(d.commitsByRole ?? {}))
391
+ addTo(commits, role, n);
392
+ }
393
+ return costPerCommitPairsFrom(cost, commits);
394
+ }
298
395
  /** One `**<label>:** …` line from costPerCommitPairs output; `-` when there is nothing. */
299
396
  function costPerCommitLine(label, pairs) {
300
397
  const parts = [
@@ -303,11 +400,8 @@ function costPerCommitLine(label, pairs) {
303
400
  ];
304
401
  return `**${label}:** ${parts.length === 0 ? "-" : parts.join(" · ")}`;
305
402
  }
306
- /** Render a report as Markdown — the pinned shape the CLI prints and the TUI usage pane
307
- * (report 3/3) reuses; the GUI tab (report 2/3) renders its own SVG charts from the same
308
- * ReportData instead. Pure function of ReportData: no I/O, no clock reads. */
309
- export function renderReportMarkdown(data) {
310
- const lines = [];
403
+ /** The title, window line, optional role line, then the report summary line. */
404
+ function pushReportHeader(lines, data) {
311
405
  lines.push("# tumwater usage report");
312
406
  lines.push("");
313
407
  lines.push(reportWindow(data.from, data.to, data.days, eventsRotationLabel()));
@@ -325,6 +419,9 @@ export function renderReportMarkdown(data) {
325
419
  : "";
326
420
  pushReportSummary(lines, t, tail, data.role !== undefined);
327
421
  lines.push("");
422
+ }
423
+ /** The day-by-day window table (day, tokens out, runs, commits, cost) and its blank line. */
424
+ function pushReportSeriesTable(lines, data) {
328
425
  const maxTokens = data.series.reduce((m, d) => Math.max(m, d.tokensOut), 0);
329
426
  lines.push(...markdownTable(["day", "tokens out", "runs", "commits", "cost"], data.series.map((d) => {
330
427
  const runs = Object.values(d.runsByRole).reduce((a, b) => a + b, 0);
@@ -333,16 +430,26 @@ export function renderReportMarkdown(data) {
333
430
  return [monthDay(d.date), `${compactTokens(d.tokensOut)}${bar}`, String(runs), String(d.commits), usd(d.costUsd)];
334
431
  }), ["left", "right", "right", "right", "right"]));
335
432
  lines.push("");
433
+ }
434
+ /** The per-role runs/tokens/prompt/cost breakdown lines. */
435
+ function pushReportRoleLines(lines, data) {
336
436
  lines.push(rankedRoleLine("Runs by role", rankedRoleTotals(data.series, (d) => d.runsByRole), String));
337
437
  // The same breakdown for output tokens (the resource the budget charges): window totals per
338
438
  // role from tokensByRole, ranked like the runs line. Zero-token roles are omitted; an
339
439
  // all-zero window renders "-" like the runs line.
340
440
  pushRankedRoleLine(lines, "Tokens by role", rankedRoleTotals(data.series, (d) => d.tokensByRole ?? {}), compactTokens);
441
+ // The input side of that same bill, straight after the output-token line: each role's prompt
442
+ // totals and, when some run reported a cache read, the share of them served from cache — so
443
+ // the same report that says who burned output tokens also says whose prompts are cached.
444
+ lines.push(promptTokensLine(rankedRoleTotals(data.series, (d) => d.promptTokensByRole ?? {}), rankedRoleTotals(data.series, (d) => d.cacheReadTokensByRole ?? {})));
341
445
  // The same breakdown for spend: window totals per role from costByRole, ranked by spend
342
446
  // desc then name asc — spend is what the operator acts on (an operator tuning per-role
343
447
  // intervals wants the burning role first, not the busiest one). Zero-spend roles are
344
448
  // omitted; an all-zero window renders "-" like the runs line.
345
449
  pushRankedRoleLine(lines, "Cost by role", rankedRoleTotals(data.series, (d) => d.costByRole), usd);
450
+ }
451
+ /** The team-only per-provider and per-model breakdown lines. */
452
+ function pushReportProviderLines(lines, data) {
346
453
  // The same breakdown per PROVIDER (plans: per-provider spend): which seam of a mixed team
347
454
  // spent what, straight from the events' providerCost maps (run_end's own runs and the
348
455
  // merge's reviewer seam). Gated to the team report — a provider is not a role, so a
@@ -357,10 +464,11 @@ export function renderReportMarkdown(data) {
357
464
  // omits it (ROLE_SCOPE_NOTE covers the omission), and a window with no modelled start
358
465
  // prints no line (keeping a model-less day's output unchanged), exactly as the provider
359
466
  // lines stay absent when no provider spent. Ranked and formatted like Cost by role.
360
- const modelPairs = rankedRoleTotals(data.series, (d) => d.runsByModel ?? {}).filter(([, n]) => n > 0);
361
- if (modelPairs.length > 0)
362
- lines.push(rankedRoleLine("Runs by model", modelPairs, String));
467
+ pushPresentRankedLine(lines, "Runs by model", rankedRoleTotals(data.series, (d) => d.runsByModel ?? {}));
363
468
  }
469
+ }
470
+ /** The per-role review-friction and outright-failure lines. */
471
+ function pushReportFrictionLines(lines, data) {
364
472
  // The same breakdown for review friction: window rejections per role from review_rejected
365
473
  // events, ranked like the runs line; a window with none renders "-". The post-merge line
366
474
  // is the afterMerge: true subset — rejections that reached an already-merged commit.
@@ -374,17 +482,20 @@ export function renderReportMarkdown(data) {
374
482
  // failure-free window renders no line and an all-zero window renders "-" like the others.
375
483
  pushRankedRoleLine(lines, "Failed runs by role", rankedRoleTotals(data.series, (d) => d.failedRunsByRole ?? {}));
376
484
  pushRankedRoleLine(lines, "Fallbacks by role", rankedRoleTotals(data.series, (d) => d.fallbacksByRole ?? {}));
377
- pushRankedRoleLine(lines, "Image reroutes by role", rankedRoleTotals(data.series, (d) => d.imageReroutesByRole ?? {}));
485
+ }
486
+ /** The team-only deferred-runs-by-reason line. */
487
+ function pushReportDeferralLines(lines, data) {
378
488
  // Deferred maintenance runs per reason code — how many due maintenance runs stood down
379
489
  // and why, so an idle day reads as backlog-open / no-work / plans-full rather than blank,
380
490
  // the per-role breakdown of the deferred maintenances. Gated to the team report — a
381
491
  // reason is not a role (ROLE_SCOPE_NOTE covers the omission). Zero-means-absent: a window
382
492
  // with no deferral prints no line, so a deferral-free day's output is unchanged.
383
493
  if (data.role === undefined) {
384
- const deferred = rankedRoleTotals(data.series, (d) => d.deferredByReason ?? {}).filter(([, n]) => n > 0);
385
- if (deferred.length > 0)
386
- lines.push(rankedRoleLine("Deferred runs by reason", deferred, String));
494
+ pushPresentRankedLine(lines, "Deferred runs by reason", rankedRoleTotals(data.series, (d) => d.deferredByReason ?? {}));
387
495
  }
496
+ }
497
+ /** The Commits-by-role line plus the team-only merge/main-red/cameo block. */
498
+ function pushReportMergeLines(lines, data) {
388
499
  // Work ratio 4/4: the merged commits split by base role and by tier. The totals line's
389
500
  // `commits` carries the count; these lines say who made it and how much of it was work.
390
501
  lines.push(rankedRoleLine("Commits by role", rankedRoleTotals(data.series, (d) => d.commitsByRole ?? {}), String));
@@ -398,14 +509,10 @@ export function renderReportMarkdown(data) {
398
509
  if (data.role === undefined) {
399
510
  const mergeDone = rankedRoleTotals(data.series, (d) => d.mergedByRole ?? {});
400
511
  lines.push(rankedRoleLine("Merged by role", mergeDone, String));
401
- const mergeFailures = rankedRoleTotals(data.series, (d) => d.mergeFailedByRole ?? {}).filter(([, n]) => n > 0);
402
- if (mergeFailures.length > 0)
403
- lines.push(rankedRoleLine("Merge failures by role", mergeFailures, String));
512
+ pushPresentRankedLine(lines, "Merge failures by role", rankedRoleTotals(data.series, (d) => d.mergeFailedByRole ?? {}));
404
513
  // The --since reason twin, folded across the series the same way; a reason-free window
405
514
  // prints no line, so its output is byte-for-byte unchanged.
406
- const mergeFailureReasons = rankedRoleTotals(data.series, (d) => d.mergeFailedByReason ?? {}).filter(([, n]) => n > 0);
407
- if (mergeFailureReasons.length > 0)
408
- lines.push(rankedRoleLine("Merge failures by reason", mergeFailureReasons, String));
515
+ pushPresentRankedLine(lines, "Merge failures by reason", rankedRoleTotals(data.series, (d) => d.mergeFailedByReason ?? {}));
409
516
  // Per-day median passing check time, so a suite slowing over days shows; days without a
410
517
  // check are left out, and no line prints when no day has one (zero means absent).
411
518
  pushDayCameoLine(lines, data.series, "Merge check median", (d) => d.checkMedianMs);
@@ -413,9 +520,17 @@ export function renderReportMarkdown(data) {
413
520
  // suite went red before passing shows here. Zero-absent: no line when no day fails.
414
521
  pushDayCameoLine(lines, data.series, "Red merge checks", (d) => d.checkFailures, (n) => `×${n}`);
415
522
  // Per-day main red wall-clock, beside the red-check counts above: how long each day main
416
- // had no passing full check (the span every code role was held). Team-only (main's own
523
+ // had its own check red (the span main had no passing full check). Team-only (main's own
417
524
  // red time, not any role's) and zero-absent: no line when no day was red.
418
525
  pushDayCameoLine(lines, data.series, "Main red", (d) => d.mainRedMs, durationLabel);
526
+ // The day twin of the --since window's totals line: the same tests ranked across the
527
+ // series' per-day main-red maps (Main red by test). Zero-absent: no line when no day
528
+ // named a failing test.
529
+ pushPresentRankedLine(lines, "Main red by test", rankedRoleTotals(data.series, (d) => d.mainRedByTest ?? {}));
530
+ // The day twin of the --since window's merges line: the same culprits ranked across the
531
+ // series' per-day main-red maps (Main red by merge). Zero-absent: no line when no day
532
+ // blamed a merge.
533
+ pushPresentRankedLine(lines, "Main red by merge", rankedRoleTotals(data.series, (d) => d.mainRedByCulprit ?? {}));
419
534
  pushDayCameoLine(lines, data.series, "Merge wait median", (d) => d.mergeWaitMedianMs);
420
535
  // How long the merge slot itself held each change it landed, beside the merge-wait median:
421
536
  // the queue backlog (merge wait minus slot time) apart from the slot's own review/check/
@@ -429,6 +544,10 @@ export function renderReportMarkdown(data) {
429
544
  pushDayCameoLine(lines, data.series, "Workspace wait median", (d) => d.slotWaitMedianMs);
430
545
  pushDayCameoLine(lines, data.series, "Check wait median", (d) => d.checkWaitMedianMs);
431
546
  }
547
+ }
548
+ /** Cost/commits tail: cost per commit, notional block, lag, validation gaps, sparse note. */
549
+ function pushReportTail(lines, data) {
550
+ const t = data.totals;
432
551
  lines.push(costPerCommitLine("Cost per commit", costPerCommitPairs(data.series)));
433
552
  // Subscription spend is notional: its own two lines, only when the window has any.
434
553
  if ((t.notionalCostUsd ?? 0) > 0) {
@@ -466,5 +585,19 @@ export function renderReportMarkdown(data) {
466
585
  // it prints — an empty or fully covered log never claims a history it cannot see.
467
586
  if (!data.coversFullWindow)
468
587
  lines.push("", SPARSE_WINDOW_NOTE);
588
+ }
589
+ /** Render a report as Markdown — the pinned shape the CLI prints and the TUI usage pane
590
+ * (report 3/3) reuses; the GUI tab (report 2/3) renders its own SVG charts from the same
591
+ * ReportData instead. Pure function of ReportData: no I/O, no clock reads. */
592
+ export function renderReportMarkdown(data) {
593
+ const lines = [];
594
+ pushReportHeader(lines, data);
595
+ pushReportSeriesTable(lines, data);
596
+ pushReportRoleLines(lines, data);
597
+ pushReportProviderLines(lines, data);
598
+ pushReportFrictionLines(lines, data);
599
+ pushReportDeferralLines(lines, data);
600
+ pushReportMergeLines(lines, data);
601
+ pushReportTail(lines, data);
469
602
  return lines.join("\n");
470
603
  }
@@ -0,0 +1,218 @@
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-render.ts. */
11
+ import { assertReportSinceMs, eventWindowCovers, readWindowEvents, sinceWindowTiming } from "../events/event-window.js";
12
+ import { dayKey } from "../text/datetime.js";
13
+ import { addTo } from "../collections.js";
14
+ import { nonemptyObj, roleSum, scopeBases, scopedByRole, zeroCommitCounts, zeroMergeCounts, } from "./report-fold.js";
15
+ import { foldUsageEvent } from "./report-usage-fold.js";
16
+ import { mainRedCulpritsByDay, mainRedFailingTestsByDay, mainRedMinutesByDay } from "./report-main-red.js";
17
+ /** Aggregate team usage over the trailing `sinceMs` window ending now (or `untilMs` ago, which
18
+ * drops events at or after that end; `coversFullWindow` still judges the far edge
19
+ * only), from the event log only (run_end/merged — see SinceReport for why the backlog files
20
+ * are not read). Kept a
21
+ * separate function rather than a `collectReport` variant flag: the day collector's
22
+ * zero-filled series and backlog-file reads have no place in a window totals view. */
23
+ export function collectReportSince(root, sinceMs, role, untilMs) {
24
+ assertReportSinceMs(sinceMs);
25
+ const { now, cutoff, end, slice } = sinceWindowTiming(sinceMs, untilMs);
26
+ const { raw, acc } = foldSinceWindow(root, cutoff, now, end, untilMs);
27
+ // The empty-window branch of the shared proof matters here: with no retained events nothing
28
+ // can have rotated away, and printing a rotation note over a fresh install would claim a
29
+ // history that never existed. When neither proof holds, the oldest retained event lies inside
30
+ // the window and the render notes the possible truncation.
31
+ const coversFullWindow = eventWindowCovers(raw, cutoff);
32
+ // Role scope: every map and total below derives from the fold's per-role maps for the named
33
+ // base roles alone — merge usage (the merge slot's, not the role's) and the team-only
34
+ // fields stay out, so a scoped --since report sums exactly the same events the scoped day
35
+ // report would. The scope is the base roles, so instances (`feature-2`) aggregate into their
36
+ // group as the cap does.
37
+ const bases = scopeBases(role);
38
+ return {
39
+ ...slice,
40
+ ...(bases === undefined ? {} : { role: bases.join(",") }),
41
+ totals: sinceScopedTotals(acc, bases),
42
+ ...sinceRoleMaps(acc, bases),
43
+ // Main's red time is main's own (G1's rule), not any role's, so a role-scoped window
44
+ // carries 0 — the type's always-present default for the team-only field.
45
+ mainRedMs: bases === undefined
46
+ ? [...mainRedMinutesByDay(raw.events, cutoff, end).values()].reduce((a, b) => a + b, 0)
47
+ : 0,
48
+ ...nonemptyObj("mainRedByTest", sinceMainRedByTest(raw, cutoff, end, bases)),
49
+ ...nonemptyObj("mainRedByCulprit", sinceMainRedByCulprit(raw, cutoff, end, bases)),
50
+ coversFullWindow,
51
+ };
52
+ }
53
+ /** The window's event read and fold, one function so the read happens exactly once per window
54
+ * and every downstream builder — the coverage proof and the main-red by-test/by-culprit maps
55
+ * — consumes the same `raw`. `raw` is the one day-keyed log read the whole report is built on
56
+ * (its `events` feed both the `acc` fold below and the red maps), so its retention-backed
57
+ * coverage and its event set cannot disagree across the builders. */
58
+ function foldSinceWindow(root, cutoff, now, end, untilMs) {
59
+ // The window key is the cutoff's local calendar day, from the shared dayKey helper
60
+ // eventDayKey buckets events with, so the read's day keys cannot disagree with the ts
61
+ // filter below; the day-keyed read may include earlier hours of that day, which the ts
62
+ // filter removes (over-read is at most one day's events). Future-dated events are dropped
63
+ // here too, matching collectReport (its day map only holds the window's days).
64
+ const fromKey = dayKey(cutoff);
65
+ const raw = readWindowEvents(root, fromKey);
66
+ const acc = {
67
+ runs: 0,
68
+ runsByRole: {},
69
+ rejectionsByRole: {},
70
+ postMergeRejectionsByRole: {},
71
+ revisionsByRole: {},
72
+ refusalsByRole: {},
73
+ checkDurationsMs: [],
74
+ checkFailures: 0,
75
+ mergeWaitsMs: [],
76
+ mergeDurationsMs: [],
77
+ runDurationsMs: [],
78
+ providerWaitsMs: [],
79
+ slotWaitsMs: [],
80
+ checkWaitsMs: [],
81
+ reviewDurationsMs: [],
82
+ failedRunsByRole: {},
83
+ fallbacksByRole: {},
84
+ tokensByRole: {},
85
+ promptTokensByRole: {},
86
+ cacheReadTokensByRole: {},
87
+ costByRole: {},
88
+ tokensOut: 0,
89
+ ...zeroCommitCounts(),
90
+ costUsd: 0,
91
+ notionalCostUsd: 0,
92
+ notionalByRole: {},
93
+ ...zeroMergeCounts(),
94
+ mergedByRole: {},
95
+ mergeFailedByRole: {},
96
+ mergeFailedByReason: {},
97
+ costByProvider: {},
98
+ notionalByProvider: {},
99
+ notionalByBackend: {},
100
+ runsByModel: {},
101
+ deferredByReason: {},
102
+ };
103
+ for (const ev of raw.events) {
104
+ if (typeof ev.ts !== "number" || ev.ts < cutoff || ev.ts > now || (untilMs !== undefined && ev.ts >= end))
105
+ continue;
106
+ foldUsageEvent(acc, ev);
107
+ }
108
+ return { raw, acc };
109
+ }
110
+ /** The window's `totals` group under both scope rules, named so the team-vs-role branch reads
111
+ * as one function instead of a ternary buried in the return: the team figure sums the merge-slot
112
+ * fields in (the merge slot's dash tokens/cost are the same team spend the budget charges), while
113
+ * the role branch sums just the base roles through `roleSum` and zeroes the merge counts — a
114
+ * role's merge usage is the merge slot's, not the role's. */
115
+ function sinceScopedTotals(acc, bases) {
116
+ return bases === undefined
117
+ ? {
118
+ tokensOut: acc.tokensOut + (acc.mergeTokens ?? 0),
119
+ runs: acc.runs ?? 0,
120
+ commits: acc.commits,
121
+ costUsd: acc.costUsd + (acc.mergeCostUsd ?? 0),
122
+ ...(acc.notionalCostUsd > 0 ? { notionalCostUsd: acc.notionalCostUsd } : {}),
123
+ ...nonemptyObj("notionalByBackend", acc.notionalByBackend),
124
+ mergeRuns: acc.mergeRuns ?? 0,
125
+ mergeTokens: acc.mergeTokens ?? 0,
126
+ mergeCostUsd: acc.mergeCostUsd ?? 0,
127
+ ...((acc.mergeFailed ?? 0) > 0 ? { mergeFailed: acc.mergeFailed } : {}),
128
+ }
129
+ : (() => {
130
+ // The role's own notional spend, from its run_end events; the merge slot's
131
+ // notional is team-wide and stays out like its cost.
132
+ const notional = roleSum(acc.notionalByRole, bases);
133
+ return {
134
+ tokensOut: roleSum(acc.tokensByRole, bases),
135
+ runs: roleSum(acc.runsByRole, bases),
136
+ commits: roleSum(acc.commitsByRole, bases),
137
+ costUsd: roleSum(acc.costByRole, bases),
138
+ ...(notional > 0 ? { notionalCostUsd: notional } : {}),
139
+ ...zeroMergeCounts(),
140
+ };
141
+ })();
142
+ }
143
+ /** Flatten a per-day main-red record map (day → label → count) into one flat label→count map,
144
+ * the shared body of sinceMainRedByTest and sinceMainRedByCulprit. Main's red state is main's
145
+ * own, not any role's, so a role-scoped window carries none — the empty map `nonemptyObj` then
146
+ * omits from the JSON, keeping a red-free --since byte-identical to today. */
147
+ function flattenMainRedByDay(perDay, bases) {
148
+ if (bases !== undefined)
149
+ return {};
150
+ const flat = {};
151
+ for (const record of perDay.values()) {
152
+ for (const [label, n] of Object.entries(record))
153
+ addTo(flat, label, n);
154
+ }
155
+ return flat;
156
+ }
157
+ /** Main's red failing tests over the window, flattened across the per-day records of the same
158
+ * red `main_check` events the red time uses (G1's rule) into one map. Main's failing tests are
159
+ * main's own, not any role's, so a role-scoped window carries none (see flattenMainRedByDay). */
160
+ function sinceMainRedByTest(raw, cutoff, end, bases) {
161
+ return flattenMainRedByDay(mainRedFailingTestsByDay(raw.events, cutoff, end), bases);
162
+ }
163
+ /** Main's red culprits over the window, flattened across the per-day records of the same red
164
+ * `main_culprit` events the live `mainred` alert's `red.culprit` reads (the historical twin)
165
+ * into one map. Main's own blame, not any role's, so a role-scoped window carries none (see
166
+ * flattenMainRedByDay). */
167
+ function sinceMainRedByCulprit(raw, cutoff, end, bases) {
168
+ return flattenMainRedByDay(mainRedCulpritsByDay(raw.events, cutoff, end), bases);
169
+ }
170
+ /** The `SinceReport` return's role-scoped map group — every field a reader names when asking
171
+ * "which fields are team-only?": each `scopedByRole` call applies the base-role scope (the
172
+ * rule `collectReportSince`'s `bases` comment spells), so a role-scoped window shows exactly
173
+ * the maps the scoped day report would, and the team-only fields (providers, backends, models,
174
+ * deferred reasons) carry full maps for the team or empty maps for a scoped role. */
175
+ function sinceRoleMaps(acc, bases) {
176
+ // The --since render/JSON's Notional by role line is conditional on the window's own
177
+ // notional spend, scoped to base when a role is given — the same conditional the day
178
+ // collector applies to ReportDay.notionalByRole (line ~573), so a role-scoped window
179
+ // whose role spent no notional carries no notionalByRole instead of an empty map.
180
+ const notionalByRole = scopedByRole(acc.notionalByRole, bases);
181
+ // Deferred runs are bucketed by a reason code (not a role), so a role-scoped window
182
+ // carries no entries — the same team-only rule runsByModel follows. Only set when the
183
+ // window actually deferred (zero means absent), so a deferral-free --since JSON omits it.
184
+ const deferred = scopedByRole(acc.deferredByReason, bases);
185
+ return {
186
+ runsByRole: scopedByRole(acc.runsByRole, bases),
187
+ costByRole: scopedByRole(acc.costByRole, bases),
188
+ rejectionsByRole: scopedByRole(acc.rejectionsByRole, bases),
189
+ postMergeRejectionsByRole: scopedByRole(acc.postMergeRejectionsByRole, bases),
190
+ revisionsByRole: scopedByRole(acc.revisionsByRole, bases),
191
+ refusalsByRole: scopedByRole(acc.refusalsByRole, bases),
192
+ failedRunsByRole: scopedByRole(acc.failedRunsByRole, bases),
193
+ fallbacksByRole: scopedByRole(acc.fallbacksByRole, bases),
194
+ tokensByRole: scopedByRole(acc.tokensByRole, bases),
195
+ ...nonemptyObj("promptTokensByRole", scopedByRole(acc.promptTokensByRole, bases)),
196
+ ...nonemptyObj("cacheReadTokensByRole", scopedByRole(acc.cacheReadTokensByRole, bases)),
197
+ commitsByRole: scopedByRole(acc.commitsByRole, bases),
198
+ mergedByRole: scopedByRole(acc.mergedByRole, bases),
199
+ mergeFailedByRole: scopedByRole(acc.mergeFailedByRole, bases),
200
+ // A result code is not a role, so a role-scoped window carries an empty map (a code
201
+ // never baseRoleOf to the role) — the render gates the reason line to the team report.
202
+ mergeFailedByReason: scopedByRole(acc.mergeFailedByReason, bases),
203
+ ...nonemptyObj("notionalByRole", notionalByRole),
204
+ // Provider spend is a team attribution (a provider is not a role), so a role-scoped
205
+ // window carries empty maps — providers never baseRoleOf to the role — and the render
206
+ // gates the provider lines to the team report anyway. Always present, empty when none.
207
+ costByProvider: scopedByRole(acc.costByProvider, bases),
208
+ notionalByProvider: scopedByRole(acc.notionalByProvider, bases),
209
+ // Backend spend is a team attribution (a backend is not a role), so a role-scoped window
210
+ // omits it — the same absent-when-empty rule the day collector applies.
211
+ ...nonemptyObj("notionalByBackend", scopedByRole(acc.notionalByBackend, bases)),
212
+ // Model attribution is team-only (a model is not a role, so no model folds to the
213
+ // scoped role), the same full-map-for-team / empty-for-scoped rule costByProvider
214
+ // follows. Always present so the render can gate on the window's own events.
215
+ runsByModel: scopedByRole(acc.runsByModel, bases),
216
+ ...nonemptyObj("deferredByReason", deferred),
217
+ };
218
+ }