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
@@ -16,7 +16,7 @@ import { renderStatus } from "../ui/status-render.js";
16
16
  import { backlogPayload } from "../backlog/backlog.js";
17
17
  import { backlogHolds, backlogInProgress } from "../backlog/backlog-eligibility.js";
18
18
  import { readyBacklog, grepBacklog, renderBacklogMarkdown } from "../backlog/backlog-render.js";
19
- import { loadQuarantine } from "../review/known-flakes.js";
19
+ import { loadQuarantine, releaseQuarantinedTest } from "../review/known-flakes.js";
20
20
  import { quarantineRows, renderQuarantine } from "../review/quarantine-render.js";
21
21
  import { collectChangeOverlaps, scopeChangeOverlaps, scopeChangeOverlapsToPath, collectTeamChanges, collectRoleChange, collectRoleChanges, normalizeDiffPath } from "../change/change-data.js";
22
22
  import { renderChangeOverlaps, renderTeamChange, renderRoleChange, renderRoleChanges } from "../change/change-render.js";
@@ -338,14 +338,49 @@ export async function cmdBacklog(root, args, seams = {}) {
338
338
  const result = readBacklog();
339
339
  sayJsonOrRender(rest, result.view, () => renderBacklogText(result));
340
340
  }
341
- // `tumwater quarantine [--json]` — the read-only view of the review gate's flake
342
- // quarantine. No requireReadyRepo gate: loadQuarantine degrades an absent or malformed
343
- // file to the empty list (the cmdBacklog rationale), so the command prints the empty line
344
- // in any directory, and it never writes quarantine.json (a test leaves the list only
345
- // through releaseQuarantine). --json serves the same positioned rows to scripts.
341
+ // `tumwater quarantine [--release <n>] [--json]` — the view of the review gate's flake
342
+ // quarantine, read-only except the one destructive `--release <n>` mode that removes the Nth
343
+ // entry the list prints at the operator's request. The read path keeps its no-gate
344
+ // refuseTeamWorkspace stance: loadQuarantine degrades an absent or malformed file to the empty
345
+ // list, so the command prints the empty line in any directory and the read never writes
346
+ // quarantine.json (a test otherwise leaves the list through releaseQuarantine). --json serves
347
+ // the same positioned rows to scripts.
346
348
  export async function cmdQuarantine(root, args) {
349
+ // `--release <n>` is the read-only command's one destructive mode: route it to its own
350
+ // branch before the read gate, exactly as cmdReviews routes `--accept`.
351
+ if (args.includes("--release")) {
352
+ await cmdQuarantineRelease(root, args);
353
+ return;
354
+ }
347
355
  rejectUnknownArgs("quarantine", args, [JSON_FLAG]);
348
356
  refuseTeamWorkspace(root);
349
357
  const rows = quarantineRows(loadQuarantine(root));
350
358
  sayJsonOrRender(args, { tests: rows }, () => renderQuarantine(rows, Date.now()));
351
359
  }
360
+ /** The `tumwater quarantine --release <n>` branch: remove the Nth entry `tumwater quarantine`
361
+ * lists (the same oldest-first 1..N row `quarantineRows` prints) at the operator's request,
362
+ * the manual twin of the auto-release that fires when a filed bug leaves `## Open`. Parsed the
363
+ * way cmdReviewsAccept parses `--accept`: `--release <n>` the one valued flag, `--json` the one
364
+ * boolean (at most once), `--help`/`-h` tolerated, anything else refused. An out-of-range
365
+ * position fails naming the current depth; a concurrent removal answers `false`. */
366
+ async function cmdQuarantineRelease(root, args) {
367
+ const { json, position, rest } = scanPositionArgs(args, "--release", "--release 2");
368
+ rejectUnknownArgs("quarantine", rest, []);
369
+ if (position === null)
370
+ fail("--release needs a position (usage: tumwater quarantine --release <n>)");
371
+ await requireReadyRepo(root);
372
+ const rows = quarantineRows(loadQuarantine(root));
373
+ if (position < 1 || position > rows.length) {
374
+ fail(`no quarantined test at position ${position} (flake quarantine holds ${rows.length} tests)`);
375
+ }
376
+ const test = rows[position - 1].test;
377
+ if (!releaseQuarantinedTest(root, test)) {
378
+ fail(`quarantined test at position ${position} is already gone`);
379
+ }
380
+ if (json) {
381
+ sayJson({ released: { position, test } });
382
+ }
383
+ else {
384
+ say(`released quarantined test ${position}: ${test} — the gate no longer excuses it`);
385
+ }
386
+ }
@@ -4,7 +4,8 @@
4
4
  * `readPostReviews` reads a missing, torn or hand-edited file as the well-formed subset (or `[]`),
5
5
  * so the command prints the empty line in any directory. Read-only except the one destructive
6
6
  * `--accept <n>` mode, which removes the Nth entry the operator deliberately accepts unreviewed
7
- * (the action the at-risk alert's advice names); that mode adds no new ready-repo requirement to
7
+ * (the action the doctor's at-risk post-review row's advice names); that mode adds no new
8
+ * ready-repo requirement to
8
9
  * the read path and gates the write with `requireReadyRepo`. Unlike `cmdQueue`'s `--cancel`, the
9
10
  * read case still writes no file. */
10
11
  import { JSON_FLAG, ROLE_FLAG, rejectUnknownArgs, statusFlagSpec } from "./cli-flag-specs.js";
@@ -17,7 +17,7 @@ import { renderRoleMarkdown } from "../roles/role-render.js";
17
17
  import { DIRECTOR_ROLE, unknownRoleMessage } from "../roles/roles.js";
18
18
  import { saveRoleNote } from "../agent/role-notes-write.js";
19
19
  import { roleNotesPath } from "../paths.js";
20
- import { errorMessage } from "../text/text.js";
20
+ import { errorMessage, isExponentSpelling } from "../text/text.js";
21
21
  import { readPromptFileArg } from "./cli-command-args.js";
22
22
  import { canonicalRoleId } from "../roles/legacy-role-ids.js";
23
23
  import { INSTANCE_ROLES, baseRoleOf } from "../roles/runner-ids.js";
@@ -44,12 +44,6 @@ const CAP_SPEC = { names: ["--cap"], value: true, valueName: "<usd>" };
44
44
  function plainDecimalToJson(raw) {
45
45
  return /^\d*\.?\d+$/.test(raw) ? String(Number(raw)) : null;
46
46
  }
47
- /** Detect exponent spellings that the cap, interval and instances writes must reject
48
- * before JSON parsing can turn them into otherwise valid numeric config values. Other
49
- * invalid spellings fall through to the config validator, which names the key. */
50
- function isExponentSpelling(raw) {
51
- return raw.trim() !== "" && plainDecimalToJson(raw) === null && /^\d+(\.\d+)?[eE][+-]?\d+$/.test(raw.trim());
52
- }
53
47
  /** `role`'s min-run-interval write flags: clear it, or set it to a second count (0 is
54
48
  * back-to-back). */
55
49
  const CLEAR_MIN_RUN_INTERVAL_SPEC = { names: ["--clear-min-run-interval"] };
@@ -119,6 +113,19 @@ function requireKnownRole(root, id) {
119
113
  if (!knownRoleIds(cfg).includes(id))
120
114
  throw new Error(unknownRoleMessage(id, knownRoleIds(cfg)));
121
115
  }
116
+ /** Refuse the two mistakes a per-role config write must reject up front: handing both a
117
+ * set flag and its clear twin (e.g. `--model` with `--clear-model`), and passing --json,
118
+ * which only the read/render path honors. `setFlag`/`clearFlag` are the exact spellings the
119
+ * refusal names (the clear twin is not always `--clear` + the set flag — `--notes-file`'s
120
+ * twin is `--clear-notes`), and `writeNoun` is the "<a|an> X write" phrase the --json
121
+ * refusal needs. Every set/clear write branch opens with the same two-line prelude, so
122
+ * this keeps it in one place. */
123
+ function rejectSetClearAndJson(rest, setFlag, clearFlag, writeNoun) {
124
+ if (rest.includes(setFlag) && rest.includes(clearFlag))
125
+ fail(`give ${clearFlag} or ${setFlag}, not both`);
126
+ if (rest.includes("--json"))
127
+ fail(`--json does not apply to ${writeNoun}`);
128
+ }
122
129
  /** The config-key write the seven per-role hold branches (quiet hours, daily cap, min run
123
130
  * interval, provider, model, thinking and backend) share: with `clear`, or an empty typed
124
131
  * value, unset `key` under `base`; else set it. `serialize` shapes the value `setConfigKey`
@@ -237,10 +244,7 @@ export async function cmdRole(root, args) {
237
244
  if (writes.length > 1)
238
245
  fail(`${writes.join(" and ")} writes cannot share one invocation`);
239
246
  if (notesWrite) {
240
- if (clear && rest.includes("--notes-file"))
241
- fail("give --clear-notes or --notes-file, not both");
242
- if (rest.includes("--json"))
243
- fail("--json does not apply to a notes write");
247
+ rejectSetClearAndJson(rest, "--notes-file", "--clear-notes", "a notes write");
244
248
  if (id === DIRECTOR_ROLE)
245
249
  fail("the director has no notes");
246
250
  requireKnownRole(root, id);
@@ -259,10 +263,7 @@ export async function cmdRole(root, args) {
259
263
  // enforces the 4096-char cap and the write is atomic. The director is allowed here (its
260
264
  // override reaches its own runs), unlike the notes branch's director refusal.
261
265
  if (instructionsWrite) {
262
- if (clearInst && rest.includes("--instructions-file"))
263
- fail("give --clear-instructions or --instructions-file, not both");
264
- if (rest.includes("--json"))
265
- fail("--json does not apply to an instructions write");
266
+ rejectSetClearAndJson(rest, "--instructions-file", "--clear-instructions", "an instructions write");
266
267
  requireKnownRole(root, id);
267
268
  const base = baseRoleOf(id);
268
269
  if (clearInst) {
@@ -287,10 +288,7 @@ export async function cmdRole(root, args) {
287
288
  // The director is exempt — its quietHoursPerRole.director entry is inert (the gate and
288
289
  // doctor both exempt it), like the notes branch's director refusal.
289
290
  if (quietWrite) {
290
- if (clearQuiet && rest.includes("--quiet-hours"))
291
- fail("give --clear-quiet-hours or --quiet-hours, not both");
292
- if (rest.includes("--json"))
293
- fail("--json does not apply to a quiet-hours write");
291
+ rejectSetClearAndJson(rest, "--quiet-hours", "--clear-quiet-hours", "a quiet-hours write");
294
292
  requireKnownRole(root, id);
295
293
  if (id === DIRECTOR_ROLE)
296
294
  fail("the director has no quiet hours");
@@ -307,10 +305,7 @@ export async function cmdRole(root, args) {
307
305
  // atomic. The director is exempt — it has no cap, like the notes and quiet-hours branches'
308
306
  // director refusals.
309
307
  if (capWrite) {
310
- if (clearCap && rest.includes("--cap"))
311
- fail("give --clear-cap or --cap, not both");
312
- if (rest.includes("--json"))
313
- fail("--json does not apply to a cap write");
308
+ rejectSetClearAndJson(rest, "--cap", "--clear-cap", "a cap write");
314
309
  requireKnownRole(root, id);
315
310
  if (id === DIRECTOR_ROLE)
316
311
  fail("the director has no daily cap");
@@ -326,7 +321,7 @@ export async function cmdRole(root, args) {
326
321
  // already a plain decimal, so the exponent form is exactly the silent-write set; hex,
327
322
  // negative and non-numeric spellings fall through to the write path, whose
328
323
  // validateConfig message names the key.
329
- if (isExponentSpelling(cap))
324
+ if (isExponentSpelling(cap.trim()))
330
325
  fail(`${cfgKey} must be a plain dollar amount like 25, 12.50, or .5 — no +/-, exponent, or hex notation`);
331
326
  writeRoleConfigKey(root, base, clearCap, cap, cfgKey, "daily cap", (v) => plainDecimalToJson(v) ?? v, (v) => v);
332
327
  return;
@@ -339,10 +334,7 @@ export async function cmdRole(root, args) {
339
334
  // write is atomic. Unlike the notes/quiet-hours/cap branches there is no director refusal:
340
335
  // the director runs on the same clock.
341
336
  if (intervalWrite) {
342
- if (clearInterval && rest.includes("--min-run-interval"))
343
- fail("give --clear-min-run-interval or --min-run-interval, not both");
344
- if (rest.includes("--json"))
345
- fail("--json does not apply to a min-run-interval write");
337
+ rejectSetClearAndJson(rest, "--min-run-interval", "--clear-min-run-interval", "a min-run-interval write");
346
338
  requireKnownRole(root, id);
347
339
  const base = baseRoleOf(id);
348
340
  const cfgKey = `roles.${base}.minRunIntervalSeconds`;
@@ -356,7 +348,7 @@ export async function cmdRole(root, args) {
356
348
  // plain decimal, so the exponent form is exactly the silent-write set; hex, negative and
357
349
  // non-numeric spellings fall through to the write path, whose validateConfig message
358
350
  // names the key.
359
- if (isExponentSpelling(secs))
351
+ if (isExponentSpelling(secs.trim()))
360
352
  fail(`${cfgKey} must be a plain second count like 300, 0.5, or .5 — no +/-, exponent, or hex notation`);
361
353
  writeRoleConfigKey(root, base, clearInterval, secs, cfgKey, "min run interval", (v) => plainDecimalToJson(v) ?? v, (v) => `${v}s`);
362
354
  return;
@@ -369,10 +361,7 @@ export async function cmdRole(root, args) {
369
361
  // validateConfig's `!INSTANCE_ROLES.has(id)` branch answers for the director and every
370
362
  // custom role, and an unset/empty value clears the key.
371
363
  if (instancesWrite) {
372
- if (clearInstances && rest.includes("--instances"))
373
- fail("give --clear-instances or --instances, not both");
374
- if (rest.includes("--json"))
375
- fail("--json does not apply to an instances write");
364
+ rejectSetClearAndJson(rest, "--instances", "--clear-instances", "an instances write");
376
365
  const loaded = loadConfigSafe(root);
377
366
  const cfg = loaded.config ?? defaultConfig();
378
367
  if (!knownRoleIds(cfg).includes(id))
@@ -389,7 +378,7 @@ export async function cmdRole(root, args) {
389
378
  // or out ("9e0" → 9 too) — is refused here, while fractional, hex, negative and plain
390
379
  // out-of-range integer spellings fall through to the write path, whose validateConfig
391
380
  // message names the key.
392
- if (isExponentSpelling(n))
381
+ if (isExponentSpelling(n.trim()))
393
382
  fail(`${cfgKey} must be a plain integer from 1 to 8 — no exponent notation`);
394
383
  // An absent count already means 1 (instanceCount's default), so asking for 1 is a no-op —
395
384
  // but only for a role that may split; any other role falls through to validateConfig's refusal.
@@ -401,65 +390,30 @@ export async function cmdRole(root, args) {
401
390
  writeRoleConfigKey(root, base, clearInstances, n, `roles.${base}.instances`, "instances", (v) => v, (v) => v);
402
391
  return;
403
392
  }
404
- // The model-triple writes: the terminal twins of `config set roles.<base>.provider|model|thinking
405
- // <value>` and `config unset` — the three fields configForRole resolves through seamOverrides.
406
- // `tumwater role <id>` renders them as its Model: line. Each lands in roles.<base>.<field>.
407
- // Through setConfigKey/unsetConfigKey, validateConfig's checkModelTripleField ("tier-name"
408
- // shape) owns validation (model accepts a tier name; thinking must be a THINKING_LEVELS level)
409
- // and each write is atomic. No director refusal: accepted exactly as --min-run-interval is.
410
- if (providerWrite) {
411
- if (clearProvider && rest.includes("--provider"))
412
- fail("give --clear-provider or --provider, not both");
413
- if (rest.includes("--json"))
414
- fail("--json does not apply to a provider write");
415
- const cfg = loadConfigSafe(root).config ?? defaultConfig();
416
- if (!knownRoleIds(cfg).includes(id))
417
- throw new Error(unknownRoleMessage(id, knownRoleIds(cfg)));
418
- const base = baseRoleOf(id);
419
- const provider = flagValue(rest, "--provider") ?? "";
420
- writeRoleConfigKey(root, base, clearProvider, provider, `roles.${base}.provider`, "provider", (v) => JSON.stringify(v), (v) => v);
421
- return;
422
- }
423
- if (modelWrite) {
424
- if (clearModel && rest.includes("--model"))
425
- fail("give --clear-model or --model, not both");
426
- if (rest.includes("--json"))
427
- fail("--json does not apply to a model write");
428
- const cfg = loadConfigSafe(root).config ?? defaultConfig();
429
- if (!knownRoleIds(cfg).includes(id))
430
- throw new Error(unknownRoleMessage(id, knownRoleIds(cfg)));
431
- const base = baseRoleOf(id);
432
- const model = flagValue(rest, "--model") ?? "";
433
- writeRoleConfigKey(root, base, clearModel, model, `roles.${base}.model`, "model", (v) => JSON.stringify(v), (v) => v);
434
- return;
435
- }
436
- if (thinkingWrite) {
437
- if (clearThinking && rest.includes("--thinking"))
438
- fail("give --clear-thinking or --thinking, not both");
439
- if (rest.includes("--json"))
440
- fail("--json does not apply to a thinking write");
441
- const cfg = loadConfigSafe(root).config ?? defaultConfig();
442
- if (!knownRoleIds(cfg).includes(id))
443
- throw new Error(unknownRoleMessage(id, knownRoleIds(cfg)));
444
- const base = baseRoleOf(id);
445
- const level = flagValue(rest, "--thinking") ?? "";
446
- writeRoleConfigKey(root, base, clearThinking, level, `roles.${base}.thinking`, "thinking", (v) => JSON.stringify(v), (v) => v);
447
- return;
448
- }
449
- // The backend write: the twin of the provider/model/thinking writes, landing the role's
450
- // backend (`pi`, `claude`, `opencode` or `codex`) in roles.<base>.backend through the same
451
- // setConfigKey/unsetConfigKey core, so validateConfig's checkEntryBackend owns the id
452
- // (rejecting an unknown one, and a `model` that cannot pair with the named backend) and
453
- // the write stays atomic. An instance id writes its base role's key, like the others.
454
- if (backendWrite) {
455
- if (clearBackend && rest.includes("--backend"))
456
- fail("give --clear-backend or --backend, not both");
457
- if (rest.includes("--json"))
458
- fail("--json does not apply to a backend write");
393
+ // The model-writing branches — provider, model, thinking and backend — are one shape: each
394
+ // lands the role's field in roles.<base>.<field> (the terminal twin of `config set
395
+ // roles.<base>.<field> <value>` and `config unset`) through the same setConfigKey/unsetConfigKey
396
+ // core, so validateConfig owns the id — checkModelTripleField ("tier-name" shape: model
397
+ // accepts a tier name, thinking a THINKING_LEVELS level) for the provider/model/thinking
398
+ // fields that configForRole resolves through seamOverrides, checkEntryBackend for backend
399
+ // (`pi`, `claude`, `opencode` or `codex`, refusing an unknown one and a model that cannot
400
+ // pair with it) — and each write is atomic. No director refusal: each is accepted exactly as
401
+ // --min-run-interval is, and an instance id writes its base role's key. Every branch calls
402
+ // writeRoleConfigKey with the same JSON-encode/identity shaping, differing only in the flag
403
+ // spellings, the key field and the noun the confirmation line names — one table, one body.
404
+ for (const { flag, clearFlag, write, clear, field, noun } of [
405
+ { flag: "--provider", clearFlag: "--clear-provider", write: providerWrite, clear: clearProvider, field: "provider", noun: "provider" },
406
+ { flag: "--model", clearFlag: "--clear-model", write: modelWrite, clear: clearModel, field: "model", noun: "model" },
407
+ { flag: "--thinking", clearFlag: "--clear-thinking", write: thinkingWrite, clear: clearThinking, field: "thinking", noun: "thinking" },
408
+ { flag: "--backend", clearFlag: "--clear-backend", write: backendWrite, clear: clearBackend, field: "backend", noun: "backend" },
409
+ ]) {
410
+ if (!write)
411
+ continue;
412
+ rejectSetClearAndJson(rest, flag, clearFlag, `a ${noun} write`);
459
413
  requireKnownRole(root, id);
460
414
  const base = baseRoleOf(id);
461
- const backend = flagValue(rest, "--backend") ?? "";
462
- writeRoleConfigKey(root, base, clearBackend, backend, `roles.${base}.backend`, "backend", (v) => JSON.stringify(v), (v) => v);
415
+ const value = flagValue(rest, flag) ?? "";
416
+ writeRoleConfigKey(root, base, clear, value, `roles.${base}.${field}`, noun, (v) => JSON.stringify(v), (v) => v);
463
417
  return;
464
418
  }
465
419
  // The enable/disable write: the scoped write for the enabled state `tumwater role <id>`
@@ -489,7 +443,7 @@ export async function cmdRole(root, args) {
489
443
  say(`${base}: ${state}`);
490
444
  return;
491
445
  }
492
- // The review-after-merge write: the one command the post-merge churn alert's remedy
446
+ // The review-after-merge write: the one command the post-merge churn row's remedy
493
447
  // needs — add the role to, or remove it from, review.afterMerge for the base id, the
494
448
  // membership test reviewedAfterMerge shares. The whole-list value lands through the same
495
449
  // setConfigKey core `config set review.afterMerge <json>` uses, so validateConfig owns the
@@ -24,25 +24,10 @@ import { ONCE_FAILURE_RESULTS, inheritedWindowStart, onceFailures, onceReport, o
24
24
  * renames the other. */
25
25
  const GUI_TOKEN_ENV = "TUMWATER_GUI_TOKEN";
26
26
  export { ONCE_FAILURE_RESULTS, inheritedWindowStart, onceFailures, onceReport, onceSummary, parseRunWindow, renderOnceSummary, windowReport, windowSummary, };
27
- /** `tumwater start`: boot the team and stream its event feed to this terminal until it stops.
28
- * Three layers cooperate here: without SUPERVISED_ENV this process becomes the supervisor
29
- * (superviseStartCommand below), spawning the real orchestrator as a child generation; the child
30
- * runs runOrchestrator to completion, handing the terminal back via RESTART_EXIT_CODE after a
31
- * self-redeploy so the supervisor respawns on the new build; --once collapses the whole thing
32
- * into one round of runs and a summary line (onceSummary below), for cron-style invocations.
33
- * `--for <duration>` keeps the daemon shape (redeployer, LaunchServicesWatch, supervisor hand-off
34
- * all stay) but arms a deadline: at the deadline the same graceful stop a Ctrl+C runs fires, and
35
- * the process exits after printing the summary. A mid-run self-redeploy hands the same args
36
- * (including `--for`) to the supervisor's next child, and the supervisor hands every generation
37
- * the same window start (RUN_WINDOW_START_ENV), so the deadline and the summary span the whole
38
- * window; only the final exit prints the summary. */
39
- export async function cmdStart(root, args) {
40
- // The one function the self-redeploy asks before swapping onto a successor and the supervisor
41
- // asks when a generation dies, so the three cannot disagree about what boots. The supervisor
42
- // half runs it too, so a start that cannot boot fails before any child spawns. The flag
43
- // vocabulary is validated by the dispatcher (cli.ts, from the same START_FLAG_SPECS).
44
- const once = args.includes("--once");
45
- const json = args.includes("--json");
27
+ /** Refuse the --once run-mode rivals: a TUI and a dashboard each cannot be hosted by a run that
28
+ * is about to stop, so both are refused together before any boot. --for stays allowed — both
29
+ * serve for the window. Owns the run-mode rival refusals. */
30
+ function assertOnceRivalFlags(args, once) {
46
31
  // The TUI cannot be hosted by a run that is about to stop, like the dashboard: a --once round
47
32
  // drains and exits within the same invocation, so the two shapes are refused together before
48
33
  // any boot. --for stays allowed — the TUI runs for the window, like the dashboard.
@@ -53,6 +38,10 @@ export async function cmdStart(root, args) {
53
38
  // stays allowed — the dashboard serves for the window.
54
39
  if (once && args.includes("--gui"))
55
40
  failRivalShapes("--gui", "--once", "a one-round run cannot serve a dashboard it is about to kill");
41
+ }
42
+ /** Refuse the dashboard-only flags that name nothing without --gui. Owns the dashboard-only
43
+ * refusals. */
44
+ function assertDashboardOnlyFlags(args) {
56
45
  // `--port` chooses the `--gui` dashboard's port; without `--gui` it names no port for this
57
46
  // command, so refuse it (a valid value already passed the spec's shape gate) before any boot.
58
47
  if (args.includes("--port") && !args.includes("--gui"))
@@ -65,6 +54,10 @@ export async function cmdStart(root, args) {
65
54
  fail("--all-interfaces is only valid with --gui (e.g. `tumwater start --gui --all-interfaces`); the standalone dashboard is `tumwater gui --all-interfaces`");
66
55
  if (args.includes("--token") && !args.includes("--gui"))
67
56
  fail("--token is only valid with --gui (e.g. `tumwater start --gui --token s3cret`); the standalone dashboard is `tumwater gui --token <secret>`");
57
+ }
58
+ /** Check the --token value and scrub `--token <value>` off this process's argv. Owns the --token
59
+ * value check and the argv scrub. */
60
+ function handleTokenFlag(args) {
68
61
  // A malformed --token (valueless, empty, or flag-looking) must fail here before any boot, so
69
62
  // a bad secret never leaves the team running behind a dashboard that exited. The same
70
63
  // tokenValueProblem `tumwater gui` runs, named for the one-command form the operator typed.
@@ -82,12 +75,10 @@ export async function cmdStart(root, args) {
82
75
  process.argv.splice(argvToken, 2);
83
76
  say(`removed --token from this process's command line; prefer the ${GUI_TOKEN_ENV} env var`);
84
77
  }
85
- const forMs = parseRunWindow(args, once);
86
- const branchArg = parseBranchFlag(args);
87
- const startup = await runStartupCheck(root, branchArg);
88
- if ("problem" in startup)
89
- fail(startup.problem);
90
- const { config, mainBranch } = startup;
78
+ }
79
+ /** Parse the --role filter against the live config and guard it to --once rounds. Owns the
80
+ * --role parse and its --once guard; the disabled/unknown guards stay separate (see below). */
81
+ function resolveRoleFilter(args, config, once) {
91
82
  // A scoped once round: `--role <id>[,<id>...]` names the runners that run. Parsed here, where the
92
83
  // live config is readable. The parser accepts every known id (custom roles included, enabled
93
84
  // or not) so a typo still fails with the did-you-mean hint; an id that is known but has no
@@ -97,6 +88,11 @@ export async function cmdStart(root, args) {
97
88
  const roleFilter = parseRoleListFlag(args, knownRoleIds(config));
98
89
  if (roleFilter !== null && !once)
99
90
  fail("--role is only valid with --once");
91
+ return roleFilter;
92
+ }
93
+ /** Refuse --json without --once or --for, and the --gui/--tui rivals of a --json round/window.
94
+ * Owns the --json mode refusals. */
95
+ function assertJsonMode(args, json, once, forMs) {
100
96
  if (json && !once && forMs === null)
101
97
  fail("--json is only valid with --once or --for");
102
98
  // Both interactive modes write their own banner to the stdout a JSON document must own, so a
@@ -105,6 +101,10 @@ export async function cmdStart(root, args) {
105
101
  failRivalShapes("--json", "--gui", "the dashboard writes its banner to the stdout a JSON document must own");
106
102
  if (json && args.includes("--tui"))
107
103
  failRivalShapes("--json", "--tui", "a TUI owns the terminal");
104
+ }
105
+ /** Guard a non-null --role filter against a disabled or unknown id. Owns the disabled/unknown
106
+ * --role guards, firing after the --json mode refusals so the fire order upstream is unchanged. */
107
+ function assertRoleFilterRunnable(roleFilter, config) {
108
108
  if (roleFilter !== null) {
109
109
  const runners = runnerIds(config);
110
110
  const missing = roleFilter.find((id) => !runners.includes(id));
@@ -115,6 +115,10 @@ export async function cmdStart(root, args) {
115
115
  fail(unknownRoleMessage(missing, runners));
116
116
  }
117
117
  }
118
+ }
119
+ /** Refuse a second team in one repo by naming the running pid and the two ways to stop it. Owns
120
+ * the already-running-orchestrator refusal. */
121
+ function assertNoRunningOrchestrator(root) {
118
122
  // The refusal an operator hits by trying to start a second team in one repo: name the
119
123
  // running pid and the two ways to stop it, so the fix is in the message instead of a
120
124
  // separate `tumwater status`/help lookup. The info file is read once and reused for the
@@ -122,20 +126,38 @@ export async function cmdStart(root, args) {
122
126
  const running = readOrchestratorInfo(root);
123
127
  if (running !== null && orchestratorAlive(root, running))
124
128
  fail(`an orchestrator is already running for this repo (pid ${running.pid}) — stop it with \`tumwater stop\` from another terminal, or press Ctrl+C in the terminal that started it`);
125
- // The team-booting machinery loads only once a run actually boots: the orchestrator's
126
- // module graph (with its merge pipeline, review gate, and scheduler) is the CLI's heaviest
127
- // compile, and the supervisor/redeploy/launch-services pieces ride beside it. Loading them
128
- // dynamically, after the fail-fast gates above, keeps every other command's spawn — and
129
- // `run`'s fail-fast paths — off that cost (see cli.ts's matching lazy dispatch).
130
- const { SUPERVISED_ENV, RUN_WINDOW_START_ENV, ONCE_FAILED_EXIT_CODE, startParentDeathWatch, expectedSupervisorPid } = await import("../process/supervisor.js");
131
- if (!process.env[SUPERVISED_ENV]) {
132
- // `args` are the flags after the command token — forward them so the child generation
133
- // targets the same branch (or whatever else the invocation named).
134
- // A stop marker left by a team that died before its deadline must not stop this run.
135
- cancelScheduledStop(root);
136
- await superviseStartCommand(root, args, branchArg, forMs);
137
- return;
138
- }
129
+ }
130
+ /** Parse and validate the invocation before any team boots: the --tui/--gui/--json/--port/
131
+ * --all-interfaces/--token/--role/--for/--branch guards, the --token argv scrub, runStartupCheck,
132
+ * and the already-running-orchestrator refusal. Returns the resolved invocation. The per-job
133
+ * guard groups each live in a named helper — assertOnceRivalFlags, assertDashboardOnlyFlags,
134
+ * handleTokenFlag, resolveRoleFilter, assertJsonMode, assertRoleFilterRunnable and
135
+ * assertNoRunningOrchestrator — called here in fire order. */
136
+ async function parseStartInvocation(root, args) {
137
+ const once = args.includes("--once");
138
+ const json = args.includes("--json");
139
+ assertOnceRivalFlags(args, once);
140
+ assertDashboardOnlyFlags(args);
141
+ handleTokenFlag(args);
142
+ const forMs = parseRunWindow(args, once);
143
+ const branchArg = parseBranchFlag(args);
144
+ const startup = await runStartupCheck(root, branchArg);
145
+ if ("problem" in startup)
146
+ fail(startup.problem);
147
+ const { config, mainBranch } = startup;
148
+ const roleFilter = resolveRoleFilter(args, config, once);
149
+ assertJsonMode(args, json, once, forMs);
150
+ assertRoleFilterRunnable(roleFilter, config);
151
+ assertNoRunningOrchestrator(root);
152
+ return { once, json, forMs, branchArg, roleFilter, config, mainBranch };
153
+ }
154
+ /** Boot the supervised generation: the stop closure and SIGINT/SIGTERM handlers, the --for
155
+ * deadline timer and scheduled-stop watch, the parent-death watch, the baseline/redeploy/
156
+ * launch-services imports, the banner, the event subscription, runOrchestrator, and the
157
+ * once/window summaries and exit codes. */
158
+ async function runSupervisedGeneration(input) {
159
+ const { root, once, json, forMs, branchArg, roleFilter, config, mainBranch } = input;
160
+ const { RUN_WINDOW_START_ENV, ONCE_FAILED_EXIT_CODE, startParentDeathWatch, expectedSupervisorPid } = await import("../process/supervisor.js");
139
161
  const controller = new AbortController();
140
162
  let stopping = false;
141
163
  const stop = () => {
@@ -179,8 +201,9 @@ export async function cmdStart(root, args) {
179
201
  const enabled = runnerIds(config);
180
202
  // A scoped round's role list is exactly the filter; otherwise every enabled role runs.
181
203
  const roles = roleFilter ?? enabled;
182
- // Main's green verdicts outlive this generation: the redeployer and the roles' red-main gate
183
- // start from the ones the previous generation verified instead of re-running the suite.
204
+ // Main's green verdicts outlive this generation: the redeployer and baseline's surviving readers
205
+ // (`mainTipVerdict`, the ci hand-off's `mainRedHealNote`) start from the ones the previous
206
+ // generation verified instead of re-running the suite.
184
207
  (await import("../baseline/main-baseline.js")).persistBaselineVerdicts(root);
185
208
  // A once round never self-redeploys: it runs one round on the build it booted and exits,
186
209
  // so the hand-off machinery (and its build stamp) stays daemon-only.
@@ -265,6 +288,50 @@ export async function cmdStart(root, args) {
265
288
  // which respawns this same script — now the new code — as the next generation.
266
289
  if (exit.restart)
267
290
  process.exit(RESTART_EXIT_CODE);
291
+ // A windowed (--for) run that did not self-redeploy is done the moment its summary is out:
292
+ // exit deterministically instead of trusting the event loop to drain. This supervised
293
+ // generation runs a whole team, and any ref'd handle a shutdown misses (a watcher, marker,
294
+ // or notifier handle a join missed) would otherwise pin this process's loop open, so the
295
+ // supervisor — which waits on this child's exit — never exits and the run reads as a hang:
296
+ // summary printed, exit code null (BUGS.md 2026-10-26). The summary above was the last
297
+ // output and nothing after depends on this loop, so an explicit exit is equivalent to a
298
+ // natural one with code 0, just guaranteed.
299
+ if (forMs !== null)
300
+ process.exit(0);
301
+ }
302
+ /** `tumwater start`: boot the team and stream its event feed to this terminal until it stops.
303
+ * Three layers cooperate here: without SUPERVISED_ENV this process becomes the supervisor
304
+ * (superviseStartCommand below), spawning the real orchestrator as a child generation; the child
305
+ * runs runOrchestrator to completion, handing the terminal back via RESTART_EXIT_CODE after a
306
+ * self-redeploy so the supervisor respawns on the new build; --once collapses the whole thing
307
+ * into one round of runs and a summary line (onceSummary below), for cron-style invocations.
308
+ * `--for <duration>` keeps the daemon shape (redeployer, LaunchServicesWatch, supervisor hand-off
309
+ * all stay) but arms a deadline: at the deadline the same graceful stop a Ctrl+C runs fires, and
310
+ * the process exits after printing the summary. A mid-run self-redeploy hands the same args
311
+ * (including `--for`) to the supervisor's next child, and the supervisor hands every generation
312
+ * the same window start (RUN_WINDOW_START_ENV), so the deadline and the summary span the whole
313
+ * window; only the final exit prints the summary. */
314
+ export async function cmdStart(root, args) {
315
+ // The one function the self-redeploy asks before swapping onto a successor and the supervisor
316
+ // asks when a generation dies, so the three cannot disagree about what boots. The supervisor
317
+ // half runs it too, so a start that cannot boot fails before any child spawns. The flag
318
+ // vocabulary is validated by the dispatcher (cli.ts, from the same START_FLAG_SPECS).
319
+ const { once, json, forMs, branchArg, roleFilter, config, mainBranch } = await parseStartInvocation(root, args);
320
+ // The team-booting machinery loads only once a run actually boots: the orchestrator's
321
+ // module graph (with its merge pipeline, review gate, and scheduler) is the CLI's heaviest
322
+ // compile, and the supervisor/redeploy/launch-services pieces ride beside it. Loading them
323
+ // dynamically, after the fail-fast gates above, keeps every other command's spawn — and
324
+ // `run`'s fail-fast paths — off that cost (see cli.ts's matching lazy dispatch).
325
+ const supervisor = await import("../process/supervisor.js");
326
+ if (!process.env[supervisor.SUPERVISED_ENV]) {
327
+ // `args` are the flags after the command token — forward them so the child generation
328
+ // targets the same branch (or whatever else the invocation named).
329
+ // A stop marker left by a team that died before its deadline must not stop this run.
330
+ cancelScheduledStop(root);
331
+ await superviseStartCommand(root, args, branchArg, forMs);
332
+ return;
333
+ }
334
+ await runSupervisedGeneration({ root, config, mainBranch, branchArg, once, json, forMs, roleFilter });
268
335
  }
269
336
  /** Arm the windowed run's deadline timer: the one home of the arming `cmdStart` does for
270
337
  * `run --for <duration>`. A duration over setTimeout's own 32-bit signed ceiling
@@ -360,6 +427,20 @@ export function guiChildPlan(runArgs) {
360
427
  childStdio: runArgs.includes("--tui") ? ["ignore", "ignore", "ignore"] : "inherit",
361
428
  };
362
429
  }
430
+ /** The env a supervised dashboard child is spawned with: it marks the child as owned by this
431
+ * supervisor (`childMarkerEnv`, DASHBOARD_CHILD_ENV, names the host pid so the child's reload
432
+ * watch at src/redeploy/self-reload.ts exits RESTART_EXIT_CODE / dies with the supervisor) and,
433
+ * when the operator gave a gate token, carries it under GUI_TOKEN_ENV. The token deliberately
434
+ * never reaches the child's argv: /proc/<pid>/cmdline is world-readable, and the child's own
435
+ * self-reload (reexecSelf, which propagates env via childEnv) would re-run a command-line secret
436
+ * onto each fresh generation. Kept out of superviseDashboard so this env wiring is unit-testable
437
+ * without spawning a live dashboard child (BUGS-FIXED.md's dashboard gate-token fix). */
438
+ export function dashboardChildEnv(childMarkerEnv, guiToken, hostPid) {
439
+ const env = { [childMarkerEnv]: String(hostPid) };
440
+ if (guiToken !== undefined)
441
+ env[GUI_TOKEN_ENV] = guiToken;
442
+ return env;
443
+ }
363
444
  /** The dashboard half of `run --gui`: keep a `tumwater gui` child alive across its own
364
445
  * self-reloads. The child is marked with DASHBOARD_CHILD_ENV naming this supervisor, so its
365
446
  * reload watch (src/redeploy/self-reload.ts) exits RESTART_EXIT_CODE for a fresh sibling on a
@@ -369,12 +450,7 @@ export function guiChildPlan(runArgs) {
369
450
  async function superviseDashboard(signal, guiToken, stdio = "inherit", guiArgs = []) {
370
451
  const { DASHBOARD_CHILD_ENV } = await import("../redeploy/self-reload.js");
371
452
  const { spawnGuiChild } = await import("../process/supervisor.js");
372
- const env = { [DASHBOARD_CHILD_ENV]: String(process.pid) };
373
- // The gate token rides in the child's env, not its argv: /proc/<pid>/cmdline is world-readable,
374
- // and the child's self-reload (reexecSelf, which propagates env via childEnv) would otherwise
375
- // re-run the secret onto each fresh generation.
376
- if (guiToken !== undefined)
377
- env[GUI_TOKEN_ENV] = guiToken;
453
+ const env = dashboardChildEnv(DASHBOARD_CHILD_ENV, guiToken, process.pid);
378
454
  for (;;) {
379
455
  const exit = await spawnGuiChild(signal, env, stdio, guiArgs);
380
456
  if (signal.aborted || exit.code !== RESTART_EXIT_CODE)
@@ -3,7 +3,7 @@
3
3
  * Ctrl+C or the process that started the command exits. Lifted out of
4
4
  * cli-query-commands.ts when `report -f` landed so the follow views have one leaf
5
5
  * home instead of a per-command copy. */
6
- import { startParentDeathWatch } from "../process/supervisor.js";
6
+ import { expectedSupervisorPid, startParentDeathWatch } from "../process/supervisor.js";
7
7
  import { stripTerminalControls } from "../text/text.js";
8
8
  /** The default repaint interval for a follow view — a fixed cadence, no knob. */
9
9
  const FOLLOW_VIEW_INTERVAL_MS = 2000;
@@ -47,6 +47,7 @@ export async function followView(frameText, seams) {
47
47
  // would also leave that read's rejections unhandled. Sync callers never set `painting`, so
48
48
  // the check is a no-op for them: each of their ticks still paints exactly one frame.
49
49
  let painting = false;
50
+ const expectedSupervisor = expectedSupervisorPid();
50
51
  await new Promise((resolve) => {
51
52
  const timer = setInterval(() => {
52
53
  if (painting)
@@ -68,6 +69,7 @@ export async function followView(frameText, seams) {
68
69
  clearInterval(timer);
69
70
  resolve();
70
71
  }, {
72
+ ...(expectedSupervisor !== undefined ? { expectedPpid: expectedSupervisor } : {}),
71
73
  ...(seams.ppid ? { ppid: seams.ppid } : {}),
72
74
  ...(seams.intervalMs !== undefined ? { intervalMs: seams.intervalMs } : {}),
73
75
  });