tumwater 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -0,0 +1,288 @@
1
+ /** The single-event folding core of the usage report: fold one harness event into a
2
+ * `UsageFold` accumulator (`foldUsageEvent`), with one named helper per event family
3
+ * (run_end, review_rejected, merge_done/merge_failed, …). This is the pure per-event rule,
4
+ * shared by the since-window collector and the per-day window fold — the latter lives in
5
+ * src/report/report-fold.ts, which reads and memoizes the event log (`foldWindowEvents`)
6
+ * and delegates each parsed event here, so the two windows' aggregation rules cannot drift
7
+ * apart. Zero incidental runtime dependencies: node built-ins and the app's own modules
8
+ * only. */
9
+ import { addTo } from "../collections.js";
10
+ import { isPassingFullCheck, isRedFullCheck } from "../build/build-check-events.js";
11
+ import { eventRole, eventUsage } from "../events/event-read.js";
12
+ import { finiteNumber, nonNegativeNumber } from "../files/json-object.js";
13
+ import { OUTCOME_CLASS } from "../failure/time-spend.js";
14
+ import { commitTier } from "../roles/roles.js";
15
+ import { baseRoleOf } from "../roles/runner-ids.js";
16
+ /** Fold one event's `providerCost` map (`Record<provider, {costUsd, notionalCostUsd}>` — the
17
+ * shape run-finalize.ts's run_end and merge-slot.ts's merge_done/merge_failed emit) into the
18
+ * per-provider tallies, one addTo per provider per nonzero figure so zero stays absent. A
19
+ * missing, foreign, or empty map folds nothing, so old logs render byte-identically. */
20
+ function foldProviderCost(target, providerCost) {
21
+ if (typeof providerCost !== "object" || providerCost === null)
22
+ return;
23
+ for (const [provider, c] of Object.entries(providerCost)) {
24
+ if (typeof c !== "object" || c === null)
25
+ continue;
26
+ const entry = c;
27
+ const costUsd = finiteNumber(entry.costUsd, 0);
28
+ const notionalCostUsd = finiteNumber(entry.notionalCostUsd, 0);
29
+ if (costUsd !== 0)
30
+ addTo(target.costByProvider, provider, costUsd);
31
+ if (notionalCostUsd !== 0)
32
+ addTo(target.notionalByProvider, provider, notionalCostUsd);
33
+ }
34
+ }
35
+ /** Push `ms` into the accumulator list a day median reads, when it is positive: the event
36
+ * fields that feed these medians are missing or 0 for the same event kinds (short runs, build
37
+ * pre-checks, precedent legacy events, probe-seam waits), and those contribute nothing — a
38
+ * median over only real durations/wait times. Every median list folds through here, so the
39
+ * "positive-only" rule cannot drift between fields. */
40
+ function pushPositiveMs(list, ms) {
41
+ if (ms > 0)
42
+ list.push(ms);
43
+ }
44
+ /** Fold a run_end into the accumulator: a run (per role and, when the target counts runs, in
45
+ * total), its output/prompt/cache tokens, its cost, and its notional backend share. */
46
+ function foldRunEnd(target, ev) {
47
+ const role = eventRole(ev);
48
+ addTo(target.runsByRole, role, 1);
49
+ // Error-class runs get their own parallel tally — the day report's ``Failed runs by role``
50
+ // line — reusing the digest's OUTCOME_CLASS so "failed" cannot drift from the failure
51
+ // report's meaning. Never subtracted from runsByRole; the two are independent counts.
52
+ if (OUTCOME_CLASS[ev.result] === "error")
53
+ addTo(target.failedRunsByRole, role, 1);
54
+ // A refusal is its own deliberate outcome (src/verdict/refusal.ts), not error-class, so
55
+ // it needs an explicit result check: the one per-role rejection-without-review count a
56
+ // day report should surface beside rejections, revisions and falls. Never subtracted from
57
+ // runsByRole; a refused run still counts as a run.
58
+ if (ev.result === "refused")
59
+ addTo(target.refusalsByRole, role, 1);
60
+ if (target.runs !== undefined)
61
+ target.runs++;
62
+ const { tokens, costUsd, notionalCostUsd } = eventUsage(ev);
63
+ pushPositiveMs(target.runDurationsMs, finiteNumber(ev.durationMs, 0));
64
+ addTo(target.tokensByRole, role, tokens);
65
+ // The prompt side of the same run's bill: how many prompt tokens each role sent and how
66
+ // much of that its backend served from cache (run-finalize.ts rides both on run_end, each
67
+ // omitted when zero). Zero-means-absent like tokensByRole. The finite guard is the failure
68
+ // digest's promptStatsFor rule, and the Math.min clamp keeps a torn log's cache share from
69
+ // ever exceeding the prompt total — together they pin a rendered share to <= 100% and keep
70
+ // a non-finite reading (1e999 → Infinity) out of the maps as absent, not NaN.
71
+ const prompt = nonNegativeNumber(ev.promptTokens, 0);
72
+ const cacheRead = Math.min(nonNegativeNumber(ev.cacheReadTokens, 0), prompt);
73
+ if (prompt > 0)
74
+ addTo(target.promptTokensByRole, role, prompt);
75
+ if (cacheRead > 0)
76
+ addTo(target.cacheReadTokensByRole, role, cacheRead);
77
+ target.tokensOut += tokens;
78
+ target.costUsd += costUsd;
79
+ target.notionalCostUsd += notionalCostUsd;
80
+ if (costUsd !== 0)
81
+ addTo(target.costByRole, role, costUsd);
82
+ if (notionalCostUsd !== 0)
83
+ addTo(target.notionalByRole, role, notionalCostUsd);
84
+ // The notional spend's backend (plans/codex-backend.md part 3d/9): the subscription seam
85
+ // whose runs made the run's API-equivalent cost, from the run_end's `agent` (run-finalize
86
+ // carries it when the run reported billing/rate windows) — falling back to "claude" when
87
+ // a legacy window event (no agent, subscription billing) attributed its spend, so the old
88
+ // default stays the report's only backend. A run with no backend and no subscription
89
+ // billing is unattributed and adds no key, so the render keeps its generic line for it.
90
+ const notionalBackend = typeof ev.agent === "string" && ev.agent !== ""
91
+ ? ev.agent
92
+ : ev.billing === "subscription"
93
+ ? "claude"
94
+ : undefined;
95
+ if (notionalBackend !== undefined && notionalCostUsd !== 0)
96
+ addTo(target.notionalByBackend, notionalBackend, notionalCostUsd);
97
+ // The agent runs' per-provider spend (run-finalize.ts emits providerCost on run_end)
98
+ // — folded per provider beside the per-role maps, so a mixed team's `Cost by provider`
99
+ // line names the expensive seam.
100
+ foldProviderCost(target, ev.providerCost);
101
+ }
102
+ /** Fold a review_rejected: one rejection per event, on the day it happened, per role, with
103
+ * the post-merge subset and a review-duration entry when the rejection carried one. */
104
+ function foldReviewRejected(target, ev) {
105
+ // One rejection per event, on the day it happened, per role. Zero-means-absent: a role
106
+ // that never rejected stays absent, like costByRole.
107
+ addTo(target.rejectionsByRole, eventRole(ev), 1);
108
+ // A rejection that carried afterMerge: true is a post-merge one — the commit had already
109
+ // landed, so the reviewer's turn-back reaches the revert path off main. Kept as its own
110
+ // subset of rejectionsByRole because it is a different, costlier kind of rejection.
111
+ if (ev.afterMerge === true)
112
+ addTo(target.postMergeRejectionsByRole, eventRole(ev), 1);
113
+ // A rejection that carried a positive durationMs means a reviewer actually ran the gate
114
+ // and reached a verdict (post-merge rejections do; the build pre-check contributes no
115
+ // duration), so it joins the review-duration median beside approvals. durationMs: 0 and
116
+ // a duration-less event contribute nothing.
117
+ pushPositiveMs(target.reviewDurationsMs, finiteNumber(ev.durationMs, 0));
118
+ }
119
+ /** Fold a review_verdict approval into the review-duration median. */
120
+ function foldReviewVerdict(target, ev) {
121
+ // An approval that reached a verdict. Every reviewer run that reached a verdict, approval
122
+ // or rejection, is one entry in the review-duration median; review_failed stays out — it
123
+ // has no verdict. Mirrors the rejection branch above, on the same-day count.
124
+ pushPositiveMs(target.reviewDurationsMs, finiteNumber(ev.durationMs, 0));
125
+ }
126
+ /** Fold a revision: one rework round per event, on the day it happened, per role. */
127
+ function foldRevision(target, ev) {
128
+ // One rework round per event, on the day it happened, per role. Zero-means-absent: a role
129
+ // that never reworked a rejected change stays absent, like rejectionsByRole.
130
+ addTo(target.revisionsByRole, eventRole(ev), 1);
131
+ }
132
+ /** Fold a passing full-suite check into the day report's check-time median. */
133
+ function foldPassingCheck(target, ev) {
134
+ // The merge slot's passing full-suite checks, for the day report's check-time median.
135
+ pushPositiveMs(target.checkDurationsMs, finiteNumber(ev.durationMs, 0));
136
+ }
137
+ /** Fold a red full-suite check into the day report's red-check count. */
138
+ function foldRedCheck(target, _ev) {
139
+ // The merge slot's red full-suite checks, for the day report's red-check count.
140
+ target.checkFailures++;
141
+ }
142
+ /** Fold a model_fallback_started: one episode per start event, per role. */
143
+ function foldFallbackStarted(target, ev) {
144
+ // One episode per start event; durations are not summed (an episode can span the window edge).
145
+ addTo(target.fallbacksByRole, eventRole(ev), 1);
146
+ }
147
+ /** Fold a run_deferred: one deferral per event, bucketed by the raw reason code (team-only). */
148
+ function foldDeferred(target, ev) {
149
+ // One deferral per event, a due maintenance run that stood down. Bucket by the raw
150
+ // reason code ("backlog", "plans-full", else "no-work" for a missing/foreign/torn
151
+ // reason — the same fallback deferredReasonText applies in the feed), so the collector
152
+ // stays free of a presentation import and the render prints the code the event carried.
153
+ // Team-only: a reason is not a role.
154
+ const reason = ev.reason === "backlog" || ev.reason === "plans-full" ? ev.reason : "no-work";
155
+ addTo(target.deferredByReason, reason, 1);
156
+ }
157
+ /** Fold a merged commit: one commit split by base role and tier (Work ratio 4/4). */
158
+ function foldMerged(target, ev) {
159
+ target.commits++;
160
+ // Work ratio 4/4: split merged commits by base role and by tier. Instance suffixes fold
161
+ // into their role (`feature-2` counts as feature) so the split does not fragment.
162
+ const role = baseRoleOf(eventRole(ev));
163
+ addTo(target.commitsByRole, role, 1);
164
+ const tier = commitTier(role);
165
+ if (tier === "work")
166
+ target.workCommits++;
167
+ else if (tier === "maintenance")
168
+ target.maintenanceCommits++;
169
+ }
170
+ /** Fold a run_start: one tally per run start on a configured model. */
171
+ function foldRunStarted(target, ev) {
172
+ // One tally per run start on a configured model: the same `model` field the CLI's
173
+ // `run #N started on <model>` line prints, and the only event that carries a model
174
+ // (run_end has none), so a run that started with no configured model contributes no key.
175
+ const model = typeof ev.model === "string" && ev.model.length > 0 ? ev.model : null;
176
+ if (model !== null)
177
+ addTo(target.runsByModel, model, 1);
178
+ }
179
+ /** Fold a provider_wait into the provider-slot wait median. */
180
+ function foldProviderWait(target, ev) {
181
+ // The day report's provider-slot wait median, mirroring the slot_wait push: a wait only
182
+ // event-logged at or above the 30 s threshold counts, so a 0 ms wait (a probe seam) adds
183
+ // nothing and the median reflects real blocked admissions only.
184
+ pushPositiveMs(target.providerWaitsMs, finiteNumber(ev.waitedMs, 0));
185
+ }
186
+ /** Fold a slot_wait into the workspace-slot wait median. */
187
+ function foldSlotWait(target, ev) {
188
+ // The day report's workspace-slot wait median, mirroring the provider_wait push: a wait
189
+ // only event-logged at or above the 30 s threshold counts, so a 0 ms wait (a probe seam)
190
+ // or a pre-waitedMs event adds nothing.
191
+ pushPositiveMs(target.slotWaitsMs, finiteNumber(ev.waitedMs, 0));
192
+ }
193
+ /** Fold a check_wait into the check-slot wait median. */
194
+ function foldCheckWait(target, ev) {
195
+ // The day report's check-slot wait median, mirroring the slot_wait push: only waits logged
196
+ // at or above the 30 s threshold count, so a 0 ms or pre-waitedMs event adds nothing.
197
+ pushPositiveMs(target.checkWaitsMs, finiteNumber(ev.waitedMs, 0));
198
+ }
199
+ /** Fold a merge_done/merge_failed outcome: its run count, its accumulation into the merge
200
+ * fields only (costByRole stays a runs-only breakdown), and its notional/provider shares. */
201
+ function foldMergeOutcome(target, ev) {
202
+ target.mergeRuns = (target.mergeRuns ?? 0) + 1;
203
+ if (ev.type === "merge_failed")
204
+ target.mergeFailed = (target.mergeFailed ?? 0) + 1;
205
+ // Attribute the outcome to the authoring role (writeMergeOutcome logs role: entry.role)
206
+ // folded to base role, the same normalization the merged branch uses, so `feature-2`
207
+ // aggregates into `feature`. A parallel tally beside the team run counts, never
208
+ // subtracted from mergeRuns/mergeFailed.
209
+ const role = baseRoleOf(eventRole(ev));
210
+ if (ev.type === "merge_done") {
211
+ addTo(target.mergedByRole, role, 1);
212
+ // Events logged before queuedMs existed carry none: skipped, not counted as zero.
213
+ pushPositiveMs(target.mergeWaitsMs, finiteNumber(ev.queuedMs, 0));
214
+ // How long the merge slot itself held the change, from the verification's start to the
215
+ // outcome. Like mergeWaitsMs, only a landed merge feeds it, and a pre-durationMs event
216
+ // (or 0) contributes nothing.
217
+ pushPositiveMs(target.mergeDurationsMs, finiteNumber(ev.durationMs, 0));
218
+ }
219
+ else {
220
+ addTo(target.mergeFailedByRole, role, 1);
221
+ // The event's result (writeMergeOutcome's RunResult) names WHY the merge bounced
222
+ // off the gate — rejected/merge_conflict/merge_blocked/aborted. A legacy event with
223
+ // no result folds under `unknown`, so the reason buckets always sum to mergeFailed.
224
+ const reason = typeof ev.result === "string" && ev.result.length > 0 ? ev.result : "unknown";
225
+ addTo(target.mergeFailedByReason, reason, 1);
226
+ }
227
+ const { tokens, costUsd, notionalCostUsd } = eventUsage(ev);
228
+ target.mergeTokens = (target.mergeTokens ?? 0) + tokens;
229
+ target.mergeCostUsd = (target.mergeCostUsd ?? 0) + costUsd;
230
+ // A subscription merge's API-equivalent spend joins the notional line, never the
231
+ // budgeted cost totals (plans/agent-backends.md part 6a).
232
+ target.notionalCostUsd += notionalCostUsd;
233
+ // The merge's notional spend per backend (merge-slot.ts emits it on the event, keyed by
234
+ // each subscription run's agent) — folded beside the run-end per-backend map, so a codex
235
+ // merge's subscription spend names codex instead of falling back to the generic line.
236
+ for (const [backend, c] of Object.entries(ev.notionalByBackend ?? {})) {
237
+ const amt = finiteNumber(c, 0);
238
+ if (amt !== 0)
239
+ addTo(target.notionalByBackend, backend, amt);
240
+ }
241
+ // The merge's per-provider spend (merge-slot.ts keys it to its reviewer seam), so a
242
+ // merge's costs appear under the provider that ran the reviewer, matching the author
243
+ // side's foldMergeUsage charge.
244
+ foldProviderCost(target, ev.providerCost);
245
+ }
246
+ /** Fold one event into a usage accumulator by dispatching to a per-family builder —
247
+ * foldRunEnd, foldReviewRejected, foldReviewVerdict, foldRevision, foldPassingCheck,
248
+ * foldRedCheck, foldFallbackStarted, foldDeferred, foldMerged,
249
+ * foldRunStarted, foldProviderWait, foldSlotWait, foldCheckWait or foldMergeOutcome — each
250
+ * named for the family it folds, so how a family folds is found by name. The dispatch holds
251
+ * no accumulation of its own. All behavior lives in the builders: run_end events add a run
252
+ * (per role and, when the target counts runs, in total), their output tokens, and their
253
+ * cost — zero-cost runs contribute nothing, so they leave no costByRole key, keeping the
254
+ * render's "$0 roles are omitted" rule true on the aggregation itself, not just at display
255
+ * time; merged events add a commit; merge_done/merge_failed events add a merge run and
256
+ * accumulate their tokens/cost into the merge fields only — costByRole stays a runs-only
257
+ * breakdown, and the merge line is how the reviewer's share is named. Both collectors fold
258
+ * through this one place, so the two windows' aggregation rules cannot drift apart. */
259
+ export function foldUsageEvent(target, ev) {
260
+ if (ev.type === "run_end")
261
+ foldRunEnd(target, ev);
262
+ else if (ev.type === "review_rejected")
263
+ foldReviewRejected(target, ev);
264
+ else if (ev.type === "review_verdict")
265
+ foldReviewVerdict(target, ev);
266
+ else if (ev.type === "revision")
267
+ foldRevision(target, ev);
268
+ else if (isPassingFullCheck(ev))
269
+ foldPassingCheck(target, ev);
270
+ else if (isRedFullCheck(ev))
271
+ foldRedCheck(target, ev);
272
+ else if (ev.type === "model_fallback_started")
273
+ foldFallbackStarted(target, ev);
274
+ else if (ev.type === "run_deferred")
275
+ foldDeferred(target, ev);
276
+ else if (ev.type === "merged")
277
+ foldMerged(target, ev);
278
+ else if (ev.type === "run_start")
279
+ foldRunStarted(target, ev);
280
+ else if (ev.type === "provider_wait")
281
+ foldProviderWait(target, ev);
282
+ else if (ev.type === "slot_wait")
283
+ foldSlotWait(target, ev);
284
+ else if (ev.type === "check_wait")
285
+ foldCheckWait(target, ev);
286
+ else if (ev.type === "merge_done" || ev.type === "merge_failed")
287
+ foldMergeOutcome(target, ev);
288
+ }
@@ -2,7 +2,8 @@
2
2
  * src/report/report-data.ts / src/failure/failure-data.ts, and print. The Markdown renderers
3
3
  * live in the pure-render modules they pair with: src/report/report-render.ts and
4
4
  * src/failure/failure-render.ts. */
5
- import { collectReport, collectReportSince } from "./report-data.js";
5
+ import { collectReport } from "./report-data.js";
6
+ import { collectReportSince } from "./report-since-data.js";
6
7
  import { collectFailureReport, collectFailureReportSince } from "../failure/failure-data.js";
7
8
  import { renderFailureMarkdown } from "../failure/failure-render.js";
8
9
  import { renderReportMarkdown, renderSinceReportMarkdown } from "./report-render.js";
@@ -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";
@@ -65,11 +65,11 @@ export function flakeCounts(events) {
65
65
  /** The bar at which a normalized check counts as flapping: one warning in the window is a
66
66
  * routine one-off the gate's single re-run already absorbed, while two means the check flapped
67
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
68
+ * checks` row reads, folded through
69
69
  * repeatedFlakeCounts. */
70
70
  const FLAKE_REPEAT_WARN = 2;
71
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
72
+ * frequent first — the filter flakeCounts feeds the doctor's `flaky checks` row, so the
73
73
  * threshold lives in the one home. Pure. */
74
74
  export function repeatedFlakeCounts(events) {
75
75
  return flakeCounts(events).filter(([, n]) => n >= FLAKE_REPEAT_WARN);
@@ -148,6 +148,25 @@ export function quarantineTests(root, tests, tail) {
148
148
  return [];
149
149
  return added;
150
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 doctor's `flaky checks` row stops
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
+ }
151
170
  /** Record the BUGS.md heading filed for each of `tests`, dropping their kept output. */
152
171
  export function markQuarantineFiled(root, bugs) {
153
172
  const entries = loadQuarantine(root);
@@ -2,8 +2,9 @@
2
2
  * (src/cli/cli-review-commands.ts `tumwater reviews --accept <n>`), split out of the CLI so
3
3
  * the review queue stays a pure reader/editor and this module owns the destructive half —
4
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
5
+ * <n>` (src/merge/merge-cancel.ts): the queue's no-third-path gap that the doctor's at-risk
6
+ * post-review row's shared sentence names (“…or accept the commit unreviewed deliberately”).
7
+ * It reads the same
7
8
  * FIFO list `readPostReviews` numbers 1..N and settles the entry by its commit, so the removed
8
9
  * entry is the very one the printed position named. */
9
10
  import { readPostReviews, settlePostReview } from "./post-merge-review.js";
@@ -1,4 +1,5 @@
1
1
  import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
2
+ import { compareCodeUnits } from "../collections.js";
2
3
  import { postReviewPath, postReviewWorktreePath, nativeLogPath, piLogPath, reviewRunSessionDir, reviewFixRef } from "../paths.js";
3
4
  import { reviewedAfterMerge, reviewRunConfig } from "../config/config-views.js";
4
5
  import { logEventBestEffort, TUMWATER_SOURCE, tryOrWarn } from "../events/events.js";
@@ -19,20 +20,21 @@ import { fixLine } from "../verdict/reply-contract.js";
19
20
  import { parseVerdict } from "./review-verdict.js";
20
21
  /** A commit's review is dropped, with a warning, after this many runs with no verdict. */
21
22
  export const POST_REVIEW_FAILURE_LIMIT = 3;
22
- /** The bar a day's post-merge rejections must reach before the `post-merge-churn` amber alert
23
- * (src/ui/team-alerts.ts) fires: one post-merge rejection is the tiering's ordinary cost, two in
24
- * a day is a role whose already-landed changes keep being turned back — the reviewer-fix signal
25
- * for an operator to narrow `review.afterMerge` or raise the authoring bar. */
23
+ /** The bar a day's post-merge rejections must reach before the doctor's `post-merge churn` row
24
+ * fires: one post-merge rejection is the tiering's ordinary cost,
25
+ * two in a day is a role whose already-landed changes keep being turned back — the
26
+ * reviewer-fix signal for an operator to narrow `review.afterMerge` or raise the
27
+ * authoring bar. */
26
28
  export const POST_MERGE_REJECT_WARN = 2;
27
29
  /** How far back the post-merge-churn rule looks — one day, the 24 h event-window convention
28
- * every event-reading alert uses (PRIMARY_OVERWRITE_WINDOW_MS sorts beside it). */
30
+ * every event-reading doctor row uses. */
29
31
  export const POST_MERGE_REJECT_WINDOW_MS = 24 * 60 * 60_000;
30
32
  /** Group the `afterMerge: true` `review_rejected` events of `events` by base role — the same
31
33
  * `e.type === "review_rejected" && e.afterMerge === true` test the report fold
32
- * (src/report/report-fold.ts `postMergeRejectionsByRole`) applies, so the churn alert and the
34
+ * (src/report/report-fold.ts `postMergeRejectionsByRole`) applies, so the churn row and the
33
35
  * report cannot disagree on which rejections are post-merge. The key is the event's base role
34
36
  * (`baseRoleOf(eventRole(ev))`), not the raw source: an event's `source` is the runner id
35
- * (`bugfix-2`), and both the alert's remedy ("narrow `review.afterMerge`") and the scoped
37
+ * (`bugfix-2`), and both the row's remedy ("narrow `review.afterMerge`") and the scoped
36
38
  * report are keyed by base role, so one base role's churn must read as one row. Pre-merge
37
39
  * rejections and other event types contribute nothing. */
38
40
  export function postMergeRejectionCounts(events) {
@@ -45,10 +47,10 @@ export function postMergeRejectionCounts(events) {
45
47
  }
46
48
  return [...byRole.entries()]
47
49
  .map(([role, count]) => ({ role, count }))
48
- .sort((a, b) => b.count - a.count || (a.role < b.role ? -1 : a.role > b.role ? 1 : 0));
50
+ .sort((a, b) => b.count - a.count || compareCodeUnits(a.role, b.role));
49
51
  }
50
- /** The one home of the trigger and selection the `post-merge-churn` alert (src/ui/team-alerts.ts)
51
- * and the doctor's `post-merge churn` row (src/doctor/doctor-project-checks.ts) share: whether
52
+ /** The one home of the trigger and selection the doctor's `post-merge churn` row
53
+ * (src/doctor/doctor-project-checks.ts) reads: whether
52
54
  * the trailing `POST_MERGE_REJECT_WINDOW_MS` holds at least `POST_MERGE_REJECT_WARN` post-merge
53
55
  * `review_rejected` events, and, when it does, which roles and how many each. null when the
54
56
  * window holds fewer than the bar, or none — so the two surfaces cannot disagree on when churn
@@ -195,9 +197,9 @@ export async function reviewMergedCommit(root, config, entry, runGatePi, signal)
195
197
  logEventBestEffort(root, { source: role, type: "review_verdict", head: commit, reason: verdict.reasons[0], durationMs, afterMerge: true });
196
198
  }
197
199
  // A rejecting review whose reply carries a FIX: line and whose worktree holds an edit
198
- // survives as one fix commit on the review ref, kept for merge-post-review.ts to land on
199
- // main through the ordinary change path. A clean worktree or a reply with no FIX line keeps
200
- // today's outcomes and deletes the ref.
200
+ // survives as one fix commit on the review ref, kept for
201
+ // merge-post-review-drain.ts to land on main through the ordinary change path. A clean
202
+ // worktree or a reply with no FIX line keeps today's outcomes and deletes the ref.
201
203
  const paths = pi.ok && !pi.aborted ? fixLine(pi.verdictText ?? "") : null;
202
204
  if (paths && verdict.verdict === "reject" && (await gitTry(wt, "status", "--porcelain"))?.trim().length) {
203
205
  // The self-fix chain's round is one past the reviewed commit's `Revision:` trailer; at
@@ -221,7 +223,7 @@ export async function reviewMergedCommit(root, config, entry, runGatePi, signal)
221
223
  }
222
224
  const fix = await commitReviewerFix(wt, commit, verdict.reasons, nextRound);
223
225
  // Advance the review-fix ref from C to the new fix commit, so the repair is reachable and
224
- // the merge-post-review landing (which checks the fix out by this sha) finds it.
226
+ // the merge-post-review-drain landing (which checks the fix out by this sha) finds it.
225
227
  await setRef(root, fixRef, fix);
226
228
  return { kind: "fixed", fix, reasons: verdict.reasons };
227
229
  }
@@ -6,6 +6,7 @@
6
6
  * so an operator can see which tests the gate is excusing and why. Pure over its inputs
7
7
  * (no I/O, no clock): every branch has its own unit tests. */
8
8
  import { plural } from "../text/phrases.js";
9
+ import { compareCodeUnits } from "../collections.js";
9
10
  import { squash } from "../text/text.js";
10
11
  import { humanAgo } from "../text/datetime.js";
11
12
  import { quarantineWarning } from "../baseline/main-guardrails.js";
@@ -14,7 +15,7 @@ import { quarantineWarning } from "../baseline/main-guardrails.js";
14
15
  * through with `""` staying `""`. */
15
16
  export function quarantineRows(entries) {
16
17
  return [...entries]
17
- .sort((a, b) => a.firstSeen - b.firstSeen || (a.test < b.test ? -1 : a.test > b.test ? 1 : 0))
18
+ .sort((a, b) => a.firstSeen - b.firstSeen || compareCodeUnits(a.test, b.test))
18
19
  .map((e, i) => ({
19
20
  position: i + 1,
20
21
  test: e.test,
@@ -43,12 +44,13 @@ export function renderQuarantine(rows, now) {
43
44
  }
44
45
  return lines.join("\n");
45
46
  }
46
- /** The Markdown section the Failures surfaces (the dashboard's Failures view and the TUI's
47
- * Failures pane) append below the failure digest — the read twin of the amber quarantine
48
- * alert, whose `See failures` action lands there. It reads the same `quarantineRows` rule as
49
- * `tumwater quarantine` (and shares its `quarantineWarning` phrase when one fires), so the CLI
50
- * and the always-on surfaces cannot disagree. Empty for an empty list, so a window with a
51
- * digest but a clean quarantine adds nothing. Pure over its inputs (no I/O, no clock). */
47
+ /** The Markdown section the TUI's Failures pane appends below the failure digest — no longer
48
+ * reachable from the dashboard, whose Failures view paints the structured { warning, rows }
49
+ * card (quarantineViewRows) with a Release button per row instead. It reads the same
50
+ * `quarantineRows` rule as `tumwater quarantine` (and shares its `quarantineWarning` phrase
51
+ * when one fires), so the CLI and the TUI cannot disagree. Empty for an empty list, so a
52
+ * window with a digest but a clean quarantine adds nothing. Pure over its inputs (no I/O, no
53
+ * clock). */
52
54
  export function renderQuarantineSection(rows, now) {
53
55
  if (rows.length === 0)
54
56
  return "";
@@ -56,10 +58,26 @@ export function renderQuarantineSection(rows, now) {
56
58
  const warning = quarantineWarning(rows, now);
57
59
  if (warning !== null)
58
60
  lines.push(warning, "");
59
- for (const r of rows) {
60
- const filed = r.bug ? `filed: ${squash(r.bug, 80)}` : "not filed";
61
- lines.push(`- ${squash(r.test, 80)} — first seen ${humanAgo(r.firstSeen, now)}, ` +
62
- `last seen ${humanAgo(r.lastSeen, now)}, ${plural(r.count, "flake")} — ${filed}`);
61
+ for (const r of quarantineViewRows(rows, now)) {
62
+ // The test name is NOT squashed: the Failures Ctrl+X release editor matches the exact
63
+ // path the list shows (releaseQuarantinedTest(session.root, test)), so truncating here
64
+ // would make a title over the bound impossible to release. `tumwater quarantine` keeps
65
+ // its own numbered squash because --release <n> never needs the full name.
66
+ lines.push(`- ${r.test} — ${r.detail}`);
63
67
  }
64
68
  return lines.join("\n");
65
69
  }
70
+ /** Build the structured rows for the dashboard's quarantine card: `quarantineViewRows` over the
71
+ * sorted `rows`, each `detail` the exact per-row text `renderQuarantineSection` prints after the
72
+ * test. */
73
+ export function quarantineViewRows(rows, now) {
74
+ return rows.map((r) => {
75
+ const filed = r.bug ? `filed: ${squash(r.bug, 80)}` : "not filed";
76
+ return {
77
+ position: r.position,
78
+ test: r.test,
79
+ detail: `first seen ${humanAgo(r.firstSeen, now)}, ` +
80
+ `last seen ${humanAgo(r.lastSeen, now)}, ${plural(r.count, "flake")} — ${filed}`,
81
+ };
82
+ });
83
+ }
@@ -146,7 +146,8 @@ export async function gateBuildPrecheck(ctx, state, head, reject) {
146
146
  if (main.status === "red") {
147
147
  // Main fails too: not this change's failure. Leave the commit (and with it the pin) and
148
148
  // do not advance the discard counter — the transport-failure rule (BUGS.md 2026-09-20):
149
- // nothing judged this diff. main-red.ts's gate and the bugfix handoff own the repair,
149
+ // nothing judged this diff. main-red.ts's healer hand-off (`mainRedHealNote`) and the
150
+ // bugfix handoff own the repair,
150
151
  // and the orchestrator poll (requeueMainRedPin) re-queues the kept pin once main's head
151
152
  // moves; leftover recovery holds it until then.
152
153
  const detail = mainRedNotMine(main.sha);
@@ -4,7 +4,7 @@
4
4
  * in roles.ts. Pure catalog data — the shared instruction fragments role prompts embed live in
5
5
  * role-guidance.ts (import-only downward, so prompt assembly depends on both without a cycle).
6
6
  * roles.ts re-exports ROLES and Role, so every existing importer keeps its import path. */
7
- import { backlogMoveGuidance, CONVERGENCE_HOLD_MARKER, DECOMPOSITION_GUIDANCE, HELD_TAG_PREFIX, NEEDS_REPLAN_NOTE, NEEDS_REVIEW_NOTE, PLAN_QUEUE_GUIDANCE, PLAN_SIZING, PRIOR_HELD_PREFIX, searchGuidance, VALIDATION_GAP_GUIDANCE, VALIDATION_GAP_QUERY, VALIDATION_GAP_TALLY, WAITING_FOR_FAILURE_NOTE, WAITING_FOR_FAILURE_PREFIX, } from "./role-guidance.js";
7
+ import { backlogMoveGuidance, CHECK_DECLARATION_MARKER, CONVERGENCE_HOLD_MARKER, DECOMPOSITION_GUIDANCE, NEEDS_REVIEW_NOTE, PLAN_QUEUE_GUIDANCE, PLAN_SIZING, searchGuidance, VALIDATION_GAP_GUIDANCE, VALIDATION_GAP_QUERY, VALIDATION_GAP_TALLY, } from "./role-guidance.js";
8
8
  import { roleCommitGrep } from "./legacy-role-ids.js";
9
9
  import { NOTHING_TO_DO } from "../verdict/reply-contract.js";
10
10
  /** The trailer the readme role's commits carry and its `git log --grep` reads back; git parses
@@ -24,25 +24,25 @@ export const ROLES = [
24
24
  tier: "default",
25
25
  title: "feature implementer",
26
26
  find: `Implement the SINGLE most valuable planned feature in PLANS.md that is not yet implemented.
27
+ A \`<no-check>\` block in your prompt names the \`## Planned\` entry marked ${CHECK_DECLARATION_MARKER}
28
+ — that project-check declaration outranks every other planned entry: implement it so a merge
29
+ makes it live (the doctor's \`check\` row ok, \`tumwater status --json\` with no \`noDeclaredCheck\`),
30
+ then mark it done. Do not chase an in-run re-read: while unmerged both still reflect the
31
+ pre-declaration state, so the merge confirms it.
32
+
27
33
  1. The prompt's <backlog-index> block lists PLANS.md's \`## Planned\` entries with each
28
34
  entry's line range; read the one you pick by that range.
29
35
  2. Pick one entry under \`## Planned\`, preferring ones marked ready or with a written plan.
30
36
  Skip entries the index block marks \`[blocked: …]\` — \`[blocked: requires …]\` (a
31
- prerequisite has not merged), \`[blocked: refused]\`, \`[blocked: needs review]\` or
32
- \`[blocked: needs replan]\`. Skip plans whose entry carries a Refused note, and skip
33
- entries already carrying a **Needs review …** note or a ${NEEDS_REPLAN_NOTE}. If PLANS.md
37
+ prerequisite has not merged), \`[blocked: refused]\`, \`[blocked: needs review]\`.
38
+ Skip plans whose entry carries a Refused note, and skip
39
+ entries already carrying a **Needs review …** note. If PLANS.md
34
40
  is empty or everything is done, there is nothing to do.
35
41
  3. Read only the chosen entry's line range and the code it names.
36
42
  4. A plan too large to finish in this run is not split by you: append the note
37
43
  ${NEEDS_REVIEW_NOTE}
38
44
  under its heading, skip it, and implement the next available plan that fits. You may mark
39
- several oversized entries while scanning, but land exactly one plan. The held tag you write
40
- — \`${HELD_TAG_PREFIX}<N>)\` on a Needs-review note, \`${HELD_TAG_PREFIX}<H>)\` on a
41
- Needs-replan note — is ONE past the count already on the entry: read your own or an earlier
42
- mark's \`${HELD_TAG_PREFIX}N)\` tag, else the \`${PRIOR_HELD_PREFIX}<N>**\` line a lift left
43
- behind, else start at 1 — never reset it while the same plan ping-pongs, so three holds
44
- without a merge reach \`${HELD_TAG_PREFIX}3)\`. A plan that merged and left \`## Planned\`
45
- carries no marker — the convergence backstop starts fresh there.
45
+ several oversized entries while scanning, but land exactly one plan.
46
46
  5. Implement it completely: code, tests, and any docs. The reviewer checks your diff against
47
47
  the entry's files-touched list and acceptance criteria, so land everything the entry
48
48
  promises, or update the entry to say what changed and why. When a plan's anchors no longer
@@ -64,8 +64,8 @@ than forcing it.`,
64
64
  latent-bug hunt at the end instead.
65
65
  2. Reproduce it if possible: a failing test or a scratch script. When it cannot be reproduced
66
66
  and this run made its failure message name the cause next time (or found it already does),
67
- record a Progress note, write ${WAITING_FOR_FAILURE_NOTE} as its own line under the
68
- heading, and end the run. Do not keep investigating without new evidence.
67
+ record a Progress note, write a **Needs review <YYYY-MM-DD> by bugfix: <what the next failure's message
68
+ will show>** note as its own line under the heading, and end the run. Do not keep investigating without new evidence.
69
69
  3. Fix it, and add a regression test that fails without the fix — run that test to watch it
70
70
  fail before the fix and pass after.
71
71
  4. Mark it fixed in BUGS.md — ${backlogMoveGuidance("BUGS.md", "## Open", "## Fixed", "BUGS-FIXED.md", "fixed")}
@@ -92,10 +92,17 @@ within that budget, there is nothing to do.`,
92
92
  find: `Write ONE concrete plan for what this project needs next. Do not implement it.
93
93
  1. Check what is already waiting: the prompt's <backlog-index> block lists PLANS.md's
94
94
  \`## Planned\` entries with each entry's line range.
95
+ - A \`<no-check>\` block in your prompt (this project declares no check and no declaration
96
+ plan waits) is this run's task over adding another plan: write the declaration entry it
97
+ names, headed \`### Declare the project's deterministic check\` and marked on its own line
98
+ with ${CHECK_DECLARATION_MARKER}, before anything else — the eligible-plan stop below
99
+ yields to it within an admitted run (a full plan backlog can still defer the plan role
100
+ before this prompt is built). A Needs-review lift and an assigned claim still
101
+ come first.
95
102
  - A plan carrying a ${NEEDS_REVIEW_NOTE} note outranks adding another plan: split it into
96
103
  independently mergeable sub-plans that cross-reference each other (per PLAN_SIZING below,
97
104
  each with its own acceptance criteria), then remove the note. That split is this run's task.
98
- - A BUGS.md \`## Open\` entry carrying a ${NEEDS_REVIEW_NOTE} or ${NEEDS_REPLAN_NOTE} note
105
+ - A BUGS.md \`## Open\` entry carrying a ${NEEDS_REVIEW_NOTE} note
99
106
  (the feature or bugfix role parked it as too large for one run) also outranks adding another
100
107
  plan: resolve it in the same run by splitting it into independently fixable sub-bugs (each
101
108
  its own BUGS.md heading) then removing the note, or by lifting the note when the work does
@@ -106,19 +113,13 @@ within that budget, there is nothing to do.`,
106
113
  the quoted objections and the code they name and rewrite the plan so an implementation
107
114
  following it would answer each — correcting the approach or the anchors, tightening the
108
115
  acceptance criteria, or splitting it per PLAN_SIZING — then remove the note.
109
- - A plan carrying a ${NEEDS_REPLAN_NOTE} note also outranks adding another plan: read the
110
- quoted objections and the code they name, then rewrite the plan so an implementation
111
- following it would answer them — correcting the approach or the anchors, tightening the
112
- acceptance criteria, splitting it per PLAN_SIZING, or refusing it with the objection
113
- recorded when it should not be done — and remove both the note and the quoted objections.
114
- - When you lift a Needs-review or Needs-replan note (a split or a rewrite) and the change
115
- has NOT merged, keep the held count instead of letting it reset: remove any existing
116
- \`**Previously held N**\` line on the surviving parent and every child part the split
117
- produces, then write \`${PRIOR_HELD_PREFIX}<N>**\` at the lifted note's count on the
118
- parent and copy it to each child, so exactly one marker survives and heldCount reads the
119
- lifted count everywhere — a merged parent (one whose plan left \`## Planned\`) leaves no
120
- marker, and a note with no \`(held N)\` tag (the automatic convergence hold) is lifted
121
- without writing a \`**Previously held 0**\` marker.
116
+ - A plan carrying a **Needs review …** note that quotes the reviewer's surviving objections
117
+ (a final-round rejection — \`rejected after <N> review rounds\`) also outranks adding another
118
+ plan: read the quoted objections and the code they name, then rewrite the plan so an
119
+ implementation following it would answer them — correcting the approach or the anchors,
120
+ tightening the acceptance criteria, splitting it per PLAN_SIZING, or refusing it with the
121
+ objection recorded when it should not be done — and remove both the note and the quoted
122
+ objections.
122
123
  - Otherwise, when PLANS.md \`## Planned\` already holds one more eligible plan than there
123
124
  are feature instances (one waiting per instance, plus one for the next planner run),
124
125
  end with ${NOTHING_TO_DO} — feature has work, and a waiting plan is refined by the
@@ -418,8 +419,8 @@ own response failing, so it is fileable as it stands under rule 1.
418
419
  outranks one that burned seconds — cite its cost when it is the reason you file.
419
420
  4. Before filing, check BUGS.md's ## Open section and \`git log ${roleCommitGrep("selfcheck")}\`
420
421
  for an entry that already names this cluster — no duplicate filings. When that Open entry
421
- carries a ${WAITING_FOR_FAILURE_PREFIX}…** note and the cluster failed again after the
422
- note's date, add the new occurrence under the entry (time, merged commit, failure message)
422
+ carries a **Needs review …** note naming what would make the failure show itself and the cluster
423
+ failed again after the note's date, add the new occurrence under the entry (time, merged commit, failure message)
423
424
  and delete the note line, instead of filing a duplicate.
424
425
  5. File it: cite the cluster's normalized key plus the correlated \`merged\` commit in the
425
426
  entry, so the bugfix role can reproduce it.