tumwater 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -4,14 +4,14 @@
4
4
  * probe's runMarkers check and doctor's orphan judgment), and the sweep that SIGTERMs
5
5
  * this run's leftovers whatever process group they ended up in. Split out of
6
6
  * process-table.ts — which keeps the doctor-facing probe interface and the ps/lsof/top
7
- * parsing it is made of — because this half serves src/agent/run-spawn.ts's run stamping and
7
+ * parsing it is made of — because this half serves src/agent/agent-spawn.ts's run stamping and
8
8
  * the exit sweep, and shares none of the probe's table-parsing concerns; the probe reads marks
9
9
  * through the same parsers exported here. */
10
10
  import crypto from "node:crypto";
11
11
  import fs from "node:fs";
12
12
  import path from "node:path";
13
- import { plural } from "../text/phrases.js";
14
- import { execFileAsync, withoutLaunchServicesCheckIn } from "./process.js";
13
+ import { agree, plural } from "../text/phrases.js";
14
+ import { execFileAsync, KILL_GRACE_MS, withoutLaunchServicesCheckIn } from "./process.js";
15
15
  /** Every lookup normally finishes in well under a second; a wedged one must not hang
16
16
  * its caller — the same ceiling process-table.ts's probe lookups carry. */
17
17
  const PS_ENVIRON_TIMEOUT_MS = 10_000;
@@ -22,14 +22,33 @@ const PS_ENVIRON_TIMEOUT_MS = 10_000;
22
22
  * command `detached`, a group leader in its own right, so a backgrounded grandchild sits in
23
23
  * a group whose leader is not pi and the group sweep's kill(-pi.pid) never reaches it. */
24
24
  const RUN_MARKER_VAR = "TUMWATER_RUN";
25
- /** A fresh marker for one pi run: `<harness pid>-<random hex>`. The pid prefix is what a
26
- * reader of a leaked process's environment needs to tell a live run's straggler (its own
27
- * sweep will reap it at exit) from a dead run's orphan (nothing will): a future doctor check
28
- * can report the latter wherever the leak's cwd is. The random half makes the marker unique
29
- * per run, so one run's sweep can never signal a concurrent sibling run's processes — a
30
- * machine running the team has several pi runs alive at once. */
31
- export function makeRunMarker(pid = process.pid) {
32
- return `${pid}-${crypto.randomBytes(6).toString("hex")}`;
25
+ /** The first 8 hex of sha1 of `root`'s realpath — the short stable token that names a team
26
+ * root inside a run marker's third segment, so a dead-marked orphan can be attributed to the
27
+ * checkout that minted it (BUGS.md, "Reap the doctor's orphans, part 1/3"). Realpath-resolving
28
+ * first keeps two spellings of one root (e.g. macOS's /var vs /private/var) hashing identically;
29
+ * an unreadable root realpaths to its resolved given path. */
30
+ export function rootToken(root) {
31
+ const resolved = path.resolve(root);
32
+ let real;
33
+ try {
34
+ real = fs.realpathSync(resolved);
35
+ }
36
+ catch {
37
+ real = resolved;
38
+ }
39
+ return crypto.createHash("sha1").update(real).digest("hex").slice(0, 8);
40
+ }
41
+ /** A fresh marker for one pi run: `<harness pid>-<random hex>` (`<rootToken>` appended when a
42
+ * team `root` is given). The pid prefix is what a reader of a leaked process's environment needs
43
+ * to tell a live run's straggler (its own sweep will reap it at exit) from a dead run's orphan
44
+ * (nothing will): a future doctor check can report the latter wherever the leak's cwd is. The
45
+ * random half makes the marker unique per run, so one run's sweep can never signal a concurrent
46
+ * sibling run's processes — a machine running the team has several pi runs alive at once. The
47
+ * optional root token stamps the team root the run belongs to, so a reader that sees a dead
48
+ * mark can tell this checkout's orphan from another checkout's and never signal the latter. */
49
+ export function makeRunMarker(pid = process.pid, root) {
50
+ const base = `${pid}-${crypto.randomBytes(6).toString("hex")}`;
51
+ return root === undefined ? base : `${base}-${rootToken(root)}`;
33
52
  }
34
53
  /** `base` plus this run's marker APPENDED to any inherited one, comma-separated. A harness
35
54
  * run under test inside a pi run inherits the outer run's mark; keeping it means the inner
@@ -43,7 +62,7 @@ export function runMarkerEnv(base, marker) {
43
62
  * check-in preload removed (`withoutLaunchServicesCheckIn`) and this run's marker appended
44
63
  * (`runMarkerEnv`). All three adapters (pi, claude, opencode) layer their own settings on
45
64
  * top, so the composition lives here rather than being re-derived in each. `binDir`, when
46
- * given, is prepended to PATH — run-spawn passes the notes shim dir (notesShimDir) so every
65
+ * given, is prepended to PATH — agent-spawn passes the notes shim dir (notesShimDir) so every
47
66
  * run finds the `tumwater-notes` command first (plans/notes-command.md "Reaching it from a
48
67
  * run"). */
49
68
  export function backendBaseEnv(marker, base = process.env, binDir) {
@@ -188,8 +207,7 @@ async function findMarkedPids(marker) {
188
207
  export function leftoversSweptMessage(kind, count, killed) {
189
208
  if (count === 0 || killed)
190
209
  return undefined;
191
- const verb = count === 1 ? "was" : "were";
192
- return `run sweep: ${plural(count, "process", "processes")} the ${kind} run started ${verb} still running after pi exited; sent SIGTERM`;
210
+ return `run sweep: ${plural(count, "process", "processes")} the ${kind} run started ${agree(count, "was", "were")} still running after pi exited; sent SIGTERM`;
193
211
  }
194
212
  /** SIGTERM every same-user process whose environment names `marker`, escalating to SIGKILL
195
213
  * after 10 s for any that survive — the run's cross-group leftovers: what a tool call
@@ -226,7 +244,7 @@ export async function sweepRunMarker(marker) {
226
244
  // Already gone.
227
245
  }
228
246
  }
229
- }, 10_000).unref();
247
+ }, KILL_GRACE_MS).unref();
230
248
  }
231
249
  return signaled;
232
250
  }
@@ -113,37 +113,55 @@ export const MAX_RAPID_RESPAWNS = 5;
113
113
  function exitCodeOf(exit) {
114
114
  return exit.code ?? 1;
115
115
  }
116
+ /** The code supervision must exit with when this generation ends it, or null when the
117
+ * generation asked for a restart and the loop should respawn it. A restart request while the
118
+ * operator is stopping is an asked-for ending, so it maps to the child's own code. */
119
+ function terminalExitCode(exit, stopping) {
120
+ if (exit.code === RESTART_EXIT_CODE && !stopping)
121
+ return null;
122
+ return exitCodeOf(exit);
123
+ }
124
+ /** Whether a terminal exit is a failure the operator did not ask for and so must be recorded
125
+ * through onTeamDown: not a clean exit, not a `run --once` failed round, and not while stopping. */
126
+ function isUnaskedFailure(code, stopping) {
127
+ return code !== 0 && code !== ONCE_FAILED_EXIT_CODE && !stopping;
128
+ }
129
+ /** Record one respawn at `at`, drop the ones that aged out of RESPAWN_WINDOW_MS, and return
130
+ * whether the rapid-respawn guard is now tripped. Mutates `recentRespawns`, the loop's window. */
131
+ function trackRespawn(recentRespawns, at) {
132
+ while (recentRespawns.length > 0 && at - (recentRespawns[0] ?? at) > RESPAWN_WINDOW_MS)
133
+ recentRespawns.shift();
134
+ recentRespawns.push(at);
135
+ return recentRespawns.length > MAX_RAPID_RESPAWNS;
136
+ }
137
+ /** Run a best-effort hook: a throwing hook must not change the supervisor's exit, so its error
138
+ * is swallowed. Used for onTeamDown (the trace) and onDone (the build repair). */
139
+ async function runBestEffort(hook) {
140
+ try {
141
+ await hook?.();
142
+ }
143
+ catch {
144
+ // Best-effort: the hook's own reporting is its responsibility.
145
+ }
146
+ }
116
147
  /** Run child generations until one exits with something other than a restart request, and
117
148
  * return the code the supervisor should exit with. Pure apart from the injected effects. */
118
149
  export async function superviseStart(opts, signal) {
119
150
  const now = opts.now ?? Date.now;
120
151
  let generation = 1;
121
152
  const recentRespawns = [];
122
- const teamDown = async (down) => {
123
- try {
124
- await opts.onTeamDown?.(down);
125
- }
126
- catch {
127
- // Best-effort (see onTeamDown): a trace that cannot be written must not change the exit.
128
- }
129
- };
153
+ const teamDown = (down) => runBestEffort(() => opts.onTeamDown?.(down));
130
154
  try {
131
155
  for (;;) {
132
156
  const exit = await opts.spawnChild(signal);
133
- if (exit.code !== RESTART_EXIT_CODE || opts.stopping()) {
134
- // A clean exit (a child stopped by its own SIGTERM logs orchestrator_stop) and anything
135
- // while the operator is stopping are asked-for endings, and so is a once round that
136
- // finished with failed runs; everything else is the team dying.
137
- const code = exitCodeOf(exit);
138
- if (code !== 0 && code !== ONCE_FAILED_EXIT_CODE && !opts.stopping())
157
+ const stopping = opts.stopping();
158
+ const terminal = terminalExitCode(exit, stopping);
159
+ if (terminal !== null) {
160
+ if (isUnaskedFailure(terminal, stopping))
139
161
  await teamDown({ generation, exit, crashLoop: false });
140
- return exitCodeOf(exit);
162
+ return terminal;
141
163
  }
142
- const t = now();
143
- while (recentRespawns.length > 0 && t - (recentRespawns[0] ?? t) > RESPAWN_WINDOW_MS)
144
- recentRespawns.shift();
145
- recentRespawns.push(t);
146
- if (recentRespawns.length > MAX_RAPID_RESPAWNS) {
164
+ if (trackRespawn(recentRespawns, now())) {
147
165
  opts.onCrashLoop?.();
148
166
  await teamDown({ generation, exit, crashLoop: true });
149
167
  return 1;
@@ -153,17 +171,11 @@ export async function superviseStart(opts, signal) {
153
171
  }
154
172
  }
155
173
  finally {
156
- // The end-of-supervision repair (see onDone): a hook that throws must not change the exit.
157
- try {
158
- await opts.onDone?.();
159
- }
160
- catch {
161
- // Best-effort — see onDone.
162
- }
174
+ await runBestEffort(() => opts.onDone?.());
163
175
  }
164
176
  }
165
177
  /** The `supervisor_exit` event for a team that went down (see onTeamDown). `diagnosis` is the
166
- * startup gate re-asked after the death (startup-gate.ts's runStartupProblem): the likeliest
178
+ * startup gate re-asked after the death (startup-gates.ts's runStartupProblem): the likeliest
167
179
  * reason a generation dies right after a respawn is an environment that no longer boots, and the
168
180
  * child's own message reached only the terminal. Null when every precondition passes — the event
169
181
  * then carries no reason rather than a guess. A crash loop's reason is the guard itself. */
@@ -56,7 +56,7 @@ export const CODEX_DIALECT = {
56
56
  ],
57
57
  };
58
58
  /** The dialect table keyed by backend id: every backend but claude speaks pi's dialect (and
59
- * an unknown id falls back to pi because run-agent.ts routes an unregistered id to the pi
59
+ * an unknown id falls back to pi because agent-run.ts routes an unregistered id to the pi
60
60
  * backend). Adding a backend means one row here, not another branch. */
61
61
  const DIALECTS = {
62
62
  pi: PI_DIALECT,
@@ -51,7 +51,7 @@ export function buildRejectedReviewNote(review, role) {
51
51
  return `Your previous change was rejected; fix each numbered objection or drop the change — it is this run's primary task${context}:\n${list}${tail}`;
52
52
  }
53
53
  /** The note injected into a role's run prompt when its rejected change has been re-applied to
54
- * current main as uncommitted edits (plans/revise-rejected.md, runner/revision.ts's
54
+ * current main as uncommitted edits (plans/revise-rejected.md, runner/runner-revision.ts's
55
55
  * applyRevision): this run's one task is to revise the change already in the worktree, not
56
56
  * author a new one. The prior objections are numbered so the author can work through them; a
57
57
  * revision round past the first also reaches here. The chain keeps revising while each round
@@ -156,7 +156,7 @@ ${lines.join("\n")}
156
156
  </merged-prerequisites>`;
157
157
  }
158
158
  /** The note injected into a role's prompt after leftover recovery discarded its pinned change
159
- * at MERGE_CONFLICT_LIMIT (src/runner/leftover.ts): at the cap recovery ordinarily hands the
159
+ * at MERGE_CONFLICT_LIMIT (src/runner/runner-leftover.ts): at the cap recovery ordinarily hands the
160
160
  * change back to its author with markers instead, so a discard means that lineage already used
161
161
  * its hand-back. The merge failures were harness-level, so without the note the author would
162
162
  * never learn its work is gone — or that redoing it from memory of the old diff would conflict
@@ -156,7 +156,7 @@ ${TEST_RUNNER_RULE}${noCheckRunRule}
156
156
  * revision of a previously rejected change (plans/revise-rejected.md part 2/2): it names the round
157
157
  * and shows the prior objections plus the interdiff so the re-review checks each one first.
158
158
  * Omitted, the prompt is unchanged from a fresh change's. `postMerge` marks the post-merge review
159
- * of a commit already on main (review.afterMerge): a finding the reviewer can repair is the work —
159
+ * of a commit already on main: a finding the reviewer can repair is the work —
160
160
  * it makes the minimal fix in the worktree, leaves the edit, and ends with the verdict line plus a
161
161
  * `FIX:` line — so the instruction and the repair rule render only for that caller, and every other
162
162
  * (pre-merge) caller's prompt is byte-identical. */
@@ -286,49 +286,6 @@ ${TEST_RUNNER_RULE}
286
286
  FIX: <files you changed>` : ""}`);
287
287
  return parts.join("\n\n");
288
288
  }
289
- /** The prompt for the next attempt at a review whose previous run hit its time budget while
290
- * still making progress (RunnerState.reviewResume): that run continues the timed-out reviewer's
291
- * own session (`--continue`), so the diff and everything the reviewer already read are in
292
- * context. This prompt carries no diff — re-sending it would defeat the resume — and names the
293
- * frozen comparison base and the unchanged patch-id so the reviewer knows the change it was
294
- * judging has not moved. `baseRev` is changeBaseRev's frozen revision (see buildReviewPrompt);
295
- * `patchId` is git/git.ts's patchId for the change. It closes with the same VERDICT_ENDING
296
- * contract as the full review prompt, so the accepted forms cannot drift. */
297
- export function buildReviewResumePrompt(baseRev, patchId) {
298
- return `${dateLine()}
299
-
300
- You are resuming an adversarial review of a tumwater change: the previous attempt hit its time budget while still making progress, and this run continues that same reviewer session. Everything you already read — the diff and the files you checked — is in your context; do not re-read it.
301
-
302
- The change is unchanged since that attempt (patch-id ${patchId}) and is still measured against ${shortSha(baseRev)}. Other roles merge on main while you review, so compare against ${shortSha(baseRev)}, never against \`main\`.
303
-
304
- Finish the checks you had not completed, then end your reply with exactly one line in this form:
305
- ${VERDICT_ENDING.replace(/\n/g, "\n ")}`;
306
- }
307
- /** The one-turn follow-up sent into the REVIEWER's own session (--continue) when its run
308
- * completed but the reply carried no parseable VERDICT line — the review-gate sibling of
309
- * prompt-followup.ts's buildSummaryRequestPrompt. The session already holds the full review, so one
310
- * short reply recovers the verdict the gate needs without paying a second full review run;
311
- * the caller bounds the run tightly and counts the strike against the HEAD only when this
312
- * too yields nothing (BUGS.md 2026-09-29). It shares the review prompt's closing rule
313
- * (reply-contract.ts's VERDICT_ENDING), so the accepted forms cannot drift between the prompts. */
314
- export function buildVerdictRequestPrompt() {
315
- return `Your review reply above did not include the verdict line tumwater parses, so
316
- your judgment was lost. Reply now with ONLY the closing block — no tool calls, no other text:
317
- one line in this form:
318
- ${VERDICT_ENDING}`;
319
- }
320
- /** The no-re-run nudge (BUGS.md 2026-10-02): the reviewer broke the prompt's no-re-run rule
321
- * despite a green pre-check, so this turn on its own session names the exact tool call and
322
- * asks it to finish the review from what it already read. The reviewer's only output channel
323
- * is the verdict, so the closing rule names both accepted forms like the review prompt does. */
324
- export function buildNoRerunPrompt(rerun) {
325
- return `You started a tool call tumwater's pre-check already covered, so it must not run:
326
- ${rerun}
327
- The full check passed at this exact tree before your review began. Do not re-run it — not in
328
- this worktree, not in a scratch copy. Finish the review now from what you have already read:
329
- reply with ONLY the closing block — no tool calls, no other text — one line in this form:
330
- ${VERDICT_ENDING}`;
331
- }
332
289
  /** The one-turn follow-up sent into a run's OWN session (--continue) when the harness's
333
290
  * pre-queue self-check found problems likely to get the change rejected. Only some findings are
334
291
  * the merge gate's own deterministic checks; the rest (path drift, plan dependencies) are
@@ -96,63 +96,70 @@ function holdWording(state) {
96
96
  return "eligible";
97
97
  if (state === "refused")
98
98
  return "refused (only the operator clears it)"; // vocabulary-ok: the plan-mandated refused hold wording refers to the tumwater operator
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
99
+ if (state === "needs-review")
100
+ return "needs review";
101
101
  return `waits on ${state.blockedBy.join(", ")}`;
102
102
  }
103
103
  /** The one summary line of a named series' planned-part state. */
104
104
  function seriesSummary(info) {
105
105
  const counts = [];
106
106
  if (info.review > 0)
107
- counts.push(`${info.review} ${info.review === 1 ? "needs review" : "need review"}`);
107
+ counts.push(plural(info.review, "needs review", "need review"));
108
108
  if (info.waiting > 0)
109
- counts.push(`${info.waiting} ${info.waiting === 1 ? "waits" : "wait"} on an earlier part`);
109
+ counts.push(`${plural(info.waiting, "waits", "wait")} on an earlier part`);
110
110
  counts.push(info.refused === 0 ? "none refused" : `${info.refused} refused`);
111
111
  return `${plural(info.plannedCount, "part")} planned: ${counts.join(", ")}`;
112
112
  }
113
- /** The live backlog block for the notes, or undefined when they name nothing live. */
114
- export function notesBacklogBlock(notes, backlog) {
115
- const { planned, openBugs, donePlanTitles } = backlog;
116
- const series = seriesInfos(planned, donePlanTitles);
117
- const allParts = plannedPartKeys(planned);
113
+ /** Index each live series' first word to its count, so a bare first word names a series only
114
+ * when no other live series shares it. */
115
+ function firstWordCountsOf(series) {
118
116
  const firstWordCounts = new Map();
119
117
  for (const info of series.values()) {
120
118
  const w = firstWord(info.name);
121
119
  firstWordCounts.set(w, (firstWordCounts.get(w) ?? 0) + 1);
122
120
  }
123
- const allEntries = [...planned, ...openBugs];
124
- const namedSeries = new Set();
125
- const namedParts = new Map();
126
- const namedEntries = new Map();
127
- for (const rawLine of notes.split("\n")) {
128
- const line = collapseWhitespace(rawLine).toLowerCase();
129
- if (line === "")
121
+ return firstWordCounts;
122
+ }
123
+ /** The state a named part renders for one part key: its planned hold, `done`, or `not in the
124
+ * backlog`. */
125
+ function partStateOf(info, key) {
126
+ return info.holds.has(key) ? info.holds.get(key) : info.done.has(key) ? "done" : "not-in-backlog";
127
+ }
128
+ /** Name the live series a line mentions: by full name, or by its sole-shared first word; the
129
+ * `i/n` part refs of that series fill `namedParts` (under a `\u0000`-joined key) and a line with
130
+ * a series token but no part ref names the series. */
131
+ function nameSeriesInLine(line, series, firstWordCounts, namedSeries, namedParts) {
132
+ for (const [sLower, info] of series) {
133
+ const hasFull = lineHas(line, info.name.toLowerCase());
134
+ const fw = firstWord(info.name);
135
+ const hasFirst = firstWordCounts.get(fw) === 1 && lineHas(line, fw);
136
+ if (!hasFull && !hasFirst)
130
137
  continue;
131
- for (const [sLower, info] of series) {
132
- const hasFull = lineHas(line, info.name.toLowerCase());
133
- const fw = firstWord(info.name);
134
- const hasFirst = firstWordCounts.get(fw) === 1 && lineHas(line, fw);
135
- if (!hasFull && !hasFirst)
136
- continue;
137
- const refs = partRefsInLine(line).filter((r) => r.of === info.of);
138
- if (refs.length === 0) {
139
- namedSeries.add(sLower);
140
- }
141
- else {
142
- for (const r of refs) {
143
- const key = partKey(info.name, r.part);
144
- const state = info.holds.has(key) ? info.holds.get(key) : info.done.has(key) ? "done" : "not-in-backlog";
145
- namedParts.set(`${sLower}\u0000${r.part}\u0000${r.of}`, { seriesLower: sLower, part: r.part, of: r.of, state });
146
- }
147
- }
138
+ const refs = partRefsInLine(line).filter((r) => r.of === info.of);
139
+ if (refs.length === 0) {
140
+ namedSeries.add(sLower);
148
141
  }
149
- for (const [i, entry] of allEntries.entries()) {
150
- const phrase = firstSixWords(entryKey(entry.title));
151
- if (phrase !== "" && line.includes(phrase)) {
152
- namedEntries.set(entryKey(entry.title), { title: entry.title, file: i < planned.length ? "plans" : "bugs", state: entryHoldAgainst(entry, allParts) });
142
+ else {
143
+ for (const r of refs) {
144
+ const state = partStateOf(info, partKey(info.name, r.part));
145
+ namedParts.set(`${sLower}\u0000${r.part}\u0000${r.of}`, { seriesLower: sLower, part: r.part, of: r.of, state });
153
146
  }
154
147
  }
155
148
  }
149
+ }
150
+ /** Name a planned entry or Open bug whose first six words (entryKey-normalized) appear in the
151
+ * line. */
152
+ function nameEntriesInLine(line, allEntries, allParts, plannedCount, namedEntries) {
153
+ for (const [i, entry] of allEntries.entries()) {
154
+ const phrase = firstSixWords(entryKey(entry.title));
155
+ if (phrase !== "" && line.includes(phrase)) {
156
+ namedEntries.set(entryKey(entry.title), { title: entry.title, file: i < plannedCount ? "plans" : "bugs", state: entryHoldAgainst(entry, allParts) });
157
+ }
158
+ }
159
+ }
160
+ /** Render the named series summaries, part lines and entry lines, truncating to `MAX_BLOCK_LINES`
161
+ * with a remainder line naming the dropped items; undefined when nothing live was named. */
162
+ function renderNamedItems(namedSeries, namedParts, namedEntries, series) {
156
163
  const lines = [];
157
164
  for (const sLower of namedSeries) {
158
165
  const info = series.get(sLower);
@@ -175,3 +182,25 @@ export function notesBacklogBlock(notes, backlog) {
175
182
  const shown = lines.slice(0, cap - 1);
176
183
  return [HEADER, ...shown, `… and ${plural(lines.length - shown.length, "more named item")}`].join("\n");
177
184
  }
185
+ /** Build the live backlog block: index each series' first word (`firstWordCountsOf`), read every
186
+ * notes line naming the series, parts and entries it mentions (`nameSeriesInLine`/
187
+ * `nameEntriesInLine`), then render and truncate the names (`renderNamedItems`). Returns undefined
188
+ * when the notes name nothing live. */
189
+ export function notesBacklogBlock(notes, backlog) {
190
+ const { planned, openBugs, donePlanTitles } = backlog;
191
+ const series = seriesInfos(planned, donePlanTitles);
192
+ const allParts = plannedPartKeys(planned);
193
+ const firstWordCounts = firstWordCountsOf(series);
194
+ const allEntries = [...planned, ...openBugs];
195
+ const namedSeries = new Set();
196
+ const namedParts = new Map();
197
+ const namedEntries = new Map();
198
+ for (const rawLine of notes.split("\n")) {
199
+ const line = collapseWhitespace(rawLine).toLowerCase();
200
+ if (line === "")
201
+ continue;
202
+ nameSeriesInLine(line, series, firstWordCounts, namedSeries, namedParts);
203
+ nameEntriesInLine(line, allEntries, allParts, planned.length, namedEntries);
204
+ }
205
+ return renderNamedItems(namedSeries, namedParts, namedEntries, series);
206
+ }
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { readTextOrNull } from "../files/files.js";
2
+ import { readRegularTextOrNull } from "../files/files.js";
3
3
  import { truncateWithNote } from "../text/text.js";
4
4
  /** Cap on the PRINCIPLES.md text injected into every prompt, so a runaway file cannot blow up
5
5
  * each run's prefill. */
@@ -7,10 +7,12 @@ export const PRINCIPLES_MAX_CHARS = 4000;
7
7
  /** The project's design principles (PRINCIPLES.md), capped for injection into prompts. Empty
8
8
  * string when the file is missing or unreadable — prompt building must never throw on it.
9
9
  * (Extracted from prompt.ts, which now builds prompts on top of this reader; the truncation
10
- * marker format is truncateWithNote's in text.ts.) */
10
+ * marker format is truncateWithNote's in text.ts.) A symlinked PRINCIPLES.md is read as empty
11
+ * (readRegularTextOrNull never follows a symlink out of the worktree, INV-G3/G4's lstat
12
+ * policy), so a hostile tree cannot inject an arbitrary host file into every run's prompt. */
11
13
  export function readPrinciples(root) {
12
14
  const file = path.join(root, "PRINCIPLES.md");
13
- return truncateWithNote(readTextOrNull(file)?.trim() ?? "", PRINCIPLES_MAX_CHARS, "PRINCIPLES.md");
15
+ return truncateWithNote(readRegularTextOrNull(file)?.trim() ?? "", PRINCIPLES_MAX_CHARS, "PRINCIPLES.md");
14
16
  }
15
17
  /** True when PRINCIPLES.md's trimmed text exceeds the prompt cap, so every run's <principles>
16
18
  * block carries only its head plus a truncation marker — the always-on amber twin of the doctor
@@ -20,6 +22,6 @@ export function readPrinciples(root) {
20
22
  * (the directory-in-place case readPrinciples degrades to "") and at-or-under-cap files are all
21
23
  * false. */
22
24
  export function principlesBlockOverCap(root) {
23
- const raw = readTextOrNull(path.join(root, "PRINCIPLES.md"));
25
+ const raw = readRegularTextOrNull(path.join(root, "PRINCIPLES.md"));
24
26
  return raw !== null && raw.trim().length > PRINCIPLES_MAX_CHARS;
25
27
  }
@@ -68,16 +68,24 @@ ${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") {
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) {
72
75
  const note = cause === "budget-capped"
73
76
  ? `An earlier API-billed claude run of the "${roleId}" role hit its per-run budget cap before it
74
77
  could finish; its uncommitted edits are in your worktree — review \`git diff\`, then finish or
75
78
  discard them. This run starts on the model job the daily budget left you.`
76
- : cause === "salvaged"
77
- ? `An earlier run of the "${roleId}" role was cut short before tumwater could save its work;
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;
78
86
  its uncommitted edits were recovered and are in your worktree — review \`git status\` and
79
87
  \`git diff\` (new files show as untracked), then finish or discard them.`
80
- : `An earlier run of the "${roleId}" role on another model was interrupted; its
88
+ : `An earlier run of the "${roleId}" role on another model was interrupted; its
81
89
  uncommitted edits are in your worktree — review \`git diff\`, then finish or discard them.`;
82
90
  return `${runPrompt}\n\n${note}`;
83
91
  }
@@ -82,6 +82,20 @@ export const KILL_BY_PID_RULE = `Never kill processes by name or pattern (\`pkil
82
82
  export const TEST_RUNNER_RULE = `- Run tests only through the project's declared check or the test framework it already uses —
83
83
  never a runner you guessed (say, \`npx vitest\` in a suite written for node:test): a mismatched
84
84
  runner finds no tests at best, and can kill them mid-run, leaking processes and temp files.`;
85
+ /** The synthetic-load rule every run that may run commands carries, word for word: the run and
86
+ * director rules (full and compact). The bounding sentence is the point — a loader that ends with
87
+ * the command cannot leak. 2026-10-28: seven leaked \`while :; do :; done\` loops from bugfix runs
88
+ * reproducing a load flake stood at PPID 1 loading the shared host (BUGS.md): macOS \`ps -E\`
89
+ * hides the environment of platform binaries like bash, so the end-of-run marker sweep cannot
90
+ * attribute them to a run to reap them. The only reliable prevention is never starting unbounded
91
+ * load in the first place — a leaked platform-binary loader cannot be reaped on macOS by any
92
+ * sweep that exists. */
93
+ export const SYNTHETIC_LOAD_RULE = `The host is shared by every run, review, and check on the team,
94
+ and already under load; never add synthetic CPU load (a \`while :; do :; done\` spinner, a
95
+ CPU-spinning node process) to reproduce a load flake unless it ends with the command that starts
96
+ it — a \`timeout\`, or backgrounded and killed with \`kill $!\` before that tool call returns. A
97
+ loader that outlives its command is an orphan no sweep can reliably reap, and loads the host
98
+ every sibling run and check runs on until a \`tumwater doctor\` sweep or a human stops it.`;
85
99
  /** Render a dialect's `backendRules` as one bullet each, or nothing when the dialect has none
86
100
  * (pi and claude) — so a backend's extra shell/reasoning guidance rides the reading-budget
87
101
  * section right after the oversized-output rule. */
@@ -128,9 +142,9 @@ export const DEFAULT_SCOPE = `- Do exactly ONE focused task, then stop. Small, c
128
142
  * ~2.5k tokens of every run before its first edit (2026-10-17 audit). */
129
143
  function commonRules(check, briefFile = "README.md", scope = DEFAULT_SCOPE, dialect = PI_DIALECT, assigned = false, gateCommand) {
130
144
  const verify = check && gateCommand
131
- ? `verify with \`${gateCommand}\` (lint, compile and the tests your change affects) — run it
132
- after your change and fix what you broke. Tumwater runs the full check (${describeCheck(check)})
133
- itself; do not run it.`
145
+ ? `verify the change with a compile or the one or two test files that cover it — run them
146
+ after your change and fix what you broke. Tumwater runs the full gate (\`${gateCommand}\`)
147
+ itself before the change merges; do not run it.`
134
148
  : check
135
149
  ? `verify with ${describeCheck(check)} (the project's declared check) — run it after your
136
150
  change and fix what you broke.`
@@ -178,6 +192,7 @@ Where you work:
178
192
  servers, watch modes). To test such a program, background it with a hard time limit (at most
179
193
  30 minutes) and never give it a real TTY.
180
194
  - ${KILL_BY_PID_RULE} A pattern also kills other agents' runs and checks.
195
+ - ${SYNTHETIC_LOAD_RULE}
181
196
  - Leave the project working: ${verify} Write the check's full output to a scratch file once
182
197
  (\`${gateCommand ? "<gate>" : "<check>"} > <scratch-dir>/check.log 2>&1\`) and \`tail\` it; grep that file for anything else, and
183
198
  re-run the check only after an edit changes the tree.
@@ -307,48 +322,83 @@ from the repo.`,
307
322
  }
308
323
  /** The prompt for one role run. */
309
324
  export function buildRunPrompt(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
- const dialect = dialectFor(agent);
312
- const day = today ?? todayStamp();
325
+ const dialect = dialectFor(input.agent);
326
+ const day = input.today ?? todayStamp();
313
327
  const parts = [
328
+ ...runIdentityBlocks(input.role, input.rejectedReview, day),
329
+ ...runContextBlocks(input),
330
+ ...runEvidenceBlocks(input),
331
+ ...runNotesBlocks(input, day),
332
+ ...runTaskBlocks(input),
333
+ ...runRulesBlocks(input, dialect),
334
+ ];
335
+ return parts.join("\n\n");
336
+ }
337
+ /** The role identity line and the shared worktree + date preamble. */
338
+ function runIdentityBlocks(role, rejectedReview, day) {
339
+ return [
314
340
  ...(rejectedReview ? [rejectedReview.trim()] : []),
315
341
  `You are the "${role.id}" role (${role.title}) on this project's tumwater team — tumwater runs a team of AI roles that develop the project.`,
316
342
  ...sharedPreamble(day),
317
343
  ];
318
- if (principles)
319
- parts.push(principlesBlock(principles));
320
- if (userRequest)
344
+ }
345
+ /** The principles block and the explicit user-request steering block, in that order. */
346
+ function runContextBlocks(input) {
347
+ const parts = [];
348
+ if (input.principles)
349
+ parts.push(principlesBlock(input.principles));
350
+ if (input.userRequest)
321
351
  parts.push(`An explicit request from the user, aimed at this role. Treat it as this run's steering;
322
352
  your role's rules and the merge gate still apply unchanged. If the request is out of this
323
- role's scope, say so in your reply instead of doing it anyway.\n<user-request>\n${userRequest.trim()}\n</user-request>`);
324
- if (coverage)
325
- parts.push(coverage);
326
- if (ciParity)
327
- parts.push(ciParity);
328
- if (filings)
329
- parts.push(filings);
330
- if (digest)
331
- parts.push(digestBlock(digest));
332
- if (backlogStructure)
333
- parts.push(backlogStructure);
334
- if (backlogIndex)
335
- parts.push(backlogIndex);
336
- if (notes && notes.trim() !== "")
337
- parts.push(roleNotesBlock(notes, Boolean(backlogIndex), notesSavedDay, day));
338
- if (notesBacklog)
339
- parts.push(notesBacklog);
340
- parts.push(`Your task this run:\n${role.find.trim()}`);
341
- if (noCheck)
342
- parts.push(noCheck.trim());
343
- if (planTargetNote)
344
- parts.push(planTargetNote.trim());
345
- if (flakyChecks)
346
- parts.push(flakyChecks.trim());
347
- if (extraInstructions)
348
- parts.push(`Additional standing instructions from the user:\n${extraInstructions.trim()}`);
349
- parts.push(roleNotesInstruction());
350
- parts.push(commonRules(check, briefFile, role.scope, dialect, assigned, gateCommand).trim());
351
- return parts.join("\n\n");
353
+ role's scope, say so in your reply instead of doing it anyway.\n<user-request>\n${input.userRequest.trim()}\n</user-request>`);
354
+ return parts;
355
+ }
356
+ /** The six rendered evidence blocks, in the fixed order. */
357
+ function runEvidenceBlocks(input) {
358
+ const parts = [];
359
+ if (input.coverage)
360
+ parts.push(input.coverage);
361
+ if (input.ciParity)
362
+ parts.push(input.ciParity);
363
+ if (input.filings)
364
+ parts.push(input.filings);
365
+ if (input.digest)
366
+ parts.push(digestBlock(input.digest));
367
+ if (input.backlogStructure)
368
+ parts.push(input.backlogStructure);
369
+ if (input.backlogIndex)
370
+ parts.push(input.backlogIndex);
371
+ return parts;
372
+ }
373
+ /** The role-notes block (only when a note exists) and the live backlog state block. */
374
+ function runNotesBlocks(input, day) {
375
+ const parts = [];
376
+ if (input.notes && input.notes.trim() !== "")
377
+ parts.push(roleNotesBlock(input.notes, Boolean(input.backlogIndex), input.notesSavedDay, day));
378
+ if (input.notesBacklog)
379
+ parts.push(input.notesBacklog);
380
+ return parts;
381
+ }
382
+ /** The task text and its optional noCheck/plan-target/flake/extra notes. */
383
+ function runTaskBlocks(input) {
384
+ const parts = [];
385
+ parts.push(`Your task this run:\n${input.role.find.trim()}`);
386
+ if (input.noCheck)
387
+ parts.push(input.noCheck.trim());
388
+ if (input.planTargetNote)
389
+ parts.push(input.planTargetNote.trim());
390
+ if (input.flakyChecks)
391
+ parts.push(input.flakyChecks.trim());
392
+ if (input.extraInstructions)
393
+ parts.push(`Additional standing instructions from the user:\n${input.extraInstructions.trim()}`);
394
+ return parts;
395
+ }
396
+ /** The note-write instruction and the shared rules, which close the prompt. */
397
+ function runRulesBlocks(input, dialect) {
398
+ return [
399
+ roleNotesInstruction(),
400
+ commonRules(input.check, input.briefFile, input.role.scope, dialect, input.assigned, input.gateCommand).trim(),
401
+ ];
352
402
  }
353
403
  /** The prompt for a director run, which routes a user request into the project. */
354
404
  export function buildDirectorPrompt(userPrompt, principles,