tumwater 0.4.2 → 0.4.3

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 (305) 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} +25 -12
  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 +1 -8
  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 +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -215,6 +215,113 @@ export function doubleBlockFixedEntries(md) {
215
215
  }
216
216
  return found;
217
217
  }
218
+ /** The duplicate-heading rule (a): the first `## ` title `head` carries more of than `base` did,
219
+ * or undefined when none. A base that already carries a duplicate never blocks unrelated edits —
220
+ * this fires only when the head's count EXCEEDS the base's, so a change that removes a duplicate
221
+ * always passes. */
222
+ function duplicateSectionReason(file, headCounts, baseCounts) {
223
+ for (const [title, count] of headCounts)
224
+ if (count > 1 && count > (baseCounts.get(title) ?? 0))
225
+ return (`${file} adds another "## ${title}" heading — readers take the first section of a ` +
226
+ `name, so anything under the duplicate is invisible; keep one "## ${title}" per file`);
227
+ return undefined;
228
+ }
229
+ /** The dropped-known-section rule (b): the first `## ` title the base carried but `head` dropped,
230
+ * restricted to this file's KNOWN_SECTIONS, or undefined when none. A `## ` title that is not one
231
+ * of this file's known sections is not load-bearing: ADDING one is a legitimate project extension
232
+ * (this repo grew `## Verified`, a doc-only stack can grow `## Notes`), so a new one passes — but
233
+ * DROPPING one is the repair for a misplaced heading, so this rule guards only known sections and
234
+ * leaves an unknown one free to be demoted to `### ` under a known section (BUGS.md 2026-10-23). */
235
+ function droppedSectionReason(file, headCounts, baseCounts) {
236
+ // A `## ` title that is not one of this file's known sections (KNOWN_SECTIONS) is not
237
+ // load-bearing: ADDING one is a legitimate project extension (this repo grew `## Verified`,
238
+ // a doc-only stack can grow `## Notes`), so a new one passes — but DROPPING one is the repair
239
+ // for a misplaced heading, so rule (b) guards only known sections and leaves an unknown one
240
+ // free to be demoted to `### ` under a known section (BUGS.md 2026-10-23).
241
+ const known = KNOWN_SECTIONS[file] ?? [];
242
+ for (const title of baseCounts.keys())
243
+ if (!headCounts.has(title) && known.includes(title))
244
+ return (`${file} drops the "## ${title}" section the base had — entries filed under it would ` +
245
+ `be invisible to every section reader; restore "## ${title}"`);
246
+ return undefined;
247
+ }
248
+ /** The re-added-archive-heading rule: for PLANS.md's and BUGS.md's archives only, rejects a head
249
+ * that adds a copy of a `### ` heading the base archive already carries (readdedHeadings) —
250
+ * finished history pasted back, never a new finished entry — or undefined for every other file.
251
+ * An archive only grows by new headings: a copy of one the base archive already carries is
252
+ * finished history pasted back (readdedHeadings), never a new finished entry.
253
+ */
254
+ function readdedHeadingReason(file, base, head) {
255
+ if (file === PLANS_ARCHIVE || file === BUGS_ARCHIVE) {
256
+ const readded = readdedHeadings(base, head);
257
+ if (readded.length > 0)
258
+ return (`${file} adds ${plural(readded.length, '"### " heading', '"### " headings')} it already carries ` +
259
+ `(first: "${truncate(readded[0], 160)}") — the archive holds each finished entry once, so the ` +
260
+ `copies duplicate its history; remove them, and edit an archived entry in place instead`);
261
+ }
262
+ return undefined;
263
+ }
264
+ /** The misplaced-plan rule (part 4/4), for PLANS.md and its archive only, else undefined: rejects
265
+ * a head that ADDS a `### ` entry directly under `## Done` whose joined heading metadata carries
266
+ * `(planned YYYY-MM-DD` but no `done YYYY-MM-DD`, and whose key the base's PLANS.md and archive
267
+ * never carried in ANY section — a plan filed into the wrong section. strandedPlanEntries already
268
+ * does the fence-aware, joined-metadata reading the first two conditions need; the base's key set
269
+ * — over both files, since tumwater moves a finished plan from one to the other
270
+ * (plans/backlog-archive.md) — supplies the third. Judged against the merge-base (the same base
271
+ * the heading rules use), so a Planned → Done move of an entry the base already had passes and a
272
+ * pre-existing stranded entry (whose key the base has) never blocks unrelated merges. */
273
+ async function misplacedPlanReason(file, wt, baseRev, base, head) {
274
+ // Part 4/4's rule, PLANS.md and its archive (see this function's doc comment): a NEW plan
275
+ // filed directly under ## Done with no done date. strandedPlanEntries already does the
276
+ // fence-aware, joined-metadata reading the first two conditions need; the base's key set —
277
+ // over both files, since tumwater moves a finished plan from one to the other
278
+ // (plans/backlog-archive.md) — supplies the third.
279
+ if (file === "PLANS.md" || file === PLANS_ARCHIVE) {
280
+ const baseKeys = new Set([
281
+ ...planHeadingKeys(file === "PLANS.md" ? base : await fileContentAt(wt, baseRev, "PLANS.md")),
282
+ ...planHeadingKeys(file === PLANS_ARCHIVE ? base : await fileContentAt(wt, baseRev, PLANS_ARCHIVE)),
283
+ ]);
284
+ const newcomer = strandedPlanEntries(head).find((e) => e.section === "Done" && !baseKeys.has(planHeadingKey(e.title)));
285
+ if (newcomer)
286
+ // The fault and its fix differ by file: in PLANS.md a NEW plan (the base never carried
287
+ // it in any section) was pasted under ## Done unfinished and belongs under ## Planned;
288
+ // in the archive the same heading is a finished plan the commit-time sweep already moved
289
+ // here, whose only fault is a missing `done YYYY-MM-DD` token — telling its author to
290
+ // "file it under ## Planned" is the opposite of the fix and wastes a revision.
291
+ return file === PLANS_ARCHIVE
292
+ ? `${file} files "${newcomer.title}" directly under "## Done" with no done date — the ` +
293
+ `entry is finished and archived, so the only fix is to add \`done YYYY-MM-DD\` to its ` +
294
+ `"### " heading; do not move it back under "## Planned"`
295
+ : `${file} files "${newcomer.title}" directly under "## Done" with no done date — a plan ` +
296
+ `written into the done section is invisible to every Planned reader; file it under ` +
297
+ `"## Planned" instead`;
298
+ }
299
+ return undefined;
300
+ }
301
+ /** The hidden-fixed-record rule, for BUGS.md and its archive only, else undefined: rejects a head
302
+ * whose multi-block `## Fixed` entries (doubleBlockFixedEntries) the base did not carry — a new
303
+ * headingless record merged into a neighbor's body — or undefined when none. A new headingless
304
+ * record cannot hide in a Fixed entry's body: reject a head whose multi-block entries the base did
305
+ * not carry (an existing one never blocks unrelated edits, matching the heading-set rules above).
306
+ * The Fix paragraph is the guard's unit, so a merged second block would otherwise sit inside the
307
+ * neighbor it was symbol-checked against. BUGS-FIXED.md holds the same entries once archived.
308
+ */
309
+ function hiddenFixedRecordReason(file, base, head) {
310
+ // A new headingless record cannot hide in a Fixed entry's body: reject a head whose
311
+ // multi-block entries the base did not carry (an existing one never blocks unrelated
312
+ // edits, matching the heading-set rules above). The Fix paragraph is the guard's unit, so
313
+ // a merged second block would otherwise sit inside the neighbor it was symbol-checked
314
+ // against. BUGS-FIXED.md holds the same entries once archived.
315
+ if (file === "BUGS.md" || file === BUGS_ARCHIVE) {
316
+ const baseTitles = new Set(doubleBlockFixedEntries(base));
317
+ const newcomer = doubleBlockFixedEntries(head).find((title) => !baseTitles.has(title));
318
+ if (newcomer)
319
+ return (`${file}'s Fixed entry "${newcomer}" holds more than one **Symptom:**/**Fix:** block — ` +
320
+ `a record with no "### " heading has merged into it, so the hidden record is invisible ` +
321
+ `to every section reader; give it its own "### " heading`);
322
+ }
323
+ return undefined;
324
+ }
218
325
  /** The first structural fault a diff merging into `mainBranch` leaves in a backlog file, or
219
326
  * undefined when none: for each touched backlog file it compares the `## ` heading set of the tree
220
327
  * being merged (`wt`) against the diff's merge-base — the same base falseFixReason measures
@@ -268,67 +375,13 @@ export async function backlogStructureReason(wt, mainBranch, files) {
268
375
  const base = await fileContentAt(wt, baseRev, file);
269
376
  const headCounts = sectionTitleCounts(head);
270
377
  const baseCounts = sectionTitleCounts(base);
271
- for (const [title, count] of headCounts)
272
- if (count > 1 && count > (baseCounts.get(title) ?? 0))
273
- return (`${file} adds another "## ${title}" heading — readers take the first section of a ` +
274
- `name, so anything under the duplicate is invisible; keep one "## ${title}" per file`);
275
- // A `## ` title that is not one of this file's known sections (KNOWN_SECTIONS) is not
276
- // load-bearing: ADDING one is a legitimate project extension (this repo grew `## Verified`,
277
- // a doc-only stack can grow `## Notes`), so a new one passes — but DROPPING one is the repair
278
- // for a misplaced heading, so rule (b) guards only known sections and leaves an unknown one
279
- // free to be demoted to `### ` under a known section (BUGS.md 2026-10-23).
280
- const known = KNOWN_SECTIONS[file] ?? [];
281
- for (const title of baseCounts.keys())
282
- if (!headCounts.has(title) && known.includes(title))
283
- return (`${file} drops the "## ${title}" section the base had — entries filed under it would ` +
284
- `be invisible to every section reader; restore "## ${title}"`);
285
- // An archive only grows by new headings: a copy of one the base archive already carries is
286
- // finished history pasted back (readdedHeadings), never a new finished entry.
287
- if (file === PLANS_ARCHIVE || file === BUGS_ARCHIVE) {
288
- const readded = readdedHeadings(base, head);
289
- if (readded.length > 0)
290
- return (`${file} adds ${plural(readded.length, '"### " heading', '"### " headings')} it already carries ` +
291
- `(first: "${truncate(readded[0], 160)}") — the archive holds each finished entry once, so the ` +
292
- `copies duplicate its history; remove them, and edit an archived entry in place instead`);
293
- }
294
- // Part 4/4's rule, PLANS.md and its archive (see this function's doc comment): a NEW plan
295
- // filed directly under ## Done with no done date. strandedPlanEntries already does the
296
- // fence-aware, joined-metadata reading the first two conditions need; the base's key set —
297
- // over both files, since tumwater moves a finished plan from one to the other
298
- // (plans/backlog-archive.md) — supplies the third.
299
- if (file === "PLANS.md" || file === PLANS_ARCHIVE) {
300
- const baseKeys = new Set([
301
- ...planHeadingKeys(file === "PLANS.md" ? base : await fileContentAt(wt, baseRev, "PLANS.md")),
302
- ...planHeadingKeys(file === PLANS_ARCHIVE ? base : await fileContentAt(wt, baseRev, PLANS_ARCHIVE)),
303
- ]);
304
- const newcomer = strandedPlanEntries(head).find((e) => e.section === "Done" && !baseKeys.has(planHeadingKey(e.title)));
305
- if (newcomer)
306
- // The fault and its fix differ by file: in PLANS.md a NEW plan (the base never carried
307
- // it in any section) was pasted under ## Done unfinished and belongs under ## Planned;
308
- // in the archive the same heading is a finished plan the commit-time sweep already moved
309
- // here, whose only fault is a missing `done YYYY-MM-DD` token — telling its author to
310
- // "file it under ## Planned" is the opposite of the fix and wastes a revision.
311
- return file === PLANS_ARCHIVE
312
- ? `${file} files "${newcomer.title}" directly under "## Done" with no done date — the ` +
313
- `entry is finished and archived, so the only fix is to add \`done YYYY-MM-DD\` to its ` +
314
- `"### " heading; do not move it back under "## Planned"`
315
- : `${file} files "${newcomer.title}" directly under "## Done" with no done date — a plan ` +
316
- `written into the done section is invisible to every Planned reader; file it under ` +
317
- `"## Planned" instead`;
318
- }
319
- // A new headingless record cannot hide in a Fixed entry's body: reject a head whose
320
- // multi-block entries the base did not carry (an existing one never blocks unrelated
321
- // edits, matching the heading-set rules above). The Fix paragraph is the guard's unit, so
322
- // a merged second block would otherwise sit inside the neighbor it was symbol-checked
323
- // against. BUGS-FIXED.md holds the same entries once archived.
324
- if (file === "BUGS.md" || file === BUGS_ARCHIVE) {
325
- const baseTitles = new Set(doubleBlockFixedEntries(base));
326
- const newcomer = doubleBlockFixedEntries(head).find((title) => !baseTitles.has(title));
327
- if (newcomer)
328
- return (`${file}'s Fixed entry "${newcomer}" holds more than one **Symptom:**/**Fix:** block — ` +
329
- `a record with no "### " heading has merged into it, so the hidden record is invisible ` +
330
- `to every section reader; give it its own "### " heading`);
331
- }
378
+ const reason = duplicateSectionReason(file, headCounts, baseCounts) ??
379
+ droppedSectionReason(file, headCounts, baseCounts) ??
380
+ readdedHeadingReason(file, base, head) ??
381
+ (await misplacedPlanReason(file, wt, baseRev, base, head)) ??
382
+ hiddenFixedRecordReason(file, base, head);
383
+ if (reason !== undefined)
384
+ return reason;
332
385
  }
333
386
  return undefined;
334
387
  }
@@ -81,7 +81,7 @@ export function sectionCompletionLags(root, fileName, sectionTitle, dateRe) {
81
81
  * because the stamp is the entry's own and the body may name other dates.
82
82
  * null when the heading carries no such clause. Routed through backlog-md.ts's firstDate -
83
83
  * the one first-match mechanism - with this module's own stamp regex.
84
- * questionFilingDate (question-answer.ts) is the same recipe for the `asked by` verb. */
84
+ * questionFilingDate (backlog-question-answer.ts) is the same recipe for the `asked by` verb. */
85
85
  const STAMP_DATE_RE = /(?:planned|reported|found by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/;
86
86
  export function filingDate(title) {
87
87
  return firstDate(title, STAMP_DATE_RE);
@@ -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