tumwater 0.3.0 → 0.4.1

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 (315) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +20 -0
  5. package/dist/src/agent/claude/claude-backend.js +3 -3
  6. package/dist/src/agent/claude/claude-stream.js +170 -166
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +4 -4
  10. package/dist/src/agent/codex/codex-rollout.js +10 -21
  11. package/dist/src/agent/codex/codex-stream.js +202 -179
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/notes-shim.js +2 -2
  15. package/dist/src/agent/opencode/opencode-backend.js +4 -4
  16. package/dist/src/agent/opencode/opencode-config.js +2 -2
  17. package/dist/src/agent/opencode/opencode-stream.js +97 -88
  18. package/dist/src/agent/run-agent.js +66 -381
  19. package/dist/src/agent/run-settle.js +3 -2
  20. package/dist/src/agent/run-spawn.js +235 -0
  21. package/dist/src/agent/run-stream.js +193 -0
  22. package/dist/src/agent/write-guard.js +5 -3
  23. package/dist/src/backlog/backlog-archive.js +88 -36
  24. package/dist/src/backlog/backlog-cli-view.js +92 -98
  25. package/dist/src/backlog/backlog-cli.js +9 -64
  26. package/dist/src/backlog/backlog-commit.js +17 -9
  27. package/dist/src/backlog/backlog-eligibility.js +45 -27
  28. package/dist/src/backlog/backlog-index-data.js +159 -0
  29. package/dist/src/backlog/backlog-index.js +7 -5
  30. package/dist/src/backlog/backlog-md.js +1 -1
  31. package/dist/src/backlog/backlog-parts.js +23 -13
  32. package/dist/src/backlog/backlog-release.js +43 -33
  33. package/dist/src/backlog/backlog-render.js +1 -1
  34. package/dist/src/backlog/backlog-structure.js +0 -144
  35. package/dist/src/backlog/backlog-write.js +30 -17
  36. package/dist/src/backlog/backlog.js +9 -0
  37. package/dist/src/backlog/question-answer.js +3 -1
  38. package/dist/src/baseline/main-baseline.js +50 -15
  39. package/dist/src/baseline/main-guardrails.js +29 -78
  40. package/dist/src/baseline/main-red.js +64 -103
  41. package/dist/src/baseline/main-watch.js +89 -35
  42. package/dist/src/brief/brief-edit.js +3 -1
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +330 -0
  45. package/dist/src/budget/budget-wiring.js +167 -0
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/dep-install.js +64 -1
  50. package/dist/src/cli/cli-args.js +72 -0
  51. package/dist/src/cli/cli-flag-specs.js +38 -0
  52. package/dist/src/cli/cli-marker-commands.js +2 -2
  53. package/dist/src/cli/cli-query-commands.js +51 -41
  54. package/dist/src/cli/cli-review-commands.js +113 -0
  55. package/dist/src/cli/cli-role-command.js +140 -58
  56. package/dist/src/cli/cli-start-summary.js +10 -3
  57. package/dist/src/cli/cli-start.js +92 -38
  58. package/dist/src/cli/completion.js +21 -1
  59. package/dist/src/cli/config-commands.js +17 -8
  60. package/dist/src/cli/help.js +84 -16
  61. package/dist/src/cli/log-commands.js +137 -105
  62. package/dist/src/cli/question-commands.js +89 -64
  63. package/dist/src/cli.js +408 -286
  64. package/dist/src/collections.js +56 -6
  65. package/dist/src/config/config-editable-keys.js +3 -0
  66. package/dist/src/config/config-fallbacks.js +3 -2
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +51 -241
  70. package/dist/src/config/config-views.js +10 -2
  71. package/dist/src/config/config-write.js +209 -140
  72. package/dist/src/config/config.js +9 -3
  73. package/dist/src/doctor/doctor-backlog-bugs.js +96 -53
  74. package/dist/src/doctor/doctor-backlog-plans.js +222 -88
  75. package/dist/src/doctor/doctor-backlog.js +171 -32
  76. package/dist/src/doctor/doctor-check-names.js +7 -1
  77. package/dist/src/doctor/doctor-checks.js +26 -19
  78. package/dist/src/doctor/doctor-claude-checks.js +6 -9
  79. package/dist/src/doctor/doctor-codex-checks.js +15 -29
  80. package/dist/src/doctor/doctor-contention-checks.js +293 -134
  81. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  82. package/dist/src/doctor/doctor-holds.js +13 -15
  83. package/dist/src/doctor/doctor-model-checks.js +45 -200
  84. package/dist/src/doctor/doctor-notify.js +7 -2
  85. package/dist/src/doctor/doctor-opencode-checks.js +23 -9
  86. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  87. package/dist/src/doctor/doctor-principles.js +26 -0
  88. package/dist/src/doctor/doctor-project-checks.js +108 -13
  89. package/dist/src/doctor/doctor-role-notes.js +51 -0
  90. package/dist/src/doctor/doctor.js +17 -5
  91. package/dist/src/doctor/probe.js +14 -1
  92. package/dist/src/events/event-format.js +313 -157
  93. package/dist/src/events/event-labels.js +0 -1
  94. package/dist/src/events/event-read.js +13 -0
  95. package/dist/src/events/event-window.js +20 -2
  96. package/dist/src/events/notify.js +1 -1
  97. package/dist/src/failure/failure-cluster.js +4 -3
  98. package/dist/src/failure/failure-data.js +127 -90
  99. package/dist/src/failure/failure-render.js +62 -21
  100. package/dist/src/failure/failure-state-change.js +174 -8
  101. package/dist/src/failure/time-spend.js +136 -167
  102. package/dist/src/files/file-queue.js +17 -0
  103. package/dist/src/files/files.js +7 -0
  104. package/dist/src/gates/budget-gates.js +116 -324
  105. package/dist/src/gates/gate-polls.js +123 -184
  106. package/dist/src/gates/maintenance-quota.js +27 -17
  107. package/dist/src/gates/role-cap-gates.js +1 -1
  108. package/dist/src/gates/streak-gate.js +1 -1
  109. package/dist/src/git/commit-message.js +12 -0
  110. package/dist/src/git/git.js +18 -0
  111. package/dist/src/git/slot-salvage.js +4 -5
  112. package/dist/src/git/slots-state.js +12 -3
  113. package/dist/src/git/worktree-pool-startup.js +127 -0
  114. package/dist/src/git/worktree-pool.js +1 -120
  115. package/dist/src/gui/gui-admin-endpoints.js +1 -1
  116. package/dist/src/gui/gui-args.js +1 -1
  117. package/dist/src/gui/gui-backlog-commands.js +21 -12
  118. package/dist/src/gui/gui-backlog-endpoints.js +6 -5
  119. package/dist/src/gui/gui-command.js +19 -1
  120. package/dist/src/gui/gui-config-commands.js +126 -85
  121. package/dist/src/gui/gui-endpoint-commands.js +5 -3
  122. package/dist/src/gui/gui-endpoint-common.js +4 -3
  123. package/dist/src/gui/gui-history-endpoints.js +36 -27
  124. package/dist/src/gui/gui-operator-commands.js +175 -10
  125. package/dist/src/gui/gui-prompt-commands.js +16 -7
  126. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  127. package/dist/src/gui/gui-report-endpoints.js +14 -4
  128. package/dist/src/gui/gui-server.js +20 -3
  129. package/dist/src/gui/gui-team-commands.js +1 -1
  130. package/dist/src/history/history-data.js +7 -1
  131. package/dist/src/history/history-queued.js +8 -0
  132. package/dist/src/history/history-render.js +15 -10
  133. package/dist/src/history/history.js +3 -6
  134. package/dist/src/inbox/inbox-top.js +4 -9
  135. package/dist/src/inbox/pending-prompt.js +3 -2
  136. package/dist/src/inbox/prompt-commands.js +189 -161
  137. package/dist/src/init/init.js +43 -17
  138. package/dist/src/merge/merge-core.js +2 -25
  139. package/dist/src/merge/merge-drain.js +23 -136
  140. package/dist/src/merge/merge-git.js +5 -5
  141. package/dist/src/merge/merge-pipeline.js +1 -1
  142. package/dist/src/merge/merge-post-review-drain.js +214 -0
  143. package/dist/src/merge/merge-queue-render.js +11 -12
  144. package/dist/src/merge/merge-queue.js +24 -2
  145. package/dist/src/merge/merge-requeue.js +1 -0
  146. package/dist/src/merge/merge-stack.js +1 -7
  147. package/dist/src/merge/merge-to-main.js +2 -10
  148. package/dist/src/merge/merge-top.js +33 -19
  149. package/dist/src/merge/merge-verify.js +65 -9
  150. package/dist/src/notes-cli.js +8 -3
  151. package/dist/src/operator/operator-commands.js +16 -5
  152. package/dist/src/operator/operator-intent.js +26 -3
  153. package/dist/src/operator/revert.js +18 -12
  154. package/dist/src/orchestrator/orchestrator-poll.js +49 -17
  155. package/dist/src/orchestrator/orchestrator-scheduling.js +4 -2
  156. package/dist/src/orchestrator/orchestrator.js +6 -5
  157. package/dist/src/orchestrator/scheduling-scope.js +51 -7
  158. package/dist/src/orchestrator/scheduling-skip.js +15 -5
  159. package/dist/src/paths.js +26 -10
  160. package/dist/src/pi/pi-backend.js +2 -2
  161. package/dist/src/pi/pi-bin.js +2 -2
  162. package/dist/src/pi/pi-models.js +9 -0
  163. package/dist/src/pi/pi-stream.js +63 -41
  164. package/dist/src/pi/pi-watchdogs.js +65 -49
  165. package/dist/src/pi-extension/full-output.js +2 -3
  166. package/dist/src/process/process.js +1 -1
  167. package/dist/src/process/run-marker.js +10 -2
  168. package/dist/src/process/supervisor.js +7 -4
  169. package/dist/src/prompt/gate-notes.js +35 -10
  170. package/dist/src/prompt/gate-prompts.js +30 -7
  171. package/dist/src/prompt/notes-backlog.js +24 -15
  172. package/dist/src/prompt/principles.js +11 -0
  173. package/dist/src/prompt/prompt.js +13 -7
  174. package/dist/src/redeploy/redeploy-probes.js +11 -1
  175. package/dist/src/redeploy/redeployer.js +54 -7
  176. package/dist/src/redeploy/self-reload.js +7 -2
  177. package/dist/src/report/report-data.js +322 -223
  178. package/dist/src/report/report-fold.js +257 -191
  179. package/dist/src/report/report-main-red.js +64 -0
  180. package/dist/src/report/report-render.js +133 -25
  181. package/dist/src/report/report.js +2 -5
  182. package/dist/src/review/diff-rules.js +53 -0
  183. package/dist/src/review/known-flakes.js +32 -1
  184. package/dist/src/review/post-merge-review-accept.js +51 -0
  185. package/dist/src/review/post-merge-review-render.js +112 -0
  186. package/dist/src/review/post-merge-review.js +159 -24
  187. package/dist/src/review/protected-edits.js +22 -7
  188. package/dist/src/review/quarantine-render.js +82 -0
  189. package/dist/src/review/review-precheck.js +2 -1
  190. package/dist/src/review/review.js +33 -49
  191. package/dist/src/roles/role-catalog.js +73 -2
  192. package/dist/src/roles/role-guidance.js +18 -0
  193. package/dist/src/roles/role-labels.js +6 -3
  194. package/dist/src/roles/role-render.js +62 -8
  195. package/dist/src/roles/role-view.js +54 -12
  196. package/dist/src/roles/roles.js +3 -23
  197. package/dist/src/run/ci-parity.js +121 -0
  198. package/dist/src/run/feedback-filings.js +10 -1
  199. package/dist/src/run/qa-coverage.js +50 -15
  200. package/dist/src/run/run-apply.js +14 -2
  201. package/dist/src/run/run-detail-data.js +36 -16
  202. package/dist/src/run/run-detail-render.js +27 -9
  203. package/dist/src/run/run-finalize.js +76 -46
  204. package/dist/src/run/run-prompt.js +231 -177
  205. package/dist/src/run/run-stage.js +168 -70
  206. package/dist/src/run/run-verdict.js +127 -13
  207. package/dist/src/run/stage-backlog-checks.js +2 -2
  208. package/dist/src/run/stage-check.js +17 -11
  209. package/dist/src/run/stage-checks-shared.js +80 -0
  210. package/dist/src/run/stage-name-checks.js +194 -0
  211. package/dist/src/run/stage-path-checks.js +35 -537
  212. package/dist/src/run/stage-prose-checks.js +261 -0
  213. package/dist/src/runner/leftover.js +61 -21
  214. package/dist/src/runner/revision.js +14 -25
  215. package/dist/src/runner/runner-fallback.js +1 -1
  216. package/dist/src/runner/runner-outcome.js +4 -2
  217. package/dist/src/runner/runner-pi.js +72 -49
  218. package/dist/src/runner/runner-recovery.js +18 -3
  219. package/dist/src/runner/runner-run.js +36 -29
  220. package/dist/src/runner/runner-state.js +4 -3
  221. package/dist/src/scheduling/claim-entries.js +49 -0
  222. package/dist/src/scheduling/claims.js +1 -1
  223. package/dist/src/status/status-data.js +144 -86
  224. package/dist/src/status/status-polls.js +56 -5
  225. package/dist/src/status/status-role-notes.js +49 -0
  226. package/dist/src/status/status-roles.js +19 -2
  227. package/dist/src/team/reclaim.js +2 -2
  228. package/dist/src/text/datetime.js +3 -2
  229. package/dist/src/text/format.js +13 -1
  230. package/dist/src/text/phrases.js +22 -5
  231. package/dist/src/text/text.js +11 -0
  232. package/dist/src/ui/alert-feeds.js +320 -0
  233. package/dist/src/ui/badges.js +35 -2
  234. package/dist/src/ui/gui/gui-client-backlog.js +25 -3
  235. package/dist/src/ui/gui/gui-client-boot.js +102 -74
  236. package/dist/src/ui/gui/gui-client-drawer-config.js +64 -17
  237. package/dist/src/ui/gui/gui-client-drawer.js +46 -5
  238. package/dist/src/ui/gui/gui-client-feed.js +4 -9
  239. package/dist/src/ui/gui/gui-client-history.js +90 -88
  240. package/dist/src/ui/gui/gui-client-labels.js +5 -1
  241. package/dist/src/ui/gui/gui-client-model.js +9 -9
  242. package/dist/src/ui/gui/gui-client-operator.js +11 -11
  243. package/dist/src/ui/gui/gui-client-pending.js +189 -12
  244. package/dist/src/ui/gui/gui-client-report-chart.js +137 -10
  245. package/dist/src/ui/gui/gui-client-report.js +81 -6
  246. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  247. package/dist/src/ui/gui/gui-client-settings.js +138 -4
  248. package/dist/src/ui/gui/gui-client-team.js +13 -2
  249. package/dist/src/ui/gui/gui-client.js +44 -5
  250. package/dist/src/ui/gui/gui-page.js +1 -0
  251. package/dist/src/ui/gui/gui-styles.js +4 -0
  252. package/dist/src/ui/progress-data.js +2 -2
  253. package/dist/src/ui/run-progress-model.js +4 -3
  254. package/dist/src/ui/status-model.js +24 -14
  255. package/dist/src/ui/status-payload.js +168 -117
  256. package/dist/src/ui/status-render.js +140 -81
  257. package/dist/src/ui/team-alert-backlog-feed.js +181 -0
  258. package/dist/src/ui/team-alert-backlog.js +267 -0
  259. package/dist/src/ui/team-alert-merge.js +183 -0
  260. package/dist/src/ui/team-alert-shared.js +79 -0
  261. package/dist/src/ui/team-alert-status.js +321 -0
  262. package/dist/src/ui/team-alert-tail.js +251 -0
  263. package/dist/src/ui/team-alerts.js +31 -513
  264. package/dist/src/ui/tone.js +7 -0
  265. package/dist/src/ui/tui/tui-backlog.js +8 -6
  266. package/dist/src/ui/tui/tui-editor-attach.js +1 -7
  267. package/dist/src/ui/tui/tui-editor-backends.js +1 -6
  268. package/dist/src/ui/tui/tui-editor-brief.js +4 -24
  269. package/dist/src/ui/tui/tui-editor-budget.js +10 -12
  270. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  271. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  272. package/dist/src/ui/tui/tui-editor-concurrency.js +1 -0
  273. package/dist/src/ui/tui/tui-editor-config.js +19 -6
  274. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  275. package/dist/src/ui/tui/tui-editor-entry.js +3 -17
  276. package/dist/src/ui/tui/tui-editor-instances.js +9 -0
  277. package/dist/src/ui/tui/tui-editor-instructions.js +51 -127
  278. package/dist/src/ui/tui/tui-editor-interval.js +51 -113
  279. package/dist/src/ui/tui/tui-editor-notes.js +29 -103
  280. package/dist/src/ui/tui/tui-editor-pause.js +2 -1
  281. package/dist/src/ui/tui/tui-editor-prompt.js +2 -1
  282. package/dist/src/ui/tui/tui-editor-quiet.js +4 -13
  283. package/dist/src/ui/tui/tui-editor-release.js +77 -0
  284. package/dist/src/ui/tui/tui-editor-retire.js +1 -7
  285. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  286. package/dist/src/ui/tui/tui-editor-role-file.js +75 -0
  287. package/dist/src/ui/tui/tui-editor-role-key.js +3 -7
  288. package/dist/src/ui/tui/tui-editor-session.js +44 -0
  289. package/dist/src/ui/tui/tui-editor-slots.js +1 -0
  290. package/dist/src/ui/tui/tui-editors.js +13 -1
  291. package/dist/src/ui/tui/tui-frame.js +23 -7
  292. package/dist/src/ui/tui/tui-history.js +12 -10
  293. package/dist/src/ui/tui/tui-keys.js +14 -0
  294. package/dist/src/ui/tui/tui-pane-activity.js +21 -8
  295. package/dist/src/ui/tui/tui-pane-backlog.js +36 -9
  296. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  297. package/dist/src/ui/tui/tui-pane-health.js +153 -3
  298. package/dist/src/ui/tui/tui-pane-history.js +308 -228
  299. package/dist/src/ui/tui/tui-pane-pending.js +285 -56
  300. package/dist/src/ui/tui/tui-pane-queued.js +7 -2
  301. package/dist/src/ui/tui/tui-pane-report.js +9 -3
  302. package/dist/src/ui/tui/tui-pane-session.js +12 -0
  303. package/dist/src/ui/tui/tui-pane-transcript.js +12 -8
  304. package/dist/src/ui/tui/tui-pane.js +16 -10
  305. package/dist/src/ui/tui/tui-panes.js +247 -134
  306. package/dist/src/ui/tui/tui.js +8 -5
  307. package/dist/src/verdict/fix-claim.js +47 -1
  308. package/dist/src/verdict/refusal.js +2 -1
  309. package/dist/src/verdict/reply-contract.js +35 -0
  310. package/package.json +1 -1
  311. package/dist/src/baseline/main-culprit.js +0 -94
  312. package/dist/src/baseline/main-revert.js +0 -133
  313. package/dist/src/merge/merge-post-review.js +0 -90
  314. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  315. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -38,6 +38,21 @@ function rankedRoleTotals(series, pick) {
38
38
  function rankedRoleLine(label, pairs, fmt) {
39
39
  return `**${label}:** ${pairs.length === 0 ? "-" : pairs.map(([r, n]) => `${r} — ${fmt(n)}`).join(" · ")}`;
40
40
  }
41
+ /** The "**Prompt tokens by role:**" line — each role's prompt-token total and, when its
42
+ * backend reported a cache read, the share of it served from cache (`12000 (80% cached)`)
43
+ * — with zero-total roles omitted (the same zero-means-absent rule the sibling lines apply)
44
+ * and "-" when no role sent a prompt. `prompt` arrives already ranked (prompt desc then
45
+ * name asc). The one home of the share format, so the day and `--since` lines cannot drift. */
46
+ function promptTokensLine(prompt, cache) {
47
+ const cacheByRole = new Map(cache);
48
+ const cells = prompt
49
+ .filter(([, n]) => n > 0)
50
+ .map(([role, n]) => {
51
+ const c = cacheByRole.get(role) ?? 0;
52
+ return `${role} — ${compactTokens(n)}${c > 0 ? ` (${Math.round((100 * c) / n)}% cached)` : ""}`;
53
+ });
54
+ return `**Prompt tokens by role:** ${cells.length === 0 ? "-" : cells.join(" · ")}`;
55
+ }
41
56
  /** Push one "**Label:** …" ranked-role line, dropping roles with a non-positive value (zero
42
57
  * count/spend) before rendering, so an all-zero window still renders "-" exactly like the
43
58
  * unfiltered lines. fmt formats each role's number (default String for count lines; pass
@@ -179,6 +194,11 @@ export function renderSinceReportMarkdown(data) {
179
194
  // resource the budget charges, so this is the line an operator reads to find which role's
180
195
  // prompt loop burned the window's tokens.
181
196
  pushRankedRoleLine(lines, "Tokens by role", rankedRoleMap(data.tokensByRole), compactTokens);
197
+ // The prompt side of the same breakdown, straight after the output-token line: how many
198
+ // prompt tokens each role sent and the share of them its backend served from cache — the
199
+ // input side of the bill an operator cannot see from Tokens by role. Zero-prompt roles are
200
+ // omitted, and an all-zero window renders "-" like the sibling lines.
201
+ lines.push(promptTokensLine(rankedRoleMap(data.promptTokensByRole ?? {}), rankedRoleMap(data.cacheReadTokensByRole ?? {})));
182
202
  // Same breakdown for spend: ranked by spend desc then name asc, zero-spend roles omitted —
183
203
  // an all-zero window renders "-" like the runs line.
184
204
  pushRankedRoleLine(lines, "Cost by role", rankedRoleMap(data.costByRole), usd);
@@ -202,12 +222,27 @@ export function renderSinceReportMarkdown(data) {
202
222
  // a window main was never red.
203
223
  if (data.mainRedMs > 0)
204
224
  lines.push(`**Main red:** ${durationLabel(data.mainRedMs)}`);
225
+ // The same "what kept main red" detail named by failing test — which tests main's own red
226
+ // checks named over the window. Team-only like main's red time, so a role-scoped window
227
+ // omits it; zero-absent: no line when the window's red checks named no test.
228
+ if (data.role === undefined) {
229
+ const mainRedByTest = rankedRoleMap(data.mainRedByTest ?? {}).filter(([, n]) => n > 0);
230
+ if (mainRedByTest.length > 0)
231
+ lines.push(rankedRoleLine("Main red by test", mainRedByTest, String));
232
+ // The same "what kept main red" detail named by merge — the landed merges the watch's
233
+ // own main_culprit events blamed, mirroring the failing-test line right above. Team-only
234
+ // like main's red time and zero-absent: no line when the window blamed no merge.
235
+ const mainRedByCulprit = rankedRoleMap(data.mainRedByCulprit ?? {}).filter(([, n]) => n > 0);
236
+ if (mainRedByCulprit.length > 0)
237
+ lines.push(rankedRoleLine("Main red by merge", mainRedByCulprit, String));
238
+ }
205
239
  // The same breakdown for review friction: rejections per role from review_rejected events;
206
- // a window with none renders "-" like the other lines.
240
+ // a window with none renders "-" like the other lines. The post-merge line is the
241
+ // afterMerge: true subset — rejections that reached an already-merged commit.
207
242
  pushRankedRoleLine(lines, "Rejections by role", rankedRoleMap(data.rejectionsByRole));
243
+ pushRankedRoleLine(lines, "Post-merge rejections by role", rankedRoleMap(data.postMergeRejectionsByRole));
208
244
  pushRankedRoleLine(lines, "Revisions by role", rankedRoleMap(data.revisionsByRole));
209
245
  pushRankedRoleLine(lines, "Refusals by role", rankedRoleMap(data.refusalsByRole));
210
- pushRankedRoleLine(lines, "Reverts by role", rankedRoleMap(data.revertsByRole));
211
246
  // The same breakdown for outright failures: error-class run_ends per role under the
212
247
  // digest's OUTCOME_CLASS — the runs that burned agent time and merged nothing without
213
248
  // going through review, which rejections/revisions would miss entirely. A window with
@@ -245,10 +280,18 @@ export function renderSinceReportMarkdown(data) {
245
280
  if (mergeFailureReasons.length > 0)
246
281
  lines.push(rankedRoleLine("Merge failures by reason", mergeFailureReasons, String));
247
282
  }
283
+ // Spend per merged commit, the --since twin of the day render's `Cost per commit:` line:
284
+ // costByRole is keyed by role id (instances not folded, so `feature-2` joins `feature`),
285
+ // commitsByRole is already base-role keyed. Prints for a spend-free window as `-` and for a
286
+ // role-scoped window from its scoped maps, matching the day render.
287
+ lines.push(costPerCommitLine("Cost per commit", costPerCommitPairsFrom(foldBaseRoles(data.costByRole), data.commitsByRole)));
248
288
  // Subscription spend is notional: its own line, only when the window has any — the same
249
289
  // conditional/filtered `usd` form the day render's "Estimated by role" uses.
250
290
  if (data.notionalByRole !== undefined) {
251
291
  pushRankedRoleLine(lines, "Estimated by role", rankedRoleMap(data.notionalByRole), usd);
292
+ // The day render's `Estimated per commit:` twin, after `Estimated by role`, from the same
293
+ // notionalByRole source folded to base roles and the base-keyed commitsByRole.
294
+ lines.push(costPerCommitLine("Estimated per commit", costPerCommitPairsFrom(foldBaseRoles(data.notionalByRole), data.commitsByRole)));
252
295
  }
253
296
  lines.push("");
254
297
  // Exactly one final line, chosen by the collector's coverage proof: either a hedged note
@@ -264,22 +307,21 @@ export function renderSinceReportMarkdown(data) {
264
307
  lines.push("backlog tallies (features done / bugs fixed / plans added / bugs filed) need the day report (--days)");
265
308
  return lines.join("\n");
266
309
  }
267
- /** Cost per merged commit by base role. Spend comes from costByRole (keyed by role id, folded to
268
- * its base role so `feature-2` joins `feature`), commits from commitsByRole (already base-role
269
- * keyed). `merged` is [role, cost per commit, commits] for roles with both, ranked by cost per
310
+ /** Fold every key of a per-role-id map through its base role, so `feature-2` joins `feature`.
311
+ * The same fold the day caller does per day, applied to an already-aggregated window map. */
312
+ function foldBaseRoles(map) {
313
+ const folded = {};
314
+ for (const [id, c] of Object.entries(map))
315
+ addTo(folded, baseRoleOf(id), c);
316
+ return folded;
317
+ }
318
+ /** Rank one cost map against a base-keyed commits map into cost-per-commit output: the shared
319
+ * core of the day and --since cost-per-commit lines. `cost` is expected base-role keyed (the
320
+ * day caller folds per day, the --since caller via `foldBaseRoles`); commits are base-role
321
+ * keyed. `merged` is [role, cost per commit, commits] for roles with both, ranked by cost per
270
322
  * commit desc then name asc; `idle` is roles that spent and merged nothing, ranked by spend.
271
- * Merge spend (the reviewer's) and notional subscription spend stay out by default, matching
272
- * `**Cost by role:**` (`pick` selects another per-role-id spend map, e.g. notionalByRole); a
273
- * role with commits but no spend in the picked map appears in neither list. */
274
- function costPerCommitPairs(series, pick = (d) => d.costByRole) {
275
- const cost = {};
276
- const commits = {};
277
- for (const d of series) {
278
- for (const [id, c] of Object.entries(pick(d)))
279
- addTo(cost, baseRoleOf(id), c);
280
- for (const [role, n] of Object.entries(d.commitsByRole ?? {}))
281
- addTo(commits, role, n);
282
- }
323
+ * A role with commits but no spend appears in neither list. */
324
+ function costPerCommitPairsFrom(cost, commits) {
283
325
  const mergeDone = [];
284
326
  const idleCost = [];
285
327
  for (const [role, c] of Object.entries(cost)) {
@@ -294,6 +336,22 @@ function costPerCommitPairs(series, pick = (d) => d.costByRole) {
294
336
  sortByValueDesc(mergeDone);
295
337
  return { mergeDone, idle: rankCountEntries(idleCost) };
296
338
  }
339
+ /** The day caller of the cost-per-commit core: fold each day's role-id-keyed spend map through
340
+ * its base role (so `feature-2` joins `feature`) and aggregate the base-keyed commitsByRole,
341
+ * then hand both to `costPerCommitPairsFrom`. `pick` selects the per-role-id spend map; merge
342
+ * spend (the reviewer's) and notional subscription spend stay out by default, matching
343
+ * `**Cost by role:**`. */
344
+ function costPerCommitPairs(series, pick = (d) => d.costByRole) {
345
+ const cost = {};
346
+ const commits = {};
347
+ for (const d of series) {
348
+ for (const [id, c] of Object.entries(pick(d)))
349
+ addTo(cost, baseRoleOf(id), c);
350
+ for (const [role, n] of Object.entries(d.commitsByRole ?? {}))
351
+ addTo(commits, role, n);
352
+ }
353
+ return costPerCommitPairsFrom(cost, commits);
354
+ }
297
355
  /** One `**<label>:** …` line from costPerCommitPairs output; `-` when there is nothing. */
298
356
  function costPerCommitLine(label, pairs) {
299
357
  const parts = [
@@ -302,11 +360,8 @@ function costPerCommitLine(label, pairs) {
302
360
  ];
303
361
  return `**${label}:** ${parts.length === 0 ? "-" : parts.join(" · ")}`;
304
362
  }
305
- /** Render a report as Markdown — the pinned shape the CLI prints and the TUI usage pane
306
- * (report 3/3) reuses; the GUI tab (report 2/3) renders its own SVG charts from the same
307
- * ReportData instead. Pure function of ReportData: no I/O, no clock reads. */
308
- export function renderReportMarkdown(data) {
309
- const lines = [];
363
+ /** The title, window line, optional role line, then the report summary line. */
364
+ function pushReportHeader(lines, data) {
310
365
  lines.push("# tumwater usage report");
311
366
  lines.push("");
312
367
  lines.push(reportWindow(data.from, data.to, data.days, eventsRotationLabel()));
@@ -324,6 +379,9 @@ export function renderReportMarkdown(data) {
324
379
  : "";
325
380
  pushReportSummary(lines, t, tail, data.role !== undefined);
326
381
  lines.push("");
382
+ }
383
+ /** The day-by-day window table (day, tokens out, runs, commits, cost) and its blank line. */
384
+ function pushReportSeriesTable(lines, data) {
327
385
  const maxTokens = data.series.reduce((m, d) => Math.max(m, d.tokensOut), 0);
328
386
  lines.push(...markdownTable(["day", "tokens out", "runs", "commits", "cost"], data.series.map((d) => {
329
387
  const runs = Object.values(d.runsByRole).reduce((a, b) => a + b, 0);
@@ -332,16 +390,26 @@ export function renderReportMarkdown(data) {
332
390
  return [monthDay(d.date), `${compactTokens(d.tokensOut)}${bar}`, String(runs), String(d.commits), usd(d.costUsd)];
333
391
  }), ["left", "right", "right", "right", "right"]));
334
392
  lines.push("");
393
+ }
394
+ /** The per-role runs/tokens/prompt/cost breakdown lines. */
395
+ function pushReportRoleLines(lines, data) {
335
396
  lines.push(rankedRoleLine("Runs by role", rankedRoleTotals(data.series, (d) => d.runsByRole), String));
336
397
  // The same breakdown for output tokens (the resource the budget charges): window totals per
337
398
  // role from tokensByRole, ranked like the runs line. Zero-token roles are omitted; an
338
399
  // all-zero window renders "-" like the runs line.
339
400
  pushRankedRoleLine(lines, "Tokens by role", rankedRoleTotals(data.series, (d) => d.tokensByRole ?? {}), compactTokens);
401
+ // The input side of that same bill, straight after the output-token line: each role's prompt
402
+ // totals and, when some run reported a cache read, the share of them served from cache — so
403
+ // the same report that says who burned output tokens also says whose prompts are cached.
404
+ lines.push(promptTokensLine(rankedRoleTotals(data.series, (d) => d.promptTokensByRole ?? {}), rankedRoleTotals(data.series, (d) => d.cacheReadTokensByRole ?? {})));
340
405
  // The same breakdown for spend: window totals per role from costByRole, ranked by spend
341
406
  // desc then name asc — spend is what the operator acts on (an operator tuning per-role
342
407
  // intervals wants the burning role first, not the busiest one). Zero-spend roles are
343
408
  // omitted; an all-zero window renders "-" like the runs line.
344
409
  pushRankedRoleLine(lines, "Cost by role", rankedRoleTotals(data.series, (d) => d.costByRole), usd);
410
+ }
411
+ /** The team-only per-provider and per-model breakdown lines. */
412
+ function pushReportProviderLines(lines, data) {
345
413
  // The same breakdown per PROVIDER (plans: per-provider spend): which seam of a mixed team
346
414
  // spent what, straight from the events' providerCost maps (run_end's own runs and the
347
415
  // merge's reviewer seam). Gated to the team report — a provider is not a role, so a
@@ -360,12 +428,16 @@ export function renderReportMarkdown(data) {
360
428
  if (modelPairs.length > 0)
361
429
  lines.push(rankedRoleLine("Runs by model", modelPairs, String));
362
430
  }
431
+ }
432
+ /** The per-role review-friction and outright-failure lines. */
433
+ function pushReportFrictionLines(lines, data) {
363
434
  // The same breakdown for review friction: window rejections per role from review_rejected
364
- // events, ranked like the runs line; a window with none renders "-".
435
+ // events, ranked like the runs line; a window with none renders "-". The post-merge line
436
+ // is the afterMerge: true subset — rejections that reached an already-merged commit.
365
437
  pushRankedRoleLine(lines, "Rejections by role", rankedRoleTotals(data.series, (d) => d.rejectionsByRole ?? {}));
438
+ pushRankedRoleLine(lines, "Post-merge rejections by role", rankedRoleTotals(data.series, (d) => d.postMergeRejectionsByRole ?? {}));
366
439
  pushRankedRoleLine(lines, "Revisions by role", rankedRoleTotals(data.series, (d) => d.revisionsByRole ?? {}));
367
440
  pushRankedRoleLine(lines, "Refusals by role", rankedRoleTotals(data.series, (d) => d.refusalsByRole ?? {}));
368
- pushRankedRoleLine(lines, "Reverts by role", rankedRoleTotals(data.series, (d) => d.revertsByRole ?? {}));
369
441
  // The same breakdown for outright failures: error-class run_ends per role under the
370
442
  // digest's OUTCOME_CLASS — runs that burned agent time and merged nothing without going
371
443
  // through review, which rejections/revisions would miss entirely. Zero-means-absent, so a
@@ -373,6 +445,9 @@ export function renderReportMarkdown(data) {
373
445
  pushRankedRoleLine(lines, "Failed runs by role", rankedRoleTotals(data.series, (d) => d.failedRunsByRole ?? {}));
374
446
  pushRankedRoleLine(lines, "Fallbacks by role", rankedRoleTotals(data.series, (d) => d.fallbacksByRole ?? {}));
375
447
  pushRankedRoleLine(lines, "Image reroutes by role", rankedRoleTotals(data.series, (d) => d.imageReroutesByRole ?? {}));
448
+ }
449
+ /** The team-only deferred-runs-by-reason line. */
450
+ function pushReportDeferralLines(lines, data) {
376
451
  // Deferred maintenance runs per reason code — how many due maintenance runs stood down
377
452
  // and why, so an idle day reads as backlog-open / no-work / plans-full rather than blank,
378
453
  // the per-role breakdown of the deferred maintenances. Gated to the team report — a
@@ -383,6 +458,9 @@ export function renderReportMarkdown(data) {
383
458
  if (deferred.length > 0)
384
459
  lines.push(rankedRoleLine("Deferred runs by reason", deferred, String));
385
460
  }
461
+ }
462
+ /** The Commits-by-role line plus the team-only merge/main-red/cameo block. */
463
+ function pushReportMergeLines(lines, data) {
386
464
  // Work ratio 4/4: the merged commits split by base role and by tier. The totals line's
387
465
  // `commits` carries the count; these lines say who made it and how much of it was work.
388
466
  lines.push(rankedRoleLine("Commits by role", rankedRoleTotals(data.series, (d) => d.commitsByRole ?? {}), String));
@@ -411,9 +489,21 @@ export function renderReportMarkdown(data) {
411
489
  // suite went red before passing shows here. Zero-absent: no line when no day fails.
412
490
  pushDayCameoLine(lines, data.series, "Red merge checks", (d) => d.checkFailures, (n) => `×${n}`);
413
491
  // Per-day main red wall-clock, beside the red-check counts above: how long each day main
414
- // had no passing full check (the span every code role was held). Team-only (main's own
492
+ // had its own check red (the span main had no passing full check). Team-only (main's own
415
493
  // red time, not any role's) and zero-absent: no line when no day was red.
416
494
  pushDayCameoLine(lines, data.series, "Main red", (d) => d.mainRedMs, durationLabel);
495
+ // The day twin of the --since window's totals line: the same tests ranked across the
496
+ // series' per-day main-red maps (Main red by test). Zero-absent: no line when no day
497
+ // named a failing test.
498
+ const mainRedByTest = rankedRoleTotals(data.series, (d) => d.mainRedByTest ?? {}).filter(([, n]) => n > 0);
499
+ if (mainRedByTest.length > 0)
500
+ lines.push(rankedRoleLine("Main red by test", mainRedByTest, String));
501
+ // The day twin of the --since window's merges line: the same culprits ranked across the
502
+ // series' per-day main-red maps (Main red by merge). Zero-absent: no line when no day
503
+ // blamed a merge.
504
+ const mainRedByCulprit = rankedRoleTotals(data.series, (d) => d.mainRedByCulprit ?? {}).filter(([, n]) => n > 0);
505
+ if (mainRedByCulprit.length > 0)
506
+ lines.push(rankedRoleLine("Main red by merge", mainRedByCulprit, String));
417
507
  pushDayCameoLine(lines, data.series, "Merge wait median", (d) => d.mergeWaitMedianMs);
418
508
  // How long the merge slot itself held each change it landed, beside the merge-wait median:
419
509
  // the queue backlog (merge wait minus slot time) apart from the slot's own review/check/
@@ -427,6 +517,10 @@ export function renderReportMarkdown(data) {
427
517
  pushDayCameoLine(lines, data.series, "Workspace wait median", (d) => d.slotWaitMedianMs);
428
518
  pushDayCameoLine(lines, data.series, "Check wait median", (d) => d.checkWaitMedianMs);
429
519
  }
520
+ }
521
+ /** Cost/commits tail: cost per commit, notional block, lag, validation gaps, sparse note. */
522
+ function pushReportTail(lines, data) {
523
+ const t = data.totals;
430
524
  lines.push(costPerCommitLine("Cost per commit", costPerCommitPairs(data.series)));
431
525
  // Subscription spend is notional: its own two lines, only when the window has any.
432
526
  if ((t.notionalCostUsd ?? 0) > 0) {
@@ -464,5 +558,19 @@ export function renderReportMarkdown(data) {
464
558
  // it prints — an empty or fully covered log never claims a history it cannot see.
465
559
  if (!data.coversFullWindow)
466
560
  lines.push("", SPARSE_WINDOW_NOTE);
561
+ }
562
+ /** Render a report as Markdown — the pinned shape the CLI prints and the TUI usage pane
563
+ * (report 3/3) reuses; the GUI tab (report 2/3) renders its own SVG charts from the same
564
+ * ReportData instead. Pure function of ReportData: no I/O, no clock reads. */
565
+ export function renderReportMarkdown(data) {
566
+ const lines = [];
567
+ pushReportHeader(lines, data);
568
+ pushReportSeriesTable(lines, data);
569
+ pushReportRoleLines(lines, data);
570
+ pushReportProviderLines(lines, data);
571
+ pushReportFrictionLines(lines, data);
572
+ pushReportDeferralLines(lines, data);
573
+ pushReportMergeLines(lines, data);
574
+ pushReportTail(lines, data);
467
575
  return lines.join("\n");
468
576
  }
@@ -8,7 +8,7 @@ import { renderFailureMarkdown } from "../failure/failure-render.js";
8
8
  import { renderReportMarkdown, renderSinceReportMarkdown } from "./report-render.js";
9
9
  import { fail, say, sayJson, sayJsonOrRender } from "../cli/cli-output.js";
10
10
  import { followView } from "../cli/follow-view.js";
11
- import { durationLabel, failRivalShapes, flagValue, parseCountFlag, parseRoleListScope, parseSinceFlag } from "../cli/cli-args.js";
11
+ import { failRivalShapes, failUntilShorterThanSince, flagValue, parseCountFlag, parseRoleListScope, parseSinceFlag } from "../cli/cli-args.js";
12
12
  import { hasFollowFlag } from "../cli/cli-flag-specs.js";
13
13
  import { REPORT_DEFAULT_DAYS, REPORT_MAX_DAYS, REPORT_SINCE_MAX_MS } from "../events/event-window.js";
14
14
  /** `tumwater report [--days <n>] [--failures] [--since <duration>]
@@ -20,10 +20,7 @@ export async function cmdReport(root, args, seams = {}) {
20
20
  // trailing window vs a series over whole days), not a modifier of either.
21
21
  const ms = parseSinceFlag(args, "report --since", REPORT_SINCE_MAX_MS);
22
22
  const untilMs = parseSinceFlag(args, "report --until", REPORT_SINCE_MAX_MS, "--until");
23
- if (untilMs !== null && ms === null)
24
- fail("report --until requires --since");
25
- if (untilMs !== null && ms !== null && untilMs >= ms)
26
- fail(`report --until (${durationLabel(untilMs)}) must be shorter than --since (${durationLabel(ms)})`);
23
+ failUntilShorterThanSince("report", untilMs, ms);
27
24
  // --role scopes both report shapes: the usage report (day and --since) and the failure
28
25
  // digest, whose day and --since collectors take the same optional list of base roles.
29
26
  const role = parseRoleListScope(root, args);
@@ -0,0 +1,53 @@
1
+ /** The review gate's five deterministic diff rules, in the gate's own order and scope — the one
2
+ * call site both the gate (review.ts) and the main watch (main-watch.ts) use, so a finding reads
3
+ * the same on a changed tree and on main's tip. `baseRev` is a commit or branch name the async
4
+ * rules resolve with changeBaseRev (merge-base of HEAD and it), so the watch passes a commit
5
+ * while the gate passes the main branch. Runs conflictMarkerReason, then protectedEditReason,
6
+ * then secretLeakReason, then — only for an exempt (md-only) diff — falseFixReason, then
7
+ * backlogStructureReason for every diff, short-circuiting on the first finding exactly as the
8
+ * original gate did: a later rule never runs once an earlier one already rejects. Returns a
9
+ * one-reason array when a rule fires (the reason the gate rejects on and the watch reports) and
10
+ * `[]` when nothing is wrong. An empty `role` (the watch cannot name the tip's merging role) is
11
+ * no basis for calling a PRINCIPLES.md edit unauthorized, so the role-dependent half of the
12
+ * protected-edit rule is skipped — but its role-independent half, the brief-block rule
13
+ * (briefEditReason), still runs, so a brief edit on an unattributed head is still reported; the
14
+ * gate always passes a known role, so its rejections are unchanged.
15
+ *
16
+ * This module lives apart from review-precheck.ts so the main watch can share the rules without a
17
+ * runtime import cycle: review-precheck.ts imports src/baseline/main-watch.ts
18
+ * (onlyKnownMainFailures), so a main-watch -> review-precheck import edge would make the two
19
+ * modules mutually depend on each other at runtime (docs/code-metrics.md records "no runtime
20
+ * import cycles"). diff-rules.ts's own imports (merge-git, protected-edits, secret-leaks,
21
+ * fix-claim, backlog-structure) import nothing from main-watch or review-precheck, so it is a
22
+ * cycle-free home both consumers can import. */
23
+ import { conflictMarkerReason } from "../merge/merge-git.js";
24
+ import { briefEditReason, protectedEditReason } from "./protected-edits.js";
25
+ import { secretLeakReason } from "./secret-leaks.js";
26
+ import { falseFixReason } from "../verdict/fix-claim.js";
27
+ import { backlogStructureReason } from "../backlog/backlog-structure.js";
28
+ export async function diffRuleReasons(wt, baseRev, files, role, exempt) {
29
+ const marker = conflictMarkerReason(wt, files);
30
+ if (marker !== undefined)
31
+ return [marker];
32
+ // The brief-block rule is role-independent — its edit is forbidden for every role alike — so it
33
+ // runs even when the watch cannot name the tip's merging role (role === ""). Only the
34
+ // PRINCIPLES.md half of protectedEditReason needs a known role to condemn an edit.
35
+ const briefReason = await briefEditReason(wt, baseRev, files);
36
+ if (briefReason !== undefined)
37
+ return [briefReason];
38
+ const protectedReason = role === "" ? undefined : await protectedEditReason(wt, baseRev, files, role);
39
+ if (protectedReason !== undefined)
40
+ return [protectedReason];
41
+ const secretReason = await secretLeakReason(wt, baseRev, files);
42
+ if (secretReason !== undefined)
43
+ return [secretReason];
44
+ if (exempt) {
45
+ const falseFix = await falseFixReason(wt, baseRev, files);
46
+ if (falseFix)
47
+ return [falseFix];
48
+ }
49
+ const structure = await backlogStructureReason(wt, baseRev, files);
50
+ if (structure)
51
+ return [structure];
52
+ return [];
53
+ }
@@ -10,7 +10,7 @@
10
10
  * evidence of flakiness — it is exactly the attribution this memory exists to avoid making
11
11
  * twice. */
12
12
  import { DAY_MS } from "../text/datetime.js";
13
- import { TUMWATER_SOURCE, tryOrWarn } from "../events/events.js";
13
+ import { TUMWATER_SOURCE, tryOrWarn, warnEventBestEffort } from "../events/events.js";
14
14
  import { readEventsSince } from "../events/event-window.js";
15
15
  import { increment } from "../collections.js";
16
16
  import { normalizeClusterKey } from "../failure/failure-cluster.js";
@@ -62,6 +62,18 @@ export function flakeCounts(events) {
62
62
  increment(counts, key);
63
63
  return [...counts.entries()].sort((a, b) => b[1] - a[1]);
64
64
  }
65
+ /** The bar at which a normalized check counts as flapping: one warning in the window is a
66
+ * routine one-off the gate's single re-run already absorbed, while two means the check flapped
67
+ * again and pays that re-run on every merge it touches — the one threshold the doctor's `flaky
68
+ * checks` row and the always-on flapped-checks alert share, folded through
69
+ * repeatedFlakeCounts. */
70
+ const FLAKE_REPEAT_WARN = 2;
71
+ /** The normalized checks in `events` that flapped at least FLAKE_REPEAT_WARN times, most
72
+ * frequent first — the filter flakeCounts feeds both the doctor and the status alert, so the
73
+ * threshold lives in the one home. Pure. */
74
+ export function repeatedFlakeCounts(events) {
75
+ return flakeCounts(events).filter(([, n]) => n >= FLAKE_REPEAT_WARN);
76
+ }
65
77
  /** A Set of the flakeWarningKeys at or after `now - windowMs` of the gate's own window. */
66
78
  export function flakeKeys(events, now, windowMs = KNOWN_FLAKE_WINDOW_MS) {
67
79
  return new Set(flakeWarningKeys(events, now - windowMs));
@@ -136,6 +148,25 @@ export function quarantineTests(root, tests, tail) {
136
148
  return [];
137
149
  return added;
138
150
  }
151
+ /** Remove the one quarantined test named `test` at the operator's request — the manual twin
152
+ * of releaseQuarantine's automatic release, and `tumwater quarantine --release <n>` its only
153
+ * caller (which resolves the position the list prints down to the exact test first). Returns
154
+ * whether the entry was present and the write succeeded, so the caller can answer a concurrent
155
+ * removal: `false` with no warning and no write for a name not quarantined, or when a write
156
+ * faulted (the quarantineTests write contract: a failed write warns and changes nothing). Logs
157
+ * one `warning` under TUMWATER_SOURCE so the alert and the doctor's `flaky checks` row stop
158
+ * excusing the name. */
159
+ export function releaseQuarantinedTest(root, test) {
160
+ const entries = loadQuarantine(root);
161
+ const have = entries.some((e) => e.test === test);
162
+ if (!have)
163
+ return false;
164
+ const removed = entries.filter((e) => e.test !== test);
165
+ if (!tryOrWarn(root, TUMWATER_SOURCE, "write quarantine.json", () => saveQuarantine(root, removed)))
166
+ return false;
167
+ warnEventBestEffort(root, TUMWATER_SOURCE, `${test} released from the flake quarantine at the user's request — the gate no longer excuses its failures`);
168
+ return true;
169
+ }
139
170
  /** Record the BUGS.md heading filed for each of `tests`, dropping their kept output. */
140
171
  export function markQuarantineFiled(root, bugs) {
141
172
  const entries = loadQuarantine(root);
@@ -0,0 +1,51 @@
1
+ /** The operator's deliberate acceptance of a pending post-merge review without running it
2
+ * (src/cli/cli-review-commands.ts `tumwater reviews --accept <n>`), split out of the CLI so
3
+ * the review queue stays a pure reader/editor and this module owns the destructive half —
4
+ * removing an unattempted entry on purpose. It is the sibling of `tumwater queue --cancel
5
+ * <n>` (src/merge/merge-cancel.ts): the queue's no-third-path gap that the at-risk alert's
6
+ * shared sentence names ("…or accept the commit unreviewed deliberately"). It reads the same
7
+ * FIFO list `readPostReviews` numbers 1..N and settles the entry by its commit, so the removed
8
+ * entry is the very one the printed position named. */
9
+ import { readPostReviews, settlePostReview } from "./post-merge-review.js";
10
+ import { warnEventBestEffort } from "../events/events.js";
11
+ import { shortSha } from "../text/format.js";
12
+ /** Remove one readable entry the operator accepts unreviewed: refuse it (writing nothing) when
13
+ * it carries `fixed` — the repair already merged, so accepting it unreviewed is meaningless
14
+ * and the false "accepted unreviewed" warning would mislead — otherwise settle it with no
15
+ * verdict (`settlePostReview`; its failed write already warns and never throws, INV-BT2),
16
+ * log one `warning` under the entry's role, and return the `accepted` outcome naming what was
17
+ * retired. A rejected entry waits on nothing, so it is accepted like any other pending entry.
18
+ * The single removal both `acceptPostReview` (by position) and `acceptPostReviewBySha`
19
+ * (by sha) share, so the CLI and the dashboard's post-review-accept cannot drift on what
20
+ * accepting means. */
21
+ function removeAcceptedEntry(root, entry) {
22
+ if (entry.fixed !== undefined)
23
+ return { status: "settled", commit: entry.commit };
24
+ settlePostReview(root, entry.commit, null);
25
+ const message = `post-merge review of ${shortSha(entry.commit)} accepted unreviewed at the operator's request — the commit stays on main unreviewed`; // vocabulary-ok: the accepted-unreviewed wording those reasons it is the operator's choice
26
+ warnEventBestEffort(root, entry.role, message);
27
+ return { status: "accepted", role: entry.role, commit: entry.commit, summary: entry.summary };
28
+ }
29
+ /** Retire the Nth pending post-merge review — 1-based, as `tumwater reviews` numbers it —
30
+ * accepting the commit unreviewed at the operator's request. `position` is resolved against
31
+ * `readPostReviews` (the same readable FIFO list the command renders), so the position matches
32
+ * the line `tumwater reviews` printed. An out-of-range position writes nothing and returns the
33
+ * current readable depth; otherwise it runs the same `removeAcceptedEntry` the sha variant
34
+ * shares, so the two never drift on what accepting means (see `removeAcceptedEntry` for the
35
+ * removal, the fixed-guard, the warning, and the never-throws guarantee). */
36
+ export function acceptPostReview(root, position) {
37
+ const entries = readPostReviews(root);
38
+ if (position < 1 || position > entries.length)
39
+ return { status: "out-of-range", depth: entries.length };
40
+ return removeAcceptedEntry(root, entries[position - 1]);
41
+ }
42
+ /** Remove the pending post-merge review whose entry's commit is `sha` (the browser twin of
43
+ * `tumwater reviews --accept <n>`, resolving by commit because the dashboard rows carry one).
44
+ * A commit that matches none is `{ status: "gone" }` and changes nothing; otherwise it runs the
45
+ * same `removeAcceptedEntry` the position variant shares. */
46
+ export function acceptPostReviewBySha(root, sha) {
47
+ const entry = readPostReviews(root).find((e) => e.commit.toLowerCase() === sha.toLowerCase());
48
+ if (!entry)
49
+ return { status: "gone" };
50
+ return removeAcceptedEntry(root, entry);
51
+ }
@@ -0,0 +1,112 @@
1
+ /** The renderer behind `tumwater reviews` — the operator-facing text for the post-merge review
2
+ * queue (.tumwater/state/post-review.json): one line per well-formed entry in the given order,
3
+ * plus a `post-merge review queue (N):` header. Pure function of the entry list — the optional
4
+ * `now` is the only clock input, and when it is a number and an entry carries `enqueuedAt` the
5
+ * line names the wait since the entry was first queued, exactly as the merge queue names its
6
+ * age — so a test can pin its output exactly. The `merge queue empty` convention
7
+ * (src/merge/merge-queue-render.ts) is reused for the empty case. */
8
+ import { roleLabel } from "../roles/role-labels.js";
9
+ import { shortSha } from "../text/format.js";
10
+ import { squash } from "../text/text.js";
11
+ import { filterKeepPositions } from "../collections.js";
12
+ import { plural } from "../text/phrases.js";
13
+ import { humanAgo } from "../text/datetime.js";
14
+ /** The one state every observer surface narrows on: `fixed` when the repair already merged
15
+ * (the terminal Forward-never-back 1a/6 record — naming both shas, it is not pending anything),
16
+ * `rejected` when the reviewer turned the commit back (empty `rejected` array still counts),
17
+ * `failing` when the review runs keep ending without a verdict (`failures` positive), else
18
+ * `waiting`. The precedence matches what postReviewStatusLabel already printed — an entry can
19
+ * never be both — so the status label and `tumwater reviews --status` share this one definition
20
+ * and cannot drift. */
21
+ export function postReviewState(entry) {
22
+ if (entry.fixed !== undefined)
23
+ return "fixed";
24
+ if (entry.rejected !== undefined)
25
+ return "rejected";
26
+ if (typeof entry.failures === "number" && entry.failures > 0)
27
+ return "failing";
28
+ return "waiting";
29
+ }
30
+ /** The states `tumwater reviews --status` accepts, in the order the filter's error message
31
+ * lists them (the terminal `fixed` last). `postReviewState` is the definition; this only names
32
+ * the vocabulary. */
33
+ export const POST_REVIEW_STATES = ["waiting", "failing", "rejected", "fixed"];
34
+ /** The bracketed status suffix one post-merge review entry carries on every observer surface
35
+ * (`tumwater reviews`, the dashboard's Post-merge reviews card, and the TUI Pending pane's
36
+ * review block): the reviewer's recorded rejected reasons when `rejected` is set; else the
37
+ * number of verdict-less review runs when `failures` is positive, naming the
38
+ * last failure's reason beside the count when `lastFailure` is a non-empty string; else `""`.
39
+ * Reasons are squashed to 60 as `tumwater reviews` has always printed them, and the failure
40
+ * detail to 80. Branches on `postReviewState` so the label and the `--status` filter share one
41
+ * state definition; extracted so the
42
+ * CLI, the dashboard and the TUI share one status wording and cannot drift. */
43
+ export function postReviewStatusLabel(entry) {
44
+ switch (postReviewState(entry)) {
45
+ case "fixed":
46
+ return `[fixed — the repair ${shortSha(entry.fixed)} already merged on main]`;
47
+ case "rejected":
48
+ return `[rejected — ${entry.rejected.map((r) => squash(r, 60)).join("; ")}]`;
49
+ case "failing": {
50
+ const failures = entry.failures; // postReviewState only says "failing" when failures > 0
51
+ if (typeof entry.lastFailure === "string" && entry.lastFailure.length > 0) {
52
+ return `[${plural(failures, "failed review run")} — last: ${squash(entry.lastFailure, 80)}]`;
53
+ }
54
+ return `[${plural(failures, "failed review run")}]`;
55
+ }
56
+ default:
57
+ return "";
58
+ }
59
+ }
60
+ /** Narrows a whole post-merge review list to the entries `keep` accepts, and returns
61
+ * the `scope` renderPostReviews takes: the kept roles, each kept entry's 1-based place in the
62
+ * whole list, and the whole list's entry count — plus, when a status filter is active, the
63
+ * `filter` text the header and no-match line print. The filtering-and-positioning step is the
64
+ * shared `filterKeepPositions` (src/collections.ts) that `scopeMergeQueue` (src/merge/
65
+ * merge-queue-render.ts) also uses, so `tumwater reviews --role` keeps the same whole-queue
66
+ * positions `--accept <n>` still addresses. */
67
+ export function scopePostReviews(entries, roles, keep, filter) {
68
+ const { kept, positions } = filterKeepPositions(entries, keep);
69
+ return {
70
+ entries: kept,
71
+ scope: { roles, positions, total: entries.length, ...(filter !== undefined ? { filter } : {}) },
72
+ };
73
+ }
74
+ /** The text `tumwater reviews` prints. A totally empty queue renders `post-merge review queue
75
+ * empty`; otherwise a header and one line per entry — position, role, short commit, and, when
76
+ * `now` and the entry's `enqueuedAt` are both numbers, the wait since the commit was queued
77
+ * (`<age> ago`, between the short commit and the summary) — then the squashed summary and the
78
+ * status suffix from `postReviewStatusLabel`, appended as `" · " + label` only when it is
79
+ * non-empty. Without a `now`, or for an entry with no `enqueuedAt`, the line is byte-identical
80
+ * to before, so an older `post-review.json` and every existing renderer test read unchanged.
81
+ * With the optional `scope` (`reviews --role`/`--status`: `entries` already holds only the kept
82
+ * entries', `positions` their 1-based places in the whole queue, `total` the whole queue's
83
+ * entry count, and `filter` the `--role`/`--status` filter text when one is active) each line
84
+ * keeps its whole-queue number and the header reads
85
+ * `post-merge review queue (<kept> of <total>, <filter>):`, where `<filter>` is `scope.filter`
86
+ * when set else `--role <ids>`. A scope that keeps nothing of a non-empty queue prints
87
+ * `no post-merge reviews matching <filter> (post-merge review queue holds <total>)` when a
88
+ * filter is set, else `no post-merge reviews for <ids> (…)`. Without `scope` and `now` the
89
+ * output is byte-identical to before. */
90
+ export function renderPostReviews(entries, scope, now) {
91
+ const total = scope ? scope.total : entries.length;
92
+ if (total === 0)
93
+ return "post-merge review queue empty";
94
+ if (scope && entries.length === 0) {
95
+ const reason = scope.filter !== undefined ? `matching ${scope.filter}` : `for ${scope.roles.join(",")}`;
96
+ return `no post-merge reviews ${reason} (post-merge review queue holds ${total})`;
97
+ }
98
+ const lines = [
99
+ scope
100
+ ? `post-merge review queue (${entries.length} of ${total}, ${scope.filter ?? `--role ${scope.roles.join(",")}`}):`
101
+ : `post-merge review queue (${plural(total, "entry", "entries")}):`,
102
+ ];
103
+ entries.forEach((entry, i) => {
104
+ const label = postReviewStatusLabel(entry);
105
+ const suffix = label === "" ? "" : ` · ${label}`;
106
+ const age = typeof now === "number" && typeof entry.enqueuedAt === "number"
107
+ ? ` · ${humanAgo(entry.enqueuedAt, now)}`
108
+ : "";
109
+ lines.push(` ${scope ? scope.positions[i] : i + 1}. ${roleLabel(entry.role)} ${shortSha(entry.commit)}${age} · ${squash(entry.summary, 80)}${suffix}`);
110
+ });
111
+ return lines.join("\n");
112
+ }