tumwater 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +25 -12
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +1 -8
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -4,11 +4,11 @@ import { unknownRoleMessage } from "../roles/roles.js";
4
4
  import { fail, say, sayJson } from "./cli-output.js";
5
5
  import { durationLabel, failRivalShapes, parseBranchFlag, parsePortFlag, parseRoleListFlag, tokenValueProblem, } from "./cli-args.js";
6
6
  import { cancelScheduledStop, scheduledStopAt } from "../team/team-state.js";
7
- import { orchestratorAlive, readOrchestratorInfo } from "../team/orchestrator-info.js";
8
- import { runStartupCheck, runStartupProblem } from "../gates/startup-gate.js";
7
+ import { orchestratorAlive, readOrchestratorInfo } from "../team/team-orchestrator-info.js";
8
+ import { runStartupCheck, runStartupProblem } from "../gates/startup-gates.js";
9
9
  import { repairInterruptedSwap } from "../build/build-stage.js";
10
10
  import { logEvent, subscribeEvents } from "../events/events.js";
11
- import { notifyTeamDown } from "../events/notify.js";
11
+ import { notifyTeamDown } from "../events/event-notify.js";
12
12
  import { formatEvent } from "../events/event-format.js";
13
13
  import { RESTART_EXIT_CODE } from "../redeploy/redeploy-policy.js";
14
14
  import { runStartupMigrations } from "../orchestrator/startup-migration-steps.js";
@@ -151,13 +151,11 @@ async function parseStartInvocation(root, args) {
151
151
  assertNoRunningOrchestrator(root);
152
152
  return { once, json, forMs, branchArg, roleFilter, config, mainBranch };
153
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");
154
+ /** Create the abort handle and the graceful-stop closure a supervised generation passes to
155
+ * every watchdog, and register the SIGINT/SIGTERM handlers that trigger it. Returns an
156
+ * `isStopping` query so the later once-exit check reads the live flag instead of a captured
157
+ * boolean. */
158
+ function installStopHandlers(json) {
161
159
  const controller = new AbortController();
162
160
  let stopping = false;
163
161
  const stop = () => {
@@ -173,6 +171,12 @@ async function runSupervisedGeneration(input) {
173
171
  };
174
172
  process.on("SIGINT", stop);
175
173
  process.on("SIGTERM", stop);
174
+ return { controller, stop, isStopping: () => stopping };
175
+ }
176
+ /** Arm the windowed run's deadline timer and the scheduled-stop watch, both of which run the
177
+ * same graceful `stop` a Ctrl+C runs. Returns the window start and the two clearable handles. */
178
+ async function armRunWatchdogs(root, json, forMs, stop, isStopping) {
179
+ const { RUN_WINDOW_START_ENV } = await import("../process/supervisor.js");
176
180
  // A windowed run's deadline arms the same graceful stop a Ctrl+C runs — the identical `stop`
177
181
  // closure, so the drain path is one code path either way. clearTimeout in the finally below
178
182
  // retracts it when the run ends first (an early Ctrl+C), so no stray timer outlives the run.
@@ -185,22 +189,13 @@ async function runSupervisedGeneration(input) {
185
189
  stop();
186
190
  }) : null;
187
191
  const scheduledStopWatch = armScheduledStopWatch(root, () => {
188
- if (!stopping)
192
+ if (!isStopping())
189
193
  stop(); // A drain already under way must not be turned into a force-kill.
190
194
  }, json);
191
- // A supervisor that dies without forwarding — SIGKILL (the OOM killer, `kill -9`) cannot be
192
- // trapped or forwarded, and an uncaught supervisor crash forwards nothing either — leaves this
193
- // generation reparented and running the team unattended. The parent-death watch polls for the
194
- // reparent and runs the same graceful stop a forwarded SIGTERM would have run, so a killed
195
- // supervisor takes its team down with it (BUGS.md 2026-09-30). The spawner hands its pid
196
- // down (SUPERVISOR_PID_ENV), so a supervisor killed during this child's startup — before the
197
- // watch's first ppid read — still counts as gone. Everything below runs only in
198
- // the supervised generation: an unsupervised `run` became the supervisor above and returned.
199
- const expectedPpid = expectedSupervisorPid();
200
- startParentDeathWatch(stop, expectedPpid !== undefined ? { expectedPpid } : {});
201
- const enabled = runnerIds(config);
202
- // A scoped round's role list is exactly the filter; otherwise every enabled role runs.
203
- const roles = roleFilter ?? enabled;
195
+ return { windowStart, deadlineTimer, scheduledStopWatch };
196
+ }
197
+ /** Persist the baseline verdicts and boot the once-gated redeployer and launch-services watch. */
198
+ async function bootGenerationParts(root, branchArg, once) {
204
199
  // Main's green verdicts outlive this generation: the redeployer and baseline's surviving readers
205
200
  // (`mainTipVerdict`, the ci hand-off's `mainRedHealNote`) start from the ones the previous
206
201
  // generation verified instead of re-running the suite.
@@ -210,10 +205,13 @@ async function runSupervisedGeneration(input) {
210
205
  const redeploy = once
211
206
  ? null
212
207
  : await (await import("../redeploy/redeploy.js")).createRedeployer(root, (e) => logEvent(root, e), () => runStartupProblem(root, branchArg));
213
- const build = redeploy ? ` · build ${shortSha(redeploy.build.sha)}` : "";
214
208
  // A long-running team watches launchservicesd's port count (a no-op off macOS); a once round
215
209
  // is over long before a leak could matter.
216
210
  const launchServicesWatch = once ? null : new (await import("../process/launch-services.js")).LaunchServicesWatch(root);
211
+ return { redeploy, launchServicesWatch };
212
+ }
213
+ /** Print the supervised generation's banner and subscribe to its event stream on non-json runs. */
214
+ function announceGeneration(roles, mainBranch, once, json, forMs, root, build) {
217
215
  // Name the resolved root when it differs from the cwd: an operator who started the team
218
216
  // from a subdirectory must see where .tumwater/ actually lives.
219
217
  const rootNote = root !== process.cwd() ? ` · root ${root}` : "";
@@ -229,38 +227,17 @@ async function runSupervisedGeneration(input) {
229
227
  say(`roles: ${roles.join(", ")}`);
230
228
  say("watch: `tumwater tui` or `tumwater logs -f` in another terminal; events stream below\n");
231
229
  }
232
- const unsubscribe = json ? () => { } : subscribeEvents((e) => say(formatEvent(e)));
233
- // Snapshot each role's run counter so the once summary can tell this round's runs from
234
- // the persisted history (the state file accumulates across rounds).
235
- await runStartupMigrations(root); // so the snapshot reads a migrated state/<new>.json
236
- const runsBefore = new Map(roles.map((role) => [role, loadRunnerState(root, role).runs]));
237
- let exit;
238
- try {
239
- exit = await (await import("../orchestrator/orchestrator.js")).runOrchestrator({
240
- root,
241
- config,
242
- mainBranch,
243
- signal: controller.signal,
244
- redeploy,
245
- launchServicesWatch,
246
- once,
247
- roleFilter: roleFilter ?? undefined,
248
- stopsAt: forMs !== null ? windowStart + forMs : undefined,
249
- });
250
- }
251
- finally {
252
- if (deadlineTimer)
253
- deadlineTimer.clear();
254
- scheduledStopWatch.clear();
255
- unsubscribe();
256
- }
230
+ return json ? () => { } : subscribeEvents((e) => say(formatEvent(e)));
231
+ }
232
+ /** Print the round summary: the once report/summary, or a windowed run's window report. */
233
+ function printRoundSummary(root, roles, runsBefore, once, json, forMs, windowStart, settled, runsDone, restart) {
257
234
  if (once) {
258
235
  if (json)
259
- sayJson(onceReport(root, roles, runsBefore, exit.settled, exit.runsDone));
236
+ sayJson(onceReport(root, roles, runsBefore, settled, runsDone));
260
237
  else
261
- say(onceSummary(root, roles, runsBefore, exit.settled, exit.runsDone));
238
+ say(onceSummary(root, roles, runsBefore, settled, runsDone));
262
239
  }
263
- else if (forMs !== null && !exit.restart) {
240
+ else if (forMs !== null && !restart) {
264
241
  const { rows, covered } = readRunRowsSince(root, Date.now() - windowStart, null);
265
242
  const summary = summarizeRunRows(rows);
266
243
  if (json)
@@ -268,8 +245,12 @@ async function runSupervisedGeneration(input) {
268
245
  else
269
246
  say(windowSummary(durationLabel(forMs), summary, covered));
270
247
  }
248
+ }
249
+ /** Set the process exit code for a once round that failed or was cut short before completion. */
250
+ async function applyOnceExitCode(root, roles, runsBefore, runsDone, once, json, isStopping) {
251
+ const { ONCE_FAILED_EXIT_CODE } = await import("../process/supervisor.js");
271
252
  if (once) {
272
- const failures = onceFailures(root, roles, runsBefore, exit.runsDone);
253
+ const failures = onceFailures(root, roles, runsBefore, runsDone);
273
254
  if (failures.length > 0) {
274
255
  // In --json mode the failure facts already ride in the report's `failures`; the exit
275
256
  // code is the contract a CI caller needs, so only the prose line is suppressed.
@@ -281,21 +262,73 @@ async function runSupervisedGeneration(input) {
281
262
  // not read as success (0) to the CI caller parsing the JSON: the document still carries
282
263
  // the round's partial facts, but the truncated round exits non-zero. Non-json once keeps
283
264
  // its historical behavior (prose + 0 unless a role failed).
284
- if (json && stopping)
265
+ if (json && isStopping())
285
266
  process.exitCode = ONCE_FAILED_EXIT_CODE;
286
267
  }
268
+ }
269
+ /** Boot the supervised generation: the stop closure and SIGINT/SIGTERM handlers, the --for
270
+ * deadline timer and scheduled-stop watch, the parent-death watch, the baseline/redeploy/
271
+ * launch-services imports, the banner, the event subscription, runOrchestrator, and the
272
+ * once/window summaries and exit codes. */
273
+ async function runSupervisedGeneration(input) {
274
+ const { root, once, json, forMs, branchArg, roleFilter, config, mainBranch } = input;
275
+ const { startParentDeathWatch, expectedSupervisorPid } = await import("../process/supervisor.js");
276
+ const { controller, stop, isStopping } = installStopHandlers(json);
277
+ const { windowStart, deadlineTimer, scheduledStopWatch } = await armRunWatchdogs(root, json, forMs, stop, isStopping);
278
+ // A supervisor that dies without forwarding — SIGKILL (the OOM killer, `kill -9`) cannot be
279
+ // trapped or forwarded, and an uncaught supervisor crash forwards nothing either — leaves this
280
+ // generation reparented and running the team unattended. The parent-death watch polls for the
281
+ // reparent and runs the same graceful stop a forwarded SIGTERM would have run, so a killed
282
+ // supervisor takes its team down with it (BUGS.md 2026-09-30). The spawner hands its pid
283
+ // down (SUPERVISOR_PID_ENV), so a supervisor killed during this child's startup — before the
284
+ // watch's first ppid read — still counts as gone. Everything below runs only in
285
+ // the supervised generation: an unsupervised `run` became the supervisor above and returned.
286
+ const expectedPpid = expectedSupervisorPid();
287
+ startParentDeathWatch(stop, expectedPpid !== undefined ? { expectedPpid } : {});
288
+ const enabled = runnerIds(config);
289
+ // A scoped round's role list is exactly the filter; otherwise every enabled role runs.
290
+ const roles = roleFilter ?? enabled;
291
+ const { redeploy, launchServicesWatch } = await bootGenerationParts(root, branchArg, once);
292
+ const build = redeploy ? ` · build ${shortSha(redeploy.build.sha)}` : "";
293
+ const unsubscribe = announceGeneration(roles, mainBranch, once, json, forMs, root, build);
294
+ // Snapshot each role's run counter so the once summary can tell this round's runs from
295
+ // the persisted history (the state file accumulates across rounds).
296
+ await runStartupMigrations(root); // so the snapshot reads a migrated state/<new>.json
297
+ const runsBefore = new Map(roles.map((role) => [role, loadRunnerState(root, role).runs]));
298
+ let exit;
299
+ try {
300
+ exit = await (await import("../orchestrator/orchestrator.js")).runOrchestrator({
301
+ root,
302
+ config,
303
+ mainBranch,
304
+ signal: controller.signal,
305
+ redeploy,
306
+ launchServicesWatch,
307
+ once,
308
+ roleFilter: roleFilter ?? undefined,
309
+ stopsAt: forMs !== null ? windowStart + forMs : undefined,
310
+ });
311
+ }
312
+ finally {
313
+ if (deadlineTimer)
314
+ deadlineTimer.clear();
315
+ scheduledStopWatch.clear();
316
+ unsubscribe();
317
+ }
318
+ printRoundSummary(root, roles, runsBefore, once, json, forMs, windowStart, exit.settled, exit.runsDone, exit.restart);
319
+ await applyOnceExitCode(root, roles, runsBefore, exit.runsDone, once, json, isStopping);
287
320
  // A self-redeploy swapped the new build into dist/: hand the terminal back to the supervisor,
288
321
  // which respawns this same script — now the new code — as the next generation.
289
322
  if (exit.restart)
290
323
  process.exit(RESTART_EXIT_CODE);
291
324
  // A windowed (--for) run that did not self-redeploy is done the moment its summary is out:
292
325
  // 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,
326
+ // generation runs a whole team, and any ref'd handle a shutdown missed (a watcher, marker,
294
327
  // or notifier handle a join missed) would otherwise pin this process's loop open, so the
295
328
  // supervisor — which waits on this child's exit — never exits and the run reads as a hang:
296
329
  // 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.
330
+ // output and nothing after depends on this loop, so an explicit exit is equivalent to the
331
+ // natural exit 0 this replaces, just guaranteed.
299
332
  if (forMs !== null)
300
333
  process.exit(0);
301
334
  }
@@ -13,41 +13,68 @@ import { EVENT_TYPE_LABELS } from "../events/event-labels.js";
13
13
  import { POST_REVIEW_STATES } from "../review/post-merge-review-render.js";
14
14
  import { HELP, helpStanzas } from "./help.js";
15
15
  export const SUPPORTED_SHELLS = ["bash", "zsh", "fish"];
16
- /** The completable words of a stanza's usage part: the text after `tumwater <command>` up to the
17
- * description column (two or more spaces). Bare lowercase words count inside `[...]` and as the
18
- * leading run directly after the command; outside brackets the first other token ends the run, so
19
- * a single-space description (`role <id> --clear-notes Clear that notes`) adds nothing. */
20
- function usageWords(firstLine, command) {
16
+ /** The usage text of a stanza first line: the part after `tumwater <command>` up to the
17
+ * description column (two or more spaces). */
18
+ function usagePortion(firstLine, command) {
21
19
  const rest = firstLine.replace(new RegExp(`^ {2}tumwater ${command}`), "");
22
20
  // Usage arguments follow the command after a single space; two or more spaces start the
23
21
  // description.
24
- const usage = /^ {2,}/.test(rest) ? "" : (rest.split(/ {2,}/)[0] ?? "");
22
+ return /^ {2,}/.test(rest) ? "" : (rest.split(/ {2,}/)[0] ?? "");
23
+ }
24
+ /** An angle-bracket alternation of plain words — `<bash|zsh|fish>`, the completion command's
25
+ * shell positional — expands to each alternative as a subcommand word, so `tumwater completion
26
+ * <TAB>` lists exactly the shells the command accepts. A bare placeholder like `<id>` or `<k>`
27
+ * carries no `|` and so stays a placeholder, skipped as before (its `<`/`>` never leave the
28
+ * token, so it never looked like a word). The expansion only fires while the leading run is
29
+ * open; the returned words contribute as the leading words of the usage part. */
30
+ function alternationWords(token, leading) {
31
+ const alternation = /^<([a-z][a-z-]*(?:\|[a-z][a-z-]*)+)>$/.exec(token);
32
+ return alternation && leading ? alternation[1].split("|") : null;
33
+ }
34
+ /** Whether a token counts as a completable bare word: it is a plain lowercase word and either
35
+ * sits inside brackets, opens a bracket itself, or is still within the leading run. */
36
+ function isCompletableWord(bare, token, depth, leading) {
37
+ return /^[a-z][a-z-]*$/.test(bare) && (depth > 0 || token.startsWith("[") || leading);
38
+ }
39
+ /** Whether a token ends the leading run: outside brackets, not opening a bracket, and not
40
+ * blank once its brackets and bars are stripped (so an angle-bracket placeholder closes it). */
41
+ function endsLeadingRun(bare, token, depth) {
42
+ return depth === 0 && !token.startsWith("[") && bare !== "";
43
+ }
44
+ /** The bracket-depth delta of a token: `[` +1 and `]` -1 per character, everything else 0. */
45
+ function bracketDelta(token) {
46
+ let delta = 0;
47
+ for (const ch of token)
48
+ delta += ch === "[" ? 1 : ch === "]" ? -1 : 0;
49
+ return delta;
50
+ }
51
+ /** The completable words of a usage text: the angle-bracket alternation expands, bare lowercase
52
+ * words count inside `[...]` and as the leading run directly after the command, and outside
53
+ * brackets the first other token ends the run. */
54
+ function usageWordsIn(usage) {
25
55
  const words = [];
26
56
  let depth = 0;
27
57
  let leading = true;
28
58
  for (const token of usage.split(/\s+/).filter(Boolean)) {
29
- // An angle-bracket alternation of plain words — `<bash|zsh|fish>`, the completion
30
- // command's shell positional — expands to each alternative as a subcommand word, so
31
- // `tumwater completion <TAB>` lists exactly the shells the command accepts. A bare
32
- // placeholder like `<id>` or `<k>` carries no `|` and so stays a placeholder, skipped
33
- // as before (its `<`/`>` never leave the token, so it never looked like a word).
34
- const alternation = /^<([a-z][a-z-]*(?:\|[a-z][a-z-]*)+)>$/.exec(token);
35
- if (alternation && leading) {
36
- for (const alt of alternation[1].split("|"))
37
- words.push(alt);
59
+ const alternates = alternationWords(token, leading);
60
+ if (alternates) {
61
+ words.push(...alternates);
38
62
  continue;
39
63
  }
40
64
  const bare = token.replace(/[[\]|]/g, "");
41
- const isWord = /^[a-z][a-z-]*$/.test(bare);
42
- if (isWord && (depth > 0 || token.startsWith("[") || leading))
65
+ if (isCompletableWord(bare, token, depth, leading))
43
66
  words.push(bare);
44
- else if (depth === 0 && !token.startsWith("[") && bare !== "")
67
+ else if (endsLeadingRun(bare, token, depth))
45
68
  leading = false;
46
- for (const ch of token)
47
- depth += ch === "[" ? 1 : ch === "]" ? -1 : 0;
69
+ depth += bracketDelta(token);
48
70
  }
49
71
  return words;
50
72
  }
73
+ /** The completable words of a stanza's usage part: the text after `tumwater <command>` up to the
74
+ * description column (two or more spaces). */
75
+ function usageWords(firstLine, command) {
76
+ return usageWordsIn(usagePortion(firstLine, command));
77
+ }
51
78
  export function completionSpec(help = HELP) {
52
79
  const stanzas = helpStanzas(help);
53
80
  const commands = [...new Set(stanzas.map((s) => s.command))];
@@ -152,6 +179,15 @@ export function configValueWords(key) {
152
179
  const ROLE_POSITIONAL_COMMANDS = ["role", "history", "revert"];
153
180
  /** Flags whose value is a file path: completion offers file names after them. */
154
181
  const PATH_FLAGS = ["--file", "--attach", "--notes-file", "--instructions-file", "--path"];
182
+ /** The constant fish value suffix for the flags whose candidates are a fixed `__tumwater_*` list:
183
+ * `--role`, `--result`, `--type` and `--status` take comma lists, so they complete per item through
184
+ * `__fish_complete_list`, skipping the items already listed. */
185
+ const FISH_LIST_FLAG_SUFFIXES = {
186
+ "--role": ` -x -a "(__fish_complete_list , __tumwater_roles)"`,
187
+ "--result": ` -x -a "(__fish_complete_list , __tumwater_results)"`,
188
+ "--type": ` -x -a "(__fish_complete_list , __tumwater_types)"`,
189
+ "--status": ` -x -a "(__fish_complete_list , __tumwater_review_states)"`,
190
+ };
155
191
  /** The event-type codes `logs --type` filters by: the keys of the labels map, so a new event
156
192
  * type completes the moment it gains a label. Sorted for a stable, alphabetical offer of a
157
193
  * vocabulary too large to recall offhand — the same reason `--role`, `--template`, and
@@ -289,37 +325,37 @@ function bashScript(help) {
289
325
  ``,
290
326
  ].join("\n");
291
327
  }
292
- /** The value suffix a fish flag line carries: what completes after the flag. `--role`, `--result`
293
- * `--type` and `--status` take comma lists, so they complete per item through
294
- * `__fish_complete_list`,
295
- * skipping the items already listed. */
296
- function fishFlagValue(command, flag) {
297
- if (flag === "--role")
298
- return ` -x -a "(__fish_complete_list , __tumwater_roles)"`;
328
+ /** The fish value suffix for the flags whose value depends on the command: `--transcript` has role
329
+ * ids only under `logs`, and `--model` completes the closed tier set under `role` but the log's
330
+ * selectors under `history`. Undefined for a flag this helper does not own. */
331
+ function fishScopedFlagSuffix(command, flag) {
299
332
  if (flag === "--transcript" && command === "logs")
300
- return ` -x -a "(__fish_complete_list , __tumwater_roles)"`;
301
- if (PATH_FLAGS.includes(flag))
302
- return ` -r -F`;
303
- if (flag === "--template")
304
- return ` -x -a "${templateIds().join(" ")}"`;
305
- if (flag === "--result")
306
- return ` -x -a "(__fish_complete_list , __tumwater_results)"`;
307
- if (flag === "--type")
308
- return ` -x -a "(__fish_complete_list , __tumwater_types)"`;
309
- if (flag === "--status")
310
- return ` -x -a "(__fish_complete_list , __tumwater_review_states)"`;
311
- if (flag === "--thinking")
312
- return ` -x -a "${THINKING_LEVEL_WORDS.join(" ")}"`;
313
- if (flag === "--backend")
314
- return ` -x -a "${BACKEND_IDS.join(" ")}"`;
315
- if (flag === "--by")
316
- return ` -x -a "${HISTORY_SUMMARY_GROUPS.join(" ")}"`;
317
- // `role --model` completes the closed tier set; `history --model` completes the log's selectors.
333
+ return FISH_LIST_FLAG_SUFFIXES["--role"];
318
334
  if (flag === "--model" && command === "role")
319
335
  return ` -x -a "${MODEL_TIERS.join(" ")}"`;
320
336
  if (flag === "--model" && command === "history")
321
337
  return ` -x -a "(__fish_complete_list , __tumwater_models)"`;
322
- return "";
338
+ return undefined;
339
+ }
340
+ /** The fish value suffix for the flags whose candidates are a joined literal list. Undefined for a
341
+ * flag this helper does not own. */
342
+ function fishWordSuffix(flag) {
343
+ switch (flag) {
344
+ case "--template": return ` -x -a "${templateIds().join(" ")}"`;
345
+ case "--thinking": return ` -x -a "${THINKING_LEVEL_WORDS.join(" ")}"`;
346
+ case "--backend": return ` -x -a "${BACKEND_IDS.join(" ")}"`;
347
+ case "--by": return ` -x -a "${HISTORY_SUMMARY_GROUPS.join(" ")}"`;
348
+ default: return undefined;
349
+ }
350
+ }
351
+ /** The value suffix a fish flag line carries: what completes after the flag. `--role`, `--result`
352
+ * `--type` and `--status` take comma lists, so they complete per item through
353
+ * `__fish_complete_list`,
354
+ * skipping the items already listed. Tries the command-scoped flags, then the fixed
355
+ * `__tumwater_*` list table, then the joined literal-word flags, and finally the path flags, in
356
+ * the same order the ladder did. */
357
+ function fishFlagValue(command, flag) {
358
+ return fishScopedFlagSuffix(command, flag) ?? FISH_LIST_FLAG_SUFFIXES[flag] ?? fishWordSuffix(flag) ?? (PATH_FLAGS.includes(flag) ? " -r -F" : "");
323
359
  }
324
360
  function fishScript(help) {
325
361
  const spec = completionSpec(help);
@@ -59,16 +59,11 @@ function screenOperableKey(root, key) {
59
59
  fail(fieldError);
60
60
  }
61
61
  else if (parsed.kind === "map") {
62
- // Only role-keyed maps check the role catalog. `maxConcurrentPerProvider.<provider>` is
63
- // keyed by provider, not a role id, and `config set` writes it without the role check —
64
- // so get/unset must agree, or set would succeed while get/unset refused the provider as
65
- // an "unknown role". A typo'd role id in the other two maps is refused the same way set's
62
+ // Role-keyed maps check the role catalog: a typo'd role id is refused the way set's
66
63
  // validator treats it.
67
- if (parsed.map !== "maxConcurrentPerProvider") {
68
- const ids = knownRoleIds(config);
69
- if (!ids.includes(parsed.role))
70
- fail(unknownRoleMessage(parsed.role, ids));
71
- }
64
+ const ids = knownRoleIds(config);
65
+ if (!ids.includes(parsed.role))
66
+ fail(unknownRoleMessage(parsed.role, ids));
72
67
  }
73
68
  return { parsed, config };
74
69
  }
@@ -89,72 +84,74 @@ function resolvedConfigValue(config, parsed, key) {
89
84
  ? record.roles?.[parsed.id]?.[parsed.field]
90
85
  : record[canonicalConfigKey(key)];
91
86
  }
92
- /** The `tumwater config` command body: bare prints the resolved config; `get` and `unset`
93
- * screen their key through screenOperableKey, while `set` writes through setConfigKey. */
94
- export async function cmdConfig(root, args = []) {
95
- const [sub, key, ...rest] = args;
96
- if (sub === "get") {
97
- const { parsed, config } = screenOperableKey(root, key ?? "");
98
- const value = resolvedConfigValue(config, parsed, key ?? "");
99
- sayJsonLine(value === undefined ? null : value); // Absent optional key → JSON null.
100
- return;
87
+ /** The `config get` verb: screen the key through screenOperableKey, then print its resolved
88
+ * value (defaults merged in) as JSON, or JSON null when nothing sets it. */
89
+ async function configGet(root, key) {
90
+ const { parsed, config } = screenOperableKey(root, key ?? "");
91
+ const value = resolvedConfigValue(config, parsed, key ?? "");
92
+ sayJsonLine(value === undefined ? null : value); // Absent optional key → JSON null.
93
+ }
94
+ /** The `config unset` verb: screen the key, remove it, and confirm what, if anything, was
95
+ * removed (or what the key now resolves to). */
96
+ async function configUnset(root, key) {
97
+ // cli.ts's arity gate requires exactly two args (`unset <key>`) before cmdConfig runs;
98
+ // this is the defensive tail for a direct caller, matching the bare-config tail below.
99
+ if (key === undefined)
100
+ fail(CONFIG_USAGE);
101
+ const k = canonicalConfigKey(key);
102
+ // The same screens get runs first, so a typo'd top-level key, role id, or role field
103
+ // fails identically — unsetConfigKey's own parse/field checks are the mutation-side
104
+ // backstop, not the wording the operator sees.
105
+ const { parsed } = screenOperableKey(root, k);
106
+ const result = unsetConfigKey(root, k);
107
+ if (!result.ok)
108
+ fail(result.error);
109
+ // The reload only feeds the message, so a failed one leaves `now` undefined.
110
+ const reloaded = loadConfigSafe(root).config;
111
+ const now = reloaded === undefined ? undefined : resolvedConfigValue(reloaded, parsed, k);
112
+ const resolves = now === undefined ? "" : JSON.stringify(now);
113
+ if (result.oldValue === undefined) {
114
+ say(`${k} is not set in tumwater.json, so nothing was removed` +
115
+ (now === undefined ? "" : ` (it resolves to ${resolves})`));
101
116
  }
102
- if (sub === "unset") {
103
- // cli.ts's arity gate requires exactly two args (`unset <key>`) before cmdConfig runs;
104
- // this is the defensive tail for a direct caller, matching the bare-config tail below.
105
- if (key === undefined)
106
- fail(CONFIG_USAGE);
107
- const k = canonicalConfigKey(key);
108
- // The same screens get runs first, so a typo'd top-level key, role id, or role field
109
- // fails identically — unsetConfigKey's own parse/field checks are the mutation-side
110
- // backstop, not the wording the operator sees.
111
- const { parsed } = screenOperableKey(root, k);
112
- const result = unsetConfigKey(root, k);
113
- if (!result.ok)
114
- fail(result.error);
115
- // The reload only feeds the message, so a failed one leaves `now` undefined.
116
- const reloaded = loadConfigSafe(root).config;
117
- const now = reloaded === undefined ? undefined : resolvedConfigValue(reloaded, parsed, k);
118
- const resolves = now === undefined ? "" : JSON.stringify(now);
119
- if (result.oldValue === undefined) {
120
- say(`${k} is not set in tumwater.json, so nothing was removed` +
121
- (now === undefined ? "" : ` (it resolves to ${resolves})`));
122
- }
123
- else {
124
- const was = JSON.stringify(result.oldValue);
125
- say(`unset ${k} (was ${was}${now === undefined ? "" : `; now resolves to ${resolves}`})`);
126
- }
127
- return;
117
+ else {
118
+ const was = JSON.stringify(result.oldValue);
119
+ say(`unset ${k} (was ${was}${now === undefined ? "" : `; now resolves to ${resolves}`})`);
128
120
  }
129
- if (sub === "set") {
130
- // A flag-looking value is a dropped `--` in disguise: `config set model --json` would
131
- // otherwise take the literal string "--json" as the value (the JSON-parse fallback
132
- // swallows any token) and validateConfig's string keys accept it silently — the same
133
- // shape gui/gui-command.ts's --token gate refuses, where the value the next flag holds is never
134
- // what the operator meant. Refuse before setConfigKey reads it; a value that really
135
- // does begin with `--` has no legitimate shape among the config keys' values
136
- // (identifiers, durations, windows, JSON scalars and arrays), so nothing is lost.
137
- const raw = rest[0] ?? "";
138
- if (raw.startsWith("--"))
139
- fail(`config set got the flag-looking value ${JSON.stringify(raw)} instead of a value — is a flag missing its value, or did a token slip into the value's position? (e.g. \`tumwater config set model gpt-5\`)`);
140
- const result = setConfigKey(root, key ?? "", raw);
141
- if (!result.ok)
142
- fail(result.error);
143
- if (result.unchanged) {
144
- say(`${canonicalConfigKey(key ?? "")} is already ${JSON.stringify(result.value)}, so tumwater.json was not written`);
145
- return;
146
- }
147
- say(`set ${canonicalConfigKey(key ?? "")} to ${JSON.stringify(result.value)}`);
148
- const note = result.note;
149
- if (note)
150
- say(`note: ${note}`);
151
- // A bare `roles` set replaces the FILE's section, but the built-in catalog always merges
152
- // per entry at load, so the replacement cannot disable a role by omission. Say so here
153
- // rather than let the confirmation imply the role is gone.
154
- if (key === "roles")
155
- say("note: the built-in role catalog still merges per entry — disable a role with `tumwater config set roles.<id>.enabled false`, not by omitting it");
121
+ }
122
+ /** The `config set` verb: write one value through setConfigKey (refusing a flag-looking
123
+ * value first) and confirm the write or that it was already that value. */
124
+ async function configSet(root, key, rest) {
125
+ // A flag-looking value is a dropped `--` in disguise: `config set model --json` would
126
+ // otherwise take the literal string "--json" as the value (the JSON-parse fallback
127
+ // swallows any token) and validateConfig's string keys accept it silently — the same
128
+ // shape gui/gui-command.ts's --token gate refuses, where the value the next flag holds is never
129
+ // what the operator meant. Refuse before setConfigKey reads it; a value that really
130
+ // does begin with `--` has no legitimate shape among the config keys' values
131
+ // (identifiers, durations, windows, JSON scalars and arrays), so nothing is lost.
132
+ const raw = rest[0] ?? "";
133
+ if (raw.startsWith("--"))
134
+ fail(`config set got the flag-looking value ${JSON.stringify(raw)} instead of a value — is a flag missing its value, or did a token slip into the value's position? (e.g. \`tumwater config set model gpt-5\`)`);
135
+ const result = setConfigKey(root, key ?? "", raw);
136
+ if (!result.ok)
137
+ fail(result.error);
138
+ if (result.unchanged) {
139
+ say(`${canonicalConfigKey(key ?? "")} is already ${JSON.stringify(result.value)}, so tumwater.json was not written`);
156
140
  return;
157
141
  }
142
+ say(`set ${canonicalConfigKey(key ?? "")} to ${JSON.stringify(result.value)}`);
143
+ const note = result.note;
144
+ if (note)
145
+ say(`note: ${note}`);
146
+ // A bare `roles` set replaces the FILE's section, but the built-in catalog always merges
147
+ // per entry at load, so the replacement cannot disable a role by omission. Say so here
148
+ // rather than let the confirmation imply the role is gone.
149
+ if (key === "roles")
150
+ say("note: the built-in role catalog still merges per entry — disable a role with `tumwater config set roles.<id>.enabled false`, not by omitting it");
151
+ }
152
+ /** The bare-config tail: the whole resolved config as pretty JSON, refusing a surplus
153
+ * subcommand the arity gate somehow missed. */
154
+ async function configDump(root, sub) {
158
155
  // Bare config, or anything else: the whole-config dump is the default, and cli.ts's arity
159
156
  // check has already rejected a malformed get/set — this usage line is the defensive tail.
160
157
  if (sub !== undefined)
@@ -164,3 +161,17 @@ export async function cmdConfig(root, args = []) {
164
161
  fail(error); // validateConfig's message, via the standard fail()
165
162
  sayJson(config);
166
163
  }
164
+ /** The `tumwater config` command body: bare prints the resolved config; `get` and `unset`
165
+ * screen their key through screenOperableKey, while `set` writes through setConfigKey. Each
166
+ * verb's guards and confirmation wording live in one named helper — configGet, configUnset,
167
+ * configSet, configDump — that this dispatcher delegates to. */
168
+ export async function cmdConfig(root, args = []) {
169
+ const [sub, key, ...rest] = args;
170
+ if (sub === "get")
171
+ return configGet(root, key);
172
+ if (sub === "unset")
173
+ return configUnset(root, key);
174
+ if (sub === "set")
175
+ return configSet(root, key, rest);
176
+ return configDump(root, sub);
177
+ }
@@ -313,9 +313,8 @@ Usage:
313
313
  non-blank line is the symptom, the rest is the body kept
314
314
  line for line; wakes the bugfix role
315
315
  tumwater bug --release <n> [--json]
316
- Lift the Refused or a
317
- Needs-review note held past its threshold
318
- (one per run) from the Nth entry in BUGS.md ## Open (as
316
+ Lift the Refused or the
317
+ Needs review notes from the Nth entry in BUGS.md ## Open (as
319
318
  \`tumwater bug --list\` lists it), commit it on main, and wake
320
319
  the bugfix role; --json prints {file, title, lifted}
321
320
  tumwater bug --top <n> [--json]
@@ -370,9 +369,8 @@ Usage:
370
369
  the first non-blank line is the title, the rest is the body
371
370
  kept line for line; wakes the feature role
372
371
  tumwater plan --release <n> [--json]
373
- Lift the Refused or a
374
- Needs-review note held past its threshold
375
- (one per run) from the Nth entry in PLANS.md ## Planned
372
+ Lift the Refused or the
373
+ Needs review notes from the Nth entry in PLANS.md ## Planned
376
374
  (as \`tumwater plan --list\` lists it), commit it on main,
377
375
  and wake the feature role; --json prints {file, title, lifted}
378
376
  tumwater plan --top <n> [--json]