tumwater 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (464) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +55 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +6 -4
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +404 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +281 -55
  41. package/dist/src/backlog/backlog-cli-view.js +255 -0
  42. package/dist/src/backlog/backlog-cli.js +91 -335
  43. package/dist/src/backlog/backlog-commit.js +20 -13
  44. package/dist/src/backlog/backlog-eligibility.js +193 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +264 -0
  48. package/dist/src/backlog/backlog-release.js +134 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-guardrails.js +136 -0
  56. package/dist/src/baseline/main-red.js +153 -36
  57. package/dist/src/baseline/main-watch.js +232 -0
  58. package/dist/src/brief/brief-edit.js +12 -12
  59. package/dist/src/brief/brief.js +31 -51
  60. package/dist/src/budget/budget-fallback-view.js +288 -0
  61. package/dist/src/budget/budget-wiring.js +158 -0
  62. package/dist/src/budget/budget.js +31 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +65 -3
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +181 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +183 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +291 -598
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-review-commands.js +113 -0
  84. package/dist/src/cli/cli-role-command.js +525 -0
  85. package/dist/src/cli/cli-start-summary.js +167 -0
  86. package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
  87. package/dist/src/cli/completion.js +169 -22
  88. package/dist/src/cli/config-commands.js +58 -26
  89. package/dist/src/cli/follow-view.js +75 -0
  90. package/dist/src/cli/help.js +258 -82
  91. package/dist/src/cli/log-commands.js +164 -85
  92. package/dist/src/cli/question-commands.js +211 -10
  93. package/dist/src/cli.js +173 -45
  94. package/dist/src/collections.js +56 -13
  95. package/dist/src/concurrency/check-permit.js +20 -3
  96. package/dist/src/concurrency/lock.js +17 -13
  97. package/dist/src/concurrency/provider-lease.js +44 -8
  98. package/dist/src/concurrency/semaphore.js +102 -26
  99. package/dist/src/config/config-aliases.js +0 -1
  100. package/dist/src/config/config-editable-keys.js +16 -1
  101. package/dist/src/config/config-fallbacks.js +94 -56
  102. package/dist/src/config/config-field-checks.js +40 -10
  103. package/dist/src/config/config-keys.js +30 -8
  104. package/dist/src/config/config-live.js +63 -24
  105. package/dist/src/config/config-request.js +137 -0
  106. package/dist/src/config/config-schema.js +31 -24
  107. package/dist/src/config/config-selector-checks.js +123 -0
  108. package/dist/src/config/config-validation-agents.js +80 -0
  109. package/dist/src/config/config-validation-model.js +116 -0
  110. package/dist/src/config/config-validation.js +169 -222
  111. package/dist/src/config/config-views.js +136 -54
  112. package/dist/src/config/config-write-core.js +135 -0
  113. package/dist/src/config/config-write.js +164 -265
  114. package/dist/src/config/config.js +53 -38
  115. package/dist/src/config/model-selector.js +34 -34
  116. package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
  117. package/dist/src/doctor/doctor-backlog-plans.js +398 -0
  118. package/dist/src/doctor/doctor-backlog.js +249 -512
  119. package/dist/src/doctor/doctor-check-names.js +85 -0
  120. package/dist/src/doctor/doctor-checks.js +61 -593
  121. package/dist/src/doctor/doctor-claude-checks.js +142 -0
  122. package/dist/src/doctor/doctor-codex-checks.js +276 -0
  123. package/dist/src/doctor/doctor-contention-checks.js +506 -0
  124. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  125. package/dist/src/doctor/doctor-holds.js +80 -66
  126. package/dist/src/doctor/doctor-install.js +2 -1
  127. package/dist/src/doctor/doctor-model-checks.js +216 -537
  128. package/dist/src/doctor/doctor-notify.js +9 -5
  129. package/dist/src/doctor/doctor-opencode-checks.js +164 -0
  130. package/dist/src/doctor/doctor-orphans.js +182 -63
  131. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  132. package/dist/src/doctor/doctor-project-checks.js +397 -0
  133. package/dist/src/doctor/doctor.js +126 -77
  134. package/dist/src/doctor/image-probe.js +21 -8
  135. package/dist/src/doctor/outcome.js +3 -2
  136. package/dist/src/doctor/probe.js +21 -12
  137. package/dist/src/events/event-format.js +185 -89
  138. package/dist/src/events/event-labels.js +11 -5
  139. package/dist/src/events/event-legacy.js +39 -15
  140. package/dist/src/events/event-read.js +52 -39
  141. package/dist/src/events/event-tally.js +16 -0
  142. package/dist/src/events/event-window.js +80 -26
  143. package/dist/src/events/events.js +30 -13
  144. package/dist/src/events/notify.js +7 -7
  145. package/dist/src/failure/failure-cluster.js +12 -12
  146. package/dist/src/failure/failure-data.js +65 -60
  147. package/dist/src/failure/failure-render.js +29 -21
  148. package/dist/src/failure/failure-state-change.js +17 -12
  149. package/dist/src/failure/time-spend.js +35 -35
  150. package/dist/src/files/file-queue.js +17 -0
  151. package/dist/src/files/files.js +111 -35
  152. package/dist/src/files/json-files.js +3 -3
  153. package/dist/src/files/json-object.js +28 -7
  154. package/dist/src/files/stat-cache.js +2 -2
  155. package/dist/src/files/tail.js +1 -1
  156. package/dist/src/gates/bootstrap-gates.js +4 -5
  157. package/dist/src/gates/budget-gates.js +88 -225
  158. package/dist/src/gates/disk-gate.js +1 -1
  159. package/dist/src/gates/gate-polls.js +101 -143
  160. package/dist/src/gates/maintenance-quota.js +37 -30
  161. package/dist/src/gates/pause-gates.js +28 -10
  162. package/dist/src/gates/readiness.js +7 -3
  163. package/dist/src/gates/role-cap-gates.js +8 -8
  164. package/dist/src/gates/startup-gate.js +94 -32
  165. package/dist/src/gates/streak-gate.js +54 -30
  166. package/dist/src/git/commit-message.js +89 -46
  167. package/dist/src/git/git-diff.js +16 -10
  168. package/dist/src/git/git-run.js +79 -38
  169. package/dist/src/git/git.js +54 -34
  170. package/dist/src/git/slot-salvage.js +253 -0
  171. package/dist/src/git/slots-state.js +6 -6
  172. package/dist/src/git/worktree-pool.js +132 -38
  173. package/dist/src/git/worktree-use.js +4 -11
  174. package/dist/src/git/worktree.js +94 -37
  175. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  176. package/dist/src/gui/gui-args.js +79 -8
  177. package/dist/src/gui/gui-backlog-commands.js +43 -19
  178. package/dist/src/gui/gui-backlog-endpoints.js +156 -0
  179. package/dist/src/gui/gui-command.js +26 -13
  180. package/dist/src/gui/gui-config-commands.js +168 -80
  181. package/dist/src/gui/gui-endpoint-commands.js +5 -4
  182. package/dist/src/gui/gui-endpoint-common.js +4 -4
  183. package/dist/src/gui/gui-endpoints.js +11 -380
  184. package/dist/src/gui/gui-history-endpoints.js +301 -0
  185. package/dist/src/gui/gui-operator-commands.js +235 -13
  186. package/dist/src/gui/gui-prompt-commands.js +26 -20
  187. package/dist/src/gui/gui-report-endpoints.js +97 -0
  188. package/dist/src/gui/gui-server.js +26 -5
  189. package/dist/src/gui/gui-team-commands.js +7 -7
  190. package/dist/src/gui/http-body.js +1 -1
  191. package/dist/src/history/history-data.js +122 -234
  192. package/dist/src/history/history-queued.js +155 -0
  193. package/dist/src/history/history-render.js +42 -22
  194. package/dist/src/history/history.js +190 -49
  195. package/dist/src/inbox/inbox-attachments.js +29 -30
  196. package/dist/src/inbox/inbox-cancel.js +10 -11
  197. package/dist/src/inbox/inbox-edit.js +9 -9
  198. package/dist/src/inbox/inbox-submit.js +18 -18
  199. package/dist/src/inbox/inbox-top.js +28 -17
  200. package/dist/src/inbox/inbox.js +42 -43
  201. package/dist/src/inbox/pending-prompt.js +26 -26
  202. package/dist/src/inbox/prompt-commands.js +62 -49
  203. package/dist/src/inbox/prompt-not-before.js +2 -2
  204. package/dist/src/init/init-templates.js +1 -2
  205. package/dist/src/init/init.js +28 -23
  206. package/dist/src/merge/backlog-conflicts.js +215 -35
  207. package/dist/src/merge/merge-batch.js +14 -8
  208. package/dist/src/merge/merge-cancel.js +53 -0
  209. package/dist/src/merge/merge-check-failures.js +44 -22
  210. package/dist/src/merge/merge-core.js +58 -32
  211. package/dist/src/merge/merge-diff.js +5 -4
  212. package/dist/src/merge/merge-drain.js +45 -19
  213. package/dist/src/merge/merge-git.js +177 -38
  214. package/dist/src/merge/merge-pipeline.js +15 -3
  215. package/dist/src/merge/merge-post-review.js +214 -0
  216. package/dist/src/merge/merge-queue-render.js +48 -11
  217. package/dist/src/merge/merge-queue.js +52 -15
  218. package/dist/src/merge/merge-requeue.js +103 -0
  219. package/dist/src/merge/merge-slot.js +61 -16
  220. package/dist/src/merge/merge-stack.js +27 -11
  221. package/dist/src/merge/merge-to-main.js +77 -26
  222. package/dist/src/merge/merge-top.js +83 -0
  223. package/dist/src/merge/merge-verify.js +75 -18
  224. package/dist/src/notes-cli.js +35 -15
  225. package/dist/src/operator/operator-commands.js +192 -81
  226. package/dist/src/operator/operator-intent.js +69 -42
  227. package/dist/src/operator/operator-requests.js +35 -40
  228. package/dist/src/operator/retire.js +5 -6
  229. package/dist/src/operator/revert.js +142 -34
  230. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  231. package/dist/src/orchestrator/orchestrator-poll.js +352 -0
  232. package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
  233. package/dist/src/orchestrator/orchestrator.js +129 -369
  234. package/dist/src/orchestrator/retention.js +42 -8
  235. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  236. package/dist/src/orchestrator/scheduling-scope.js +245 -0
  237. package/dist/src/orchestrator/scheduling-skip.js +195 -0
  238. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  239. package/dist/src/orchestrator/startup-migrations.js +11 -7
  240. package/dist/src/paths.js +126 -49
  241. package/dist/src/pi/command-shape.js +12 -1
  242. package/dist/src/pi/pi-args.js +6 -4
  243. package/dist/src/pi/pi-backend.js +6 -6
  244. package/dist/src/pi/pi-bin.js +54 -63
  245. package/dist/src/pi/pi-listed-models.js +53 -0
  246. package/dist/src/pi/pi-models.js +63 -30
  247. package/dist/src/pi/pi-run-result.js +1 -1
  248. package/dist/src/pi/pi-settings.js +1 -1
  249. package/dist/src/pi/pi-stream.js +6 -6
  250. package/dist/src/pi/pi-watchdogs.js +18 -19
  251. package/dist/src/pi-extension/bounded-output.js +5 -3
  252. package/dist/src/pi-extension/context-budget.js +2 -2
  253. package/dist/src/pi-extension/context-shake.js +1 -1
  254. package/dist/src/pi-extension/context-window.js +0 -1
  255. package/dist/src/pi-extension/full-output.js +3 -10
  256. package/dist/src/pi-extension/strip-thinking.js +0 -1
  257. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  258. package/dist/src/pi-extension/write-guard.js +37 -0
  259. package/dist/src/process/launch-services.js +1 -2
  260. package/dist/src/process/node-bin.js +16 -0
  261. package/dist/src/process/process-group.js +8 -18
  262. package/dist/src/process/process.js +41 -2
  263. package/dist/src/process/run-marker.js +26 -6
  264. package/dist/src/process/supervisor.js +22 -21
  265. package/dist/src/prompt/dialect.js +17 -2
  266. package/dist/src/prompt/gate-notes.js +203 -0
  267. package/dist/src/prompt/gate-prompts.js +150 -217
  268. package/dist/src/prompt/notes-backlog.js +184 -0
  269. package/dist/src/prompt/principles.js +1 -2
  270. package/dist/src/prompt/prompt-followup.js +19 -14
  271. package/dist/src/prompt/prompt.js +131 -204
  272. package/dist/src/redeploy/redeploy-policy.js +24 -19
  273. package/dist/src/redeploy/redeploy-probes.js +1 -1
  274. package/dist/src/redeploy/redeploy.js +2 -3
  275. package/dist/src/redeploy/redeployer.js +20 -20
  276. package/dist/src/redeploy/self-reload.js +2 -1
  277. package/dist/src/report/report-data.js +188 -124
  278. package/dist/src/report/report-fold.js +130 -54
  279. package/dist/src/report/report-main-red.js +55 -0
  280. package/dist/src/report/report-render.js +136 -65
  281. package/dist/src/report/report-types.js +1 -0
  282. package/dist/src/report/report.js +23 -9
  283. package/dist/src/request-timeouts.js +2 -2
  284. package/dist/src/review/diff-rules.js +53 -0
  285. package/dist/src/review/known-flakes.js +112 -1
  286. package/dist/src/review/post-merge-review-accept.js +51 -0
  287. package/dist/src/review/post-merge-review-render.js +112 -0
  288. package/dist/src/review/post-merge-review.js +239 -0
  289. package/dist/src/review/protected-edits.js +24 -10
  290. package/dist/src/review/quarantine-render.js +65 -0
  291. package/dist/src/review/review-followup.js +8 -8
  292. package/dist/src/review/review-precheck.js +66 -23
  293. package/dist/src/review/review-run.js +395 -0
  294. package/dist/src/review/review-verdict.js +2 -2
  295. package/dist/src/review/review.js +180 -401
  296. package/dist/src/review/secret-leaks.js +36 -16
  297. package/dist/src/review/suite-rerun.js +0 -1
  298. package/dist/src/roles/legacy-role-migration.js +80 -7
  299. package/dist/src/roles/role-catalog.js +127 -12
  300. package/dist/src/roles/role-guidance.js +65 -20
  301. package/dist/src/roles/role-labels.js +7 -5
  302. package/dist/src/roles/role-render.js +108 -56
  303. package/dist/src/roles/role-view.js +95 -56
  304. package/dist/src/roles/roles.js +33 -33
  305. package/dist/src/roles/runner-ids.js +97 -0
  306. package/dist/src/run/acceptance-greps.js +119 -0
  307. package/dist/src/run/ci-parity.js +121 -0
  308. package/dist/src/run/feedback-filings.js +51 -0
  309. package/dist/src/run/qa-coverage.js +125 -0
  310. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  311. package/dist/src/run/run-detail-data.js +175 -0
  312. package/dist/src/run/run-detail-render.js +91 -0
  313. package/dist/src/run/run-detail.js +168 -0
  314. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
  315. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  316. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
  317. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  318. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
  319. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  320. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  321. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
  322. package/dist/src/run/selfcheck-digest.js +40 -0
  323. package/dist/src/run/stage-backlog-checks.js +249 -0
  324. package/dist/src/run/stage-check.js +178 -0
  325. package/dist/src/run/stage-checks-shared.js +65 -0
  326. package/dist/src/run/stage-name-checks.js +204 -0
  327. package/dist/src/run/stage-path-checks.js +231 -0
  328. package/dist/src/run/stage-prose-checks.js +261 -0
  329. package/dist/src/runner/leftover.js +216 -0
  330. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  331. package/dist/src/runner/revision.js +191 -0
  332. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  333. package/dist/src/runner/runner-outcome.js +109 -0
  334. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  335. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
  336. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
  337. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  338. package/dist/src/runner/runner-state.js +103 -0
  339. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  340. package/dist/src/scheduling/backoff.js +38 -38
  341. package/dist/src/scheduling/claim-entries.js +49 -0
  342. package/dist/src/scheduling/claims.js +35 -22
  343. package/dist/src/scheduling/once-round.js +22 -22
  344. package/dist/src/scheduling/quiet-hours.js +5 -6
  345. package/dist/src/scheduling/scheduling.js +74 -64
  346. package/dist/src/scheduling/work-merged-cache.js +2 -2
  347. package/dist/src/status/status-budget.js +55 -47
  348. package/dist/src/status/status-data.js +49 -17
  349. package/dist/src/status/status-inbox.js +2 -2
  350. package/dist/src/status/status-install.js +0 -1
  351. package/dist/src/status/status-polls.js +43 -28
  352. package/dist/src/status/status-roles.js +50 -13
  353. package/dist/src/team/error-storm.js +8 -8
  354. package/dist/src/team/failure-spread.js +2 -2
  355. package/dist/src/team/orchestrator-info.js +15 -9
  356. package/dist/src/team/reclaim.js +7 -8
  357. package/dist/src/team/team-hold.js +7 -7
  358. package/dist/src/team/team-polls.js +91 -50
  359. package/dist/src/team/team-state.js +8 -2
  360. package/dist/src/text/datetime.js +65 -18
  361. package/dist/src/text/format.js +48 -11
  362. package/dist/src/text/markdown.js +8 -5
  363. package/dist/src/text/phrases.js +60 -23
  364. package/dist/src/text/suggest.js +1 -1
  365. package/dist/src/text/text.js +78 -13
  366. package/dist/src/ui/badges.js +99 -38
  367. package/dist/src/ui/gui/gui-client-backlog.js +314 -0
  368. package/dist/src/ui/gui/gui-client-boot.js +89 -61
  369. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  370. package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
  371. package/dist/src/ui/gui/gui-client-drawer.js +155 -289
  372. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  373. package/dist/src/ui/gui/gui-client-history.js +437 -68
  374. package/dist/src/ui/gui/gui-client-labels.js +6 -3
  375. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  376. package/dist/src/ui/gui/gui-client-model.js +27 -36
  377. package/dist/src/ui/gui/gui-client-operator.js +60 -52
  378. package/dist/src/ui/gui/gui-client-pending.js +405 -24
  379. package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
  380. package/dist/src/ui/gui/gui-client-report.js +105 -430
  381. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  382. package/dist/src/ui/gui/gui-client-settings.js +163 -5
  383. package/dist/src/ui/gui/gui-client-team.js +36 -465
  384. package/dist/src/ui/gui/gui-client.js +38 -26
  385. package/dist/src/ui/gui/gui-icons.js +0 -2
  386. package/dist/src/ui/gui/gui-page.js +10 -7
  387. package/dist/src/ui/gui/gui-styles.js +38 -34
  388. package/dist/src/ui/progress-data.js +9 -11
  389. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
  390. package/dist/src/ui/status-model.js +133 -125
  391. package/dist/src/ui/status-payload.js +364 -49
  392. package/dist/src/ui/status-render.js +109 -82
  393. package/dist/src/ui/team-alerts.js +728 -62
  394. package/dist/src/ui/tone.js +12 -6
  395. package/dist/src/ui/transcript-run.js +58 -0
  396. package/dist/src/ui/transcript-tail.js +28 -20
  397. package/dist/src/ui/transcript.js +63 -23
  398. package/dist/src/ui/tui/tui-app.js +0 -1
  399. package/dist/src/ui/tui/tui-backlog.js +53 -14
  400. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  401. package/dist/src/ui/tui/tui-editor-attach.js +4 -12
  402. package/dist/src/ui/tui/tui-editor-backends.js +127 -0
  403. package/dist/src/ui/tui/tui-editor-brief.js +14 -36
  404. package/dist/src/ui/tui/tui-editor-budget.js +36 -39
  405. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  406. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  407. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  408. package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
  409. package/dist/src/ui/tui/tui-editor-config.js +99 -0
  410. package/dist/src/ui/tui/tui-editor-entry.js +39 -64
  411. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  412. package/dist/src/ui/tui/tui-editor-instances.js +46 -0
  413. package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
  414. package/dist/src/ui/tui/tui-editor-interval.js +29 -16
  415. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  416. package/dist/src/ui/tui/tui-editor-notes.js +9 -29
  417. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  418. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  419. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  420. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  421. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  422. package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
  423. package/dist/src/ui/tui/tui-editor-retire.js +5 -13
  424. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  425. package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
  426. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  427. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  428. package/dist/src/ui/tui/tui-editor-session.js +101 -34
  429. package/dist/src/ui/tui/tui-editor-slots.js +24 -0
  430. package/dist/src/ui/tui/tui-editors.js +44 -2
  431. package/dist/src/ui/tui/tui-frame.js +68 -11
  432. package/dist/src/ui/tui/tui-history.js +8 -9
  433. package/dist/src/ui/tui/tui-input.js +13 -11
  434. package/dist/src/ui/tui/tui-keymap.js +0 -1
  435. package/dist/src/ui/tui/tui-keys.js +33 -9
  436. package/dist/src/ui/tui/tui-pane-activity.js +35 -19
  437. package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
  438. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  439. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  440. package/dist/src/ui/tui/tui-pane-health.js +168 -8
  441. package/dist/src/ui/tui/tui-pane-history.js +446 -82
  442. package/dist/src/ui/tui/tui-pane-pending.js +539 -15
  443. package/dist/src/ui/tui/tui-pane-queued.js +78 -57
  444. package/dist/src/ui/tui/tui-pane-report.js +54 -16
  445. package/dist/src/ui/tui/tui-pane-session.js +34 -6
  446. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  447. package/dist/src/ui/tui/tui-pane.js +91 -28
  448. package/dist/src/ui/tui/tui-panes.js +153 -34
  449. package/dist/src/ui/tui/tui.js +18 -15
  450. package/dist/src/verdict/fix-claim.js +129 -33
  451. package/dist/src/verdict/no-change.js +4 -4
  452. package/dist/src/verdict/refusal.js +40 -16
  453. package/dist/src/verdict/reply-contract.js +77 -31
  454. package/dist/src/version.js +0 -1
  455. package/package.json +2 -1
  456. package/dist/src/loop/leftover.js +0 -130
  457. package/dist/src/loop/revision.js +0 -70
  458. package/dist/src/roles/loop-ids.js +0 -77
  459. package/dist/src/tick/qa-coverage.js +0 -108
  460. package/dist/src/tick/stage-check.js +0 -419
  461. package/dist/src/tick/telemetry-digest.js +0 -25
  462. package/dist/src/tick/tick-detail-data.js +0 -108
  463. package/dist/src/tick/tick-detail.js +0 -163
  464. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,12 +1,13 @@
1
1
  import { addTo, rankCountEntries, sortByValueDesc } from "../collections.js";
2
2
  import { compactTokens, usd } from "../text/format.js";
3
3
  import { durationLabel } from "../cli/cli-args.js";
4
- import { monthDay, reportWindow, sinceWindowLine } from "../text/datetime.js";
4
+ import { boundedWindowLine, monthDay, reportWindow, sinceWindowLine } from "../text/datetime.js";
5
5
  import { eventsRotationLabel } from "../events/events.js";
6
6
  import { SPARSE_WINDOW_NOTE } from "../events/event-window.js";
7
7
  import { shortSpanPhrase } from "../text/phrases.js";
8
8
  import { markdownTable } from "../text/markdown.js";
9
- import { baseRoleOf } from "../roles/loop-ids.js";
9
+ import { baseRoleOf } from "../roles/runner-ids.js";
10
+ import { backendDisplayName } from "../agent/backend-traits.js";
10
11
  /** Bar width for one day: up to 20 blocks scaled to the window's max tokensOut —
11
12
  * round(20·v/max), min 1 when v > 0. */
12
13
  function barWidth(v, max) {
@@ -37,18 +38,37 @@ function rankedRoleTotals(series, pick) {
37
38
  function rankedRoleLine(label, pairs, fmt) {
38
39
  return `**${label}:** ${pairs.length === 0 ? "-" : pairs.map(([r, n]) => `${r} — ${fmt(n)}`).join(" · ")}`;
39
40
  }
40
- /** The per-day-median line of the team day report — "MMM D <span>" cells for each day that
41
+ /** Push one "**Label:** …" ranked-role line, dropping roles with a non-positive value (zero
42
+ * count/spend) before rendering, so an all-zero window still renders "-" exactly like the
43
+ * unfiltered lines. fmt formats each role's number (default String for count lines; pass
44
+ * usd/compactTokens for spend/token lines). All the "X by role" lines share this filter-rule
45
+ * home so the zero-means-render-dash guard cannot drift between the --since and day renders. */
46
+ function pushRankedRoleLine(lines, label, pairs, fmt = String) {
47
+ lines.push(rankedRoleLine(label, pairs.filter(([, n]) => n > 0), fmt));
48
+ }
49
+ /** The per-day cameo line of the team day report — "MMM D <cell>" cells for each day that
41
50
  * carries a value, joined under a bold title, or undefined when no day has one (zero means
42
- * absent). The one home of that cameo rendering, shared by the merge check-time and merge-wait
43
- * medians so their date+span cells and title line cannot drift; the planned provider-slot wait
44
- * median is a third caller of the same rule. */
45
- function medianLine(series, title, pick) {
51
+ * absent). The one home of that cameo rendering, shared by the per-day medians, the red-check
52
+ * count and the main-red wall-clock lines so their date+cell shape and title line cannot drift.
53
+ * Each caller supplies its own cell formatter (shortSpanPhrase for the medians, `×n` for the
54
+ * red-check count, durationLabel for the main-red span). */
55
+ function dayCameoLine(series, title, pick, fmt = shortSpanPhrase) {
46
56
  const cells = series.flatMap((d) => {
47
57
  const v = pick(d);
48
- return v === undefined ? [] : [`${monthDay(d.date)} ${shortSpanPhrase(v)}`];
58
+ return v === undefined ? [] : [`${monthDay(d.date)} ${fmt(v)}`];
49
59
  });
50
60
  return cells.length > 0 ? `**${title}:** ${cells.join(" · ")}` : undefined;
51
61
  }
62
+ /** Push a day-cameo line onto `lines` only when some day carries a value — the “compute a
63
+ * cameo line, push it when present” boilerplate every per-day cell repeats. Renders via
64
+ * dayCameoLine (the one cameo shape), so the date+cell shape and title line cannot drift.
65
+ * Zero-means-absent: a day whose field is undefined renders no cell, and a series with no such
66
+ * day pushes no line. */
67
+ function pushDayCameoLine(lines, series, title, pick, fmt = shortSpanPhrase) {
68
+ const line = dayCameoLine(series, title, pick, fmt);
69
+ if (line !== undefined)
70
+ lines.push(line);
71
+ }
52
72
  /** Push the `Cost by provider` and `Estimated by provider` lines of the per-provider spend
53
73
  * breakdown (plans: per-provider spend), from the caller's already-ranked real- and notional-
54
74
  * spend pairs. Role scoping is the caller's `role === undefined` gate — this helper only
@@ -80,13 +100,38 @@ function mergeShareLine(t) {
80
100
  const failed = (t.mergeFailed ?? 0) > 0 ? ` (${t.mergeFailed} failed)` : "";
81
101
  return `of which merge runs: ${t.mergeRuns} runs${failed} · ${compactTokens(t.mergeTokens)} tokens · ${usd(t.mergeCostUsd)} (reviewer + conflict resolution)`;
82
102
  }
83
- /** The notional-spend line (plans/agent-backends.md part 6a): the API-equivalent cost of
84
- * claude.ai subscription runs folded into the window. Never part of the totals' costUsd,
85
- * because a subscription run is exempt from the daily dollar cap; the caller prints it only
86
- * when a subscription run actually folded in (zero means absent, like the merge line), so
87
- * an API-only team's report is unchanged. */
88
- function notionalLine(t) {
89
- return `claude subscription, API-equivalent: ${usd(t.notionalCostUsd ?? 0)} estimated`;
103
+ /** A float-robust "the per-backend notional sums account for the window's total" check: the two
104
+ * numbers fold in different orders (per-event vs per-backend-then-summed), so their float traces
105
+ * need not coincide exactly even when every subscription event is attributed — the gap lands
106
+ * around machine epsilon. A small relative slack absorbs that reordering noise, while a genuinely
107
+ * partial map (an unattributed subscription merge, or a legacy event with no agent) is off by a
108
+ * real, much larger amount and must fall back to the generic line. */
109
+ function notionalSumsCoverTotal(summed, total) {
110
+ return Math.abs(summed - total) <= 1e-9 * Math.max(1, Math.abs(total));
111
+ }
112
+ /** The notional-spend line(s) (plans/agent-backends.md part 6a, codex-backend.md part 3d/9):
113
+ * the API-equivalent cost of subscription runs folded into the window. Never part of the
114
+ * totals' costUsd, because a subscription run is exempt from the daily dollar cap; the caller
115
+ * prints it only when a subscription run actually folded in (zero means absent, like the merge
116
+ * line), so an API-only team's report is unchanged. When the window's notional spend fully
117
+ * decomposes into backends, one labeled line names each backend — including a single non-claude
118
+ * backend (a codex-only window), so a codex team's line never reads "claude". A claude-only
119
+ * account, an old-log window, or unattributed merge spend keeps one generic lowercase "claude
120
+ * subscription" line — byte-identical to the pre-codex render. */
121
+ function notionalLines(t) {
122
+ const total = t.notionalCostUsd ?? 0;
123
+ const byBackend = t.notionalByBackend ?? {};
124
+ const backends = Object.entries(byBackend).filter(([, c]) => c > 0);
125
+ const summed = backends.reduce((n, [, c]) => n + c, 0);
126
+ // A single fully-attributed claude account collapses to the generic line (its true or
127
+ // fallback backend is claude, and the capitalization keeps old/byte-identical output);
128
+ // every other fully-attributed window — multi-backend, or a single codex/other backend —
129
+ // names each backend on its own line.
130
+ const singleClaude = backends.length === 1 && backends[0]?.[0] === "claude";
131
+ if (notionalSumsCoverTotal(summed, total) && !singleClaude) {
132
+ return backends.map(([backend, c]) => `${backendDisplayName(backend)} subscription, API-equivalent: ${usd(c)} estimated`);
133
+ }
134
+ return [`claude subscription, API-equivalent: ${usd(total)} estimated`];
90
135
  }
91
136
  /** The scope note a `--role` report prints: merge runs belong to the merge slot and the
92
137
  * day-granular backlog tallies (features done / bugs fixed / plans added / bugs filed) come
@@ -104,7 +149,7 @@ function pushReportSummary(lines, t, tail = "", scoped = false) {
104
149
  if (t.mergeRuns > 0)
105
150
  lines.push(mergeShareLine(t));
106
151
  if ((t.notionalCostUsd ?? 0) > 0)
107
- lines.push(notionalLine(t));
152
+ lines.push(...notionalLines(t));
108
153
  if (scoped)
109
154
  lines.push(ROLE_SCOPE_NOTE);
110
155
  }
@@ -116,7 +161,9 @@ export function renderSinceReportMarkdown(data) {
116
161
  const lines = [];
117
162
  lines.push("# tumwater usage report");
118
163
  lines.push("");
119
- lines.push(sinceWindowLine(durationLabel(data.sinceMs), data.fromIso));
164
+ lines.push(data.untilMs !== undefined && data.toIso !== undefined
165
+ ? boundedWindowLine(durationLabel(data.sinceMs), durationLabel(data.untilMs), data.fromIso, data.toIso)
166
+ : sinceWindowLine(durationLabel(data.sinceMs), data.fromIso));
120
167
  if (data.role !== undefined)
121
168
  lines.push(`role: ${data.role}`);
122
169
  lines.push("");
@@ -131,10 +178,10 @@ export function renderSinceReportMarkdown(data) {
131
178
  // omitted — an all-zero window renders "-" like the other lines. Output tokens are the
132
179
  // resource the budget charges, so this is the line an operator reads to find which role's
133
180
  // prompt loop burned the window's tokens.
134
- lines.push(rankedRoleLine("Tokens by role", rankedRoleMap(data.tokensByRole).filter(([, n]) => n > 0), compactTokens));
181
+ pushRankedRoleLine(lines, "Tokens by role", rankedRoleMap(data.tokensByRole), compactTokens);
135
182
  // Same breakdown for spend: ranked by spend desc then name asc, zero-spend roles omitted —
136
- // an all-zero window renders "-" like the ticks line.
137
- lines.push(rankedRoleLine("Cost by role", rankedRoleMap(data.costByRole).filter(([, c]) => c > 0), usd));
183
+ // an all-zero window renders "-" like the runs line.
184
+ pushRankedRoleLine(lines, "Cost by role", rankedRoleMap(data.costByRole), usd);
138
185
  // The same breakdown per PROVIDER (plans: per-provider spend), gated to the team report
139
186
  // like its day sibling. SinceReport.costByProvider is always present (empty when none), so
140
187
  // the zero-means-absent gate prints no line for a window with no provider spend.
@@ -150,19 +197,26 @@ export function renderSinceReportMarkdown(data) {
150
197
  if (modelPairs.length > 0)
151
198
  lines.push(rankedRoleLine("Runs by model", modelPairs, String));
152
199
  }
200
+ // Main's red wall-clock over the window (main's own, not any role's — a role-scoped
201
+ // collector carries 0, so this never prints for a scoped window). Zero-absent: no line for
202
+ // a window main was never red.
203
+ if (data.mainRedMs > 0)
204
+ lines.push(`**Main red:** ${durationLabel(data.mainRedMs)}`);
153
205
  // The same breakdown for review friction: rejections per role from review_rejected events;
154
- // a window with none renders "-" like the other lines.
155
- lines.push(rankedRoleLine("Rejections by role", rankedRoleMap(data.rejectionsByRole).filter(([, r]) => r > 0), String));
156
- lines.push(rankedRoleLine("Revisions by role", rankedRoleMap(data.revisionsByRole).filter(([, r]) => r > 0), String));
157
- lines.push(rankedRoleLine("Refusals by role", rankedRoleMap(data.refusalsByRole).filter(([, r]) => r > 0), String));
206
+ // a window with none renders "-" like the other lines. The post-merge line is the
207
+ // afterMerge: true subset — rejections that reached an already-merged commit.
208
+ pushRankedRoleLine(lines, "Rejections by role", rankedRoleMap(data.rejectionsByRole));
209
+ pushRankedRoleLine(lines, "Post-merge rejections by role", rankedRoleMap(data.postMergeRejectionsByRole));
210
+ pushRankedRoleLine(lines, "Revisions by role", rankedRoleMap(data.revisionsByRole));
211
+ pushRankedRoleLine(lines, "Refusals by role", rankedRoleMap(data.refusalsByRole));
158
212
  // The same breakdown for outright failures: error-class run_ends per role under the
159
- // digest's OUTCOME_CLASS — the ticks that burned agent time and merged nothing without
213
+ // digest's OUTCOME_CLASS — the runs that burned agent time and merged nothing without
160
214
  // going through review, which rejections/revisions would miss entirely. A window with
161
215
  // none renders "-" like the other lines.
162
- lines.push(rankedRoleLine("Failed runs by role", rankedRoleMap(data.failedRunsByRole).filter(([, n]) => n > 0), String));
163
- lines.push(rankedRoleLine("Fallbacks by role", rankedRoleMap(data.fallbacksByRole).filter(([, n]) => n > 0), String));
164
- lines.push(rankedRoleLine("Image reroutes by role", rankedRoleMap(data.imageReroutesByRole).filter(([, n]) => n > 0), String));
165
- // Deferred maintenance ticks per reason code, the --since twin of the day render's line:
216
+ pushRankedRoleLine(lines, "Failed runs by role", rankedRoleMap(data.failedRunsByRole));
217
+ pushRankedRoleLine(lines, "Fallbacks by role", rankedRoleMap(data.fallbacksByRole));
218
+ pushRankedRoleLine(lines, "Image reroutes by role", rankedRoleMap(data.imageReroutesByRole));
219
+ // Deferred maintenance runs per reason code, the --since twin of the day render's line:
166
220
  // how many due maintenance runs stood down and why (backlog / no-work / plans-full).
167
221
  // Gated to the team report — a reason is not a role, so a role-scoped window omits it.
168
222
  // Zero-means-absent: a window with no deferral prints no line.
@@ -195,7 +249,7 @@ export function renderSinceReportMarkdown(data) {
195
249
  // Subscription spend is notional: its own line, only when the window has any — the same
196
250
  // conditional/filtered `usd` form the day render's "Estimated by role" uses.
197
251
  if (data.notionalByRole !== undefined) {
198
- lines.push(rankedRoleLine("Estimated by role", rankedRoleMap(data.notionalByRole).filter(([, c]) => c > 0), usd));
252
+ pushRankedRoleLine(lines, "Estimated by role", rankedRoleMap(data.notionalByRole), usd);
199
253
  }
200
254
  lines.push("");
201
255
  // Exactly one final line, chosen by the collector's coverage proof: either a hedged note
@@ -211,12 +265,12 @@ export function renderSinceReportMarkdown(data) {
211
265
  lines.push("backlog tallies (features done / bugs fixed / plans added / bugs filed) need the day report (--days)");
212
266
  return lines.join("\n");
213
267
  }
214
- /** Cost per merged commit by base role. Spend comes from costByRole (keyed by loop id, folded to
268
+ /** Cost per merged commit by base role. Spend comes from costByRole (keyed by role id, folded to
215
269
  * its base role so `feature-2` joins `feature`), commits from commitsByRole (already base-role
216
270
  * keyed). `merged` is [role, cost per commit, commits] for roles with both, ranked by cost per
217
271
  * commit desc then name asc; `idle` is roles that spent and merged nothing, ranked by spend.
218
272
  * Merge spend (the reviewer's) and notional subscription spend stay out by default, matching
219
- * `**Cost by role:**` (`pick` selects another per-loop-id spend map, e.g. notionalByRole); a
273
+ * `**Cost by role:**` (`pick` selects another per-role-id spend map, e.g. notionalByRole); a
220
274
  * role with commits but no spend in the picked map appears in neither list. */
221
275
  function costPerCommitPairs(series, pick = (d) => d.costByRole) {
222
276
  const cost = {};
@@ -273,22 +327,22 @@ export function renderReportMarkdown(data) {
273
327
  lines.push("");
274
328
  const maxTokens = data.series.reduce((m, d) => Math.max(m, d.tokensOut), 0);
275
329
  lines.push(...markdownTable(["day", "tokens out", "runs", "commits", "cost"], data.series.map((d) => {
276
- const ticks = Object.values(d.runsByRole).reduce((a, b) => a + b, 0);
330
+ const runs = Object.values(d.runsByRole).reduce((a, b) => a + b, 0);
277
331
  const w = maxTokens > 0 ? barWidth(d.tokensOut, maxTokens) : 0;
278
332
  const bar = w > 0 ? ` ${"█".repeat(w)}` : ""; // Zero days carry no bar (and no stray space).
279
- return [monthDay(d.date), `${compactTokens(d.tokensOut)}${bar}`, String(ticks), String(d.commits), usd(d.costUsd)];
333
+ return [monthDay(d.date), `${compactTokens(d.tokensOut)}${bar}`, String(runs), String(d.commits), usd(d.costUsd)];
280
334
  }), ["left", "right", "right", "right", "right"]));
281
335
  lines.push("");
282
336
  lines.push(rankedRoleLine("Runs by role", rankedRoleTotals(data.series, (d) => d.runsByRole), String));
283
337
  // The same breakdown for output tokens (the resource the budget charges): window totals per
284
- // role from tokensByRole, ranked like the ticks line. Zero-token roles are omitted; an
285
- // all-zero window renders "-" like the ticks line.
286
- lines.push(rankedRoleLine("Tokens by role", rankedRoleTotals(data.series, (d) => d.tokensByRole ?? {}).filter(([, n]) => n > 0), compactTokens));
338
+ // role from tokensByRole, ranked like the runs line. Zero-token roles are omitted; an
339
+ // all-zero window renders "-" like the runs line.
340
+ pushRankedRoleLine(lines, "Tokens by role", rankedRoleTotals(data.series, (d) => d.tokensByRole ?? {}), compactTokens);
287
341
  // The same breakdown for spend: window totals per role from costByRole, ranked by spend
288
342
  // desc then name asc — spend is what the operator acts on (an operator tuning per-role
289
- // intervals wants the burning loop first, not the busiest one). Zero-spend roles are
290
- // omitted; an all-zero window renders "-" like the ticks line.
291
- lines.push(rankedRoleLine("Cost by role", rankedRoleTotals(data.series, (d) => d.costByRole).filter(([, c]) => c > 0), usd));
343
+ // intervals wants the burning role first, not the busiest one). Zero-spend roles are
344
+ // omitted; an all-zero window renders "-" like the runs line.
345
+ pushRankedRoleLine(lines, "Cost by role", rankedRoleTotals(data.series, (d) => d.costByRole), usd);
292
346
  // The same breakdown per PROVIDER (plans: per-provider spend): which seam of a mixed team
293
347
  // spent what, straight from the events' providerCost maps (run_end's own runs and the
294
348
  // merge's reviewer seam). Gated to the team report — a provider is not a role, so a
@@ -308,18 +362,20 @@ export function renderReportMarkdown(data) {
308
362
  lines.push(rankedRoleLine("Runs by model", modelPairs, String));
309
363
  }
310
364
  // The same breakdown for review friction: window rejections per role from review_rejected
311
- // events, ranked like the ticks line; a window with none renders "-".
312
- lines.push(rankedRoleLine("Rejections by role", rankedRoleTotals(data.series, (d) => d.rejectionsByRole ?? {}).filter(([, r]) => r > 0), String));
313
- lines.push(rankedRoleLine("Revisions by role", rankedRoleTotals(data.series, (d) => d.revisionsByRole ?? {}).filter(([, r]) => r > 0), String));
314
- lines.push(rankedRoleLine("Refusals by role", rankedRoleTotals(data.series, (d) => d.refusalsByRole ?? {}).filter(([, r]) => r > 0), String));
365
+ // events, ranked like the runs line; a window with none renders "-". The post-merge line
366
+ // is the afterMerge: true subset — rejections that reached an already-merged commit.
367
+ pushRankedRoleLine(lines, "Rejections by role", rankedRoleTotals(data.series, (d) => d.rejectionsByRole ?? {}));
368
+ pushRankedRoleLine(lines, "Post-merge rejections by role", rankedRoleTotals(data.series, (d) => d.postMergeRejectionsByRole ?? {}));
369
+ pushRankedRoleLine(lines, "Revisions by role", rankedRoleTotals(data.series, (d) => d.revisionsByRole ?? {}));
370
+ pushRankedRoleLine(lines, "Refusals by role", rankedRoleTotals(data.series, (d) => d.refusalsByRole ?? {}));
315
371
  // The same breakdown for outright failures: error-class run_ends per role under the
316
- // digest's OUTCOME_CLASS — ticks that burned agent time and merged nothing without going
372
+ // digest's OUTCOME_CLASS — runs that burned agent time and merged nothing without going
317
373
  // through review, which rejections/revisions would miss entirely. Zero-means-absent, so a
318
374
  // failure-free window renders no line and an all-zero window renders "-" like the others.
319
- lines.push(rankedRoleLine("Failed runs by role", rankedRoleTotals(data.series, (d) => d.failedRunsByRole ?? {}).filter(([, n]) => n > 0), String));
320
- lines.push(rankedRoleLine("Fallbacks by role", rankedRoleTotals(data.series, (d) => d.fallbacksByRole ?? {}).filter(([, n]) => n > 0), String));
321
- lines.push(rankedRoleLine("Image reroutes by role", rankedRoleTotals(data.series, (d) => d.imageReroutesByRole ?? {}).filter(([, n]) => n > 0), String));
322
- // Deferred maintenance ticks per reason code — how many due maintenance runs stood down
375
+ pushRankedRoleLine(lines, "Failed runs by role", rankedRoleTotals(data.series, (d) => d.failedRunsByRole ?? {}));
376
+ pushRankedRoleLine(lines, "Fallbacks by role", rankedRoleTotals(data.series, (d) => d.fallbacksByRole ?? {}));
377
+ pushRankedRoleLine(lines, "Image reroutes by role", rankedRoleTotals(data.series, (d) => d.imageReroutesByRole ?? {}));
378
+ // Deferred maintenance runs per reason code — how many due maintenance runs stood down
323
379
  // and why, so an idle day reads as backlog-open / no-work / plans-full rather than blank,
324
380
  // the per-role breakdown of the deferred maintenances. Gated to the team report — a
325
381
  // reason is not a role (ROLE_SCOPE_NOTE covers the omission). Zero-means-absent: a window
@@ -352,29 +408,32 @@ export function renderReportMarkdown(data) {
352
408
  lines.push(rankedRoleLine("Merge failures by reason", mergeFailureReasons, String));
353
409
  // Per-day median passing check time, so a suite slowing over days shows; days without a
354
410
  // check are left out, and no line prints when no day has one (zero means absent).
355
- const medians = medianLine(data.series, "Merge check median", (d) => d.checkMedianMs);
356
- if (medians !== undefined)
357
- lines.push(medians);
411
+ pushDayCameoLine(lines, data.series, "Merge check median", (d) => d.checkMedianMs);
358
412
  // Per-day count of red full-suite checks, beside the passing medians above; a day whose
359
413
  // suite went red before passing shows here. Zero-absent: no line when no day fails.
360
- const reds = data.series.flatMap((d) => d.checkFailures === undefined ? [] : [`${monthDay(d.date)} ×${d.checkFailures}`]);
361
- if (reds.length > 0)
362
- lines.push(`**Red merge checks:** ${reds.join(" · ")}`);
363
- const waits = medianLine(data.series, "Merge wait median", (d) => d.mergeWaitMedianMs);
364
- if (waits !== undefined)
365
- lines.push(waits);
366
- const providerWaits = medianLine(data.series, "Provider wait median", (d) => d.providerWaitMedianMs);
367
- if (providerWaits !== undefined)
368
- lines.push(providerWaits);
369
- const slotWaits = medianLine(data.series, "Workspace wait median", (d) => d.slotWaitMedianMs);
370
- if (slotWaits !== undefined)
371
- lines.push(slotWaits);
414
+ pushDayCameoLine(lines, data.series, "Red merge checks", (d) => d.checkFailures, (n) => `×${n}`);
415
+ // Per-day main red wall-clock, beside the red-check counts above: how long each day main
416
+ // had no passing full check (the span every code role was held). Team-only (main's own
417
+ // red time, not any role's) and zero-absent: no line when no day was red.
418
+ pushDayCameoLine(lines, data.series, "Main red", (d) => d.mainRedMs, durationLabel);
419
+ pushDayCameoLine(lines, data.series, "Merge wait median", (d) => d.mergeWaitMedianMs);
420
+ // How long the merge slot itself held each change it landed, beside the merge-wait median:
421
+ // the queue backlog (merge wait minus slot time) apart from the slot's own review/check/
422
+ // rebase/merge work. Team-only (the slot is the team's), omitted when no day carries one.
423
+ pushDayCameoLine(lines, data.series, "Merge slot median", (d) => d.mergeSlotMedianMs);
424
+ pushDayCameoLine(lines, data.series, "Run duration median", (d) => d.runMedianMs);
425
+ // How long the review gate ran per day — how long the reviewer's verdict run(s) took —
426
+ // beside the run/merge-wait medians; pushDayCameoLine omits the line when no day carries one.
427
+ pushDayCameoLine(lines, data.series, "Review duration median", (d) => d.reviewMedianMs);
428
+ pushDayCameoLine(lines, data.series, "Provider wait median", (d) => d.providerWaitMedianMs);
429
+ pushDayCameoLine(lines, data.series, "Workspace wait median", (d) => d.slotWaitMedianMs);
430
+ pushDayCameoLine(lines, data.series, "Check wait median", (d) => d.checkWaitMedianMs);
372
431
  }
373
432
  lines.push(costPerCommitLine("Cost per commit", costPerCommitPairs(data.series)));
374
433
  // Subscription spend is notional: its own two lines, only when the window has any.
375
434
  if ((t.notionalCostUsd ?? 0) > 0) {
376
435
  const notionalOf = (d) => d.notionalByRole ?? {};
377
- lines.push(rankedRoleLine("Estimated by role", rankedRoleTotals(data.series, notionalOf).filter(([, c]) => c > 0), usd));
436
+ pushRankedRoleLine(lines, "Estimated by role", rankedRoleTotals(data.series, notionalOf), usd);
378
437
  lines.push(costPerCommitLine("Estimated per commit", costPerCommitPairs(data.series, notionalOf)));
379
438
  }
380
439
  // Commits per backlog item is day-granular backlog data (features done + bugs fixed), so it
@@ -382,6 +441,18 @@ export function renderReportMarkdown(data) {
382
441
  const items = t.featuresDone + t.bugsFixed;
383
442
  const perItem = items > 0 ? ` · ${(t.commits / items).toFixed(1)} commits per backlog item` : "";
384
443
  lines.push(`**Commits:** ${t.workCommits ?? 0} work / ${t.maintenanceCommits ?? 0} maintenance${perItem}`);
444
+ // How long each finished plan or bug waited from filing to done, per completion day — the
445
+ // median backlog age at which the day's items closed. Team-only (a lag has no role) and
446
+ // zero-means-absent: no line when no day carries one. Values are whole days from
447
+ // daysBetween, so a plain `${n}d` is exact where medianLine's shortSpanPhrase would print
448
+ // minutes.
449
+ if (data.role === undefined) {
450
+ const lags = data.series.flatMap((d) => d.backlogLagMedianDays === undefined
451
+ ? []
452
+ : [`${monthDay(d.date)} ${d.backlogLagMedianDays}d`]);
453
+ if (lags.length > 0)
454
+ lines.push(`**Backlog lag median:** ${lags.join(" · ")}`);
455
+ }
385
456
  // Where the bugfix work was hardest to confirm: the window's fixed bugs tallied by their
386
457
  // `Validation gap` tag, so the team can see where its own test infrastructure was weakest
387
458
  // (recorded first, consumed now). Team-only (a tag has no role) and zero-means-absent: no
@@ -0,0 +1 @@
1
+ export {};
@@ -6,28 +6,38 @@ import { collectReport, collectReportSince } from "./report-data.js";
6
6
  import { collectFailureReport, collectFailureReportSince } from "../failure/failure-data.js";
7
7
  import { renderFailureMarkdown } from "../failure/failure-render.js";
8
8
  import { renderReportMarkdown, renderSinceReportMarkdown } from "./report-render.js";
9
- import { say, sayJson, sayJsonOrRender } from "../cli/cli-output.js";
10
- import { failRivalShapes, flagValue, parseCountFlag, parseRoleScope, parseSinceFlag } from "../cli/cli-args.js";
9
+ import { fail, say, sayJson, sayJsonOrRender } from "../cli/cli-output.js";
10
+ import { followView } from "../cli/follow-view.js";
11
+ import { failRivalShapes, failUntilShorterThanSince, flagValue, parseCountFlag, parseRoleListScope, parseSinceFlag } from "../cli/cli-args.js";
12
+ import { hasFollowFlag } from "../cli/cli-flag-specs.js";
11
13
  import { REPORT_DEFAULT_DAYS, REPORT_MAX_DAYS, REPORT_SINCE_MAX_MS } from "../events/event-window.js";
12
- /** `tumwater report [--days <n>] [--failures] [--since <duration>]`: parse the flags and
14
+ /** `tumwater report [--days <n>] [--failures] [--since <duration>]
15
+ * [--until <duration>]`: parse the flags and
13
16
  * print. Unknown-args rejection and the no-ready-repo-gate decision stay in cli.ts's case,
14
17
  * like every other command's. */
15
- export async function cmdReport(root, args) {
18
+ export async function cmdReport(root, args, seams = {}) {
16
19
  // --since is handled before the day-shape reads: it is a rival shape (totals over a
17
20
  // trailing window vs a series over whole days), not a modifier of either.
18
21
  const ms = parseSinceFlag(args, "report --since", REPORT_SINCE_MAX_MS);
22
+ const untilMs = parseSinceFlag(args, "report --until", REPORT_SINCE_MAX_MS, "--until");
23
+ failUntilShorterThanSince("report", untilMs, ms);
19
24
  // --role scopes both report shapes: the usage report (day and --since) and the failure
20
- // digest, whose day and --since collectors take the same optional base role.
21
- const role = parseRoleScope(root, args);
25
+ // digest, whose day and --since collectors take the same optional list of base roles.
26
+ const role = parseRoleListScope(root, args);
27
+ const follow = hasFollowFlag(args);
28
+ if (follow && args.includes("--json"))
29
+ fail("report --json prints one document; -f/--follow repaints a live view — drop one of them");
22
30
  if (ms !== null) {
23
31
  // --days is still a rival shape: it counts whole local days, while --since totals a trailing
24
32
  // window. --failures is not a rival anymore — the digest has a windowed-since collector that
25
33
  // reuses the day digest's own sections, so `report --failures --since <d>` dispatches it
26
- // through the existing --json / render choice.
34
+ // through the existing --json / render choice; --until bounds its window's end.
35
+ if (follow)
36
+ failRivalShapes("report --since", "-f/--follow", 'follow means "from now on"; --since shows a bounded past window');
27
37
  if (args.includes("--days"))
28
38
  failRivalShapes("report --since", "--days", "--days counts whole local days; --since totals a trailing window");
29
39
  if (args.includes("--failures")) {
30
- const digest = collectFailureReportSince(root, ms, role ?? undefined);
40
+ const digest = collectFailureReportSince(root, ms, role ?? undefined, untilMs ?? undefined);
31
41
  if (args.includes("--json"))
32
42
  sayJson(digest);
33
43
  else
@@ -36,7 +46,7 @@ export async function cmdReport(root, args) {
36
46
  }
37
47
  // --json swaps the renderer for the collector's own payload, exactly as status --json
38
48
  // does: bounds and cap checks above are shared, only the printing differs.
39
- const since = collectReportSince(root, ms, role ?? undefined);
49
+ const since = collectReportSince(root, ms, role ?? undefined, untilMs ?? undefined);
40
50
  sayJsonOrRender(args, since, renderSinceReportMarkdown);
41
51
  return;
42
52
  }
@@ -54,6 +64,10 @@ export async function cmdReport(root, args) {
54
64
  // report --json and doctor --json precedent: the time-and-spend fold gave the digest a
55
65
  // stable data shape, so the machine-readable form is the data itself, not a re-parse of the
56
66
  // Markdown (the old "clustered narrative with no agreed shape" refusal is retired).
67
+ if (follow) {
68
+ await followView(() => args.includes("--failures") ? renderFailureMarkdown(collectFailureReport(root, days, role ?? undefined)) : renderReportMarkdown(collectReport(root, days, role ?? undefined)), seams);
69
+ return;
70
+ }
57
71
  if (args.includes("--failures") && args.includes("--json")) {
58
72
  sayJson(collectFailureReport(root, days, role ?? undefined));
59
73
  return;
@@ -1,6 +1,6 @@
1
- export function cappedRequestTimeouts(cfg, tickCapS, quietCapS) {
1
+ export function cappedRequestTimeouts(cfg, runCapS, quietCapS) {
2
2
  return {
3
- runTimeoutSeconds: Math.min(cfg.runTimeoutSeconds, tickCapS),
3
+ runTimeoutSeconds: Math.min(cfg.runTimeoutSeconds, runCapS),
4
4
  quietTimeoutSeconds: cfg.quietTimeoutSeconds > 0 ? Math.min(cfg.quietTimeoutSeconds, quietCapS) : quietCapS,
5
5
  };
6
6
  }
@@ -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
+ }
@@ -9,15 +9,24 @@
9
9
  * repeat. Only the gate's positive flake record is trusted: a check-failure rejection is not
10
10
  * evidence of flakiness — it is exactly the attribution this memory exists to avoid making
11
11
  * twice. */
12
+ import { DAY_MS } from "../text/datetime.js";
13
+ import { TUMWATER_SOURCE, tryOrWarn } from "../events/events.js";
12
14
  import { readEventsSince } from "../events/event-window.js";
13
15
  import { increment } from "../collections.js";
14
16
  import { normalizeClusterKey } from "../failure/failure-cluster.js";
17
+ import { FAILING_TESTS_MAX } from "../build/build-check-report.js";
18
+ import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
19
+ import { isJsonObject } from "../files/json-object.js";
20
+ import { parseEntryDetails } from "../backlog/backlog-md.js";
21
+ import { readTextOrNull } from "../files/files.js";
22
+ import { quarantinePath } from "../paths.js";
23
+ import path from "node:path";
15
24
  /** How far back the gate looks for its own flake warnings. One local day: the example in
16
25
  * BUGS.md 2026-10-06 recorded a flake at 10:50 and rejected an unrelated change on the same
17
26
  * headline at 15:57, and a day is short enough that a retired flake stops excusing failures
18
27
  * while still covering a team's working span. Exported so the doctor's `flaky checks` check
19
28
  * and the gate share the one window and cannot drift. */
20
- export const KNOWN_FLAKE_WINDOW_MS = 24 * 60 * 60 * 1000;
29
+ export const KNOWN_FLAKE_WINDOW_MS = DAY_MS;
21
30
  /** The exact prefix review-precheck.ts writes before a flake's failure headline. Exported so
22
31
  * the writer and reader cannot drift over the separator. */
23
32
  export const FLAKY_WARNING_PREFIX = "gate check failed then passed on retry — flaky: ";
@@ -53,6 +62,18 @@ export function flakeCounts(events) {
53
62
  increment(counts, key);
54
63
  return [...counts.entries()].sort((a, b) => b[1] - a[1]);
55
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
+ }
56
77
  /** A Set of the flakeWarningKeys at or after `now - windowMs` of the gate's own window. */
57
78
  export function flakeKeys(events, now, windowMs = KNOWN_FLAKE_WINDOW_MS) {
58
79
  return new Set(flakeWarningKeys(events, now - windowMs));
@@ -63,3 +84,93 @@ export function isKnownFlake(root, headline) {
63
84
  const { events } = readEventsSince(root, KNOWN_FLAKE_WINDOW_MS);
64
85
  return flakeKeys(events, Date.now()).has(normalizeClusterKey(headline));
65
86
  }
87
+ /** How many quarantined tests are kept; the oldest by lastSeen fall off. */
88
+ export const QUARANTINE_MAX = 100;
89
+ /** The quarantine list, a malformed file or entry reading as no data. */
90
+ export function loadQuarantine(root) {
91
+ const stored = readJsonFile(quarantinePath(root));
92
+ const out = [];
93
+ for (const e of Array.isArray(stored?.tests) ? stored.tests : []) {
94
+ if (!isJsonObject(e) || typeof e.test !== "string")
95
+ continue;
96
+ const num = (v) => (typeof v === "number" ? v : 0);
97
+ out.push({
98
+ test: e.test,
99
+ firstSeen: num(e.firstSeen),
100
+ lastSeen: num(e.lastSeen),
101
+ count: num(e.count) || 1,
102
+ bug: typeof e.bug === "string" ? e.bug : "",
103
+ ...(typeof e.tail === "string" ? { tail: e.tail } : {}),
104
+ });
105
+ }
106
+ return out;
107
+ }
108
+ function saveQuarantine(root, entries) {
109
+ const kept = [...entries].sort((a, b) => b.lastSeen - a.lastSeen).slice(0, QUARANTINE_MAX);
110
+ writeJsonAtomic(quarantinePath(root), { tests: kept });
111
+ }
112
+ /** The quarantined test names. */
113
+ export function quarantinedTests(root) {
114
+ return loadQuarantine(root).map((e) => e.test);
115
+ }
116
+ /** The failing tests when every one is quarantined; null when none is named, any is outside the
117
+ * list, or the list may have been cut short (FAILING_TESTS_MAX). */
118
+ export function onlyQuarantined(root, failingTests) {
119
+ if (!failingTests || failingTests.length === 0 || failingTests.length >= FAILING_TESTS_MAX)
120
+ return null;
121
+ const known = new Set(quarantinedTests(root));
122
+ return failingTests.every((t) => known.has(t)) ? [...failingTests] : null;
123
+ }
124
+ /** Quarantine each test of a check that failed and then passed on the same tree. A test already
125
+ * listed counts another flake; `tail` is kept for the BUGS.md entry of a new one. Returns the
126
+ * newly quarantined tests. Nothing is recorded for an empty or possibly truncated list. A failed
127
+ * write warns and returns no tests: the callers run this after a re-run passed, so a lost record
128
+ * must not fail that check. */
129
+ export function quarantineTests(root, tests, tail) {
130
+ if (!tests || tests.length === 0 || tests.length >= FAILING_TESTS_MAX)
131
+ return [];
132
+ const entries = loadQuarantine(root);
133
+ const now = Date.now();
134
+ const added = [];
135
+ for (const test of new Set(tests)) {
136
+ const have = entries.find((e) => e.test === test);
137
+ if (have) {
138
+ have.lastSeen = now;
139
+ have.count += 1;
140
+ }
141
+ else {
142
+ const text = typeof tail === "string" ? tail : (tail ?? []).join("\n");
143
+ entries.push({ test, firstSeen: now, lastSeen: now, count: 1, bug: "", ...(text ? { tail: text } : {}) });
144
+ added.push(test);
145
+ }
146
+ }
147
+ if (!tryOrWarn(root, TUMWATER_SOURCE, "write quarantine.json", () => saveQuarantine(root, entries)))
148
+ return [];
149
+ return added;
150
+ }
151
+ /** Record the BUGS.md heading filed for each of `tests`, dropping their kept output. */
152
+ export function markQuarantineFiled(root, bugs) {
153
+ const entries = loadQuarantine(root);
154
+ for (const e of entries) {
155
+ const bug = bugs.get(e.test);
156
+ if (bug === undefined)
157
+ continue;
158
+ e.bug = bug;
159
+ delete e.tail;
160
+ }
161
+ saveQuarantine(root, entries);
162
+ }
163
+ /** Release the quarantined tests whose filed BUGS.md entry is no longer under `## Open`; a later
164
+ * flake of one puts it back. Returns the released tests. A missing BUGS.md releases nothing. */
165
+ export function releaseQuarantine(root) {
166
+ const entries = loadQuarantine(root);
167
+ const md = readTextOrNull(path.join(root, "BUGS.md"));
168
+ if (md === null || entries.length === 0)
169
+ return [];
170
+ const open = new Set(parseEntryDetails(md, "Open").map((e) => e.title));
171
+ const released = entries.filter((e) => e.bug !== "" && !open.has(e.bug));
172
+ if (released.length === 0)
173
+ return [];
174
+ saveQuarantine(root, entries.filter((e) => !released.includes(e)));
175
+ return released.map((e) => e.test);
176
+ }