tumwater 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +25 -12
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +1 -8
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -88,7 +88,7 @@ function applyTeamDay(day, fold) {
88
88
  setLowerMedian(day, "reviewMedianMs", fold.reviewDurationsMs);
89
89
  // Only set when nonzero, so a failure-free day keeps its exact pre-feature shape.
90
90
  setMapIfNonempty(day, "failedRunsByRole", fold.failedRunsByRole);
91
- setMapIfNonempty(day, "fallbacksByRole", fold.fallbacksByRole);
91
+ setMapIfNonempty(day, "imageReroutesByRole", fold.imageReroutesByRole);
92
92
  day.commits = fold.commits;
93
93
  day.commitsByRole = fold.commitsByRole;
94
94
  day.workCommits = fold.workCommits;
@@ -152,7 +152,7 @@ function applyRoleDay(day, fold, bases) {
152
152
  setMapIfNonempty(day, "revisionsByRole", narrowToRole(fold.revisionsByRole, bases));
153
153
  setMapIfNonempty(day, "refusalsByRole", narrowToRole(fold.refusalsByRole, bases));
154
154
  setMapIfNonempty(day, "failedRunsByRole", narrowToRole(fold.failedRunsByRole, bases));
155
- setMapIfNonempty(day, "fallbacksByRole", narrowToRole(fold.fallbacksByRole, bases));
155
+ setMapIfNonempty(day, "imageReroutesByRole", narrowToRole(fold.imageReroutesByRole, bases));
156
156
  day.commits = commits;
157
157
  day.commitsByRole = narrowToRole(fold.commitsByRole, bases);
158
158
  const tierCommits = (tier) => bases.filter((b) => commitTier(b) === tier).reduce((n, b) => n + roleSum(fold.commitsByRole, b), 0);
@@ -297,51 +297,67 @@ function applyBacklogLags(root, byDate, role) {
297
297
  byDate.get(date).backlogLagMedianDays = lowerMedian(lags);
298
298
  }
299
299
  }
300
+ /** Fold one day's run, token, commit, cost and per-role commit counts into `acc.totals`. */
301
+ function foldRunAndCommitCounts(acc, d) {
302
+ acc.totals.tokensOut += d.tokensOut;
303
+ for (const n of Object.values(d.runsByRole))
304
+ acc.totals.runs += n;
305
+ acc.totals.commits += d.commits;
306
+ for (const [role, n] of Object.entries(d.commitsByRole ?? {}))
307
+ addTo(acc.totals.commitsByRole, role, n);
308
+ acc.totals.workCommits += d.workCommits ?? 0;
309
+ acc.totals.maintenanceCommits += d.maintenanceCommits ?? 0;
310
+ acc.totals.costUsd += d.costUsd;
311
+ }
312
+ /** Fold one day's notional spend and its per-backend attribution into `acc`. */
313
+ function foldNotionalSpend(acc, d) {
314
+ acc.notionalCostUsd += d.notionalCostUsd ?? 0;
315
+ for (const [backend, c] of Object.entries(d.notionalByBackend ?? {}))
316
+ addTo(acc.notionalByBackend, backend, c);
317
+ }
318
+ /** Fold one day's features-done, bugs-fixed, plan/bug filing and merge counts into `acc`. */
319
+ function foldOutcomeCounts(acc, d) {
320
+ acc.totals.featuresDone += d.featuresDone;
321
+ acc.totals.bugsFixed += d.bugsFixed;
322
+ acc.plansAdded += d.plansAdded ?? 0;
323
+ acc.bugsFiled += d.bugsFiled ?? 0;
324
+ acc.totals.mergeRuns += d.mergeRuns ?? 0;
325
+ acc.totals.mergeTokens += d.mergeTokens ?? 0;
326
+ acc.totals.mergeCostUsd += d.mergeCostUsd ?? 0;
327
+ acc.mergeFailed += d.mergeFailed ?? 0;
328
+ }
300
329
  /** Reduce the filled day series to the report's totals, plus the nonzero-only fields the
301
330
  * return literal folds into `ReportData.totals` (notional spend, merge failures, plans/bugs
302
- * filings, backend attribution). */
331
+ * filings, backend attribution). One named helper per accumulator phase folds each day:
332
+ * `foldRunAndCommitCounts` for the run/commit counts, `foldNotionalSpend` for the notional
333
+ * spend and its backend attribution, and `foldOutcomeCounts` for the outcome/merge counts. */
303
334
  function foldSeriesTotals(series) {
304
- const totals = {
305
- tokensOut: 0,
306
- runs: 0,
307
- ...zeroCommitCounts(),
308
- costUsd: 0,
309
- featuresDone: 0,
310
- bugsFixed: 0,
311
- ...zeroMergeCounts(),
335
+ const acc = {
336
+ totals: {
337
+ tokensOut: 0,
338
+ runs: 0,
339
+ ...zeroCommitCounts(),
340
+ costUsd: 0,
341
+ featuresDone: 0,
342
+ bugsFixed: 0,
343
+ ...zeroMergeCounts(),
344
+ },
345
+ notionalCostUsd: 0,
346
+ mergeFailed: 0,
347
+ plansAdded: 0,
348
+ bugsFiled: 0,
349
+ notionalByBackend: {},
312
350
  };
313
351
  // Notional spend is kept out of the totals object until it is nonzero, so a pre-feature
314
352
  // window's totals keep their exact shape. Merge failures likewise: a failure-free window
315
353
  // carries no mergeFailed (zero means absent, like the merge-share line's other fields
316
354
  // are folded in below).
317
- let notionalCostUsd = 0;
318
- let mergeFailed = 0;
319
- let plansAdded = 0;
320
- let bugsFiled = 0;
321
- const notionalByBackend = {};
322
355
  for (const d of series) {
323
- totals.tokensOut += d.tokensOut;
324
- for (const n of Object.values(d.runsByRole))
325
- totals.runs += n;
326
- totals.commits += d.commits;
327
- for (const [role, n] of Object.entries(d.commitsByRole ?? {}))
328
- addTo(totals.commitsByRole, role, n);
329
- totals.workCommits += d.workCommits ?? 0;
330
- totals.maintenanceCommits += d.maintenanceCommits ?? 0;
331
- totals.costUsd += d.costUsd;
332
- notionalCostUsd += d.notionalCostUsd ?? 0;
333
- for (const [backend, c] of Object.entries(d.notionalByBackend ?? {}))
334
- addTo(notionalByBackend, backend, c);
335
- totals.featuresDone += d.featuresDone;
336
- totals.bugsFixed += d.bugsFixed;
337
- plansAdded += d.plansAdded ?? 0;
338
- bugsFiled += d.bugsFiled ?? 0;
339
- totals.mergeRuns += d.mergeRuns ?? 0;
340
- totals.mergeTokens += d.mergeTokens ?? 0;
341
- totals.mergeCostUsd += d.mergeCostUsd ?? 0;
342
- mergeFailed += d.mergeFailed ?? 0;
356
+ foldRunAndCommitCounts(acc, d);
357
+ foldNotionalSpend(acc, d);
358
+ foldOutcomeCounts(acc, d);
343
359
  }
344
- return { totals, notionalCostUsd, mergeFailed, plansAdded, bugsFiled, notionalByBackend };
360
+ return acc;
345
361
  }
346
362
  /** The shared coverage proof (event-window.ts), rebuilt from the memoized read's pinned
347
363
  * parts: the day-keyed proof (fileCovers — a property of the retained bytes and fromKey,
@@ -14,7 +14,7 @@ import { eventDayKey, parseEventLine } from "../events/event-read.js";
14
14
  import { readCompleteLines } from "../files/tail.js";
15
15
  import { eventsLogPath } from "../paths.js";
16
16
  import { isPassingMainCheck, isRedMain } from "../baseline/main-guardrails.js";
17
- import { memoSetLru } from "../collections.js";
17
+ import { addTo, distinctOrdered, memoSetLru } from "../collections.js";
18
18
  import { asList } from "../text/text.js";
19
19
  import { baseRoleOf } from "../roles/runner-ids.js";
20
20
  import { foldUsageEvent } from "./report-usage-fold.js";
@@ -53,13 +53,20 @@ export function scopeBases(role) {
53
53
  const ids = asList(role);
54
54
  if (ids.length === 0)
55
55
  return undefined;
56
- const out = [];
57
- for (const id of ids) {
58
- const base = baseRoleOf(id);
59
- if (!out.includes(base))
60
- out.push(base);
61
- }
62
- return out;
56
+ return distinctOrdered(ids.map((id) => baseRoleOf(id)));
57
+ }
58
+ /** Fold every key of a per-role-id count/cost map through its base role, so `feature-2` joins
59
+ * `feature` and the map is keyed by base role. The single home of the fold — the day
60
+ * renderer's cost-per-commit path folds each day's raw map and then folds it once (previously
61
+ * inline per entry), and the --since renderer folds an already-aggregated window map — so the
62
+ * two cannot drift on what an instance key joins into. Addition is associative and
63
+ * `baseRoleOf` is deterministic, so accumulating per-id then folding once (or per entry) is
64
+ * identical; the helper takes an already-aggregated map either way. */
65
+ export function foldBaseRoles(map) {
66
+ const folded = {};
67
+ for (const [id, c] of Object.entries(map))
68
+ addTo(folded, baseRoleOf(id), c);
69
+ return folded;
63
70
  }
64
71
  /** Sum a per-role-id count/cost map over the scoped base roles: instance keys (`feature-2`)
65
72
  * fold into their base (`feature`), so a scoped report aggregates a whole role group exactly
@@ -123,7 +130,7 @@ function dayFoldFor(entry, dayKey) {
123
130
  checkWaitsMs: [],
124
131
  reviewDurationsMs: [],
125
132
  failedRunsByRole: {},
126
- fallbacksByRole: {},
133
+ imageReroutesByRole: {},
127
134
  tokensByRole: {},
128
135
  promptTokensByRole: {},
129
136
  cacheReadTokensByRole: {},
@@ -1,12 +1,12 @@
1
1
  import { addTo, rankCountEntries, sortByValueDesc } from "../collections.js";
2
+ import { foldBaseRoles } from "./report-fold.js";
2
3
  import { compactTokens, usd } from "../text/format.js";
3
4
  import { durationLabel } from "../cli/cli-args.js";
4
- import { boundedWindowLine, monthDay, reportWindow, sinceWindowLine } from "../text/datetime.js";
5
+ import { monthDay, reportWindow } from "../text/datetime.js";
5
6
  import { eventsRotationLabel } from "../events/events.js";
6
7
  import { SPARSE_WINDOW_NOTE } from "../events/event-window.js";
7
8
  import { shortSpanPhrase } from "../text/phrases.js";
8
9
  import { markdownTable } from "../text/markdown.js";
9
- import { baseRoleOf } from "../roles/runner-ids.js";
10
10
  import { backendDisplayName } from "../agent/backend-traits.js";
11
11
  /** Bar width for one day: up to 20 blocks scaled to the window's max tokensOut —
12
12
  * round(20·v/max), min 1 when v > 0. */
@@ -19,7 +19,7 @@ function barWidth(v, max) {
19
19
  * collections.ts's rank rule, so the report cannot drift from the other counters that rank the
20
20
  * same way.
21
21
  * Callers filter/format the ranked pairs. */
22
- function rankedRoleMap(totals) {
22
+ export function rankedRoleMap(totals) {
23
23
  return rankCountEntries(Object.entries(totals));
24
24
  }
25
25
  /** Window totals per role: fold the per-day role maps (any `Record<string, number>` day field,
@@ -35,7 +35,7 @@ function rankedRoleTotals(series, pick) {
35
35
  }
36
36
  /** One "**Label:** role — n · role — n" Markdown line from already-ranked pairs; an empty
37
37
  * pair list (an all-zero window) renders "-". fmt formats each role's number. */
38
- function rankedRoleLine(label, pairs, fmt) {
38
+ export function rankedRoleLine(label, pairs, fmt) {
39
39
  return `**${label}:** ${pairs.length === 0 ? "-" : pairs.map(([r, n]) => `${r} — ${fmt(n)}`).join(" · ")}`;
40
40
  }
41
41
  /** The "**Prompt tokens by role:**" line — each role's prompt-token total and, when its
@@ -43,7 +43,7 @@ function rankedRoleLine(label, pairs, fmt) {
43
43
  * — with zero-total roles omitted (the same zero-means-absent rule the sibling lines apply)
44
44
  * and "-" when no role sent a prompt. `prompt` arrives already ranked (prompt desc then
45
45
  * name asc). The one home of the share format, so the day and `--since` lines cannot drift. */
46
- function promptTokensLine(prompt, cache) {
46
+ export function promptTokensLine(prompt, cache) {
47
47
  const cacheByRole = new Map(cache);
48
48
  const cells = prompt
49
49
  .filter(([, n]) => n > 0)
@@ -58,7 +58,7 @@ function promptTokensLine(prompt, cache) {
58
58
  * unfiltered lines. fmt formats each role's number (default String for count lines; pass
59
59
  * usd/compactTokens for spend/token lines). All the "X by role" lines share this filter-rule
60
60
  * home so the zero-means-render-dash guard cannot drift between the --since and day renders. */
61
- function pushRankedRoleLine(lines, label, pairs, fmt = String) {
61
+ export function pushRankedRoleLine(lines, label, pairs, fmt = String) {
62
62
  lines.push(rankedRoleLine(label, pairs.filter(([, n]) => n > 0), fmt));
63
63
  }
64
64
  /** Push a "**Label:** …" ranked-role line only when some role carries a positive value — the
@@ -68,7 +68,7 @@ function pushRankedRoleLine(lines, label, pairs, fmt = String) {
68
68
  * formats each role's number (default String for count lines). Shared by the since and day
69
69
  * renders so the filter-and-if-present guard cannot drift between them — a field that flipped
70
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) {
71
+ export function pushPresentRankedLine(lines, label, pairs, fmt = String) {
72
72
  const present = pairs.filter(([, n]) => n > 0);
73
73
  if (present.length > 0)
74
74
  lines.push(rankedRoleLine(label, present, fmt));
@@ -102,7 +102,7 @@ function pushDayCameoLine(lines, series, title, pick, fmt = shortSpanPhrase) {
102
102
  * renders. Zero-means-absent: a provider with no spend prints no line, and both lanes format
103
103
  * with usd so the day and --since windows cannot drift (the --since path ranks via rankedRoleMap,
104
104
  * the day path via rankedRoleTotals). */
105
- function pushProviderCostLines(lines, rankCost, rankNotional) {
105
+ export function pushProviderCostLines(lines, rankCost, rankNotional) {
106
106
  const providers = rankCost().filter(([, c]) => c > 0);
107
107
  if (providers.length > 0)
108
108
  lines.push(rankedRoleLine("Cost by provider", providers, usd));
@@ -171,7 +171,7 @@ const ROLE_SCOPE_NOTE = "role scope: merge runs and backlog tallies (features do
171
171
  * nonzero (zero means absent, so an API-only or merge-free window renders as before), and the
172
172
  * role-scope note when the report is scoped. One home so the day report and the --since window
173
173
  * cannot drift on the order, the omission predicates, or the scope note. */
174
- function pushReportSummary(lines, t, tail = "", scoped = false) {
174
+ export function pushReportSummary(lines, t, tail = "", scoped = false) {
175
175
  lines.push(totalsLine(t, tail));
176
176
  if (t.mergeRuns > 0)
177
177
  lines.push(mergeShareLine(t));
@@ -180,188 +180,13 @@ function pushReportSummary(lines, t, tail = "", scoped = false) {
180
180
  if (scoped)
181
181
  lines.push(ROLE_SCOPE_NOTE);
182
182
  }
183
- /** Render a trailing-window totals report as Markdown — the aggregate sibling of the
184
- * `logs --since` raw stream ("how much" here, "what exactly" there). Pure function of
185
- * SinceReport: no I/O, no clock reads (the window line's local time is rendered from the
186
- * collector's captured cutoff, so the printed window matches the aggregated one). */
187
- export function renderSinceReportMarkdown(data) {
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) {
203
- lines.push("# tumwater usage report");
204
- lines.push("");
205
- lines.push(data.untilMs !== undefined && data.toIso !== undefined
206
- ? boundedWindowLine(durationLabel(data.sinceMs), durationLabel(data.untilMs), data.fromIso, data.toIso)
207
- : sinceWindowLine(durationLabel(data.sinceMs), data.fromIso));
208
- if (data.role !== undefined)
209
- lines.push(`role: ${data.role}`);
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) {
215
- const t = data.totals;
216
- // Same totals voice as the day report, minus the features/bugs and filing cells: those tallies
217
- // come from day-granular backlog-file dates that cannot subdivide a sub-day window, so the
218
- // final line below states the omission instead of showing a day-rounded number.
219
- pushReportSummary(lines, t, "", data.role !== undefined);
220
- lines.push("");
221
- }
222
- /** Runs / tokens / prompt-tokens / cost breakdowns by role. */
223
- function pushSinceRoleLines(lines, data) {
224
- lines.push(rankedRoleLine("Runs by role", rankedRoleMap(data.runsByRole), String));
225
- // Same breakdown for token spend: ranked by tokens desc then name asc, zero-token roles
226
- // omitted — an all-zero window renders "-" like the other lines. Output tokens are the
227
- // resource the budget charges, so this is the line an operator reads to find which role's
228
- // prompt loop burned the window's tokens.
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 ?? {})));
235
- // Same breakdown for spend: ranked by spend desc then name asc, zero-spend roles omitted —
236
- // an all-zero window renders "-" like the runs line.
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) {
241
- // The same breakdown per PROVIDER (plans: per-provider spend), gated to the team report
242
- // like its day sibling. SinceReport.costByProvider is always present (empty when none), so
243
- // the zero-means-absent gate prints no line for a window with no provider spend.
244
- if (data.role === undefined) {
245
- pushProviderCostLines(lines, () => rankedRoleMap(data.costByProvider), () => rankedRoleMap(data.notionalByProvider));
246
- // The same breakdown per MODEL (each run_start's agent-qualified selector — the model a
247
- // team mixing providers, per-role overrides and fallbacks actually ran on), gated to the
248
- // team report like the provider lines: a model is not a role, so a role-scoped window
249
- // omits it. SinceReport.runsByModel is always present, so a window with no modelled
250
- // start prints no line (keeping a model-less day's output unchanged), exactly as the
251
- // provider lines stay absent when no provider spent.
252
- pushPresentRankedLine(lines, "Runs by model", rankedRoleMap(data.runsByModel));
253
- }
254
- }
255
- /** Main's red time and the test/merge culprits behind it, gated like the day sibling. */
256
- function pushSinceMainRedLines(lines, data) {
257
- // Main's red wall-clock over the window (main's own, not any role's — a role-scoped
258
- // collector carries 0, so this never prints for a scoped window). Zero-absent: no line for
259
- // a window main was never red.
260
- if (data.mainRedMs > 0)
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) {
275
- // The same breakdown for review friction: rejections per role from review_rejected events;
276
- // a window with none renders "-" like the other lines. The post-merge line is the
277
- // afterMerge: true subset — rejections that reached an already-merged commit.
278
- pushRankedRoleLine(lines, "Rejections by role", rankedRoleMap(data.rejectionsByRole));
279
- pushRankedRoleLine(lines, "Post-merge rejections by role", rankedRoleMap(data.postMergeRejectionsByRole));
280
- pushRankedRoleLine(lines, "Revisions by role", rankedRoleMap(data.revisionsByRole));
281
- pushRankedRoleLine(lines, "Refusals by role", rankedRoleMap(data.refusalsByRole));
282
- // The same breakdown for outright failures: error-class run_ends per role under the
283
- // digest's OUTCOME_CLASS — the runs that burned agent time and merged nothing without
284
- // going through review, which rejections/revisions would miss entirely. A window with
285
- // none renders "-" like the other lines.
286
- pushRankedRoleLine(lines, "Failed runs by role", rankedRoleMap(data.failedRunsByRole));
287
- pushRankedRoleLine(lines, "Fallbacks by role", rankedRoleMap(data.fallbacksByRole));
288
- }
289
- /** Deferred maintenance runs by reason code, gated to the team report. */
290
- function pushSinceDeferralLines(lines, data) {
291
- // Deferred maintenance runs per reason code, the --since twin of the day render's line:
292
- // how many due maintenance runs stood down and why (backlog / no-work / plans-full).
293
- // Gated to the team report — a reason is not a role, so a role-scoped window omits it.
294
- // Zero-means-absent: a window with no deferral prints no line.
295
- if (data.role === undefined) {
296
- pushPresentRankedLine(lines, "Deferred runs by reason", rankedRoleMap(data.deferredByReason ?? {}));
297
- }
298
- }
299
- /** Commits by role and the merged/merge-failure split, gated to the team report. */
300
- function pushSinceCommitLines(lines, data) {
301
- // The merged-commits split by base role, matching the day render's unfiltered line: the
302
- // window's totals.commits carries the count; this line says which role's commits it was.
303
- lines.push(rankedRoleLine("Commits by role", rankedRoleMap(data.commitsByRole), String));
304
- // The merged-outcomes split by base role, mirroring the day render's line: which roles'
305
- // changes reach main and which bounce off the gate (merge_failed). Gated to the team
306
- // report — a role-scoped window omits merge (ROLE_SCOPE_NOTE), so the per-role
307
- // attribution would contradict it. Zero-means-absent: the failures line prints only when
308
- // some role has one; a merge-free window renders `-` like the other lines.
309
- if (data.role === undefined) {
310
- lines.push(rankedRoleLine("Merged by role", rankedRoleMap(data.mergedByRole), String));
311
- pushPresentRankedLine(lines, "Merge failures by role", rankedRoleMap(data.mergeFailedByRole));
312
- // WHY the window's merges failed, from each merge_failed's result code — the day
313
- // report's reason twin of the role line above, so an operator can tell a review
314
- // rejection (fix the change) from a conflict/block (rebase) from a shutdown abort
315
- // (capacity). Ranked the same way (count desc then code asc), reason-free only.
316
- pushPresentRankedLine(lines, "Merge failures by reason", rankedRoleMap(data.mergeFailedByReason ?? {}));
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)));
326
- // Subscription spend is notional: its own line, only when the window has any — the same
327
- // conditional/filtered `usd` form the day render's "Estimated by role" uses.
328
- if (data.notionalByRole !== undefined) {
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)));
333
- }
334
- }
335
- /** The trailing blank and the exactly-one coverage/backlog-omission note. */
336
- function pushSinceCoverageLines(lines, data) {
337
- lines.push("");
338
- // Exactly one final line, chosen by the collector's coverage proof: either a hedged note
339
- // that stays true whenever it prints (the oldest retained event lies inside the window —
340
- // whether from rotation or a log born inside the window), or the one-time statement of the
341
- // backlog-tally omission. A flat "rotated out" claim would be false for a log that never
342
- // had the older events, so the wording names only what is provably the case. A role-scoped
343
- // report already states the omission in ROLE_SCOPE_NOTE, so it does not repeat the --days
344
- // pointer, which would imply a scoped day report shows the tallies it too omits.
345
- if (!data.coversFullWindow)
346
- lines.push(SPARSE_WINDOW_NOTE);
347
- else if (data.role === undefined)
348
- lines.push("backlog tallies (features done / bugs fixed / plans added / bugs filed) need the day report (--days)");
349
- }
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
183
  /** Rank one cost map against a base-keyed commits map into cost-per-commit output: the shared
359
184
  * core of the day and --since cost-per-commit lines. `cost` is expected base-role keyed (the
360
185
  * day caller folds per day, the --since caller via `foldBaseRoles`); commits are base-role
361
186
  * keyed. `merged` is [role, cost per commit, commits] for roles with both, ranked by cost per
362
187
  * commit desc then name asc; `idle` is roles that spent and merged nothing, ranked by spend.
363
188
  * A role with commits but no spend appears in neither list. */
364
- function costPerCommitPairsFrom(cost, commits) {
189
+ export function costPerCommitPairsFrom(cost, commits) {
365
190
  const mergeDone = [];
366
191
  const idleCost = [];
367
192
  for (const [role, c] of Object.entries(cost)) {
@@ -376,24 +201,26 @@ function costPerCommitPairsFrom(cost, commits) {
376
201
  sortByValueDesc(mergeDone);
377
202
  return { mergeDone, idle: rankCountEntries(idleCost) };
378
203
  }
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:**`. */
204
+ /** The day caller of the cost-per-commit core: aggregate each day's role-id-keyed spend map
205
+ * under its raw role id, then fold the whole map through its base role (so `feature-2` joins
206
+ * `feature`) once via the shared foldBaseRoles — addition is associative and the fold is
207
+ * deterministic, so this is the same total the --since caller's single fold reaches — and
208
+ * aggregate the base-keyed commitsByRole, then hand both to `costPerCommitPairsFrom`. `pick`
209
+ * selects the per-role-id spend map; merge spend (the reviewer's) and notional subscription
210
+ * spend stay out by default, matching `**Cost by role:**`. */
384
211
  function costPerCommitPairs(series, pick = (d) => d.costByRole) {
385
212
  const cost = {};
386
213
  const commits = {};
387
214
  for (const d of series) {
388
215
  for (const [id, c] of Object.entries(pick(d)))
389
- addTo(cost, baseRoleOf(id), c);
216
+ addTo(cost, id, c);
390
217
  for (const [role, n] of Object.entries(d.commitsByRole ?? {}))
391
218
  addTo(commits, role, n);
392
219
  }
393
- return costPerCommitPairsFrom(cost, commits);
220
+ return costPerCommitPairsFrom(foldBaseRoles(cost), commits);
394
221
  }
395
222
  /** One `**<label>:** …` line from costPerCommitPairs output; `-` when there is nothing. */
396
- function costPerCommitLine(label, pairs) {
223
+ export function costPerCommitLine(label, pairs) {
397
224
  const parts = [
398
225
  ...pairs.mergeDone.map(([r, per, n]) => `${r} — ${usd(per)} (${n})`),
399
226
  ...pairs.idle.map(([r, c]) => `${r} — ${usd(c)}, no commits`),
@@ -481,7 +308,7 @@ function pushReportFrictionLines(lines, data) {
481
308
  // through review, which rejections/revisions would miss entirely. Zero-means-absent, so a
482
309
  // failure-free window renders no line and an all-zero window renders "-" like the others.
483
310
  pushRankedRoleLine(lines, "Failed runs by role", rankedRoleTotals(data.series, (d) => d.failedRunsByRole ?? {}));
484
- pushRankedRoleLine(lines, "Fallbacks by role", rankedRoleTotals(data.series, (d) => d.fallbacksByRole ?? {}));
311
+ pushRankedRoleLine(lines, "Image reroutes by role", rankedRoleTotals(data.series, (d) => d.imageReroutesByRole ?? {}));
485
312
  }
486
313
  /** The team-only deferred-runs-by-reason line. */
487
314
  function pushReportDeferralLines(lines, data) {
@@ -7,7 +7,8 @@
7
7
  * for different reasons. The event-log folding both collectors share lives in
8
8
  * src/report/report-usage-fold.ts (foldUsageEvent) and src/report/report-fold.ts (the windowed
9
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. */
10
+ * Markdown rendering in src/report/report-since-render.ts (which imports the shared formatting
11
+ * voice from src/report/report-render.ts). */
11
12
  import { assertReportSinceMs, eventWindowCovers, readWindowEvents, sinceWindowTiming } from "../events/event-window.js";
12
13
  import { dayKey } from "../text/datetime.js";
13
14
  import { addTo } from "../collections.js";
@@ -80,7 +81,7 @@ function foldSinceWindow(root, cutoff, now, end, untilMs) {
80
81
  checkWaitsMs: [],
81
82
  reviewDurationsMs: [],
82
83
  failedRunsByRole: {},
83
- fallbacksByRole: {},
84
+ imageReroutesByRole: {},
84
85
  tokensByRole: {},
85
86
  promptTokensByRole: {},
86
87
  cacheReadTokensByRole: {},
@@ -190,7 +191,7 @@ function sinceRoleMaps(acc, bases) {
190
191
  revisionsByRole: scopedByRole(acc.revisionsByRole, bases),
191
192
  refusalsByRole: scopedByRole(acc.refusalsByRole, bases),
192
193
  failedRunsByRole: scopedByRole(acc.failedRunsByRole, bases),
193
- fallbacksByRole: scopedByRole(acc.fallbacksByRole, bases),
194
+ imageReroutesByRole: scopedByRole(acc.imageReroutesByRole, bases),
194
195
  tokensByRole: scopedByRole(acc.tokensByRole, bases),
195
196
  ...nonemptyObj("promptTokensByRole", scopedByRole(acc.promptTokensByRole, bases)),
196
197
  ...nonemptyObj("cacheReadTokensByRole", scopedByRole(acc.cacheReadTokensByRole, bases)),