tumwater 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -1,10 +1,12 @@
1
- import { addTo, rankCountEntries } from "../collections.js";
1
+ import { addTo, rankCountEntries, sortByValueDesc } from "../collections.js";
2
2
  import { compactTokens, usd } from "../text/format.js";
3
3
  import { durationLabel } from "../cli/cli-args.js";
4
4
  import { monthDay, reportWindow, sinceWindowLine } from "../text/datetime.js";
5
5
  import { eventsRotationLabel } from "../events/events.js";
6
6
  import { SPARSE_WINDOW_NOTE } from "../events/event-window.js";
7
+ import { shortSpanPhrase } from "../text/phrases.js";
7
8
  import { markdownTable } from "../text/markdown.js";
9
+ import { baseRoleOf } from "../roles/loop-ids.js";
8
10
  /** Bar width for one day: up to 20 blocks scaled to the window's max tokensOut —
9
11
  * round(20·v/max), min 1 when v > 0. */
10
12
  function barWidth(v, max) {
@@ -20,7 +22,7 @@ function rankedRoleMap(totals) {
20
22
  return rankCountEntries(Object.entries(totals));
21
23
  }
22
24
  /** Window totals per role: fold the per-day role maps (any `Record<string, number>` day field,
23
- * e.g. ticksByRole or costByRole) across the series, then rank through rankedRoleMap.
25
+ * e.g. runsByRole or costByRole) across the series, then rank through rankedRoleMap.
24
26
  * Callers filter/format the ranked pairs. */
25
27
  function rankedRoleTotals(series, pick) {
26
28
  const totals = {};
@@ -35,43 +37,72 @@ function rankedRoleTotals(series, pick) {
35
37
  function rankedRoleLine(label, pairs, fmt) {
36
38
  return `**${label}:** ${pairs.length === 0 ? "-" : pairs.map(([r, n]) => `${r} — ${fmt(n)}`).join(" · ")}`;
37
39
  }
40
+ /** The per-day-median line of the team day report — "MMM D <span>" cells for each day that
41
+ * carries a value, joined under a bold title, or undefined when no day has one (zero means
42
+ * absent). The one home of that cameo rendering, shared by the merge check-time and merge-wait
43
+ * medians so their date+span cells and title line cannot drift; the planned provider-slot wait
44
+ * median is a third caller of the same rule. */
45
+ function medianLine(series, title, pick) {
46
+ const cells = series.flatMap((d) => {
47
+ const v = pick(d);
48
+ return v === undefined ? [] : [`${monthDay(d.date)} ${shortSpanPhrase(v)}`];
49
+ });
50
+ return cells.length > 0 ? `**${title}:** ${cells.join(" · ")}` : undefined;
51
+ }
52
+ /** Push the `Cost by provider` and `Estimated by provider` lines of the per-provider spend
53
+ * breakdown (plans: per-provider spend), from the caller's already-ranked real- and notional-
54
+ * spend pairs. Role scoping is the caller's `role === undefined` gate — this helper only
55
+ * renders. Zero-means-absent: a provider with no spend prints no line, and both lanes format
56
+ * with usd so the day and --since windows cannot drift (the --since path ranks via rankedRoleMap,
57
+ * the day path via rankedRoleTotals). */
58
+ function pushProviderCostLines(lines, rankCost, rankNotional) {
59
+ const providers = rankCost().filter(([, c]) => c > 0);
60
+ if (providers.length > 0)
61
+ lines.push(rankedRoleLine("Cost by provider", providers, usd));
62
+ const notionalProviders = rankNotional().filter(([, c]) => c > 0);
63
+ if (notionalProviders.length > 0)
64
+ lines.push(rankedRoleLine("Estimated by provider", notionalProviders, usd));
65
+ }
38
66
  /** The "**Totals:** …" line both renders print — one home so field order, units, and the ·
39
67
  * separators cannot drift between the day report and the --since window. `tail` carries the
40
- * cells only one shape has: the day report's features/bugs counts (a sub-day window cannot
41
- * subdivide their day-granular dates, so the --since render passes nothing). */
68
+ * cells only one shape has: the day report's features/bugs counts and the filing counts
69
+ * (a sub-day window cannot subdivide their day-granular dates, so the --since render passes
70
+ * nothing). */
42
71
  function totalsLine(t, tail = "") {
43
- return `**Totals:** ${compactTokens(t.tokensOut)} output tokens · ${t.ticks} ticks · ${t.commits} commits · ${usd(t.costUsd)}${tail}`;
72
+ return `**Totals:** ${compactTokens(t.tokensOut)} output tokens · ${t.runs} runs · ${t.commits} commits · ${usd(t.costUsd)}${tail}`;
44
73
  }
45
- /** The "of which landing runs …" line under a totals line — one home for the landing share's
46
- * wording. The caller decides when it prints: zero-means-absent, so a fleet with no landing
47
- * spend renders exactly as before (the budget charges the landing slot's runs too, so the
74
+ /** The "of which merge runs …" line under a totals line — one home for the merge share's
75
+ * wording. The caller decides when it prints: zero-means-absent, so a team with no merge
76
+ * spend renders exactly as before (the budget charges the merge slot's runs too, so the
48
77
  * totals include them — this line says how much of the spend was reviewer + conflict
49
78
  * resolution work). */
50
- function landingShareLine(t) {
51
- return `of which landing runs: ${t.landingRuns} runs · ${compactTokens(t.landingTokens)} tokens · ${usd(t.landingCostUsd)} (reviewer + conflict resolution)`;
79
+ function mergeShareLine(t) {
80
+ const failed = (t.mergeFailed ?? 0) > 0 ? ` (${t.mergeFailed} failed)` : "";
81
+ return `of which merge runs: ${t.mergeRuns} runs${failed} · ${compactTokens(t.mergeTokens)} tokens · ${usd(t.mergeCostUsd)} (reviewer + conflict resolution)`;
52
82
  }
53
83
  /** The notional-spend line (plans/agent-backends.md part 6a): the API-equivalent cost of
54
84
  * claude.ai subscription runs folded into the window. Never part of the totals' costUsd,
55
85
  * because a subscription run is exempt from the daily dollar cap; the caller prints it only
56
- * when a subscription run actually folded in (zero means absent, like the landing line), so
57
- * an API-only fleet's report is unchanged. */
86
+ * when a subscription run actually folded in (zero means absent, like the merge line), so
87
+ * an API-only team's report is unchanged. */
58
88
  function notionalLine(t) {
59
- return `claude subscription, API-equivalent: ${usd(t.notionalCostUsd ?? 0)} notional`;
89
+ return `claude subscription, API-equivalent: ${usd(t.notionalCostUsd ?? 0)} estimated`;
60
90
  }
61
- /** The scope note a `--role` report prints: landing runs belong to the landing slot and the
62
- * features-done/bugs-fixed tallies come from day-granular backlog-file dates, so neither can
63
- * be scoped to a role and both are omitted from the scoped totals. Shared by the day and
91
+ /** The scope note a `--role` report prints: merge runs belong to the merge slot and the
92
+ * day-granular backlog tallies (features done / bugs fixed / plans added / bugs filed) come
93
+ * from day-granular backlog-file dates, so none can
94
+ * be scoped to a role and all are omitted from the scoped totals. Shared by the day and
64
95
  * --since renders so the two say the same thing. */
65
- const ROLE_SCOPE_NOTE = "role scope: landing runs and backlog tallies (features done / bugs fixed) are fleet-wide and omitted";
96
+ const ROLE_SCOPE_NOTE = "role scope: merge runs and backlog tallies (features done / bugs fixed / plans added / bugs filed) are team-wide and omitted";
66
97
  /** The summary block both report shapes print, in the same order: the totals line (carrying
67
- * the caller's extra `tail` cells), the landing-share line and the notional line when each is
68
- * nonzero (zero means absent, so an API-only or landing-free window renders as before), and the
98
+ * the caller's extra `tail` cells), the merge-share line and the notional line when each is
99
+ * nonzero (zero means absent, so an API-only or merge-free window renders as before), and the
69
100
  * role-scope note when the report is scoped. One home so the day report and the --since window
70
101
  * cannot drift on the order, the omission predicates, or the scope note. */
71
102
  function pushReportSummary(lines, t, tail = "", scoped = false) {
72
103
  lines.push(totalsLine(t, tail));
73
- if (t.landingRuns > 0)
74
- lines.push(landingShareLine(t));
104
+ if (t.mergeRuns > 0)
105
+ lines.push(mergeShareLine(t));
75
106
  if ((t.notionalCostUsd ?? 0) > 0)
76
107
  lines.push(notionalLine(t));
77
108
  if (scoped)
@@ -90,15 +121,82 @@ export function renderSinceReportMarkdown(data) {
90
121
  lines.push(`role: ${data.role}`);
91
122
  lines.push("");
92
123
  const t = data.totals;
93
- // Same totals voice as the day report, minus the features/bugs cells: those tallies come
94
- // from day-granular backlog-file dates that cannot subdivide a sub-day window, so the final
95
- // line below states the omission instead of showing a day-rounded number.
124
+ // Same totals voice as the day report, minus the features/bugs and filing cells: those tallies
125
+ // come from day-granular backlog-file dates that cannot subdivide a sub-day window, so the
126
+ // final line below states the omission instead of showing a day-rounded number.
96
127
  pushReportSummary(lines, t, "", data.role !== undefined);
97
128
  lines.push("");
98
- lines.push(rankedRoleLine("Ticks by role", rankedRoleMap(data.ticksByRole), String));
129
+ lines.push(rankedRoleLine("Runs by role", rankedRoleMap(data.runsByRole), String));
130
+ // Same breakdown for token spend: ranked by tokens desc then name asc, zero-token roles
131
+ // omitted — an all-zero window renders "-" like the other lines. Output tokens are the
132
+ // resource the budget charges, so this is the line an operator reads to find which role's
133
+ // prompt loop burned the window's tokens.
134
+ lines.push(rankedRoleLine("Tokens by role", rankedRoleMap(data.tokensByRole).filter(([, n]) => n > 0), compactTokens));
99
135
  // Same breakdown for spend: ranked by spend desc then name asc, zero-spend roles omitted —
100
136
  // an all-zero window renders "-" like the ticks line.
101
137
  lines.push(rankedRoleLine("Cost by role", rankedRoleMap(data.costByRole).filter(([, c]) => c > 0), usd));
138
+ // The same breakdown per PROVIDER (plans: per-provider spend), gated to the team report
139
+ // like its day sibling. SinceReport.costByProvider is always present (empty when none), so
140
+ // the zero-means-absent gate prints no line for a window with no provider spend.
141
+ if (data.role === undefined) {
142
+ pushProviderCostLines(lines, () => rankedRoleMap(data.costByProvider), () => rankedRoleMap(data.notionalByProvider));
143
+ // The same breakdown per MODEL (each run_start's agent-qualified selector — the model a
144
+ // team mixing providers, per-role overrides and fallbacks actually ran on), gated to the
145
+ // team report like the provider lines: a model is not a role, so a role-scoped window
146
+ // omits it. SinceReport.runsByModel is always present, so a window with no modelled
147
+ // start prints no line (keeping a model-less day's output unchanged), exactly as the
148
+ // provider lines stay absent when no provider spent.
149
+ const modelPairs = rankedRoleMap(data.runsByModel).filter(([, n]) => n > 0);
150
+ if (modelPairs.length > 0)
151
+ lines.push(rankedRoleLine("Runs by model", modelPairs, String));
152
+ }
153
+ // The same breakdown for review friction: rejections per role from review_rejected events;
154
+ // a window with none renders "-" like the other lines.
155
+ lines.push(rankedRoleLine("Rejections by role", rankedRoleMap(data.rejectionsByRole).filter(([, r]) => r > 0), String));
156
+ lines.push(rankedRoleLine("Revisions by role", rankedRoleMap(data.revisionsByRole).filter(([, r]) => r > 0), String));
157
+ lines.push(rankedRoleLine("Refusals by role", rankedRoleMap(data.refusalsByRole).filter(([, r]) => r > 0), String));
158
+ // The same breakdown for outright failures: error-class run_ends per role under the
159
+ // digest's OUTCOME_CLASS — the ticks that burned agent time and merged nothing without
160
+ // going through review, which rejections/revisions would miss entirely. A window with
161
+ // none renders "-" like the other lines.
162
+ lines.push(rankedRoleLine("Failed runs by role", rankedRoleMap(data.failedRunsByRole).filter(([, n]) => n > 0), String));
163
+ lines.push(rankedRoleLine("Fallbacks by role", rankedRoleMap(data.fallbacksByRole).filter(([, n]) => n > 0), String));
164
+ lines.push(rankedRoleLine("Image reroutes by role", rankedRoleMap(data.imageReroutesByRole).filter(([, n]) => n > 0), String));
165
+ // Deferred maintenance ticks per reason code, the --since twin of the day render's line:
166
+ // how many due maintenance runs stood down and why (backlog / no-work / plans-full).
167
+ // Gated to the team report — a reason is not a role, so a role-scoped window omits it.
168
+ // Zero-means-absent: a window with no deferral prints no line.
169
+ if (data.role === undefined) {
170
+ const deferred = rankedRoleMap(data.deferredByReason ?? {}).filter(([, n]) => n > 0);
171
+ if (deferred.length > 0)
172
+ lines.push(rankedRoleLine("Deferred runs by reason", deferred, String));
173
+ }
174
+ // The merged-commits split by base role, matching the day render's unfiltered line: the
175
+ // window's totals.commits carries the count; this line says which role's commits it was.
176
+ lines.push(rankedRoleLine("Commits by role", rankedRoleMap(data.commitsByRole), String));
177
+ // The merged-outcomes split by base role, mirroring the day render's line: which roles'
178
+ // changes reach main and which bounce off the gate (merge_failed). Gated to the team
179
+ // report — a role-scoped window omits merge (ROLE_SCOPE_NOTE), so the per-role
180
+ // attribution would contradict it. Zero-means-absent: the failures line prints only when
181
+ // some role has one; a merge-free window renders `-` like the other lines.
182
+ if (data.role === undefined) {
183
+ lines.push(rankedRoleLine("Merged by role", rankedRoleMap(data.mergedByRole), String));
184
+ const mergeFailures = rankedRoleMap(data.mergeFailedByRole).filter(([, n]) => n > 0);
185
+ if (mergeFailures.length > 0)
186
+ lines.push(rankedRoleLine("Merge failures by role", mergeFailures, String));
187
+ // WHY the window's merges failed, from each merge_failed's result code — the day
188
+ // report's reason twin of the role line above, so an operator can tell a review
189
+ // rejection (fix the change) from a conflict/block (rebase) from a shutdown abort
190
+ // (capacity). Ranked the same way (count desc then code asc), reason-free only.
191
+ const mergeFailureReasons = rankedRoleMap(data.mergeFailedByReason ?? {}).filter(([, n]) => n > 0);
192
+ if (mergeFailureReasons.length > 0)
193
+ lines.push(rankedRoleLine("Merge failures by reason", mergeFailureReasons, String));
194
+ }
195
+ // Subscription spend is notional: its own line, only when the window has any — the same
196
+ // conditional/filtered `usd` form the day render's "Estimated by role" uses.
197
+ if (data.notionalByRole !== undefined) {
198
+ lines.push(rankedRoleLine("Estimated by role", rankedRoleMap(data.notionalByRole).filter(([, c]) => c > 0), usd));
199
+ }
102
200
  lines.push("");
103
201
  // Exactly one final line, chosen by the collector's coverage proof: either a hedged note
104
202
  // that stays true whenever it prints (the oldest retained event lies inside the window —
@@ -110,9 +208,47 @@ export function renderSinceReportMarkdown(data) {
110
208
  if (!data.coversFullWindow)
111
209
  lines.push(SPARSE_WINDOW_NOTE);
112
210
  else if (data.role === undefined)
113
- lines.push("backlog tallies (features done / bugs fixed) need the day report (--days)");
211
+ lines.push("backlog tallies (features done / bugs fixed / plans added / bugs filed) need the day report (--days)");
114
212
  return lines.join("\n");
115
213
  }
214
+ /** Cost per merged commit by base role. Spend comes from costByRole (keyed by loop id, folded to
215
+ * its base role so `feature-2` joins `feature`), commits from commitsByRole (already base-role
216
+ * keyed). `merged` is [role, cost per commit, commits] for roles with both, ranked by cost per
217
+ * commit desc then name asc; `idle` is roles that spent and merged nothing, ranked by spend.
218
+ * Merge spend (the reviewer's) and notional subscription spend stay out by default, matching
219
+ * `**Cost by role:**` (`pick` selects another per-loop-id spend map, e.g. notionalByRole); a
220
+ * role with commits but no spend in the picked map appears in neither list. */
221
+ function costPerCommitPairs(series, pick = (d) => d.costByRole) {
222
+ const cost = {};
223
+ const commits = {};
224
+ for (const d of series) {
225
+ for (const [id, c] of Object.entries(pick(d)))
226
+ addTo(cost, baseRoleOf(id), c);
227
+ for (const [role, n] of Object.entries(d.commitsByRole ?? {}))
228
+ addTo(commits, role, n);
229
+ }
230
+ const mergeDone = [];
231
+ const idleCost = [];
232
+ for (const [role, c] of Object.entries(cost)) {
233
+ if (c <= 0)
234
+ continue;
235
+ const n = commits[role] ?? 0;
236
+ if (n > 0)
237
+ mergeDone.push([role, c / n, n]);
238
+ else
239
+ idleCost.push([role, c]);
240
+ }
241
+ sortByValueDesc(mergeDone);
242
+ return { mergeDone, idle: rankCountEntries(idleCost) };
243
+ }
244
+ /** One `**<label>:** …` line from costPerCommitPairs output; `-` when there is nothing. */
245
+ function costPerCommitLine(label, pairs) {
246
+ const parts = [
247
+ ...pairs.mergeDone.map(([r, per, n]) => `${r} — ${usd(per)} (${n})`),
248
+ ...pairs.idle.map(([r, c]) => `${r} — ${usd(c)}, no commits`),
249
+ ];
250
+ return `**${label}:** ${parts.length === 0 ? "-" : parts.join(" · ")}`;
251
+ }
116
252
  /** Render a report as Markdown — the pinned shape the CLI prints and the TUI usage pane
117
253
  * (report 3/3) reuses; the GUI tab (report 2/3) renders its own SVG charts from the same
118
254
  * ReportData instead. Pure function of ReportData: no I/O, no clock reads. */
@@ -125,33 +261,134 @@ export function renderReportMarkdown(data) {
125
261
  lines.push(`role: ${data.role}`);
126
262
  lines.push("");
127
263
  const t = data.totals;
128
- // A role-scoped report drops the fleet-only features/bugs cells from the totals line — their
129
- // dates carry no role — and states the omission through ROLE_SCOPE_NOTE below instead.
130
- const tail = data.role === undefined ? ` · ${t.featuresDone} features done · ${t.bugsFixed} bugs fixed` : "";
264
+ // A role-scoped report drops the team-only features/bugs and filing cells from the totals
265
+ // line — their dates carry no role — and states the omission through ROLE_SCOPE_NOTE below.
266
+ const openedTail = (t.plansAdded ?? 0) + (t.bugsFiled ?? 0) > 0
267
+ ? ` · ${t.plansAdded ?? 0} plans added · ${t.bugsFiled ?? 0} bugs filed`
268
+ : "";
269
+ const tail = data.role === undefined
270
+ ? ` · ${t.featuresDone} features done · ${t.bugsFixed} bugs fixed${openedTail}`
271
+ : "";
131
272
  pushReportSummary(lines, t, tail, data.role !== undefined);
132
273
  lines.push("");
133
274
  const maxTokens = data.series.reduce((m, d) => Math.max(m, d.tokensOut), 0);
134
- lines.push(...markdownTable(["day", "tokens out", "ticks", "commits", "cost"], data.series.map((d) => {
135
- const ticks = Object.values(d.ticksByRole).reduce((a, b) => a + b, 0);
275
+ lines.push(...markdownTable(["day", "tokens out", "runs", "commits", "cost"], data.series.map((d) => {
276
+ const ticks = Object.values(d.runsByRole).reduce((a, b) => a + b, 0);
136
277
  const w = maxTokens > 0 ? barWidth(d.tokensOut, maxTokens) : 0;
137
278
  const bar = w > 0 ? ` ${"█".repeat(w)}` : ""; // Zero days carry no bar (and no stray space).
138
279
  return [monthDay(d.date), `${compactTokens(d.tokensOut)}${bar}`, String(ticks), String(d.commits), usd(d.costUsd)];
139
280
  }), ["left", "right", "right", "right", "right"]));
140
281
  lines.push("");
141
- lines.push(rankedRoleLine("Ticks by role", rankedRoleTotals(data.series, (d) => d.ticksByRole), String));
282
+ lines.push(rankedRoleLine("Runs by role", rankedRoleTotals(data.series, (d) => d.runsByRole), String));
283
+ // The same breakdown for output tokens (the resource the budget charges): window totals per
284
+ // role from tokensByRole, ranked like the ticks line. Zero-token roles are omitted; an
285
+ // all-zero window renders "-" like the ticks line.
286
+ lines.push(rankedRoleLine("Tokens by role", rankedRoleTotals(data.series, (d) => d.tokensByRole ?? {}).filter(([, n]) => n > 0), compactTokens));
142
287
  // The same breakdown for spend: window totals per role from costByRole, ranked by spend
143
288
  // desc then name asc — spend is what the operator acts on (an operator tuning per-role
144
289
  // intervals wants the burning loop first, not the busiest one). Zero-spend roles are
145
290
  // omitted; an all-zero window renders "-" like the ticks line.
146
291
  lines.push(rankedRoleLine("Cost by role", rankedRoleTotals(data.series, (d) => d.costByRole).filter(([, c]) => c > 0), usd));
147
- // Work ratio 4/4: the landed commits split by base role and by tier. The totals line's
292
+ // The same breakdown per PROVIDER (plans: per-provider spend): which seam of a mixed team
293
+ // spent what, straight from the events' providerCost maps (run_end's own runs and the
294
+ // merge's reviewer seam). Gated to the team report — a provider is not a role, so a
295
+ // role-scoped report omits it (ROLE_SCOPE_NOTE covers the omission). Zero-means-absent: a
296
+ // window with no provider spend prints no line. Ranked and formatted exactly like Cost by
297
+ // role.
298
+ if (data.role === undefined) {
299
+ pushProviderCostLines(lines, () => rankedRoleTotals(data.series, (d) => d.costByProvider ?? {}), () => rankedRoleTotals(data.series, (d) => d.notionalByProvider ?? {}));
300
+ // The same breakdown per MODEL (each run_start's agent-qualified selector — the model a
301
+ // team mixing providers, per-role overrides and fallbacks actually ran on), gated to the
302
+ // team report like the provider lines: a model is not a role, so a role-scoped report
303
+ // omits it (ROLE_SCOPE_NOTE covers the omission), and a window with no modelled start
304
+ // prints no line (keeping a model-less day's output unchanged), exactly as the provider
305
+ // lines stay absent when no provider spent. Ranked and formatted like Cost by role.
306
+ const modelPairs = rankedRoleTotals(data.series, (d) => d.runsByModel ?? {}).filter(([, n]) => n > 0);
307
+ if (modelPairs.length > 0)
308
+ lines.push(rankedRoleLine("Runs by model", modelPairs, String));
309
+ }
310
+ // The same breakdown for review friction: window rejections per role from review_rejected
311
+ // events, ranked like the ticks line; a window with none renders "-".
312
+ lines.push(rankedRoleLine("Rejections by role", rankedRoleTotals(data.series, (d) => d.rejectionsByRole ?? {}).filter(([, r]) => r > 0), String));
313
+ lines.push(rankedRoleLine("Revisions by role", rankedRoleTotals(data.series, (d) => d.revisionsByRole ?? {}).filter(([, r]) => r > 0), String));
314
+ lines.push(rankedRoleLine("Refusals by role", rankedRoleTotals(data.series, (d) => d.refusalsByRole ?? {}).filter(([, r]) => r > 0), String));
315
+ // The same breakdown for outright failures: error-class run_ends per role under the
316
+ // digest's OUTCOME_CLASS — ticks that burned agent time and merged nothing without going
317
+ // through review, which rejections/revisions would miss entirely. Zero-means-absent, so a
318
+ // failure-free window renders no line and an all-zero window renders "-" like the others.
319
+ lines.push(rankedRoleLine("Failed runs by role", rankedRoleTotals(data.series, (d) => d.failedRunsByRole ?? {}).filter(([, n]) => n > 0), String));
320
+ lines.push(rankedRoleLine("Fallbacks by role", rankedRoleTotals(data.series, (d) => d.fallbacksByRole ?? {}).filter(([, n]) => n > 0), String));
321
+ lines.push(rankedRoleLine("Image reroutes by role", rankedRoleTotals(data.series, (d) => d.imageReroutesByRole ?? {}).filter(([, n]) => n > 0), String));
322
+ // Deferred maintenance ticks per reason code — how many due maintenance runs stood down
323
+ // and why, so an idle day reads as backlog-open / no-work / plans-full rather than blank,
324
+ // the per-role breakdown of the deferred maintenances. Gated to the team report — a
325
+ // reason is not a role (ROLE_SCOPE_NOTE covers the omission). Zero-means-absent: a window
326
+ // with no deferral prints no line, so a deferral-free day's output is unchanged.
327
+ if (data.role === undefined) {
328
+ const deferred = rankedRoleTotals(data.series, (d) => d.deferredByReason ?? {}).filter(([, n]) => n > 0);
329
+ if (deferred.length > 0)
330
+ lines.push(rankedRoleLine("Deferred runs by reason", deferred, String));
331
+ }
332
+ // Work ratio 4/4: the merged commits split by base role and by tier. The totals line's
148
333
  // `commits` carries the count; these lines say who made it and how much of it was work.
149
334
  lines.push(rankedRoleLine("Commits by role", rankedRoleTotals(data.series, (d) => d.commitsByRole ?? {}), String));
335
+ // The merged-outcomes split by base role: which authoring roles' changes reach main through
336
+ // the merge slot, and — the new signal — which roles' changes bounce off the gate (a
337
+ // merge_failed: review rejection, under-cap review, a conflict, a blocked fast-forward, a
338
+ // shutdown abort). Gated to the team report — a role-scoped report omits merge runs
339
+ // (they are the merge slot's, per ROLE_SCOPE_NOTE), so the per-role attribution would
340
+ // contradict that stance. Zero-means-absent: a window with merges renders Merged by role;
341
+ // the failures line prints only when some role actually has one.
342
+ if (data.role === undefined) {
343
+ const mergeDone = rankedRoleTotals(data.series, (d) => d.mergedByRole ?? {});
344
+ lines.push(rankedRoleLine("Merged by role", mergeDone, String));
345
+ const mergeFailures = rankedRoleTotals(data.series, (d) => d.mergeFailedByRole ?? {}).filter(([, n]) => n > 0);
346
+ if (mergeFailures.length > 0)
347
+ lines.push(rankedRoleLine("Merge failures by role", mergeFailures, String));
348
+ // The --since reason twin, folded across the series the same way; a reason-free window
349
+ // prints no line, so its output is byte-for-byte unchanged.
350
+ const mergeFailureReasons = rankedRoleTotals(data.series, (d) => d.mergeFailedByReason ?? {}).filter(([, n]) => n > 0);
351
+ if (mergeFailureReasons.length > 0)
352
+ lines.push(rankedRoleLine("Merge failures by reason", mergeFailureReasons, String));
353
+ // Per-day median passing check time, so a suite slowing over days shows; days without a
354
+ // check are left out, and no line prints when no day has one (zero means absent).
355
+ const medians = medianLine(data.series, "Merge check median", (d) => d.checkMedianMs);
356
+ if (medians !== undefined)
357
+ lines.push(medians);
358
+ // Per-day count of red full-suite checks, beside the passing medians above; a day whose
359
+ // suite went red before passing shows here. Zero-absent: no line when no day fails.
360
+ const reds = data.series.flatMap((d) => d.checkFailures === undefined ? [] : [`${monthDay(d.date)} ×${d.checkFailures}`]);
361
+ if (reds.length > 0)
362
+ lines.push(`**Red merge checks:** ${reds.join(" · ")}`);
363
+ const waits = medianLine(data.series, "Merge wait median", (d) => d.mergeWaitMedianMs);
364
+ if (waits !== undefined)
365
+ lines.push(waits);
366
+ const providerWaits = medianLine(data.series, "Provider wait median", (d) => d.providerWaitMedianMs);
367
+ if (providerWaits !== undefined)
368
+ lines.push(providerWaits);
369
+ const slotWaits = medianLine(data.series, "Workspace wait median", (d) => d.slotWaitMedianMs);
370
+ if (slotWaits !== undefined)
371
+ lines.push(slotWaits);
372
+ }
373
+ lines.push(costPerCommitLine("Cost per commit", costPerCommitPairs(data.series)));
374
+ // Subscription spend is notional: its own two lines, only when the window has any.
375
+ if ((t.notionalCostUsd ?? 0) > 0) {
376
+ const notionalOf = (d) => d.notionalByRole ?? {};
377
+ lines.push(rankedRoleLine("Estimated by role", rankedRoleTotals(data.series, notionalOf).filter(([, c]) => c > 0), usd));
378
+ lines.push(costPerCommitLine("Estimated per commit", costPerCommitPairs(data.series, notionalOf)));
379
+ }
150
380
  // Commits per backlog item is day-granular backlog data (features done + bugs fixed), so it
151
381
  // belongs only to the day report; zero items means the denominator is absent, not NaN.
152
382
  const items = t.featuresDone + t.bugsFixed;
153
383
  const perItem = items > 0 ? ` · ${(t.commits / items).toFixed(1)} commits per backlog item` : "";
154
384
  lines.push(`**Commits:** ${t.workCommits ?? 0} work / ${t.maintenanceCommits ?? 0} maintenance${perItem}`);
385
+ // Where the bugfix work was hardest to confirm: the window's fixed bugs tallied by their
386
+ // `Validation gap` tag, so the team can see where its own test infrastructure was weakest
387
+ // (recorded first, consumed now). Team-only (a tag has no role) and zero-means-absent: no
388
+ // line and no totals key for a role-scoped report or a window with no tagged fixed bug.
389
+ if (data.role === undefined && t.validationGapsByTag && Object.keys(t.validationGapsByTag).length > 0) {
390
+ lines.push(rankedRoleLine("Validation gaps", rankedRoleMap(t.validationGapsByTag), String));
391
+ }
155
392
  // The shared SPARSE_WINDOW_NOTE (one voice across the report's two windows and the other
156
393
  // windowed renders): covered is false only when events were aggregated from a log whose
157
394
  // oldest retained event lies inside the window, so the hedged sentence stays true whenever
@@ -1,6 +1,6 @@
1
1
  export function cappedRequestTimeouts(cfg, tickCapS, quietCapS) {
2
2
  return {
3
- tickTimeoutSeconds: Math.min(cfg.tickTimeoutSeconds, tickCapS),
3
+ runTimeoutSeconds: Math.min(cfg.runTimeoutSeconds, tickCapS),
4
4
  quietTimeoutSeconds: cfg.quietTimeoutSeconds > 0 ? Math.min(cfg.quietTimeoutSeconds, quietCapS) : quietCapS,
5
5
  };
6
6
  }
@@ -1,7 +1,8 @@
1
1
  /** The review gate's memory of its own flake warnings — split out of review-precheck.ts so the
2
2
  * "has this exact failure already passed on a re-run?" question lives on its own and can be
3
3
  * unit-tested over synthetic events. The gate re-runs a failed check once (BUGS.md 2026-09-23) and
4
- * logs `gate check failed then passed on retry — flaky: <headline>` when it passes; a later
4
+ * logs `gate check failed then passed on retry — flaky: <headline>` when it passes (the in-lock
5
+ * merge check's kept-pin retry logs the same with a `merge check` prefix); a later
5
6
  * repeat of that same headline is the flake firing again, not a verdict about whichever change
6
7
  * happened to hit it (BUGS.md 2026-10-06). The flake is matched by the shared failure-cluster
7
8
  * normalization so a duration, path, SHA, timestamp, or bare integer difference does not hide the
@@ -9,34 +10,53 @@
9
10
  * evidence of flakiness — it is exactly the attribution this memory exists to avoid making
10
11
  * twice. */
11
12
  import { readEventsSince } from "../events/event-window.js";
13
+ import { increment } from "../collections.js";
12
14
  import { normalizeClusterKey } from "../failure/failure-cluster.js";
13
15
  /** How far back the gate looks for its own flake warnings. One local day: the example in
14
16
  * BUGS.md 2026-10-06 recorded a flake at 10:50 and rejected an unrelated change on the same
15
17
  * headline at 15:57, and a day is short enough that a retired flake stops excusing failures
16
- * while still covering a fleet's working span. */
17
- const KNOWN_FLAKE_WINDOW_MS = 24 * 60 * 60 * 1000;
18
+ * while still covering a team's working span. Exported so the doctor's `flaky checks` check
19
+ * and the gate share the one window and cannot drift. */
20
+ export const KNOWN_FLAKE_WINDOW_MS = 24 * 60 * 60 * 1000;
18
21
  /** The exact prefix review-precheck.ts writes before a flake's failure headline. Exported so
19
22
  * the writer and reader cannot drift over the separator. */
20
23
  export const FLAKY_WARNING_PREFIX = "gate check failed then passed on retry — flaky: ";
21
- /** The normalized cluster keys of every flake warning in `events` at or after `now - windowMs`.
22
- * Pure over its inputs (no I/O, no clock) so the matching rules have their own unit tests.
23
- * Non-warning events, warnings whose message is not a string, and warnings older than the
24
- * window are ignored; a warning's headline is everything after the prefix. */
25
- export function flakeKeys(events, now, windowMs = KNOWN_FLAKE_WINDOW_MS) {
26
- const cutoff = now - windowMs;
27
- const keys = new Set();
24
+ /** The prefix merge-check-failures.ts writes when an in-lock merge check that failed passes on
25
+ * its kept-pin retry — the merge-side twin of FLAKY_WARNING_PREFIX, read by the same consumers. */
26
+ export const MERGE_FLAKY_WARNING_PREFIX = "merge check failed then passed on retry — flaky: ";
27
+ /** The normalized cluster keys of every flake warning in `events` — the shared extraction
28
+ * both the gate's flakeKeys and the doctor's `flaky checks` check fold from the log, so the
29
+ * prefix slice-offset and original cluster normalization live once and cannot drift. Non-
30
+ * warning events and warnings whose message is not a string are ignored; `cutoffMs`, when
31
+ * given, also drops events timestamped strictly before it. Pure over its inputs (no I/O, no
32
+ * clock) so the matching rules have their own unit tests. */
33
+ function flakeWarningKeys(events, cutoffMs) {
34
+ const keys = [];
28
35
  for (const e of events) {
29
36
  if (e.type !== "warning" || typeof e.message !== "string")
30
37
  continue;
31
- if (typeof e.ts === "number" && e.ts < cutoff)
38
+ if (cutoffMs !== undefined && typeof e.ts === "number" && e.ts < cutoffMs)
32
39
  continue;
33
- const at = e.message.indexOf(FLAKY_WARNING_PREFIX);
34
- if (at < 0)
40
+ const message = e.message;
41
+ const prefix = [FLAKY_WARNING_PREFIX, MERGE_FLAKY_WARNING_PREFIX].find((p) => message.includes(p));
42
+ if (prefix === undefined)
35
43
  continue;
36
- keys.add(normalizeClusterKey(e.message.slice(at + FLAKY_WARNING_PREFIX.length)));
44
+ keys.push(normalizeClusterKey(message.slice(message.indexOf(prefix) + prefix.length)));
37
45
  }
38
46
  return keys;
39
47
  }
48
+ /** Each normalized flake headline in `events` with how often it was warned, most frequent first;
49
+ * ties keep first-seen order. No cutoff: callers pass an already-windowed read. Pure. */
50
+ export function flakeCounts(events) {
51
+ const counts = new Map();
52
+ for (const key of flakeWarningKeys(events))
53
+ increment(counts, key);
54
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
55
+ }
56
+ /** A Set of the flakeWarningKeys at or after `now - windowMs` of the gate's own window. */
57
+ export function flakeKeys(events, now, windowMs = KNOWN_FLAKE_WINDOW_MS) {
58
+ return new Set(flakeWarningKeys(events, now - windowMs));
59
+ }
40
60
  /** Whether `headline` matches a flake warning the gate logged within the last
41
61
  * KNOWN_FLAKE_WINDOW_MS, read from the live log and its one archived generation. */
42
62
  export function isKnownFlake(root, headline) {
@@ -0,0 +1,33 @@
1
+ // vocabulary: plain
2
+ import path from "node:path";
3
+ import { promptBlockOf } from "../brief/brief.js";
4
+ import { readTextOrNull } from "../files/files.js";
5
+ import { changeBaseRev, fileContentAt } from "../git/git.js";
6
+ import { briefCandidates } from "../paths.js";
7
+ import { baseRoleOf } from "../roles/loop-ids.js";
8
+ /** The brief files that may carry the initial prompt block: the basenames of `briefCandidates`. */
9
+ const BRIEF_FILES = briefCandidates("").map((p) => path.basename(p));
10
+ /** The roles that may edit PRINCIPLES.md. */
11
+ const PRINCIPLES_ROLES = new Set(["director", "steward"]);
12
+ /** The deterministic gate/stage finding for a change that edits what the loop prompts forbid
13
+ * editing: the initial prompt block of a brief file (changed, removed, or the file deleted —
14
+ * a block that only moves passes), or PRINCIPLES.md from a role other than director or
15
+ * steward. undefined when neither happened. Reads the worktree and the change's merge-base
16
+ * like backlogStructureReason; a missing file reads as empty and never throws. */
17
+ export async function protectedEditReason(wt, mainBranch, files, role) {
18
+ const briefs = BRIEF_FILES.filter((f) => files.includes(f));
19
+ if (briefs.length > 0) {
20
+ const baseRev = await changeBaseRev(wt, mainBranch);
21
+ for (const file of briefs) {
22
+ const base = promptBlockOf(await fileContentAt(wt, baseRev, file));
23
+ if (base === null)
24
+ continue;
25
+ const head = promptBlockOf(readTextOrNull(path.join(wt, file)) ?? "");
26
+ if (head !== base)
27
+ return `${file} changes the brief between the tumwater:prompt markers — only you edit it (tumwater brief); restore the block`;
28
+ }
29
+ }
30
+ if (files.includes("PRINCIPLES.md") && !PRINCIPLES_ROLES.has(baseRoleOf(role)))
31
+ return `PRINCIPLES.md is edited by the ${role} role — only the director and steward roles may edit it; record an objection in PLANS.md instead and revert the edit`;
32
+ return undefined;
33
+ }
@@ -1,6 +1,6 @@
1
1
  import { agentSessionResumable } from "../agent/agent-backend.js";
2
- import { reviewRunConfig } from "../config/config-views.js";
3
- import { warnEventBestEffort } from "../events/events.js";
2
+ import { reviewRunConfig, resolvedAgentConfig } from "../config/config-views.js";
3
+ import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
4
4
  import { nativeLogPath, piLogPath } from "../paths.js";
5
5
  import { buildNoRerunPrompt, buildVerdictRequestPrompt } from "../prompt/gate-prompts.js";
6
6
  import { cappedRequestTimeouts } from "../request-timeouts.js";
@@ -17,8 +17,9 @@ async function runFollowupTurn(ctx, opts) {
17
17
  const cfg = reviewRunConfig(ctx.config);
18
18
  // The verdict/re-run follow-up continues the review session the gate just wrote. The
19
19
  // backend's own resumable rule keeps a newer line of another kind from being the session a
20
- // pi continue would actually reach; claude resumes its recorded id.
21
- if (!agentSessionResumable(dir, { kind: "gate", agent: cfg.agent ?? "pi" })) {
20
+ // pi continue would actually reach; claude resumes its recorded id (looked up under the
21
+ // team's config dir, so a team that set one continues under it).
22
+ if (!agentSessionResumable(dir, { kind: "gate", agent: cfg.agent ?? "pi" }, resolvedAgentConfig(ctx.config, "claude").configDir)) {
22
23
  return null;
23
24
  }
24
25
  return ctx.runGatePi({
@@ -39,6 +40,17 @@ async function runFollowupTurn(ctx, opts) {
39
40
  label: opts.label,
40
41
  signal: ctx.signal,
41
42
  onToolCallStalled: (message) => warnEventBestEffort(ctx.root, ctx.role, message),
43
+ // A gate run that read an image its model cannot see is logged, not killed (plans/
44
+ // image-input.md "Detecting a blind read") — same shape as the main review run in
45
+ // review.ts.
46
+ onBlindRead: (path, action) => logEventBestEffort(ctx.root, {
47
+ source: ctx.role,
48
+ type: "image_blind",
49
+ run: ctx.tick,
50
+ path,
51
+ model: cfg.model,
52
+ action,
53
+ }),
42
54
  ...(opts.onToolCallStart ? { onToolCallStart: opts.onToolCallStart } : {}),
43
55
  });
44
56
  }