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
@@ -1,168 +1,24 @@
1
- /** Collection half of the usage report: read the harness's event log (through the shared
2
- * event-folding layer in src/report/report-fold.ts) and the backlog history over a window of
3
- * local calendar days and distill it into a `ReportData` / `SinceReport` — per-day
1
+ /** Collection half of the usage report's per-day series: read the harness's event log
2
+ * (through the shared event-folding core in src/report/report-usage-fold.ts) and the backlog
3
+ * history over a window of local calendar days and distill it into a `ReportData` — per-day
4
4
  * run/token/commit/cost counts plus features-done and bugs-fixed tallies from PLANS.md and
5
5
  * BUGS.md. The Markdown rendering of this data lives in src/report/report-render.ts, a pure
6
6
  * function of it; the split mirrors the failure report's (src/failure/failure-data.ts /
7
7
  * src/failure/failure-render.ts) and keeps "what happened" (window math, aggregation, bounds)
8
8
  * apart from "how it prints" (bars, cell wording), which change for different reasons — and
9
9
  * keeps core data collection out of the presentation layer, so a core consumer (as
10
- * /api/report already is) never forces a core→ui import. This file holds the two window
11
- * collectors; the report shapes they produce live in src/report/report-types.ts, and the
12
- * event-log folding and its memoized per-window cache in src/report/report-fold.ts (its own
13
- * test, report-fold-cache). */
14
- import { assertReportSinceMs, eventWindowCovers, readWindowEvents, sinceWindowTiming } from "../events/event-window.js";
10
+ * /api/report already is) never forces a core→ui import. The trailing-window `SinceReport`
11
+ * collector lives in src/report/report-since-data.ts beside it; both fold events through the
12
+ * shared one-event core in src/report/report-usage-fold.ts and its memoized per-window cache
13
+ * in src/report/report-fold.ts, and the report shapes they produce live in
14
+ * src/report/report-types.ts (report-fold has its own test, report-fold-cache). */
15
15
  import { DONE_DATE_RE, FIXED_DATE_RE, completionDate, finishedFiles, fixedBugEntries, sectionCompletionDates, sectionCompletionLags, sectionFilingDates, } from "../backlog/backlog.js";
16
16
  import { validationGapTag } from "../backlog/backlog-md.js";
17
- import { dayAt, dayKey, dayWindow, formatDate } from "../text/datetime.js";
17
+ import { dayAt, dayWindow, formatDate } from "../text/datetime.js";
18
18
  import { addTo, lowerMedian } from "../collections.js";
19
19
  import { commitTier } from "../roles/roles.js";
20
- import { foldUsageEvent, foldWindowEvents, narrowToRole, roleSum, scopeBases, scopedByRole, zeroCommitCounts, zeroMergeCounts, } from "./report-fold.js";
21
- import { mainRedMinutesByDay } from "./report-main-red.js";
22
- /** Aggregate team usage over the trailing `sinceMs` window ending now (or `untilMs` ago, which
23
- * drops events at or after that end; `coversFullWindow` still judges the far edge
24
- * only), from the event log only (run_end/merged — see SinceReport for why the backlog files
25
- * are not read). Kept a
26
- * separate function rather than a `collectReport` variant flag: the day collector's
27
- * zero-filled series and backlog-file reads have no place in a window totals view. */
28
- export function collectReportSince(root, sinceMs, role, untilMs) {
29
- assertReportSinceMs(sinceMs);
30
- const { now, cutoff, end, slice } = sinceWindowTiming(sinceMs, untilMs);
31
- // The window key is the cutoff's local calendar day, from the shared dayKey helper
32
- // eventDayKey buckets events with, so the read's day keys cannot disagree with the ts
33
- // filter below; the day-keyed read may include earlier hours of that day, which the ts
34
- // filter removes (over-read is at most one day's events). Future-dated events are dropped
35
- // here too, matching collectReport (its day map only holds the window's days).
36
- const fromKey = dayKey(cutoff);
37
- const raw = readWindowEvents(root, fromKey);
38
- const acc = {
39
- runs: 0,
40
- runsByRole: {},
41
- rejectionsByRole: {},
42
- postMergeRejectionsByRole: {},
43
- revisionsByRole: {},
44
- refusalsByRole: {},
45
- checkDurationsMs: [],
46
- checkFailures: 0,
47
- mergeWaitsMs: [],
48
- mergeDurationsMs: [],
49
- runDurationsMs: [],
50
- providerWaitsMs: [],
51
- slotWaitsMs: [],
52
- checkWaitsMs: [],
53
- reviewDurationsMs: [],
54
- failedRunsByRole: {},
55
- fallbacksByRole: {},
56
- imageReroutesByRole: {},
57
- tokensByRole: {},
58
- costByRole: {},
59
- tokensOut: 0,
60
- ...zeroCommitCounts(),
61
- costUsd: 0,
62
- notionalCostUsd: 0,
63
- notionalByRole: {},
64
- ...zeroMergeCounts(),
65
- mergedByRole: {},
66
- mergeFailedByRole: {},
67
- mergeFailedByReason: {},
68
- costByProvider: {},
69
- notionalByProvider: {},
70
- notionalByBackend: {},
71
- runsByModel: {},
72
- deferredByReason: {},
73
- };
74
- for (const ev of raw.events) {
75
- if (typeof ev.ts !== "number" || ev.ts < cutoff || ev.ts > now || (untilMs !== undefined && ev.ts >= end))
76
- continue;
77
- foldUsageEvent(acc, ev);
78
- }
79
- // The empty-window branch of the shared proof matters here: with no retained events nothing
80
- // can have rotated away, and printing a rotation note over a fresh install would claim a
81
- // history that never existed. When neither proof holds, the oldest retained event lies inside
82
- // the window and the render notes the possible truncation.
83
- const coversFullWindow = eventWindowCovers(raw, cutoff);
84
- // Role scope: every total derives from the fold's per-role maps for the named base roles alone
85
- // — merge usage (the merge slot's, not the role's) and the team-only fields stay out, so
86
- // a scoped --since report sums exactly the same events the scoped day report would. The scope
87
- // is the base roles, so instances (`feature-2`) aggregate into their group as the cap does.
88
- const bases = scopeBases(role);
89
- const scopedTotals = bases === undefined
90
- ? {
91
- tokensOut: acc.tokensOut + (acc.mergeTokens ?? 0),
92
- runs: acc.runs ?? 0,
93
- commits: acc.commits,
94
- costUsd: acc.costUsd + (acc.mergeCostUsd ?? 0),
95
- ...(acc.notionalCostUsd > 0 ? { notionalCostUsd: acc.notionalCostUsd } : {}),
96
- ...nonemptyObj("notionalByBackend", acc.notionalByBackend),
97
- mergeRuns: acc.mergeRuns ?? 0,
98
- mergeTokens: acc.mergeTokens ?? 0,
99
- mergeCostUsd: acc.mergeCostUsd ?? 0,
100
- ...((acc.mergeFailed ?? 0) > 0 ? { mergeFailed: acc.mergeFailed } : {}),
101
- }
102
- : (() => {
103
- // The role's own notional spend, from its run_end events; the merge slot's
104
- // notional is team-wide and stays out like its cost.
105
- const notional = roleSum(acc.notionalByRole, bases);
106
- return {
107
- tokensOut: roleSum(acc.tokensByRole, bases),
108
- runs: roleSum(acc.runsByRole, bases),
109
- commits: roleSum(acc.commitsByRole, bases),
110
- costUsd: roleSum(acc.costByRole, bases),
111
- ...(notional > 0 ? { notionalCostUsd: notional } : {}),
112
- ...zeroMergeCounts(),
113
- };
114
- })();
115
- // The --since render/JSON's Notional by role line is conditional on the window's own
116
- // notional spend, scoped to base when a role is given — the same conditional the day
117
- // collector applies to ReportDay.notionalByRole (line ~573), so a role-scoped window
118
- // whose role spent no notional carries no notionalByRole instead of an empty map.
119
- const notionalByRole = scopedByRole(acc.notionalByRole, bases);
120
- // Deferred runs are bucketed by a reason code (not a role), so a role-scoped window
121
- // carries no entries — the same team-only rule runsByModel follows. Only set when the
122
- // window actually deferred (zero means absent), so a deferral-free --since JSON omits it.
123
- const deferred = scopedByRole(acc.deferredByReason, bases);
124
- return {
125
- ...slice,
126
- ...(bases === undefined ? {} : { role: bases.join(",") }),
127
- totals: scopedTotals,
128
- runsByRole: scopedByRole(acc.runsByRole, bases),
129
- costByRole: scopedByRole(acc.costByRole, bases),
130
- rejectionsByRole: scopedByRole(acc.rejectionsByRole, bases),
131
- postMergeRejectionsByRole: scopedByRole(acc.postMergeRejectionsByRole, bases),
132
- revisionsByRole: scopedByRole(acc.revisionsByRole, bases),
133
- refusalsByRole: scopedByRole(acc.refusalsByRole, bases),
134
- failedRunsByRole: scopedByRole(acc.failedRunsByRole, bases),
135
- fallbacksByRole: scopedByRole(acc.fallbacksByRole, bases),
136
- imageReroutesByRole: scopedByRole(acc.imageReroutesByRole, bases),
137
- tokensByRole: scopedByRole(acc.tokensByRole, bases),
138
- commitsByRole: scopedByRole(acc.commitsByRole, bases),
139
- mergedByRole: scopedByRole(acc.mergedByRole, bases),
140
- mergeFailedByRole: scopedByRole(acc.mergeFailedByRole, bases),
141
- // A result code is not a role, so a role-scoped window carries an empty map (a code
142
- // never baseRoleOf to the role) — the render gates the reason line to the team report.
143
- mergeFailedByReason: scopedByRole(acc.mergeFailedByReason, bases),
144
- ...nonemptyObj("notionalByRole", notionalByRole),
145
- // Provider spend is a team attribution (a provider is not a role), so a role-scoped
146
- // window carries empty maps — providers never baseRoleOf to the role — and the render
147
- // gates the provider lines to the team report anyway. Always present, empty when none.
148
- costByProvider: scopedByRole(acc.costByProvider, bases),
149
- notionalByProvider: scopedByRole(acc.notionalByProvider, bases),
150
- // Backend spend is a team attribution (a backend is not a role), so a role-scoped window
151
- // omits it — the same absent-when-empty rule the day collector applies.
152
- ...nonemptyObj("notionalByBackend", scopedByRole(acc.notionalByBackend, bases)),
153
- // Model attribution is team-only (a model is not a role, so no model folds to the
154
- // scoped role), the same full-map-for-team / empty-for-scoped rule costByProvider
155
- // follows. Always present so the render can gate on the window's own events.
156
- runsByModel: scopedByRole(acc.runsByModel, bases),
157
- ...nonemptyObj("deferredByReason", deferred),
158
- // Main's red time is main's own (G1's rule), not any role's, so a role-scoped window
159
- // carries 0 — the type's always-present default for the team-only field.
160
- mainRedMs: bases === undefined
161
- ? [...mainRedMinutesByDay(raw.events, cutoff, end).values()].reduce((a, b) => a + b, 0)
162
- : 0,
163
- coversFullWindow,
164
- };
165
- }
20
+ import { foldWindowEvents, narrowToRole, nonemptyObj, roleSum, scopeBases, zeroCommitCounts, zeroMergeCounts, } from "./report-fold.js";
21
+ import { mainRedCulpritsByDay, mainRedFailingTestsByDay, mainRedMinutesByDay } from "./report-main-red.js";
166
22
  /** Set one per-day median cell only when the day actually has samples (`ms` non-empty), so a
167
23
  * medians-free day keeps its exact pre-feature shape (the field stays absent). The single home
168
24
  * of that guard — the day collector's eight `…MedianMs` cells all followed it inline before. */
@@ -179,21 +35,9 @@ function setMapIfNonempty(day, key, map) {
179
35
  day[key] = map;
180
36
  }
181
37
  }
182
- /** Build one object-literal spread cell for a per-slice role/product map only when the map has
183
- * entries (`map` non-empty), so an entry-free window omits the field instead of carrying an
184
- * empty map. The single home of that guard — the window and totals object-literal spreads all
185
- * followed it inline before. */
186
- function nonemptyObj(key, map) {
187
- return Object.keys(map).length > 0 ? { [key]: map } : {};
188
- }
189
- /** Aggregate team usage over exactly `days` local calendar days ending today, from the event
190
- * log (run_end/merged) and the backlog history (`## Done` in PLANS.md and PLANS-DONE.md,
191
- * `## Fixed` in BUGS.md and BUGS-FIXED.md). */
192
- export function collectReport(root, days, role) {
193
- const bases = scopeBases(role);
194
- const now = new Date();
195
- // One captured instant for the whole window, so every day key derives from the same day.
196
- const { from, to } = dayWindow(days, now);
38
+ /** Zero-fill the per-day series across `days` calendar days ending `now` and index it by
39
+ * date, so the fold's day keys can address their slots and the out-of-window keys drop out. */
40
+ function buildEmptySeries(days, now) {
197
41
  const series = [];
198
42
  for (let i = days - 1; i >= 0; i--) {
199
43
  series.push({
@@ -210,138 +54,159 @@ export function collectReport(root, days, role) {
210
54
  const byDate = new Map();
211
55
  for (const d of series)
212
56
  byDate.set(d.date, d);
213
- const foldEntry = foldWindowEvents(root, from);
214
- for (const [dayKey, fold] of foldEntry.days) {
215
- const day = byDate.get(dayKey);
216
- if (!day)
217
- continue; // Out-of-window day key (e.g. future-dated) — no series slot to fill.
218
- if (bases === undefined) {
219
- day.tokensOut = fold.tokensOut;
220
- day.runsByRole = fold.runsByRole;
221
- day.tokensByRole = fold.tokensByRole;
222
- day.costByRole = fold.costByRole;
223
- // Only set when nonzero, so a rejection-free day keeps its exact pre-feature shape.
224
- setMapIfNonempty(day, "rejectionsByRole", fold.rejectionsByRole);
225
- setMapIfNonempty(day, "postMergeRejectionsByRole", fold.postMergeRejectionsByRole);
226
- setMapIfNonempty(day, "revisionsByRole", fold.revisionsByRole);
227
- setMapIfNonempty(day, "refusalsByRole", fold.refusalsByRole);
228
- // Checks belong to the merge slot, so only the team series carries the median.
229
- setLowerMedian(day, "checkMedianMs", fold.checkDurationsMs);
230
- if (fold.checkFailures > 0)
231
- day.checkFailures = fold.checkFailures;
232
- setLowerMedian(day, "mergeWaitMedianMs", fold.mergeWaitsMs);
233
- setLowerMedian(day, "mergeSlotMedianMs", fold.mergeDurationsMs);
234
- setLowerMedian(day, "runMedianMs", fold.runDurationsMs);
235
- setLowerMedian(day, "providerWaitMedianMs", fold.providerWaitsMs);
236
- // A workspace wait is the pool's, not a role's, so only the team series carries it.
237
- setLowerMedian(day, "slotWaitMedianMs", fold.slotWaitsMs);
238
- // A check permit is the team's, not a role's, so only the team series carries it.
239
- setLowerMedian(day, "checkWaitMedianMs", fold.checkWaitsMs);
240
- // A review belongs to the merge slot/reviewer, not the author role, so only the team
241
- // series carries the median, mirroring checkMedianMs.
242
- setLowerMedian(day, "reviewMedianMs", fold.reviewDurationsMs);
243
- // Only set when nonzero, so a failure-free day keeps its exact pre-feature shape.
244
- setMapIfNonempty(day, "failedRunsByRole", fold.failedRunsByRole);
245
- setMapIfNonempty(day, "fallbacksByRole", fold.fallbacksByRole);
246
- setMapIfNonempty(day, "imageReroutesByRole", fold.imageReroutesByRole);
247
- day.commits = fold.commits;
248
- day.commitsByRole = fold.commitsByRole;
249
- day.workCommits = fold.workCommits;
250
- day.maintenanceCommits = fold.maintenanceCommits;
251
- day.costUsd = fold.costUsd;
252
- // Only set when nonzero, so zero-notional days keep their exact pre-feature shape.
253
- if (fold.notionalCostUsd > 0)
254
- day.notionalCostUsd = fold.notionalCostUsd;
255
- setMapIfNonempty(day, "notionalByRole", fold.notionalByRole);
256
- // Only set when a provider actually spent (zero means absent), so a provider-free day
257
- // keeps its exact pre-feature shape and `report --json` omits the maps when empty.
258
- setMapIfNonempty(day, "costByProvider", fold.costByProvider);
259
- setMapIfNonempty(day, "notionalByProvider", fold.notionalByProvider);
260
- // Backends are a team attribution (a backend is not a role), so only the team series
261
- // carries the map; only set when a backend actually spent (zero means absent), so a
262
- // backend-free day keeps its exact pre-feature shape.
263
- setMapIfNonempty(day, "notionalByBackend", fold.notionalByBackend);
264
- // Models are a team attribution (a model is not a role), so only the team series
265
- // carries the map; only set when a modelled start happened (zero means absent), so a
266
- // model-less day keeps its exact pre-feature shape.
267
- setMapIfNonempty(day, "runsByModel", fold.runsByModel);
268
- // Deferred runs are a team attribution (a reason is not a role), so only the team
269
- // series carries the map; only set when a day actually deferred (zero means absent),
270
- // so a deferral-free day keeps its exact pre-feature shape.
271
- setMapIfNonempty(day, "deferredByReason", fold.deferredByReason);
272
- day.mergeRuns = fold.mergeRuns;
273
- day.mergeTokens = fold.mergeTokens;
274
- day.mergeCostUsd = fold.mergeCostUsd;
275
- // Only set when nonzero, so a failure-free day keeps its exact pre-feature shape.
276
- if ((fold.mergeFailed ?? 0) > 0)
277
- day.mergeFailed = fold.mergeFailed;
278
- // Only set when a role actually merged/failed that day (zero means absent), so a
279
- // merge-free day keeps its exact pre-feature shape.
280
- setMapIfNonempty(day, "mergedByRole", fold.mergedByRole);
281
- setMapIfNonempty(day, "mergeFailedByRole", fold.mergeFailedByRole);
282
- // Reason codes are a team attribution (a result is not a role), so only the team
283
- // series carries the map; only set when nonzero, so a failure-free day keeps its
284
- // exact pre-feature shape. The role branch below leaves it unset — merge runs are
285
- // the merge slot's.
286
- setMapIfNonempty(day, "mergeFailedByReason", fold.mergeFailedByReason);
287
- }
288
- else {
289
- // Role scope: derive this day from the fold's per-role maps, so the series carries only
290
- // the named role's runs/tokens/cost and (base-role-keyed) commits. The scope is the
291
- // base role, so an instance id's usage and its siblings' commits fold into one group and
292
- // the numbers match the per-role cap, which is base-role keyed too. The merge fields
293
- // stay unset — merge runs are the merge slot's, not this role's — and the work /
294
- // maintenance split follows the base role's own tier rather than the day's whole split.
295
- const commits = roleSum(fold.commitsByRole, bases);
296
- day.tokensOut = roleSum(fold.tokensByRole, bases);
297
- day.runsByRole = narrowToRole(fold.runsByRole, bases);
298
- day.tokensByRole = narrowToRole(fold.tokensByRole, bases);
299
- day.costByRole = narrowToRole(fold.costByRole, bases);
300
- setMapIfNonempty(day, "rejectionsByRole", narrowToRole(fold.rejectionsByRole, bases));
301
- setMapIfNonempty(day, "postMergeRejectionsByRole", narrowToRole(fold.postMergeRejectionsByRole, bases));
302
- setMapIfNonempty(day, "revisionsByRole", narrowToRole(fold.revisionsByRole, bases));
303
- setMapIfNonempty(day, "refusalsByRole", narrowToRole(fold.refusalsByRole, bases));
304
- setMapIfNonempty(day, "failedRunsByRole", narrowToRole(fold.failedRunsByRole, bases));
305
- setMapIfNonempty(day, "fallbacksByRole", narrowToRole(fold.fallbacksByRole, bases));
306
- setMapIfNonempty(day, "imageReroutesByRole", narrowToRole(fold.imageReroutesByRole, bases));
307
- day.commits = commits;
308
- day.commitsByRole = narrowToRole(fold.commitsByRole, bases);
309
- const tierCommits = (tier) => bases.filter((b) => commitTier(b) === tier).reduce((n, b) => n + roleSum(fold.commitsByRole, b), 0);
310
- day.workCommits = tierCommits("work");
311
- day.maintenanceCommits = tierCommits("maintenance");
312
- day.costUsd = roleSum(fold.costByRole, bases);
313
- const notional = roleSum(fold.notionalByRole, bases);
314
- if (notional > 0) {
315
- day.notionalCostUsd = notional;
316
- day.notionalByRole = narrowToRole(fold.notionalByRole, bases);
317
- }
318
- // Merge runs belong to the merge slot, not this role, so the role-scoped series
319
- // keeps them unset (matching the merge-share fields) — the render omits the merge
320
- // line for a scoped report, so these are carried only for shape consistency.
321
- setMapIfNonempty(day, "mergedByRole", narrowToRole(fold.mergedByRole, bases));
322
- setMapIfNonempty(day, "mergeFailedByRole", narrowToRole(fold.mergeFailedByRole, bases));
323
- }
57
+ return { series, byDate };
58
+ }
59
+ /** Fill one team day from its folded events: every field the fold carries, with the
60
+ * nonzero-or-absent and median-guard rules the day's exact pre-feature shape relies on. */
61
+ function applyTeamDay(day, fold) {
62
+ day.tokensOut = fold.tokensOut;
63
+ day.runsByRole = fold.runsByRole;
64
+ day.tokensByRole = fold.tokensByRole;
65
+ // Only set when nonempty, so a prompt-free day keeps its exact pre-feature shape.
66
+ setMapIfNonempty(day, "promptTokensByRole", fold.promptTokensByRole);
67
+ setMapIfNonempty(day, "cacheReadTokensByRole", fold.cacheReadTokensByRole);
68
+ day.costByRole = fold.costByRole;
69
+ // Only set when nonzero, so a rejection-free day keeps its exact pre-feature shape.
70
+ setMapIfNonempty(day, "rejectionsByRole", fold.rejectionsByRole);
71
+ setMapIfNonempty(day, "postMergeRejectionsByRole", fold.postMergeRejectionsByRole);
72
+ setMapIfNonempty(day, "revisionsByRole", fold.revisionsByRole);
73
+ setMapIfNonempty(day, "refusalsByRole", fold.refusalsByRole);
74
+ // Checks belong to the merge slot, so only the team series carries the median.
75
+ setLowerMedian(day, "checkMedianMs", fold.checkDurationsMs);
76
+ if (fold.checkFailures > 0)
77
+ day.checkFailures = fold.checkFailures;
78
+ setLowerMedian(day, "mergeWaitMedianMs", fold.mergeWaitsMs);
79
+ setLowerMedian(day, "mergeSlotMedianMs", fold.mergeDurationsMs);
80
+ setLowerMedian(day, "runMedianMs", fold.runDurationsMs);
81
+ setLowerMedian(day, "providerWaitMedianMs", fold.providerWaitsMs);
82
+ // A workspace wait is the pool's, not a role's, so only the team series carries it.
83
+ setLowerMedian(day, "slotWaitMedianMs", fold.slotWaitsMs);
84
+ // A check permit is the team's, not a role's, so only the team series carries it.
85
+ setLowerMedian(day, "checkWaitMedianMs", fold.checkWaitsMs);
86
+ // A review belongs to the merge slot/reviewer, not the author role, so only the team
87
+ // series carries the median, mirroring checkMedianMs.
88
+ setLowerMedian(day, "reviewMedianMs", fold.reviewDurationsMs);
89
+ // Only set when nonzero, so a failure-free day keeps its exact pre-feature shape.
90
+ setMapIfNonempty(day, "failedRunsByRole", fold.failedRunsByRole);
91
+ setMapIfNonempty(day, "fallbacksByRole", fold.fallbacksByRole);
92
+ day.commits = fold.commits;
93
+ day.commitsByRole = fold.commitsByRole;
94
+ day.workCommits = fold.workCommits;
95
+ day.maintenanceCommits = fold.maintenanceCommits;
96
+ day.costUsd = fold.costUsd;
97
+ // Only set when nonzero, so zero-notional days keep their exact pre-feature shape.
98
+ if (fold.notionalCostUsd > 0)
99
+ day.notionalCostUsd = fold.notionalCostUsd;
100
+ setMapIfNonempty(day, "notionalByRole", fold.notionalByRole);
101
+ // Only set when a provider actually spent (zero means absent), so a provider-free day
102
+ // keeps its exact pre-feature shape and `report --json` omits the maps when empty.
103
+ setMapIfNonempty(day, "costByProvider", fold.costByProvider);
104
+ setMapIfNonempty(day, "notionalByProvider", fold.notionalByProvider);
105
+ // Backends are a team attribution (a backend is not a role), so only the team series
106
+ // carries the map; only set when a backend actually spent (zero means absent), so a
107
+ // backend-free day keeps its exact pre-feature shape.
108
+ setMapIfNonempty(day, "notionalByBackend", fold.notionalByBackend);
109
+ // Models are a team attribution (a model is not a role), so only the team series
110
+ // carries the map; only set when a modelled start happened (zero means absent), so a
111
+ // model-less day keeps its exact pre-feature shape.
112
+ setMapIfNonempty(day, "runsByModel", fold.runsByModel);
113
+ // Deferred runs are a team attribution (a reason is not a role), so only the team
114
+ // series carries the map; only set when a day actually deferred (zero means absent),
115
+ // so a deferral-free day keeps its exact pre-feature shape.
116
+ setMapIfNonempty(day, "deferredByReason", fold.deferredByReason);
117
+ day.mergeRuns = fold.mergeRuns;
118
+ day.mergeTokens = fold.mergeTokens;
119
+ day.mergeCostUsd = fold.mergeCostUsd;
120
+ // Only set when nonzero, so a failure-free day keeps its exact pre-feature shape.
121
+ if ((fold.mergeFailed ?? 0) > 0)
122
+ day.mergeFailed = fold.mergeFailed;
123
+ // Only set when a role actually merged/failed that day (zero means absent), so a
124
+ // merge-free day keeps its exact pre-feature shape.
125
+ setMapIfNonempty(day, "mergedByRole", fold.mergedByRole);
126
+ setMapIfNonempty(day, "mergeFailedByRole", fold.mergeFailedByRole);
127
+ // Reason codes are a team attribution (a result is not a role), so only the team
128
+ // series carries the map; only set when nonzero, so a failure-free day keeps its
129
+ // exact pre-feature shape. The role branch below leaves it unset — merge runs are
130
+ // the merge slot's.
131
+ setMapIfNonempty(day, "mergeFailedByReason", fold.mergeFailedByReason);
132
+ }
133
+ /** Fill one role-scoped day from the fold's per-role maps, so the series carries only the
134
+ * named base role's usage, folded across an instance id's siblings, and keeps the merge
135
+ * fields and team-only maps unset. */
136
+ function applyRoleDay(day, fold, bases) {
137
+ // Role scope: derive this day from the fold's per-role maps, so the series carries only
138
+ // the named role's runs/tokens/cost and (base-role-keyed) commits. The scope is the
139
+ // base role, so an instance id's usage and its siblings' commits fold into one group and
140
+ // the numbers match the per-role cap, which is base-role keyed too. The merge fields
141
+ // stay unset — merge runs are the merge slot's, not this role's — and the work /
142
+ // maintenance split follows the base role's own tier rather than the day's whole split.
143
+ const commits = roleSum(fold.commitsByRole, bases);
144
+ day.tokensOut = roleSum(fold.tokensByRole, bases);
145
+ day.runsByRole = narrowToRole(fold.runsByRole, bases);
146
+ day.tokensByRole = narrowToRole(fold.tokensByRole, bases);
147
+ setMapIfNonempty(day, "promptTokensByRole", narrowToRole(fold.promptTokensByRole, bases));
148
+ setMapIfNonempty(day, "cacheReadTokensByRole", narrowToRole(fold.cacheReadTokensByRole, bases));
149
+ day.costByRole = narrowToRole(fold.costByRole, bases);
150
+ setMapIfNonempty(day, "rejectionsByRole", narrowToRole(fold.rejectionsByRole, bases));
151
+ setMapIfNonempty(day, "postMergeRejectionsByRole", narrowToRole(fold.postMergeRejectionsByRole, bases));
152
+ setMapIfNonempty(day, "revisionsByRole", narrowToRole(fold.revisionsByRole, bases));
153
+ setMapIfNonempty(day, "refusalsByRole", narrowToRole(fold.refusalsByRole, bases));
154
+ setMapIfNonempty(day, "failedRunsByRole", narrowToRole(fold.failedRunsByRole, bases));
155
+ setMapIfNonempty(day, "fallbacksByRole", narrowToRole(fold.fallbacksByRole, bases));
156
+ day.commits = commits;
157
+ day.commitsByRole = narrowToRole(fold.commitsByRole, bases);
158
+ const tierCommits = (tier) => bases.filter((b) => commitTier(b) === tier).reduce((n, b) => n + roleSum(fold.commitsByRole, b), 0);
159
+ day.workCommits = tierCommits("work");
160
+ day.maintenanceCommits = tierCommits("maintenance");
161
+ day.costUsd = roleSum(fold.costByRole, bases);
162
+ const notional = roleSum(fold.notionalByRole, bases);
163
+ if (notional > 0) {
164
+ day.notionalCostUsd = notional;
165
+ day.notionalByRole = narrowToRole(fold.notionalByRole, bases);
324
166
  }
325
- // The merge share joins its day's tokensOut/costUsd (the budget charges these same
326
- // events, so the day series must sum to the totals the budget agrees with) while the
327
- // merge fields keep the reviewer's part of it visible on its own.
167
+ // Merge runs belong to the merge slot, not this role, so the role-scoped series
168
+ // keeps them unset (matching the merge-share fields) — the render omits the merge
169
+ // line for a scoped report, so these are carried only for shape consistency.
170
+ setMapIfNonempty(day, "mergedByRole", narrowToRole(fold.mergedByRole, bases));
171
+ setMapIfNonempty(day, "mergeFailedByRole", narrowToRole(fold.mergeFailedByRole, bases));
172
+ }
173
+ /** Dispatch a day's fill on the role scope: team days read the fold whole, scoped days
174
+ * narrow to the named base roles. */
175
+ function fillDayFromFold(day, fold, bases) {
176
+ if (bases === undefined)
177
+ applyTeamDay(day, fold);
178
+ else
179
+ applyRoleDay(day, fold, bases);
180
+ }
181
+ /** The merge share joins its day's tokensOut/costUsd (the budget charges these same
182
+ * events, so the day series must sum to the totals the budget agrees with) while the
183
+ * merge fields keep the reviewer's part of it visible on its own. */
184
+ function addMergeShareToSeries(series) {
328
185
  for (const d of series) {
329
186
  d.tokensOut += d.mergeTokens ?? 0;
330
187
  d.costUsd += d.mergeCostUsd ?? 0;
331
188
  }
332
- // Main's red wall-clock per day, from G1's rule (main_red_hold / failed ran main_check open a
333
- // spell, a passing ran main_check closes it, an open spell runs to now). Team-only: main's
334
- // red time is main's own, not any role's, so a role-scoped series leaves every day unset —
335
- // exactly as checkFailures is team-only. Only set when a day actually was red (zero-absent,
336
- // like checkMedianMs).
337
- if (bases === undefined) {
338
- const redByDay = mainRedMinutesByDay(foldEntry.redEvents, dayAt(days - 1, now).getTime(), now.getTime());
339
- for (const d of series) {
340
- const ms = redByDay.get(d.date);
341
- if (ms !== undefined)
342
- d.mainRedMs = ms;
343
- }
189
+ }
190
+ /** Main's red wall-clock per day, from G1's rule (main_red_hold / failed ran main_check open a
191
+ * spell, a passing ran main_check closes it, an open spell runs to now). Team-only: main's
192
+ * red time is main's own, not any role's, so a role-scoped series leaves every day unset —
193
+ * exactly as checkFailures is team-only. Only set when a day actually was red (zero-absent,
194
+ * like checkMedianMs). */
195
+ function applyMainRedToSeries(series, foldEntry, days, now) {
196
+ const redByDay = mainRedMinutesByDay(foldEntry.redEvents, dayAt(days - 1, now).getTime(), now.getTime());
197
+ const redTestsByDay = mainRedFailingTestsByDay(foldEntry.redEvents, dayAt(days - 1, now).getTime(), now.getTime());
198
+ const redCulpritsByDay = mainRedCulpritsByDay(foldEntry.redEvents, dayAt(days - 1, now).getTime(), now.getTime());
199
+ for (const d of series) {
200
+ const ms = redByDay.get(d.date);
201
+ if (ms !== undefined)
202
+ d.mainRedMs = ms;
203
+ setMapIfNonempty(d, "mainRedByTest", redTestsByDay.get(d.date));
204
+ setMapIfNonempty(d, "mainRedByCulprit", redCulpritsByDay.get(d.date));
344
205
  }
206
+ }
207
+ /** Count the window's backlog completions and filings onto their days. Day-granular, so a
208
+ * role-scoped report keeps these at zero (a date is file metadata with no role). */
209
+ function applyCompletionAndFilingCounts(root, byDate, role) {
345
210
  const countOn = (date, field) => {
346
211
  const day = byDate.get(date);
347
212
  if (day)
@@ -377,12 +242,14 @@ export function collectReport(root, days, role) {
377
242
  countOn(d, "bugsFiled");
378
243
  }
379
244
  }
380
- // Tally the fixed bugs' `Validation gap` tags over the window, so the team can see where
381
- // its own tests were weakest. An entry counts only when it is in the window (the same
382
- // out-of-window drop countOn applies: its completion date must be a key of byDate), and
383
- // only the team report carries the tally — a tag has no role (the render gates on the
384
- // same `role === undefined`). Zero-means-absent: a window with no tagged fixed bug spreads
385
- // no key, so the byte-for-byte assertions on pre-feature windows hold.
245
+ }
246
+ /** Tally the fixed bugs' `Validation gap` tags over the window, so the team can see where
247
+ * its own tests were weakest. An entry counts only when it is in the window (the same
248
+ * out-of-window drop countOn applies: its completion date must be a key of byDate), and
249
+ * only the team report carries the tally — a tag has no role (the render gates on the
250
+ * same `role === undefined`). Zero-means-absent: a window with no tagged fixed bug spreads
251
+ * no key, so the byte-for-byte assertions on pre-feature windows hold. */
252
+ function applyValidationGaps(root, byDate, role) {
386
253
  const validationGaps = {};
387
254
  if (role === undefined) {
388
255
  for (const e of fixedBugEntries(root)) {
@@ -396,14 +263,17 @@ export function collectReport(root, days, role) {
396
263
  }
397
264
  }
398
265
  }
399
- // The per-day backlog lag median: how long each finished plan or bug waited from its filing
400
- // stamp to its done/fixed stamp, folded per completion day — the median backlog age at
401
- // which the day's items closed. Team-only (a lag has no role, like the validation tally
402
- // and the filing stamps above) and zero-means-absent: a day with no lag-carrying completion
403
- // keeps its exact pre-feature shape, and an out-of-window completion date drops out (it
404
- // must be a byDate key, the same drop countOn applies). The same entry detection and date
405
- // rules (sectionCompletionLags / entryLags) the completion and filing reads use, so the
406
- // spans pair the days `featuresDone`/`bugsFixed` count.
266
+ return validationGaps;
267
+ }
268
+ /** The per-day backlog lag median: how long each finished plan or bug waited from its filing
269
+ * stamp to its done/fixed stamp, folded per completion day — the median backlog age at
270
+ * which the day's items closed. Team-only (a lag has no role, like the validation tally
271
+ * and the filing stamps above) and zero-means-absent: a day with no lag-carrying completion
272
+ * keeps its exact pre-feature shape, and an out-of-window completion date drops out (it
273
+ * must be a byDate key, the same drop countOn applies). The same entry detection and date
274
+ * rules (sectionCompletionLags / entryLags) the completion and filing reads use, so the
275
+ * spans pair the days `featuresDone`/`bugsFixed` count. */
276
+ function applyBacklogLags(root, byDate, role) {
407
277
  if (role === undefined) {
408
278
  const lagsByDay = new Map();
409
279
  const foldLag = (date, lagDays) => {
@@ -426,6 +296,11 @@ export function collectReport(root, days, role) {
426
296
  for (const [date, lags] of lagsByDay)
427
297
  byDate.get(date).backlogLagMedianDays = lowerMedian(lags);
428
298
  }
299
+ }
300
+ /** Reduce the filled day series to the report's totals, plus the nonzero-only fields the
301
+ * return literal folds into `ReportData.totals` (notional spend, merge failures, plans/bugs
302
+ * filings, backend attribution). */
303
+ function foldSeriesTotals(series) {
429
304
  const totals = {
430
305
  tokensOut: 0,
431
306
  runs: 0,
@@ -466,18 +341,47 @@ export function collectReport(root, days, role) {
466
341
  totals.mergeCostUsd += d.mergeCostUsd ?? 0;
467
342
  mergeFailed += d.mergeFailed ?? 0;
468
343
  }
469
- // The shared coverage proof (event-window.ts), rebuilt from the memoized read's pinned
470
- // parts: the day-keyed proof (fileCovers — a property of the retained bytes and fromKey,
471
- // both in the cache key), the vacuous empty-log arm, and the oldest retained event's ts.
472
- // The window's first local midnight is the cutoff: an event at that instant is the
473
- // earliest the window could contain, so the same-day arm degenerates harmlessly. Without
474
- // this field the day report was the one windowed consumer that could undercount in
475
- // silence — the --since report and the failure digest both carry the note, and a silent
476
- // number invites an operator to trust a truncated window as an idle team.
344
+ return { totals, notionalCostUsd, mergeFailed, plansAdded, bugsFiled, notionalByBackend };
345
+ }
346
+ /** The shared coverage proof (event-window.ts), rebuilt from the memoized read's pinned
347
+ * parts: the day-keyed proof (fileCovers — a property of the retained bytes and fromKey,
348
+ * both in the cache key), the vacuous empty-log arm, and the oldest retained event's ts.
349
+ * The window's first local midnight is the cutoff: an event at that instant is the
350
+ * earliest the window could contain, so the same-day arm degenerates harmlessly. Without
351
+ * this field the day report was the one windowed consumer that could undercount in
352
+ * silence — the --since report and the failure digest both carry the note, and a silent
353
+ * number invites an operator to trust a truncated window as an idle team. */
354
+ function windowCoverage(foldEntry, days, now) {
477
355
  const cutoffMs = dayAt(days - 1, now).getTime();
478
- const coversFullWindow = foldEntry.fileCovers ||
356
+ return (foldEntry.fileCovers ||
479
357
  !foldEntry.hadEvents ||
480
- (foldEntry.firstEventTs !== undefined && foldEntry.firstEventTs <= cutoffMs);
358
+ (foldEntry.firstEventTs !== undefined && foldEntry.firstEventTs <= cutoffMs));
359
+ }
360
+ /** Aggregate team usage over exactly `days` local calendar days ending today, from the event
361
+ * log (run_end/merged) and the backlog history (`## Done` in PLANS.md and PLANS-DONE.md,
362
+ * `## Fixed` in BUGS.md and BUGS-FIXED.md). */
363
+ export function collectReport(root, days, role) {
364
+ const bases = scopeBases(role);
365
+ const now = new Date();
366
+ // One captured instant for the whole window, so every day key derives from the same day.
367
+ const { from, to } = dayWindow(days, now);
368
+ const { series, byDate } = buildEmptySeries(days, now);
369
+ const foldEntry = foldWindowEvents(root, from);
370
+ for (const [dayKey, fold] of foldEntry.days) {
371
+ const day = byDate.get(dayKey);
372
+ if (!day)
373
+ continue; // Out-of-window day key (e.g. future-dated) — no series slot to fill.
374
+ fillDayFromFold(day, fold, bases);
375
+ }
376
+ addMergeShareToSeries(series);
377
+ // Team-only: main's red time is main's own, not any role's (see applyMainRedToSeries).
378
+ if (bases === undefined)
379
+ applyMainRedToSeries(series, foldEntry, days, now);
380
+ applyCompletionAndFilingCounts(root, byDate, role);
381
+ const validationGaps = applyValidationGaps(root, byDate, role);
382
+ applyBacklogLags(root, byDate, role);
383
+ const { totals, notionalCostUsd, mergeFailed, plansAdded, bugsFiled, notionalByBackend } = foldSeriesTotals(series);
384
+ const coversFullWindow = windowCoverage(foldEntry, days, now);
481
385
  return {
482
386
  days,
483
387
  from,