tumwater 0.4.2 → 0.4.4

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 (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -86,29 +86,44 @@ export function persistBaselineVerdicts(root) {
86
86
  const file = baselineVerdictsPath(root);
87
87
  greensFile = undefined; // the load below re-writes nothing it just read
88
88
  const stored = readJsonFile(file);
89
- const entries = Array.isArray(stored?.greens) ? stored.greens : [];
90
- for (const entry of entries) {
91
- if (!isJsonObject(entry) || typeof entry.sha !== "string" || !/^[0-9a-f]{40,64}$/.test(entry.sha))
92
- continue;
93
- if (baselineCache.has(entry.sha))
94
- continue;
95
- const from = typeof entry.inheritedFrom === "string" ? { inheritedFrom: entry.inheritedFrom } : {};
96
- rememberBaseline(entry.sha, { status: "green", sha: entry.sha, ...from });
97
- }
98
- const reds = Array.isArray(stored?.reds) ? stored.reds : [];
99
- for (const entry of reds) {
100
- if (!isJsonObject(entry) || typeof entry.sha !== "string" || !/^[0-9a-f]{40,64}$/.test(entry.sha))
101
- continue;
102
- if (baselineCache.has(entry.sha))
103
- continue;
104
- const failingTests = Array.isArray(entry.failingTests)
105
- ? entry.failingTests.filter((t) => typeof t === "string")
106
- : [];
107
- const script = typeof entry.script === "string" ? { script: entry.script } : {};
108
- rememberBaseline(entry.sha, { status: "red", sha: entry.sha, ...script, failingTests });
109
- }
89
+ for (const entry of storedVerdictList(stored, "greens"))
90
+ restoreGreenVerdict(entry);
91
+ for (const entry of storedVerdictList(stored, "reds"))
92
+ restoreRedVerdict(entry);
110
93
  greensFile = file;
111
94
  }
95
+ /** The saved array for one kind of verdict, or an empty list when absent or not an array. */
96
+ function storedVerdictList(stored, key) {
97
+ const value = stored?.[key];
98
+ return Array.isArray(value) ? value : [];
99
+ }
100
+ /** A saved entry that is an object with a 40-64 hex `sha`, or null when it is not. */
101
+ function storedVerdictEntry(entry) {
102
+ if (!isJsonObject(entry))
103
+ return null;
104
+ if (typeof entry.sha !== "string" || !/^[0-9a-f]{40,64}$/.test(entry.sha))
105
+ return null;
106
+ return entry;
107
+ }
108
+ /** Restore one saved green, carrying an optional `inheritedFrom`, unless the cache holds it. */
109
+ function restoreGreenVerdict(entry) {
110
+ const parsed = storedVerdictEntry(entry);
111
+ if (parsed === null || baselineCache.has(parsed.sha))
112
+ return;
113
+ const from = typeof parsed.inheritedFrom === "string" ? { inheritedFrom: parsed.inheritedFrom } : {};
114
+ rememberBaseline(parsed.sha, { status: "green", sha: parsed.sha, ...from });
115
+ }
116
+ /** Restore one saved red: optional `script` and failing tests, unless the cache holds it. */
117
+ function restoreRedVerdict(entry) {
118
+ const parsed = storedVerdictEntry(entry);
119
+ if (parsed === null || baselineCache.has(parsed.sha))
120
+ return;
121
+ const failingTests = Array.isArray(parsed.failingTests)
122
+ ? parsed.failingTests.filter((t) => typeof t === "string")
123
+ : [];
124
+ const script = typeof parsed.script === "string" ? { script: parsed.script } : {};
125
+ rememberBaseline(parsed.sha, { status: "red", sha: parsed.sha, ...script, failingTests });
126
+ }
112
127
  /** In-flight dedup: concurrent runs on the same not-yet-cached SHA (a fresh main move wakes
113
128
  * every role's verifier at once) share one check run instead of racing N npm invocations. Keyed by
114
129
  * SHA for ordinary checks; a re-verification adds its worktree, because joining another
@@ -350,76 +365,22 @@ tier = CHECK_TIER.other) {
350
365
  const sha = await refSha(wt, "HEAD");
351
366
  if (!sha)
352
367
  return { baseline: null }; // No HEAD (unborn branch) — nothing to key on.
353
- const stored = baselineCache.get(sha);
354
- const cached = observedOnly && stored?.inheritedFrom !== undefined ? undefined : stored;
355
- if (cached && !forceFresh && !shouldRerunRed(cached, wt, reverifyRed))
368
+ const { cached, key } = cachedBaselineFor(sha, wt, { reverifyRed, observedOnly, forceFresh });
369
+ if (cached)
356
370
  return { baseline: cached };
357
- if (!cached && !forceFresh && !observedOnly) {
371
+ // A re-run of a red keys the in-flight map by worktree (key !== sha): joining another
372
+ // worktree's run would observe the environment this run exists to re-test, and it is not a
373
+ // carry-over candidate either — only a SHA with no cached verdict may inherit a green.
374
+ if (!forceFresh && !observedOnly && key === sha) {
358
375
  // Outside the in-flight dedup below on purpose: a forceFresh or observedOnly caller joining
359
376
  // a pending promise must get a run, never a carried-over verdict.
360
- const exempt = exemptPathsOf(config);
361
- const inherited = exempt.length > 0 ? await inheritedGreen(wt, sha, exempt) : null;
362
- if (inherited) {
363
- // Re-read after the await: a run that settled meanwhile knows more than the carry-over.
364
- const settled = baselineCache.get(sha);
365
- if (settled)
366
- return { baseline: settled };
367
- rememberBaseline(sha, inherited);
377
+ const inherited = await inheritedBaselineFor(wt, sha, config);
378
+ if (inherited)
368
379
  return { baseline: inherited };
369
- }
370
380
  }
371
- // A re-run of a red keys the in-flight map by worktree: joining another worktree's run would
372
- // observe the environment this run exists to re-test.
373
- const key = cached?.status === "red" ? `${sha}\u0000${wt}` : sha;
374
381
  let pending = baselineInFlight.get(key);
375
382
  if (!pending) {
376
- pending = (async () => {
377
- const check = detectBuildCheck(wt, config);
378
- if (!check)
379
- return { baseline: null }; // No declared check — nothing to verify, nothing to block on.
380
- // The same process-wide check permit the scoped checks take (build/build-check.ts's
381
- // withCheckPermit): this runs the suite directly, so it must count against the cap too.
382
- // Only the one run per key holds a permit; deduped waiters above await `pending` holding
383
- // none.
384
- const { outcome, durationMs } = await withCheckPermit(config, tier, () => runWithCleanRetry(() => runBuildCheck(wt, check, undefined, undefined, sampleSleep),
385
- // Weather (a late deadline, a foreign kill, a host sleep) made no verdict about the
386
- // tree: the first attempt is priced as its own run, and the retry's outcome stands —
387
- // the same rule runScopedBuildCheck applies at every other scope.
388
- (first, dur) => onRun?.({ outcome: first, durationMs: dur })), undefined, "main baseline");
389
- onRun?.({ outcome, durationMs });
390
- if (outcome.status === "skipped") {
391
- // Environmental (timeout/no-npm), after the clean retry above for weather shapes:
392
- // warn-and-proceed semantics like the gate's pre-check; never cache red for a skip.
393
- return { baseline: null, skipReason: outcome.skipReason, run: outcome.run };
394
- }
395
- if (outcome.status === "failed" && (outcome.run?.sleptMs ?? 0) > SLEEP_SPAN_TOLERANCE_MS) {
396
- // A failure the host slept through made no verdict about the tree either (BUGS.md
397
- // 2026-09-30): the sleep expired a test's own wall-clock wait at the wake and the suite
398
- // exited 1 inside the deadline. Caching it red would make sleep weather the team's
399
- // authoritative red main, so nothing is cached — the next consult re-runs, and a clean
400
- // verdict then settles the SHA exactly as before.
401
- return { baseline: null, run: outcome.run, unverified: true };
402
- }
403
- const prior = baselineCache.get(sha);
404
- const priorRedFrom = prior?.status === "red" ? (prior.redFrom ?? []) : [];
405
- const baseline = outcome.status === "passed"
406
- ? { status: "green", sha }
407
- : {
408
- status: "red",
409
- sha,
410
- script: outcome.script,
411
- outputTail: outcome.outputTail,
412
- failingTests: outcome.failingTests ?? [],
413
- redFrom: priorRedFrom.includes(wt) ? priorRedFrom : [...priorRedFrom, wt],
414
- };
415
- // A green is always recorded (promoting a provisional red); a red never overwrites an
416
- // observed green — a concurrent run may have promoted this SHA while this one was still
417
- // going. An inherited green is no observation, so this run's verdict replaces it either way.
418
- const current = baselineCache.get(sha);
419
- if (current?.status !== "green" || current.inheritedFrom !== undefined)
420
- rememberBaseline(sha, baseline);
421
- return { baseline };
422
- })();
383
+ pending = runBaselineOnce(sha, wt, config, onRun, sampleSleep, tier);
423
384
  baselineInFlight.set(key, pending);
424
385
  }
425
386
  try {
@@ -430,6 +391,109 @@ tier = CHECK_TIER.other) {
430
391
  baselineInFlight.delete(key);
431
392
  }
432
393
  }
394
+ /** The usable cached verdict for `sha` — or null when the suite must run — together with the
395
+ * in-flight key its run dedups on. Applies the observedOnly inherited-green filter, the
396
+ * forceFresh / red re-run short-circuit, and the worktree-qualified key a red re-run keys by.
397
+ * `key === sha` tells the caller the SHA had no verdict to re-run (a possible carry-over
398
+ * candidate); a worktree-qualified key is a pending red re-run, which never inherits. */
399
+ function cachedBaselineFor(sha, wt, opts) {
400
+ const stored = baselineCache.get(sha);
401
+ const cached = opts.observedOnly && stored?.inheritedFrom !== undefined ? undefined : stored;
402
+ const usable = cached && !opts.forceFresh && !shouldRerunRed(cached, wt, opts.reverifyRed) ? cached : null;
403
+ const key = cached?.status === "red" ? `${sha}\u0000${wt}` : sha;
404
+ return { cached: usable, key };
405
+ }
406
+ /** The inherited green for `sha` across review-exempt ancestors, or null when there is none to
407
+ * carry over. The exemptPathsOf(config) + inheritedGreen(wt, sha, exempt) walk, the post-await
408
+ * cache re-read (a run that settled meanwhile knows more than the carry-over), and the
409
+ * rememberBaseline re-seed of the carried-over verdict. */
410
+ async function inheritedBaselineFor(wt, sha, config) {
411
+ const exempt = exemptPathsOf(config);
412
+ const inherited = exempt.length > 0 ? await inheritedGreen(wt, sha, exempt) : null;
413
+ if (inherited) {
414
+ // Re-read after the await: a run that settled meanwhile knows more than the carry-over.
415
+ const settled = baselineCache.get(sha);
416
+ if (settled)
417
+ return settled;
418
+ rememberBaseline(sha, inherited);
419
+ return inherited;
420
+ }
421
+ return null;
422
+ }
423
+ /** Run the declared check once under the same process-wide check permit the scoped checks take,
424
+ * retrying once when the first attempt made no verdict about the tree (a late deadline, a
425
+ * foreign kill, a host sleep). Returns the outcome and its duration, or null when no check is
426
+ * declared (nothing to verify, nothing to block on). */
427
+ async function detectedBaselineOutcome(wt, config, onRun, sampleSleep, tier) {
428
+ const check = detectBuildCheck(wt, config);
429
+ if (!check)
430
+ return null; // No declared check — nothing to verify, nothing to block on.
431
+ // The same process-wide check permit the scoped checks take (build/build-check.ts's
432
+ // withCheckPermit): this runs the suite directly, so it must count against the cap too.
433
+ // Only the one run per key holds a permit; deduped waiters above await `pending` holding
434
+ // none.
435
+ return withCheckPermit(config, tier, () => runWithCleanRetry(() => runBuildCheck(wt, check, undefined, undefined, sampleSleep),
436
+ // Weather (a late deadline, a foreign kill, a host sleep) made no verdict about the
437
+ // tree: the first attempt is priced as its own run, and the retry's outcome stands —
438
+ // the same rule runScopedBuildCheck applies at every other scope.
439
+ (first, dur) => onRun?.({ outcome: first, durationMs: dur })), undefined, "main baseline");
440
+ }
441
+ /** Build the green/red verdict from a run's outcome, folding the prior red's observed worktrees
442
+ * (redFrom) into a fresh red. Does not write the cache — the caller decides whether to promote. */
443
+ function baselineFromOutcome(sha, wt, outcome) {
444
+ const prior = baselineCache.get(sha);
445
+ const priorRedFrom = prior?.status === "red" ? (prior.redFrom ?? []) : [];
446
+ return outcome.status === "passed"
447
+ ? { status: "green", sha }
448
+ : {
449
+ status: "red",
450
+ sha,
451
+ script: outcome.script,
452
+ outputTail: outcome.outputTail,
453
+ failingTests: outcome.failingTests ?? [],
454
+ redFrom: priorRedFrom.includes(wt) ? priorRedFrom : [...priorRedFrom, wt],
455
+ };
456
+ }
457
+ /** Persist a freshly-run verdict unless a concurrent run already promoted the SHA with an
458
+ * observed green. A green is always recorded (promoting a provisional red); a red never
459
+ * overwrites an observed green — a concurrent run may have promoted this SHA while this one was
460
+ * still going. An inherited green is no observation, so this run's verdict replaces it either
461
+ * way. */
462
+ function rememberPromotableBaseline(sha, baseline) {
463
+ // A green is always recorded (promoting a provisional red); a red never overwrites an
464
+ // observed green — a concurrent run may have promoted this SHA while this one was still
465
+ // going. An inherited green is no observation, so this run's verdict replaces it either way.
466
+ const current = baselineCache.get(sha);
467
+ if (current?.status !== "green" || current.inheritedFrom !== undefined)
468
+ rememberBaseline(sha, baseline);
469
+ }
470
+ /** Run the suite once for `sha` at `wt`: detect the check, run it under the permit with a clean
471
+ * retry and its two onRun calls, fold the outcome into a verdict — skips and slept-through
472
+ * failures return no verdict and write nothing — and promote it unless an observed green
473
+ * outranks it. This replaces the old `(async () => …)()` closure stored in the in-flight map. */
474
+ async function runBaselineOnce(sha, wt, config, onRun, sampleSleep, tier) {
475
+ const detected = await detectedBaselineOutcome(wt, config, onRun, sampleSleep, tier);
476
+ if (!detected)
477
+ return { baseline: null };
478
+ const { outcome, durationMs } = detected;
479
+ onRun?.({ outcome, durationMs });
480
+ if (outcome.status === "skipped") {
481
+ // Environmental (timeout/no-npm), after the clean retry above for weather shapes:
482
+ // warn-and-proceed semantics like the gate's pre-check; never cache red for a skip.
483
+ return { baseline: null, skipReason: outcome.skipReason, run: outcome.run };
484
+ }
485
+ if (outcome.status === "failed" && (outcome.run?.sleptMs ?? 0) > SLEEP_SPAN_TOLERANCE_MS) {
486
+ // A failure the host slept through made no verdict about the tree either (BUGS.md
487
+ // 2026-09-30): the sleep expired a test's own wall-clock wait at the wake and the suite
488
+ // exited 1 inside the deadline. Caching it red would make sleep weather the team's
489
+ // authoritative red main, so nothing is cached — the next consult re-runs, and a clean
490
+ // verdict then settles the SHA exactly as before.
491
+ return { baseline: null, run: outcome.run, unverified: true };
492
+ }
493
+ const baseline = baselineFromOutcome(sha, wt, outcome);
494
+ rememberPromotableBaseline(sha, baseline);
495
+ return { baseline };
496
+ }
433
497
  /** Is main green at the mirror worktree's HEAD? The merge path seeds a green verdict for every
434
498
  * merged code SHA (noteGreenBaseline — its in-lock post-rebase re-check, or the gate's pre-check
435
499
  * when the rebase was a no-op), so the common case is a cache hit. A head that only added doc
@@ -13,7 +13,7 @@
13
13
  * Both take an injected `now` so tests never read the clock. */
14
14
  import { readEventsSince } from "../events/event-window.js";
15
15
  import { loadQuarantine } from "../review/known-flakes.js";
16
- import { DAY_MS, clockHm, humanAgo } from "../text/datetime.js";
16
+ import { DAY_MS, HOUR_MS, clockHm, humanAgo } from "../text/datetime.js";
17
17
  import { shortSha } from "../text/format.js";
18
18
  import { roleLabel } from "../roles/role-labels.js";
19
19
  import { plural } from "../text/phrases.js";
@@ -193,5 +193,5 @@ function redDurationPhrase(ms) {
193
193
  const minutes = Math.round(ms / 60_000);
194
194
  if (minutes < 24 * 60)
195
195
  return `${minutes} min`;
196
- return `${Math.round(ms / 3_600_000)} h`;
196
+ return `${Math.round(ms / HOUR_MS)} h`;
197
197
  }
@@ -1,3 +1,4 @@
1
+ import { distinctOrdered } from "../collections.js";
1
2
  import { checkMainBaseline } from "./main-baseline.js";
2
3
  import { baselineCheckLogger, clearHealKeyOnGreen, withMainTipWorktree } from "./main-red.js";
3
4
  import { CHECK_TIER } from "../concurrency/check-permit.js";
@@ -13,6 +14,22 @@ import { treeDiffFiles } from "../git/git-diff.js";
13
14
  import { mergingRoleOf } from "../events/event-read.js";
14
15
  import { isExemptDiff } from "../review/exemptions.js";
15
16
  import { diffRuleReasons } from "../review/diff-rules.js";
17
+ /** The main_check event payload for a settled verdict: the baseline sha and status, the run
18
+ * duration (0 when nothing ran), the covered-commit count, and, on a red, the failing tests and
19
+ * the quarantined-only marker. */
20
+ function mainCheckEvent(baseline, red, quarantined, covered, durationMs) {
21
+ return {
22
+ source: TUMWATER_SOURCE,
23
+ type: "main_check",
24
+ sha: baseline.sha,
25
+ status: red ? "failed" : "passed",
26
+ durationMs,
27
+ covered,
28
+ ...(red ? { failingTests: baseline.failingTests ?? [] } : {}),
29
+ ...(quarantined ? { quarantined } : {}),
30
+ ...(baseline.inheritedFrom !== undefined ? { inherited: true } : {}),
31
+ };
32
+ }
16
33
  /** The failing tests of each root's newest watch verdict, keyed by root. */
17
34
  const knownFailures = new Map();
18
35
  /** Main's known failures: the failing tests of the watch's newest verdict — main's tip, or the
@@ -20,7 +37,7 @@ const knownFailures = new Map();
20
37
  * (review/known-flakes.ts). Empty when the verdict is green, red with no parsed test, or none has
21
38
  * been recorded yet in this process, and nothing is quarantined. */
22
39
  export function knownMainFailures(root) {
23
- return [...new Set([...(knownFailures.get(root) ?? []), ...quarantinedTests(root)])];
40
+ return distinctOrdered([...(knownFailures.get(root) ?? []), ...quarantinedTests(root)]);
24
41
  }
25
42
  /** The failing tests of a failed check when every one of them is a known main failure; null when
26
43
  * the output names no test, any test is outside the known set, or none is known. A list at
@@ -76,8 +93,9 @@ export function createMainWatch(root, mainBranch, onCulprit) {
76
93
  }
77
94
  }
78
95
  async function watchOnce(mainHead, config) {
79
- // The deterministic diff rules ("Every commit is verified on main, part 1/3"): run the same
80
- // five checks the gate runs on a change, against main's new tip. A rule failure is a
96
+ // The deterministic diff rules ("Every commit is verified on main, part 1/3"): run the full
97
+ // five-check set the watch and gate share — the gate now runs only the three a later commit
98
+ // cannot repair — against main's new tip. A rule failure is a
81
99
  // deterministic red — it takes no confirming re-run and never quarantines — reported as a
82
100
  // warning naming the rule and the head (and each committing role, when a recent merged event
83
101
  // names one). It runs on every tip the watch covers in a poll — a build green, a build
@@ -106,7 +124,7 @@ export function createMainWatch(root, mainBranch, onCulprit) {
106
124
  await git(wt, "checkout", "-q", "--detach", "--force", commit);
107
125
  const parent = `${commit}^`;
108
126
  const files = (await treeDiffFiles(wt, parent, commit)) ?? [];
109
- const [ruleReason] = await diffRuleReasons(wt, parent, files, mergingRoleOf(root, commit) ?? "", isExemptDiff(files, config.review.exemptPaths));
127
+ const [ruleReason] = await diffRuleReasons(wt, parent, files, mergingRoleOf(root, commit) ?? "", isExemptDiff(files, config.review.exemptPaths), "watch");
110
128
  if (ruleReason !== undefined) {
111
129
  logEventBestEffort(root, {
112
130
  source: TUMWATER_SOURCE,
@@ -120,17 +138,11 @@ export function createMainWatch(root, mainBranch, onCulprit) {
120
138
  }
121
139
  }
122
140
  };
123
- if (!detectBuildCheck(root, config)) {
124
- // No declared build check: nothing to watch for a build red, but the diff rules still run on
125
- // the tip. The build-red path below is what never runs here.
126
- await withMainTipWorktree(root, mainBranch, async (wt) => {
127
- await watchRules(wt, lastWatched, await headOf(wt));
128
- });
129
- lastWatched = mainHead; // no declared check: nothing else to watch until main moves
130
- return;
131
- }
132
- await withMainTipWorktree(root, mainBranch, async (wt) => {
133
- const started = Date.now();
141
+ /** Run the declared build check on main's tip, re-running a red once and quarantining a flake
142
+ * whose re-run turns green. Returns the check's result and whether any run actually executed
143
+ * (false for a cache hit, a skip, or a sleep-spanned run). `config` and `root` are captured
144
+ * from `watchOnce`. */
145
+ async function runBaselineCheck(wt) {
134
146
  let ran = false;
135
147
  const onRun = baselineCheckLogger(root, TUMWATER_SOURCE);
136
148
  const hook = (run) => {
@@ -145,45 +157,31 @@ export function createMainWatch(root, mainBranch, onCulprit) {
145
157
  if (check.baseline?.status === "green")
146
158
  quarantineTests(root, first.failingTests, (first.failingTests ?? []).join("\n"));
147
159
  }
148
- const baseline = check.baseline;
149
- const previous = lastWatched;
150
- // A skipped or sleep-spanned run made no verdict: the head still counts as watched, so the
151
- // next poll does not start another run for it — but the diff rules still run on this tip.
152
- if (!baseline) {
153
- const tip = await headOf(wt);
154
- await watchRules(wt, previous, tip);
155
- return void (lastWatched = tip);
156
- }
157
- lastWatched = baseline.sha;
160
+ return { check, ran };
161
+ }
162
+ /** The covered-commit count for a main_check event: the first-parent commits between the
163
+ * previous and new watched heads, or 1 when there is no previous head. */
164
+ async function countCovered(wt, previous, sha) {
165
+ if (previous === undefined)
166
+ return 1;
167
+ const counted = Number(((await gitTry(wt, "rev-list", "--first-parent", "--count", `${previous}..${sha}`)) ?? "").trim());
168
+ return Number.isFinite(counted) && counted > 0 ? counted : 1;
169
+ }
170
+ /** Settle the verdict for a checked baseline: distinguish quarantined-only reds, record
171
+ * main's known failures, count the covered commits, and log the main_check event. Returns
172
+ * whether the tip settled red (a red naming tests that are not all quarantined). */
173
+ async function recordMainCheck(wt, baseline, previous, ran, started) {
158
174
  // A red naming only quarantined tests counts as green: no redeploy hold.
159
175
  const quarantined = baseline.status === "red" ? onlyQuarantined(root, baseline.failingTests) : null;
160
176
  const red = baseline.status === "red" && !quarantined;
161
177
  knownFailures.set(root, red ? (baseline.failingTests ?? []) : []);
162
- const counted = previous === undefined
163
- ? 1
164
- : Number(((await gitTry(wt, "rev-list", "--first-parent", "--count", `${previous}..${baseline.sha}`)) ?? "").trim());
165
- logEventBestEffort(root, {
166
- source: TUMWATER_SOURCE,
167
- type: "main_check",
168
- sha: baseline.sha,
169
- status: red ? "failed" : "passed",
170
- durationMs: ran ? Date.now() - started : 0,
171
- covered: Number.isFinite(counted) && counted > 0 ? counted : 1,
172
- ...(red ? { failingTests: baseline.failingTests ?? [] } : {}),
173
- ...(quarantined ? { quarantined } : {}),
174
- ...(baseline.inheritedFrom !== undefined ? { inherited: true } : {}),
175
- });
176
- // The diff rules judge the tip whether the build is green or red; the build-red path below
177
- // names the whole tip as the culprit, so the two red kinds are judged independently.
178
- await watchRules(wt, previous, baseline.sha);
179
- // A settled non-red verdict (a green, or a red naming only quarantined tests) clears the
180
- // heal-dedup key, so a failing set that returns after a green main is healed again. A
181
- // settled red verdict below does NOT clear it: the heal-dedup key stays set while main
182
- // is red.
183
- if (!red) {
184
- clearHealKeyOnGreen();
185
- return;
186
- }
178
+ const covered = await countCovered(wt, previous, baseline.sha);
179
+ logEventBestEffort(root, mainCheckEvent(baseline, red, quarantined, covered, ran ? Date.now() - started : 0));
180
+ return red;
181
+ }
182
+ /** Name a settled red tip as the culprit: its merging runner, the main_culprit event, and
183
+ * the onCulprit callback with the tip and its failing tests. */
184
+ function reportCulprit(baseline) {
187
185
  // The newest head is the culprit ("Every commit is verified on main, part 2/3"): the watch
188
186
  // checks each new red tip, so whatever it found belongs to the head it checked. Once per red
189
187
  // head — lastWatched already holds it, so a later poll does not attribute it again — name
@@ -201,6 +199,42 @@ export function createMainWatch(root, mainBranch, onCulprit) {
201
199
  failingTests: baseline.failingTests ?? [],
202
200
  tip: baseline.sha,
203
201
  });
202
+ }
203
+ if (!detectBuildCheck(root, config)) {
204
+ // No declared build check: nothing to watch for a build red, but the diff rules still run on
205
+ // the tip. The build-red path below is what never runs here.
206
+ await withMainTipWorktree(root, mainBranch, async (wt) => {
207
+ await watchRules(wt, lastWatched, await headOf(wt));
208
+ });
209
+ lastWatched = mainHead; // no declared check: nothing else to watch until main moves
210
+ return;
211
+ }
212
+ await withMainTipWorktree(root, mainBranch, async (wt) => {
213
+ const started = Date.now();
214
+ const { check, ran } = await runBaselineCheck(wt);
215
+ const baseline = check.baseline;
216
+ const previous = lastWatched;
217
+ // A skipped or sleep-spanned run made no verdict: the head still counts as watched, so the
218
+ // next poll does not start another run for it — but the diff rules still run on this tip.
219
+ if (!baseline) {
220
+ const tip = await headOf(wt);
221
+ await watchRules(wt, previous, tip);
222
+ return void (lastWatched = tip);
223
+ }
224
+ lastWatched = baseline.sha;
225
+ const red = await recordMainCheck(wt, baseline, previous, ran, started);
226
+ // The diff rules judge the tip whether the build is green or red; the build-red path below
227
+ // names the whole tip as the culprit, so the two red kinds are judged independently.
228
+ await watchRules(wt, previous, baseline.sha);
229
+ // A settled non-red verdict (a green, or a red naming only quarantined tests) clears the
230
+ // heal-dedup key, so a failing set that returns after a green main is healed again. A
231
+ // settled red verdict below does NOT clear it: the heal-dedup key stays set while main
232
+ // is red.
233
+ if (!red) {
234
+ clearHealKeyOnGreen();
235
+ return;
236
+ }
237
+ reportCulprit(baseline);
204
238
  });
205
239
  }
206
240
  return {
@@ -106,16 +106,10 @@ export function briefFile(root) {
106
106
  * on-purpose bare repo. An unreadable candidate counts: a brief that cannot be read is one
107
107
  * the roles cannot use, and the underlying firstBriefText throw is caught and reported as
108
108
  * true here — same subset doctor's `brief` warn covers, minus the bare-repo state that
109
- * doctor deems fine. Consumer: src/status/status-data.ts's snapshot. */
109
+ * doctor deems fine. Thin accessor over briefStatus, so this predicate and the snapshot's
110
+ * shared flags share one implementation. */
110
111
  export function briefBlockMissing(root) {
111
- if (briefCandidates(root).every((candidate) => !fs.existsSync(candidate)))
112
- return false;
113
- try {
114
- return (readBrief(root)?.prompt ?? "") === "";
115
- }
116
- catch {
117
- return true;
118
- }
112
+ return briefStatus(root).missing;
119
113
  }
120
114
  /** True when the resolved brief block exceeds the read cap (BRIEF_TEXT_MAX_CHARS) — the
121
115
  * always-on amber twin of the doctor `brief` row's over-cap warn. extractPrompt truncates
@@ -127,15 +121,34 @@ export function briefBlockMissing(root) {
127
121
  * a surrogate pair, while an at-cap block stays equal to it. A repo with no candidate
128
122
  * resolves null (0, false), and an unreadable candidate's readBrief throw is caught to false,
129
123
  * because briefBlockMissing already raises `no-brief` for that state; an empty block stays
130
- * briefBlockMissing's (its resolved length is 0). Consumer: src/status/status-data.ts's
131
- * snapshot. */
124
+ * briefBlockMissing's (its resolved length is 0). Thin accessor over briefStatus, so this
125
+ * predicate and the snapshot's shared flags share one implementation. */
132
126
  export function briefBlockOverCap(root) {
127
+ return briefStatus(root).overCap;
128
+ }
129
+ /** The always-on brief degraded-state flags, derived from ONE candidate scan and ONE file
130
+ * read — the per-second status snapshot's `noBrief`/`briefTruncated` fields, and the single
131
+ * implementation of both predicates: the standalone briefBlockMissing/briefBlockOverCap
132
+ * accessors delegate here, so the shared and standalone paths cannot disagree. The snapshot
133
+ * used to call those two accessors, each calling readBrief — which re-lists both candidates
134
+ * and re-reads the whole brief file — twice per poll, on a file that changes only when a role
135
+ * edits it. briefStatus is the degraded-state twin of readBrief's promise (briefFile +
136
+ * readBriefText derive from one scan): the two flags share one read, and each flag applies the
137
+ * value's own predicate. `missing` is true when a candidate exists but none resolves a
138
+ * non-empty block (or the resolving read threw); `overCap` true when the resolved block
139
+ * exceeds the cap. A repo with no candidate at all is neither (both false). */
140
+ export function briefStatus(root) {
141
+ if (briefCandidates(root).every((candidate) => !fs.existsSync(candidate))) {
142
+ return { missing: false, overCap: false };
143
+ }
144
+ let prompt = "";
133
145
  try {
134
- return (readBrief(root)?.prompt.length ?? 0) > BRIEF_TEXT_MAX_CHARS;
146
+ prompt = readBrief(root)?.prompt ?? "";
135
147
  }
136
148
  catch {
137
- return false;
149
+ return { missing: true, overCap: false }; // Unreadable — the briefBlockMissing subset.
138
150
  }
151
+ return { missing: prompt === "", overCap: prompt.length > BRIEF_TEXT_MAX_CHARS };
139
152
  }
140
153
  /** The project's brief text, extracted from the resolved brief file's managed section —
141
154
  * TUMWATER.md when it exists with markers, else README.md (plans/portability.md §7a/7). The