tumwater 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { shortSha } from "../text/format.js";
2
- import { NEEDS_REPLAN_NOTE, NEEDS_REVIEW_NOTE } from "../roles/role-guidance.js";
2
+ import { NEEDS_REVIEW_PREFIX, NEEDS_REVIEW_NOTE, CHECK_DECLARATION_MARKER } from "../roles/role-guidance.js";
3
3
  import { baseRoleOf } from "../roles/runner-ids.js";
4
4
  import { formatTimestamp } from "../text/datetime.js";
5
5
  import { numberedList } from "../text/markdown.js";
@@ -29,12 +29,12 @@ export function buildRejectedReviewNote(review, role) {
29
29
  }
30
30
  // On exhaustion the rejected diff is gone. Feature's rejected change often implemented a
31
31
  // PLANS.md entry, and re-authoring the same plan from scratch is what produced the long
32
- // rejection runs this note exists to end, so feature is sent to replan the entry instead.
33
- // A plan-less role has no downstream role to replan for it, so it is sent to its own
34
- // notes instead: it records the abandoned purpose and any instances that remain
35
- // unhandled, then takes a different theme on the next run. Re-authoring the same purpose
36
- // from the same objections is what restarted the round and produced the long rejection
37
- // runs this note exists to end. The markdown-only replan note merges review-exempt, but a
32
+ // rejection runs this note exists to end, so feature is sent to write a Needs-review note so
33
+ // the plan role rewrites the entry. A plan-less role has no downstream role to replan for it,
34
+ // so it is sent to its own notes instead: it records the abandoned purpose and any instances
35
+ // that remain unhandled, then takes a different theme on the next run. Re-authoring the same
36
+ // purpose from the same objections is what restarted the round and produced the long rejection
37
+ // runs this note exists to end. The markdown-only Needs-review note merges review-exempt, but a
38
38
  // merged change clears the standing rejection (clearSupersededRejection, run-apply.ts), so
39
39
  // the instruction does not re-inject once the note has merged. `role` is the runner id, so
40
40
  // feature's instances (`feature-2`, …) are matched through their base role: matched bare,
@@ -44,7 +44,7 @@ export function buildRejectedReviewNote(review, role) {
44
44
  if (review.exhausted) {
45
45
  finality =
46
46
  role !== undefined && baseRoleOf(role) === "feature"
47
- ? ` This was the change's final revision round — the same objection survived two revisions (or the chain hit its cap), so the rejected diff and ref are gone. If it implemented a PLANS.md entry, do not re-author it: append ${NEEDS_REPLAN_NOTE} under that entry's heading, with the numbered objections above quoted beneath it, and merge that markdown-only change and nothing else so the plan role can rewrite the plan. Otherwise, redo the change against current main.`
47
+ ? ` This was the change's final revision round — the same objection survived two revisions (or the chain hit its cap), so the rejected diff and ref are gone. If it implemented a PLANS.md entry, do not re-author it: append ${NEEDS_REVIEW_PREFIX}<YYYY-MM-DD> by feature: rejected after <N> review rounds** under that entry's heading, with the numbered objections above quoted beneath it, and merge that markdown-only change and nothing else so the plan role can rewrite the plan. Otherwise, redo the change against current main.`
48
48
  : " This was the change's final revision round — the same objection survived two revisions (or the chain hit its cap), so the rejected diff and ref are gone. Do not re-author it. Record the abandoned purpose and any instances of it that remain unhandled in your notes, then take a different theme on this run: re-deriving the same purpose from the same objections is what produced the long rejection runs this note exists to end.";
49
49
  }
50
50
  const tail = finality === "" ? "" : `\n${finality.trim()}`;
@@ -106,6 +106,25 @@ Sibling instances hold these backlog entries; do not pick, edit, or implement th
106
106
  ${titles.map((t) => `- "${t}"`).join("\n")}
107
107
  </held-entries>`;
108
108
  }
109
+ /** The note injected into a `plan` or `feature` run when the project has no declared
110
+ * deterministic check (detectBuildCheck returned null) and the team should close the gap itself
111
+ * — the plan role writes the declaration entry, the feature role implements it. Exactly one of
112
+ * the two notes is in flight, decided by CHECK_DECLARATION_MARKER rather than prose (see
113
+ * declarationPlanEntry, src/backlog/backlog.ts): no marker → the plan note (write the
114
+ * declaration); marker → the feature note (implement it). run-prompt.ts picks which note to
115
+ * render from the base role and whether a marker entry waits, so a work instance (`feature-2`, …)
116
+ * matches through its base role and the marker test decides the rest. A pure function so its
117
+ * shape is pinned in tests. */
118
+ export function buildNoCheckNote(role) {
119
+ if (baseRoleOf(role) === "plan") {
120
+ return `<no-check>
121
+ This project declares no deterministic check, so the safety gates are off. Before anything else, write the entry that declares one: headed \`### Declare the project's deterministic check\`, marked on its own line with ${CHECK_DECLARATION_MARKER}, naming the exact command and the route it lands by — a \`test\`/\`typecheck\`/\`build\` script added to package.json when the walked-up install root carries one, else \`check.command\` — with acceptance criteria that the doctor's \`check\` row reads ok and the \`no-check\` alert is gone. Writing it is this run's work even when the eligible-plan target is already met, so the in-prompt queue-full stop yields to it; a full plan backlog defers the plan role before this prompt is built, so this note only reaches a run already admitted past that stop. A Needs-review lift and an assigned claim still come first. A project with nothing to verify yet — no source, no script to wire — gets no plan: end with ${NOTHING_TO_DO} instead.
122
+ </no-check>`.trim();
123
+ }
124
+ return `<no-check>
125
+ The PLANS.md \`## Planned\` entry marked ${CHECK_DECLARATION_MARKER} is this run's task unless the run was handed another entry: implement its declaration so a merge makes it live — the doctor's \`check\` row reads ok and \`tumwater status --json\` carries no \`noDeclaredCheck\`. Do not chase an in-run re-read, though: while the change is unmerged, both still reflect the pre-declaration state, so live is confirmed when the change merges, not in this run — mark the plan done when the declaration is complete and correct, and let the merge confirm it. Choose the route the repo takes: (1) add the verifying script to package.json as \`test\`/\`typecheck\`/\`build\` (detection's preference order), a tracked diff the reviewer sees, live the moment it merges; (2) otherwise write \`{ "check": { "command": "…" } }\` to \`.tumwater-config-request.json\` at the worktree root, never tumwater.json — the request is run green before it is applied, then the file is deleted. A marker entry held with a Needs-review note is not available to you, so end the run there rather than forcing it.
126
+ </no-check>`.trim();
127
+ }
109
128
  /** The longest title an entry list quotes: a PLANS.md heading's tail names the plan's dates and
110
129
  * every file it touches, which its range already leads to. */
111
130
  const LISTED_TITLE_CHARS = 100;
@@ -150,9 +169,9 @@ export function buildConflictDiscardNote(summary, attempts) {
150
169
  }
151
170
  /** The note injected into the `ci` healer's prompt when main's own suite is red (PLANS.md
152
171
  * "Red-main handoff"): the gate's warning event is harness-level and no role prompt reads it, so
153
- * the one role allowed to author on a red main is told what failed and that fixing it is this
154
- * run's job. The healer's normal charter is a starting point, not a fit — a red main blocks
155
- * every other code role, and the gate's deterministic pre-check rejects any change that leaves
172
+ * the healer — whose job is to make main green — is told what failed and that fixing it is this
173
+ * run's task. The healer's normal charter is a starting point, not a fit — a red main keeps the
174
+ * whole team working on it, and the gate's deterministic pre-check rejects any change that leaves
156
175
  * the suite red, so authoring anything else first is waste. A pure function so its shape is
157
176
  * pinned in tests; script/headline are optional because a red carries a failing script, but the
158
177
  * note must still read sensibly if it is ever absent. */
@@ -172,22 +191,28 @@ export function buildMainRedNote(sha, script, headline, tail) {
172
191
  ? `\nFailing check output (${output.length} lines):\n\`\`\`\n${output.join("\n")}\n\`\`\`\n`
173
192
  : "";
174
193
  return `<main-red>
175
- main's own suite is red at ${shortSha(sha)} (${what}). Every code-producing role is skipping authoring and no change merges until main is green.
194
+ main's own suite is red at ${shortSha(sha)} (${what}). Every role keeps working on the red main, but no change that leaves the suite red merges until main is green.
176
195
  This run your job is to make main green: reproduce that failure, fix it (add a regression test where one is feasible), and merge the fix.
177
196
  The review gate's deterministic pre-check runs main + your change, so a change that leaves the suite red is rejected — do not author anything else first.
178
197
  If the failure is environmental — flaky, load-sensitive, or a broken local toolchain — say so plainly in your reply rather than editing unrelated code.
179
198
  ${tailBlock}</main-red>`;
180
199
  }
181
- /** The note every role but bugfix gets while main is red with named failures: the tests that
182
- * already fail on main (at most 20 listed, then a `…and N more` line) and who owns them. A pure
183
- * function so its shape is pinned in tests. */
200
+ /** The note injected into every role's prompt but the `ci` healer's own while main is red with
201
+ * named failures (the `<main-known-red>` block part 3a/3 dropped with the red-main authoring
202
+ * gate, re-added by the "Every role may fix main's failing check" decision 2026-10-25): the
203
+ * tests that already fail on main, and who owns them. The `ci` role owns the repair but does
204
+ * not monopolize it — any role whose change touches what a named test covers, or that can land
205
+ * a fix, fixes it in its own worktree and lets the change land normally. It is worded as
206
+ * permission to proceed (a main that names its failures stops nobody), never as "leave them
207
+ * alone". A pure function so its shape is pinned in tests; at most 20 tests are listed, then a
208
+ * `…and N more` line. */
184
209
  export function buildMainKnownRedNote(failingTests) {
185
210
  const shown = failingTests.slice(0, 20).map((t) => `- ${t}`);
186
211
  if (failingTests.length > 20)
187
212
  shown.push(`- …and ${failingTests.length - 20} more`);
188
213
  return `<main-known-red>
189
214
  ${shown.join("\n")}
190
- These tests already fail on main. Tumwater or the bugfix role owns them. Leave them alone unless your task is about them; a change merges when it adds no new failures.
215
+ Main's tip already fails these tests. The ci role owns them and fixes them on its own clock. A role whose change touches what they test, or that can land a fix for one, fixes it in its own worktree and lets the change land normally — never weaken or delete a test to make the suite pass.
191
216
  </main-known-red>`;
192
217
  }
193
218
  /** The note appended to an assigned series part's prompt naming the later planned parts that
@@ -3,7 +3,7 @@
3
3
  * the previous note; when a note states something about the backlog (an entry is refused, a
4
4
  * series is dead, a part is done) nothing used to check that claim again — on 2026-10-21 a plan
5
5
  * run wrote "Codex 2b/9 carry Refused — never re-add or re-author them; the Codex theme is dead",
6
- * then both Refused notes became Needs-replan notes the plan role exists to act on, but its own
6
+ * then both Refused notes became Needs-review notes the plan role exists to act on, but its own
7
7
  * notes kept telling it to skip them and the series stalled. This module renders, from the notes
8
8
  * and the live backlog, a short block saying what is currently true of each entry and series the
9
9
  * notes name, so the live state wins without anyone editing a notes file.
@@ -17,10 +17,13 @@
17
17
  * names that entry.
18
18
  * Each named planned part carries its eligibility hold (entryHoldAgainst); a part in
19
19
  * PLANS-DONE.md reads `done`, a part found nowhere reads `not in the backlog`. Each named series
20
- * gets one summary line of its planned-part state. The block is at most 12 lines and returns
21
- * undefined when the notes name nothing live. */
22
- import { entryHoldAgainst, entryKindLabel } from "../backlog/backlog-eligibility.js";
20
+ * gets one summary line of its planned-part state. The block is at most 12 lines and, when the
21
+ * notes name more items than fit, its last line says how many were dropped (`… and N more named
22
+ * items`) so a dropped item cannot read as a complete answer; it returns undefined when the notes
23
+ * name nothing live. */
24
+ import { entryHoldAgainst } from "../backlog/backlog-eligibility.js";
23
25
  import { entryKey, partKey, plannedPartKeys, seriesPart } from "../backlog/backlog-parts.js";
26
+ import { plural } from "../text/phrases.js";
24
27
  import { collapseWhitespace, escapeRegExp } from "../text/text.js";
25
28
  const MAX_BLOCK_LINES = 12;
26
29
  const HEADER = "What the backlog says now about entries your notes name (PLANS.md and BUGS.md as of this run; where your notes disagree, this is current):";
@@ -37,16 +40,14 @@ function seriesInfos(planned, doneTitles) {
37
40
  const key = partKey(part.series, part.part);
38
41
  let info = map.get(part.series.toLowerCase());
39
42
  if (info === undefined) {
40
- info = { name: part.series, of: part.of, plannedCount: 0, replan: 0, review: 0, waiting: 0, refused: 0, holds: new Map(), done: new Set() };
43
+ info = { name: part.series, of: part.of, plannedCount: 0, review: 0, waiting: 0, refused: 0, holds: new Map(), done: new Set() };
41
44
  map.set(part.series.toLowerCase(), info);
42
45
  }
43
46
  info.plannedCount++;
44
47
  const hold = entryHoldAgainst(entry, allParts);
45
48
  info.holds.set(key, hold);
46
- if (hold === "needs-replan")
47
- info.replan++;
48
- else if (hold === "needs-review" || hold === "held-for-operator")
49
- info.review++; // vocabulary-ok: the held-for-operator hold is an operator decision
49
+ if (hold === "needs-review")
50
+ info.review++;
50
51
  else if (hold === "refused")
51
52
  info.refused++;
52
53
  else if (typeof hold === "object" && hold !== null)
@@ -85,11 +86,8 @@ function partRefsInLine(line) {
85
86
  function firstSixWords(normalizedKey) {
86
87
  return normalizedKey.split(/\s+/).slice(0, 6).join(" ");
87
88
  }
88
- /** The wording of one named item's state: an eligibility hold, `done`, or `not in the backlog`.
89
- * `file` is which backlog the entry lives in, so a held-for-operator entry names the operator's
90
- * matching release command (`tumwater plan --release` for a plan, `tumwater bug --release` for a
91
- * bug) — the same Plan/Bug derivation the operator-held alert uses. */
92
- function holdWording(state, file = "plans") {
89
+ /** The wording of one named item's state: an eligibility hold, `done`, or `not in the backlog`. */
90
+ function holdWording(state) {
93
91
  if (state === "done")
94
92
  return "done";
95
93
  if (state === "not-in-backlog")
@@ -98,27 +96,19 @@ function holdWording(state, file = "plans") {
98
96
  return "eligible";
99
97
  if (state === "refused")
100
98
  return "refused (only the operator clears it)"; // vocabulary-ok: the plan-mandated refused hold wording refers to the tumwater operator
101
- if (state === "needs-review")
102
- return "needs review";
103
- if (state === "needs-replan")
104
- return "needs replan (the plan role rewrites it)";
105
- if (state === "held-for-operator")
106
- return `held for the operator (tumwater ${entryKindLabel(file).toLowerCase()} --release)`; // vocabulary-ok: names the operator-held wording and the operator's release command
107
- if (state === "waiting-for-failure")
108
- return "waiting for the next failure";
99
+ if (state === "needs-review" || state === "held-for-operator")
100
+ return "needs review"; // vocabulary-ok: the deprecated held-for-operator hold name, kept only until part 2f/6 retires it
109
101
  return `waits on ${state.blockedBy.join(", ")}`;
110
102
  }
111
103
  /** The one summary line of a named series' planned-part state. */
112
104
  function seriesSummary(info) {
113
105
  const counts = [];
114
- if (info.replan > 0)
115
- counts.push(`${info.replan} ${info.replan === 1 ? "needs a replan" : "need a replan"}`);
116
106
  if (info.review > 0)
117
107
  counts.push(`${info.review} ${info.review === 1 ? "needs review" : "need review"}`);
118
108
  if (info.waiting > 0)
119
109
  counts.push(`${info.waiting} ${info.waiting === 1 ? "waits" : "wait"} on an earlier part`);
120
110
  counts.push(info.refused === 0 ? "none refused" : `${info.refused} refused`);
121
- return `${info.plannedCount} ${info.plannedCount === 1 ? "part" : "parts"} planned: ${counts.join(", ")}`;
111
+ return `${plural(info.plannedCount, "part")} planned: ${counts.join(", ")}`;
122
112
  }
123
113
  /** The live backlog block for the notes, or undefined when they name nothing live. */
124
114
  export function notesBacklogBlock(notes, backlog) {
@@ -173,12 +163,15 @@ export function notesBacklogBlock(notes, backlog) {
173
163
  const info = series.get(p.seriesLower);
174
164
  if (info === undefined)
175
165
  continue;
176
- lines.push(`- ${info.name}, part ${p.part}/${p.of} — ${holdWording(p.state, "plans")}`);
166
+ lines.push(`- ${info.name}, part ${p.part}/${p.of} — ${holdWording(p.state)}`);
177
167
  }
178
168
  for (const entry of namedEntries.values())
179
- lines.push(`- ${entry.title} — ${holdWording(entry.state, entry.file)}`);
169
+ lines.push(`- ${entry.title} — ${holdWording(entry.state)}`);
180
170
  if (lines.length === 0)
181
171
  return undefined;
182
- const shown = lines.slice(0, MAX_BLOCK_LINES - 1);
183
- return [HEADER, ...shown].join("\n");
172
+ const cap = MAX_BLOCK_LINES - 1; // the item lines; the header is the twelfth line
173
+ if (lines.length <= cap)
174
+ return [HEADER, ...lines].join("\n");
175
+ const shown = lines.slice(0, cap - 1);
176
+ return [HEADER, ...shown, `… and ${plural(lines.length - shown.length, "more named item")}`].join("\n");
184
177
  }
@@ -12,3 +12,14 @@ export function readPrinciples(root) {
12
12
  const file = path.join(root, "PRINCIPLES.md");
13
13
  return truncateWithNote(readTextOrNull(file)?.trim() ?? "", PRINCIPLES_MAX_CHARS, "PRINCIPLES.md");
14
14
  }
15
+ /** True when PRINCIPLES.md's trimmed text exceeds the prompt cap, so every run's <principles>
16
+ * block carries only its head plus a truncation marker — the always-on amber twin of the doctor
17
+ * `principles` row's over-cap warn, surfaced on `tumwater status`, the dashboard and the TUI
18
+ * without a doctor run. Uses the doctor row's own predicate (raw.trim().length >
19
+ * PRINCIPLES_MAX_CHARS), so the alert and the doctor row cannot disagree. Missing, unreadable
20
+ * (the directory-in-place case readPrinciples degrades to "") and at-or-under-cap files are all
21
+ * false. */
22
+ export function principlesBlockOverCap(root) {
23
+ const raw = readTextOrNull(path.join(root, "PRINCIPLES.md"));
24
+ return raw !== null && raw.trim().length > PRINCIPLES_MAX_CHARS;
25
+ }
@@ -68,24 +68,16 @@ ${REPLY_ENDINGS}`;
68
68
  * so the bridge must send the new run to `git diff` before it starts its own work. A `salvaged`
69
69
  * handoff's edits were restored from a patch of a run the harness lost track of
70
70
  * (src/git/slot-salvage.ts) rather than kept in place. */
71
- export function buildHandoffPrompt(roleId, runPrompt, cause = "handoff",
72
- /** Named facts an image-reroute handoff note needs that the cause alone cannot carry: the
73
- * model the blind run was on and the image file it read. */
74
- imageReroute) {
71
+ export function buildHandoffPrompt(roleId, runPrompt, cause = "handoff") {
75
72
  const note = cause === "budget-capped"
76
73
  ? `An earlier API-billed claude run of the "${roleId}" role hit its per-run budget cap before it
77
74
  could finish; its uncommitted edits are in your worktree — review \`git diff\`, then finish or
78
75
  discard them. This run starts on the model job the daily budget left you.`
79
- : cause === "image-reroute"
80
- ? `An earlier run of the "${roleId}" role on ${imageReroute?.from ?? "another model"} stopped
81
- when it read ${imageReroute?.path ?? "an image"}, because that model cannot read images; its
82
- uncommitted edits are in your worktree — review \`git diff\`, then finish or discard them. This
83
- run is on a model that CAN read images, so read the image again.`
84
- : cause === "salvaged"
85
- ? `An earlier run of the "${roleId}" role was cut short before tumwater could save its work;
76
+ : cause === "salvaged"
77
+ ? `An earlier run of the "${roleId}" role was cut short before tumwater could save its work;
86
78
  its uncommitted edits were recovered and are in your worktree — review \`git status\` and
87
79
  \`git diff\` (new files show as untracked), then finish or discard them.`
88
- : `An earlier run of the "${roleId}" role on another model was interrupted; its
80
+ : `An earlier run of the "${roleId}" role on another model was interrupted; its
89
81
  uncommitted edits are in your worktree — review \`git diff\`, then finish or discard them.`;
90
82
  return `${runPrompt}\n\n${note}`;
91
83
  }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { describeCheck } from "../build/build-check-report.js";
3
- import { DECOMPOSITION_GUIDANCE, NEEDS_REPLAN_NOTE, NEEDS_REVIEW_NOTE, PLAN_QUEUE_GUIDANCE, PLAN_SIZING } from "../roles/role-guidance.js";
3
+ import { DECOMPOSITION_GUIDANCE, NEEDS_REVIEW_NOTE, PLAN_QUEUE_GUIDANCE, PLAN_SIZING } from "../roles/role-guidance.js";
4
4
  import { CLAIMS_RULE, REPLY_ENDINGS } from "../verdict/reply-contract.js";
5
5
  import { todayStamp } from "../budget/budget.js";
6
6
  import { daysBetween } from "../text/datetime.js";
@@ -93,7 +93,7 @@ function backendRuleLines(rules) {
93
93
  /** The date line every pi prompt carries — run and director (via sharedPreamble) and the gate's
94
94
  * conflict and review runs — naming the local calendar day as YYYY-MM-DD. No prompt
95
95
  * used to say what day it is, so a run that had to write one (a BUGS.md heading's "(found by …
96
- * YYYY-MM-DD)", a Fixed or Refused date, NEEDS_REVIEW_NOTE's and NEEDS_REPLAN_NOTE's
96
+ * YYYY-MM-DD)", a Fixed or Refused date, NEEDS_REVIEW_NOTE's
97
97
  * <YYYY-MM-DD>, a plan deadline)
98
98
  * inferred it from the newest dates in the repo, which were themselves drifting: the team
99
99
  * stamped entries days into the future and each wrong date seeded the next. The day is
@@ -261,7 +261,7 @@ note's say-so.`
261
261
  source of what is currently true of each entry and series, so check the entry itself (and, when this run carries
262
262
  one, its line in the backlog) before acting on a note's say-so.`;
263
263
  return `Notes your role wrote in earlier runs (${savedClause(savedDay, today)}yours, unverified — check against the code before
264
- relying on them). A claim they make about an entry's backlog state — refused, blocked, abandoned, needs-replan,
264
+ relying on them). A claim they make about an entry's backlog state — refused, blocked, abandoned,
265
265
  done, behind another entry — is a snapshot of the past, not the present: ${liveSource}
266
266
  A note that tells you to skip or never re-author an entry is always overridden by the backlog.
267
267
  <role-notes>\n${notes.trim()}\n</role-notes>`;
@@ -307,7 +307,7 @@ from the repo.`,
307
307
  }
308
308
  /** The prompt for one role run. */
309
309
  export function buildRunPrompt(input) {
310
- const { role, rejectedReview, principles, digest, coverage, ciParity, filings, backlogStructure, backlogIndex, notes, notesSavedDay, notesBacklog, extraInstructions, planTargetNote, flakyChecks, check, gateCommand, briefFile, today, userRequest, agent, assigned } = input;
310
+ const { role, rejectedReview, principles, digest, coverage, ciParity, filings, backlogStructure, backlogIndex, notes, notesSavedDay, notesBacklog, extraInstructions, planTargetNote, flakyChecks, noCheck, check, gateCommand, briefFile, today, userRequest, agent, assigned } = input;
311
311
  const dialect = dialectFor(agent);
312
312
  const day = today ?? todayStamp();
313
313
  const parts = [
@@ -338,6 +338,8 @@ role's scope, say so in your reply instead of doing it anyway.\n<user-request>\n
338
338
  if (notesBacklog)
339
339
  parts.push(notesBacklog);
340
340
  parts.push(`Your task this run:\n${role.find.trim()}`);
341
+ if (noCheck)
342
+ parts.push(noCheck.trim());
341
343
  if (planTargetNote)
342
344
  parts.push(planTargetNote.trim());
343
345
  if (flakyChecks)
@@ -396,8 +398,7 @@ work yourself:
396
398
  direction — so roles can pick it up again.
397
399
  - A decision about a marked plan (e.g. "split plan X", "keep plan X whole", "replan X"):
398
400
  split it into independently landable sub-plans per PLAN_SIZING, or clear the
399
- ${NEEDS_REVIEW_NOTE} note, or clear a ${NEEDS_REPLAN_NOTE} note and rewrite the entry — per
400
- the user's direction.
401
+ ${NEEDS_REVIEW_NOTE} note — per the user's direction.
401
402
  - A request to manage custom roles ("add a role named X that does Y", "remove X",
402
403
  "move X before Y"): write the FULL replacement customRoles array to
403
404
  .tumwater-config-request.json at your worktree root — the shape is
@@ -421,12 +422,14 @@ work yourself:
421
422
  parts.push(`Additional standing instructions from the user:\n${extraInstructions.trim()}`);
422
423
  // Before the shared rules, not after them: the closing reply contract stays the last thing
423
424
  // in the prompt, where a model attends to it most (the run prompt's invariant too).
424
- parts.push(`Note on one boundary in the rules below, director only: user-defined-role requests are
425
- executed by writing .tumwater-config-request.json in your worktree (shape and worked example
426
- above) — never by editing tumwater.json, which stays off-limits to you as to every role. Tumwater
427
- validates the request and applies only its customRoles array; a request that also names
428
- any other setting (timeouts, budgets, role enablement, review settings) has that key discarded
429
- with a warning, so such a request is guidance to record per the routing rules, not an edit you
425
+ parts.push(`Note on one boundary in the rules below, director only: user-defined-role requests and a
426
+ project check declaration are executed by writing .tumwater-config-request.json in your worktree
427
+ (shape and worked example above) — never by editing tumwater.json, which stays off-limits to you
428
+ as to every role. The request may carry only customRoles and check.command: tumwater validates it
429
+ and runs a declared check.command green in the worktree before writing it. A request that also
430
+ names any other setting (timeouts, budgets, role enablement, review settings, gateCommand,
431
+ mergeCommand, check.cwd) has that key discarded with a warning, so such a request is guidance
432
+ that stays staged or is recorded per the routing rules, not an edit you
430
433
  can make.`);
431
434
  // A director run is never assigned an entry.
432
435
  parts.push(commonRules(check, briefFile, undefined, dialectFor(backend)).trim());
@@ -20,7 +20,10 @@ export function track(promise) {
20
20
  * shape prewarms nothing further — there is no point staging a compile for a tree the check
21
21
  * condemned — and is simply not adopted (the episode re-runs it under its own rules).
22
22
  * Skipped while an episode is pending: its own steps are the warm-up, and the mirror worktree
23
- * both run in must not serve two checks at once. */
23
+ * both run in must not serve two checks at once. A new head that arrives while a step is still
24
+ * running is likewise not adopted: returning without resetting keeps the mirror pointed at the
25
+ * head the running check (or compile) is testing, never re-pointed under it; the next poll after
26
+ * both settle takes the newest head as today. */
24
27
  export class PrewarmProbes {
25
28
  deps;
26
29
  head = null;
@@ -37,6 +40,13 @@ export class PrewarmProbes {
37
40
  if (pending)
38
41
  return;
39
42
  if (this.head !== head) {
43
+ // A newer head arrived while this head's green check or staged compile is still running
44
+ // (!done): never re-point the mirror under a running step — it may still be testing the
45
+ // sha it read before the reset, and a verdict recorded under that sha would not cover the
46
+ // tree it tested. Keep probing for the older head; the next poll after both settle adopts
47
+ // the newest head as today.
48
+ if ((this.green !== null && !this.green.done) || (this.compiled !== null && !this.compiled.done))
49
+ return;
40
50
  this.head = head;
41
51
  this.green = null;
42
52
  this.compiled = null;
@@ -254,6 +254,32 @@ export class Redeployer {
254
254
  this.autoRestartOn = autoRestart;
255
255
  if (!this.selfHosted || !mainHead)
256
256
  return this.endDrain();
257
+ await this.observeHead(mainHead, now);
258
+ // The sustained-pin escalation runs BEFORE the early returns below: a latched block on a
259
+ // frozen main never re-enters the episode logic, and that pinned-and-stuck state is exactly
260
+ // what must escalate (BUGS.md 2026-09-29).
261
+ if (this.staleness?.stale && autoRestart)
262
+ this.escalateIfSustained(now);
263
+ if (!this.staleness?.stale || !autoRestart || this.blockedHead === mainHead)
264
+ return this.endDrain();
265
+ const cooldown = this.cooldownStage(mainHead, now);
266
+ if (cooldown !== null)
267
+ return cooldown;
268
+ const start = await this.startEpisode(mainHead, inFlight, now);
269
+ if (start !== null)
270
+ return start;
271
+ const green = this.greenStage(mainHead, now);
272
+ if (green !== null)
273
+ return green;
274
+ const compile = this.compileStage(mainHead, now);
275
+ if (compile !== null)
276
+ return compile;
277
+ return this.drainAndSwap(mainHead, inFlight, now);
278
+ }
279
+ /** Observe a moved main head (a no-op on an unchanged head): update staleness, log build_stale
280
+ * on the fresh transition, run the sustained-pin clock and its forced-restart waiver, and drop
281
+ * any restart pending for a superseded head. */
282
+ async observeHead(mainHead, now) {
257
283
  if (mainHead !== this.lastHead) {
258
284
  const wasStale = this.staleness?.stale ?? false;
259
285
  this.lastHead = mainHead;
@@ -286,13 +312,12 @@ export class Redeployer {
286
312
  if (this.pendingHead !== null && this.pendingHead !== mainHead)
287
313
  this.clearPending();
288
314
  }
289
- // The sustained-pin escalation runs BEFORE the early returns below: a latched block on a
290
- // frozen main never re-enters the episode logic, and that pinned-and-stuck state is exactly
291
- // what must escalate (BUGS.md 2026-09-29).
292
- if (this.staleness?.stale && autoRestart)
293
- this.escalateIfSustained(now);
294
- if (!this.staleness?.stale || !autoRestart || this.blockedHead === mainHead)
295
- return this.endDrain();
315
+ }
316
+ /** The restart cooldown / forced-restart waiver gate: while the deferral holds, warn once, keep
317
+ * the team running on the stale build, prewarm the deferred head's green check and compile, and
318
+ * end any drain. Once lapsed, say why a red running build starts the episode early. Returns
319
+ * null when the cooldown has passed and the episode may begin. */
320
+ cooldownStage(mainHead, now) {
296
321
  // Completed auto-restarts are rate-limited to one per RESTART_COOLDOWN_MS (BUGS.md 2026-09-11):
297
322
  // under sustained churn every stale head would otherwise drive a full hold+drain+swap episode
298
323
  // back to back. Inside the cooldown the team keeps running on the stale build exactly as when
@@ -341,6 +366,12 @@ export class Redeployer {
341
366
  this.warn(`auto-restart of ${shortSha(mainHead)} proceeding early — the running version ${shortSha(this.build.sha)} fails its check, ` +
342
367
  `the cooldown was cut to ${RESTART_URGENT_COOLDOWN_MIN} min and that deadline has already passed`);
343
368
  }
369
+ return null;
370
+ }
371
+ /** The boot gate behind which an episode opens: refuse to hold onto a generation that cannot
372
+ * boot here, else adopt the pre-warmed green/compile verdicts and hold the team until the
373
+ * staged tree is ready. Returns null when an episode is already pending. */
374
+ async startEpisode(mainHead, inFlight, now) {
344
375
  if (this.pendingHead === null) {
345
376
  // Before holding anything: could a new generation even boot here? A refusal here costs
346
377
  // nothing — no hold, no green check, no compile — so asking again every poll is how a
@@ -369,6 +400,11 @@ export class Redeployer {
369
400
  this.logRestartPending(mainHead);
370
401
  return "hold";
371
402
  }
403
+ return null;
404
+ }
405
+ /** The green check: hold while it is unresolved, drop the episode when it could not run, block
406
+ * when it returns a red verdict. Returns null (proceed to compile) only on a green verdict. */
407
+ greenStage(mainHead, now) {
372
408
  if (!this.green?.done)
373
409
  return "hold";
374
410
  // A REJECTED check is not a verdict: it could not run (git broke inside the mirror worktree,
@@ -390,6 +426,12 @@ export class Redeployer {
390
426
  this.block(mainHead, reason, `${reason} — holding the restart until main is green`);
391
427
  return this.endDrain();
392
428
  }
429
+ return null;
430
+ }
431
+ /** The compile stage: kick off (or hold on) the staged build, and on a verdict block or drop the
432
+ * episode when the tree failed to build. Returns null (proceed to drain) only on a successful
433
+ * compile. */
434
+ compileStage(mainHead, now) {
393
435
  if (!this.compiled) {
394
436
  this.compiled = this.probes.adoptCompile(mainHead) ?? track(this.deps.compile(mainHead));
395
437
  this.logRestartPending(mainHead);
@@ -425,6 +467,11 @@ export class Redeployer {
425
467
  this.block(mainHead, reason, `${reason} — staying on version ${shortSha(this.build.sha)}: ${detail}`);
426
468
  return this.endDrain();
427
469
  }
470
+ return null;
471
+ }
472
+ /** The drain and swap: hold while runs keep the drain open, re-ask the boot gate before the
473
+ * point of no return, then swap the staged tree in and record the completed restart. */
474
+ async drainAndSwap(mainHead, inFlight, now) {
428
475
  // The drain rule, split by who requested the work (BUGS.md 2026-09-08): a director run is
429
476
  // an explicit human prompt and outranks the redeploy — while one is in flight the hold has no
430
477
  // time cap: no swap and no abort until it finishes. The per-run watchdogs already bound how
@@ -5,6 +5,7 @@ import { RESTART_EXIT_CODE } from "./redeploy-policy.js";
5
5
  import { childEnv } from "../process/process.js";
6
6
  import { startParentDeathWatch } from "../process/supervisor.js";
7
7
  import { liveNodeBin } from "../process/node-bin.js";
8
+ import { parsePositiveInt } from "../text/text.js";
8
9
  /** Auto-reload for the user-launched dashboards (`tumwater tui`, `tumwater gui`).
9
10
  *
10
11
  * Unlike the orchestrator — which the supervisor respawns onto a fresh build — a dashboard is
@@ -50,12 +51,16 @@ export function watchReloadSupervisor(onGone, seams = {}) {
50
51
  const mark = (seams.env ?? process.env)[DASHBOARD_CHILD_ENV];
51
52
  if (mark === undefined)
52
53
  return () => { };
53
- const supervisor = Number(mark);
54
+ // The mark is a pid spelled in plain decimal (reexecSelf writes String(process.pid)); a
55
+ // hex/exponent/whitespace spelling is never a pid the writer meant, so read it by the shared
56
+ // plain-decimal rule rather than Number()'s silent coercion — the supervisor's expecter twin
57
+ // parses the same way.
58
+ const supervisor = parsePositiveInt(mark);
54
59
  return startParentDeathWatch(onGone, {
55
60
  ...(seams.ppid ? { ppid: seams.ppid } : {}),
56
61
  intervalMs: seams.intervalMs ?? SUPERVISOR_POLL_MS,
57
62
  // An older supervisor's "1" names no pid (1 is launchd/init): the watch's first read stands.
58
- ...(Number.isInteger(supervisor) && supervisor > 1 ? { expectedPpid: supervisor } : {}),
63
+ ...(supervisor !== null && supervisor > 1 ? { expectedPpid: supervisor } : {}),
59
64
  });
60
65
  }
61
66
  /** Hand this process's dashboard duty to a fresh copy of itself (`process.argv.slice(1)` re-runs