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
@@ -9,15 +9,15 @@ import { DETACHED_HEAD_MESSAGE, NOT_A_REPO_MESSAGE, NOT_INITIALIZED_MESSAGE, NO_
9
9
  import { owningTeamRoot } from "../paths.js";
10
10
  import { backendMissingMessage, findBackendBinary, resolveBackendBin } from "../pi/pi-bin.js";
11
11
  import { seedClaudeAccount } from "../agent/claude/claude-account.js";
12
- import { seedCodexAccount } from "../agent/codex/codex-account.js";
12
+ import { codexApiKeyRefusalMessage, seedCodexAccount } from "../agent/codex/codex-account.js";
13
13
  import { opencodeCatalogStale, readOpencodeCache, refreshOpencodeCatalog, } from "../agent/opencode/opencode-catalog.js";
14
14
  import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
15
15
  import { HOUR_MS } from "../text/datetime.js";
16
16
  import { errorMessage } from "../text/text.js";
17
17
  import { backendById } from "../agent/agent-backend.js";
18
18
  // Register the built-in pi and claude adapters into the registry this module reads (see
19
- // unregisteredBackendInUse) — importing run-agent.ts is the one place they register.
20
- import "../agent/run-agent.js";
19
+ // unregisteredBackendInUse) — importing agent-run.ts is the one place they register.
20
+ import "../agent/agent-run.js";
21
21
  /** The repo-level preconditions every repo-bound command shares (requireReadyRepo in
22
22
  * cli/cli-repo-gate.ts): git
23
23
  * on PATH, a repository, tumwater.json present, at least one commit. The first unmet one's
@@ -58,7 +58,7 @@ opencodeSeed = {}) {
58
58
  // A backend an enabled seam routes to that this build has no registered adapter for cannot
59
59
  // run its jobs: refuse to start rather than letting the run fall through to pi with a model
60
60
  // id pi cannot serve (plans/opencode-backend.md part 2/7). Read the registry through
61
- // run-agent.ts, whose module scope registers the built-in pi and claude adapters.
61
+ // agent-run.ts, whose module scope registers the built-in pi and claude adapters.
62
62
  const unregistered = unregisteredBackendInUse(config);
63
63
  if (unregistered !== undefined) {
64
64
  return { problem: `backend ${unregistered} is configured but this build has no adapter for it` };
@@ -88,7 +88,7 @@ opencodeSeed = {}) {
88
88
  const codexAccount = seedOnce(root, config, "codex", codexSeedAttempted, () => seedCodexAccount(root, config));
89
89
  if (codexAccount?.billing === "api") {
90
90
  return {
91
- problem: "Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run `CODEX_HOME=<home> codex login --device-auth`",
91
+ problem: codexApiKeyRefusalMessage("<home>"),
92
92
  };
93
93
  }
94
94
  // Seed the opencode models catalog (part 6b/7) before the first run: the poll surfaces price
@@ -189,7 +189,7 @@ export function opencodeProviders(config) {
189
189
  return [...providers];
190
190
  }
191
191
  /** Every agent backend an enabled seam resolves to — the distinct agents of the one
192
- * seamsInUse list (startup-gate's consumer of it), so a claude or pi clause that exists only
192
+ * seamsInUse list (startup-gates's consumer of it), so a claude or pi clause that exists only
193
193
  * as a fallback, the resolver, or the director is checked exactly like a primary seam.
194
194
  * One list the startup gate and doctor's binary check share, so a route the harness would
195
195
  * spawn is checked on both surfaces. */
@@ -198,7 +198,7 @@ export function backendsInUse(config) {
198
198
  }
199
199
  /** The first backend an enabled seam routes to that this build has no registered adapter for,
200
200
  * or undefined when every seam's backend is registered. The built-in pi and claude adapters
201
- * are registered by importing src/agent/run-agent.ts (see the import above); a seam backend
201
+ * are registered by importing src/agent/agent-run.ts (see the import above); a seam backend
202
202
  * that is not in the registry is one this build cannot run. Doctor's binary check asks the
203
203
  * same question, so the startup gate and doctor cannot disagree about which backend a build
204
204
  * can serve. opencode's and codex's adapters now register like pi's and claude's, so only a
@@ -1,9 +1,10 @@
1
1
  /** The error-streak circuit breaker (PLANS.md 2026-09-30): a role whose runs keep failing
2
2
  * consecutively is warned at ERROR_STREAK_WARN (src/run/run-apply.ts) and named by the team-wide
3
- * storm alarms (src/team/error-storm.ts, src/team/failure-spread.ts), but both only talk — the
4
- * role keeps running on the error ladder's 600 s max backoff forever, burning a model slot and
5
- * spend on a role that cannot succeed. Past ERROR_STREAK_BREAKER consecutive failed runs this gate
6
- * pauses the role through the same per-role pause marker the operator's `tumwater pause --role
3
+ * storm alarms (src/team/team-error-storm.ts, src/team/team-failure-spread.ts), but both
4
+ * only talk — the role keeps running on the error ladder's 600 s max backoff forever,
5
+ * burning a model slot and spend on a role that cannot succeed. Past ERROR_STREAK_BREAKER
6
+ * consecutive failed runs this gate pauses the role through the same per-role pause marker
7
+ * the operator's `tumwater pause --role
7
8
  * <id>` writes (src/team/team-state.ts's pauseRole — the marker, lock, and idempotence come
8
9
  * free), so the scheduler's existing pausedRolesSet skip blocks new runs with zero scheduler
9
10
  * changes. The director is NOT exempt: a failing director cannot process prompts anyway, and one
@@ -44,6 +45,73 @@ import { logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
44
45
  export function newStreakGateState() {
45
46
  return { acked: new Map() };
46
47
  }
48
+ /** Seed a fresh in-run ack cache from the persisted excused streak, so a restart keeps the
49
+ * grace the breaker (and a later operator resume) relies on instead of re-pausing a role
50
+ * whose frozen streak never ran down (BUGS.md 2026-10-23). */
51
+ function seedStreakAck(state, role, s) {
52
+ if (!state.acked.has(role) && s.streakAcked !== undefined) {
53
+ state.acked.set(role, s.streakAcked);
54
+ }
55
+ }
56
+ /** Ack while paused: the marker is the ack carrier. The streak is frozen while a role is
57
+ * paused (no new runs start), so recording it here is exactly the level the resume excuses. */
58
+ function ackPausedStreak(root, role, streak, s, state) {
59
+ if ((state.acked.get(role) ?? 0) !== streak) {
60
+ state.acked.set(role, streak);
61
+ s.streakAcked = streak;
62
+ saveRunnerStateBestEffort(root, s);
63
+ }
64
+ }
65
+ /** A streak that reset through the role's own success ends the excused episode: the next one
66
+ * must climb a fresh ERROR_STREAK_BREAKER of its own, so drop the durable ack too. */
67
+ function clearResetStreak(root, role, s, state) {
68
+ if (state.acked.get(role) !== undefined) {
69
+ state.acked.delete(role);
70
+ delete s.streakAcked;
71
+ saveRunnerStateBestEffort(root, s);
72
+ }
73
+ }
74
+ /** Ack at trip time, before the pause is observed: the bar for the next trip is
75
+ * ERROR_STREAK_BREAKER more failures from this level, whether or not any poll sees the role
76
+ * paused in between (an operator resume racing the next poll must not re-trip). The durable
77
+ * copy is written only once the pause is confirmed below — a failed or crashed pauseRole must
78
+ * not leave a permanent excused ack with no marker behind it. Returns the pause key when the
79
+ * pause was written, undefined when pauseRole failed. */
80
+ function tripStreakPause(root, role, streak, s, state, pauseKey) {
81
+ state.acked.set(role, streak);
82
+ if (!pauseRole(root, pauseKey(role)))
83
+ return undefined;
84
+ s.streakAcked = streak;
85
+ saveRunnerStateBestEffort(root, s);
86
+ logEventBestEffort(root, {
87
+ source: TUMWATER_SOURCE,
88
+ type: "role_streak_paused",
89
+ role: pauseKey(role),
90
+ streak,
91
+ ...(s.lastError ? { lastError: s.lastError } : {}),
92
+ });
93
+ return pauseKey(role);
94
+ }
95
+ /** One runner's turn in the poll: seed the in-run ack, ack while paused, clear on a reset,
96
+ * and trip past the bar. Returns the pause key when this poll tripped the role, undefined
97
+ * otherwise. The order is forced and unchanged: seed, then paused, then reset, then the bar,
98
+ * then trip. */
99
+ function pollStreakRunner(root, role, s, state, pausedRoles, pauseKey) {
100
+ const streak = s.consecutiveErrors ?? 0;
101
+ seedStreakAck(state, role, s);
102
+ if (runnerPaused(pausedRoles, role)) {
103
+ ackPausedStreak(root, role, streak, s, state);
104
+ return undefined;
105
+ }
106
+ if (streak === 0) {
107
+ clearResetStreak(root, role, s, state);
108
+ return undefined;
109
+ }
110
+ const ack = state.acked.get(role) ?? 0;
111
+ if (streak < ERROR_STREAK_BREAKER + ack)
112
+ return undefined;
113
+ return tripStreakPause(root, role, streak, s, state, pauseKey);
114
+ }
47
115
  /** Step the gate by one orchestrator poll. For each runner whose consecutive error streak
48
116
  * has reached ERROR_STREAK_BREAKER plus what its ack already excused, and whose role is not
49
117
  * in `pausedRoles`: write the per-role pause marker and log one `role_streak_paused` event
@@ -59,54 +127,9 @@ export function newStreakGateState() {
59
127
  export function pollStreakGate(root, state, runners, pausedRoles, pauseKey = (r) => r) {
60
128
  const tripped = [];
61
129
  for (const { role, state: s } of runners) {
62
- const streak = s.consecutiveErrors ?? 0;
63
- // Seed a fresh in-run ack cache from the persisted excused streak, so a restart keeps the
64
- // grace the breaker (and a later operator resume) relies on instead of re-pausing a role
65
- // whose frozen streak never ran down (BUGS.md 2026-10-23).
66
- if (!state.acked.has(role) && s.streakAcked !== undefined) {
67
- state.acked.set(role, s.streakAcked);
68
- }
69
- // Ack while paused: the marker is the ack carrier. The streak is frozen while a role is
70
- // paused (no new runs start), so recording it here is exactly the level the resume excuses.
71
- if (runnerPaused(pausedRoles, role)) {
72
- if ((state.acked.get(role) ?? 0) !== streak) {
73
- state.acked.set(role, streak);
74
- s.streakAcked = streak;
75
- saveRunnerStateBestEffort(root, s);
76
- }
77
- continue;
78
- }
79
- if (streak === 0) {
80
- // A streak that reset through the role's own success ends the excused episode: the
81
- // next one must climb a fresh ERROR_STREAK_BREAKER of its own, so drop the durable ack too.
82
- if (state.acked.get(role) !== undefined) {
83
- state.acked.delete(role);
84
- delete s.streakAcked;
85
- saveRunnerStateBestEffort(root, s);
86
- }
87
- continue;
88
- }
89
- const ack = state.acked.get(role) ?? 0;
90
- if (streak < ERROR_STREAK_BREAKER + ack)
91
- continue;
92
- // Ack at trip time, before the pause is observed: the bar for the next trip is
93
- // ERROR_STREAK_BREAKER more failures from this level, whether or not any poll sees the
94
- // role paused in between (an operator resume racing the next poll must not re-trip). The
95
- // durable copy is written only once the pause is confirmed below — a failed or crashed
96
- // pauseRole must not leave a permanent excused ack with no marker behind it.
97
- state.acked.set(role, streak);
98
- if (pauseRole(root, pauseKey(role))) {
99
- s.streakAcked = streak;
100
- saveRunnerStateBestEffort(root, s);
101
- logEventBestEffort(root, {
102
- source: TUMWATER_SOURCE,
103
- type: "role_streak_paused",
104
- role: pauseKey(role),
105
- streak,
106
- ...(s.lastError ? { lastError: s.lastError } : {}),
107
- });
108
- tripped.push(pauseKey(role));
109
- }
130
+ const key = pollStreakRunner(root, role, s, state, pausedRoles, pauseKey);
131
+ if (key !== undefined)
132
+ tripped.push(key);
110
133
  }
111
134
  return tripped;
112
135
  }
@@ -64,40 +64,57 @@ const GIT_REDIRECT_ENV = ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_NAM
64
64
  /** Refs git keeps per worktree (in the worktree's own git dir, not the common one). They are
65
65
  * left to git. */
66
66
  const PER_WORKTREE_REF = /^refs\/(?:bisect|worktree|rewritten)\//;
67
- /** `dir`'s git dirs when its `.git` resolves from files to a files-backend repository this
68
- * process's user owns, or undefined when git should decide. */
69
- function repoFiles(dir) {
70
- if (GIT_REDIRECT_ENV.some((name) => process.env[name]))
71
- return undefined;
72
- const uid = process.getuid?.();
73
- if (uid === undefined)
74
- return undefined;
75
- const gitdir = resolveGitDir(dir);
76
- if (gitdir === undefined)
77
- return undefined;
78
- let commondir = gitdir;
67
+ /** Whether an environment variable makes git look somewhere other than the files `dir` holds. */
68
+ function gitRedirectsSet() {
69
+ return GIT_REDIRECT_ENV.some((name) => process.env[name]);
70
+ }
71
+ /** The shared refs/objects dir a gitdir points at via its `commondir` file: the gitdir itself
72
+ * when there is no file (a primary checkout), the resolved pointer when there is, and
73
+ * undefined when the file is blank or unreadable for any reason but absence. */
74
+ function resolveCommondir(gitdir) {
79
75
  try {
80
76
  const pointer = fs.readFileSync(path.join(gitdir, "commondir"), "utf8").trim();
81
77
  if (pointer === "")
82
78
  return undefined;
83
- commondir = path.resolve(gitdir, pointer);
79
+ return path.resolve(gitdir, pointer);
84
80
  }
85
81
  catch (err) {
86
82
  if (errCode(err) !== "ENOENT")
87
83
  return undefined; // A primary checkout has none.
84
+ return gitdir;
88
85
  }
86
+ }
87
+ /** Whether the files-backend trust checks hold: this process's user owns both dirs, the
88
+ * objects dir and HEAD are present, and no reftable store shadows them. */
89
+ function filesBackendTrusted(dir, gitdir, commondir, uid) {
89
90
  try {
90
91
  if (fs.statSync(dir).uid !== uid || fs.statSync(gitdir).uid !== uid)
91
- return undefined;
92
+ return false;
92
93
  if (!fs.statSync(path.join(commondir, "objects")).isDirectory())
93
- return undefined;
94
+ return false;
94
95
  if (!fs.statSync(path.join(gitdir, "HEAD")).isFile())
95
- return undefined;
96
+ return false;
96
97
  }
97
98
  catch {
98
- return undefined;
99
+ return false;
99
100
  }
100
- if (fs.existsSync(path.join(commondir, "reftable")))
101
+ return !fs.existsSync(path.join(commondir, "reftable"));
102
+ }
103
+ /** `dir`'s git dirs when its `.git` resolves from files to a files-backend repository this
104
+ * process's user owns, or undefined when git should decide. */
105
+ function repoFiles(dir) {
106
+ if (gitRedirectsSet())
107
+ return undefined;
108
+ const uid = process.getuid?.();
109
+ if (uid === undefined)
110
+ return undefined;
111
+ const gitdir = resolveGitDir(dir);
112
+ if (gitdir === undefined)
113
+ return undefined;
114
+ const commondir = resolveCommondir(gitdir);
115
+ if (commondir === undefined)
116
+ return undefined;
117
+ if (!filesBackendTrusted(dir, gitdir, commondir, uid))
101
118
  return undefined;
102
119
  return { gitdir, commondir };
103
120
  }
@@ -147,35 +164,33 @@ function storedRef(commondir, ref) {
147
164
  }
148
165
  return shaFromPackedRefs(packed, ` ${ref}`);
149
166
  }
150
- /** The SHA `git rev-parse --verify <ref>` would print in `dir`, read from files, or undefined
151
- * when the files do not settle it (see the section comment): the caller then spawns git. `ref`
152
- * is HEAD (followed when it is a branch symref or a detached SHA), a full `refs/…` name, or a
153
- * short branch name, taken only when git's DWIM order could not resolve it to anything else
154
- * first (`<gitdir>/<name>`, `refs/<name>`, `refs/tags/<name>` all absent). */
155
- export function refShaFromFiles(dir, ref) {
156
- const repo = repoFiles(dir);
157
- if (repo === undefined)
158
- return undefined;
159
- if (ref === "HEAD") {
160
- let head;
161
- try {
162
- head = fs.readFileSync(path.join(repo.gitdir, "HEAD"), "utf8").trim();
163
- }
164
- catch {
165
- return undefined;
166
- }
167
- if (isSha(head))
168
- return head;
169
- const target = head.startsWith("ref: ") ? head.slice("ref: ".length).trim() : "";
170
- if (!target.startsWith("refs/heads/") || !isPlainRefName(target))
171
- return undefined;
172
- return storedRef(repo.commondir, target) ?? undefined;
167
+ /** The HEAD read: the detached SHA held in `<repo.gitdir>/HEAD`, else the SHA its `ref: `
168
+ * branch symref (a plain `refs/heads/…` name) resolves to, or undefined when the files do not
169
+ * settle it. */
170
+ function refShaFromHead(repo) {
171
+ let head;
172
+ try {
173
+ head = fs.readFileSync(path.join(repo.gitdir, "HEAD"), "utf8").trim();
173
174
  }
174
- if (ref.startsWith("refs/")) {
175
- if (!isPlainRefName(ref) || PER_WORKTREE_REF.test(ref))
176
- return undefined;
177
- return storedRef(repo.commondir, ref) ?? undefined;
175
+ catch {
176
+ return undefined;
178
177
  }
178
+ if (isSha(head))
179
+ return head;
180
+ const target = head.startsWith("ref: ") ? head.slice("ref: ".length).trim() : "";
181
+ if (!target.startsWith("refs/heads/") || !isPlainRefName(target))
182
+ return undefined;
183
+ return storedRef(repo.commondir, target) ?? undefined;
184
+ }
185
+ /** A full `refs/…` name: its plain-name and per-worktree guards, then the stored read. */
186
+ function refShaFromFullRefName(repo, ref) {
187
+ if (!isPlainRefName(ref) || PER_WORKTREE_REF.test(ref))
188
+ return undefined;
189
+ return storedRef(repo.commondir, ref) ?? undefined;
190
+ }
191
+ /** A short branch name: the DWIM-order guards (`<gitdir>/<name>`, `refs/<name>` and
192
+ * `refs/tags/<name>` all outrank the branch), then `refs/heads/<name>`. */
193
+ function refShaFromShortName(repo, ref) {
179
194
  // A full object name means the object to git, ahead of any same-named ref.
180
195
  if (isSha(ref) || !isPlainRefName(`refs/heads/${ref}`))
181
196
  return undefined;
@@ -189,6 +204,21 @@ export function refShaFromFiles(dir, ref) {
189
204
  }
190
205
  return storedRef(repo.commondir, `refs/heads/${ref}`) ?? undefined;
191
206
  }
207
+ /** The SHA `git rev-parse --verify <ref>` would print in `dir`, read from files, or undefined
208
+ * when the files do not settle it (see the section comment): the caller then spawns git. `ref`
209
+ * is HEAD (followed when it is a branch symref or a detached SHA), a full `refs/…` name, or a
210
+ * short branch name, taken only when git's DWIM order could not resolve it to anything else
211
+ * first (`<gitdir>/<name>`, `refs/<name>`, `refs/tags/<name>` all absent). */
212
+ export function refShaFromFiles(dir, ref) {
213
+ const repo = repoFiles(dir);
214
+ if (repo === undefined)
215
+ return undefined;
216
+ if (ref === "HEAD")
217
+ return refShaFromHead(repo);
218
+ if (ref.startsWith("refs/"))
219
+ return refShaFromFullRefName(repo, ref);
220
+ return refShaFromShortName(repo, ref);
221
+ }
192
222
  /** False when the files settle that `root`'s repository has no linked-worktree registrations
193
223
  * (its `<commondir>/worktrees` admin dir is absent or empty), so `git worktree prune` has
194
224
  * nothing to do. True when there are some, and undefined when git should decide. */
@@ -19,7 +19,6 @@ import { beginWorktreeUse } from "./worktree-use.js";
19
19
  import { ensureDetachedWorktree, removeWorktreeDir } from "./worktree.js";
20
20
  import { logEventBestEffort, warnEventBestEffort, tryOrWarn, TUMWATER_SOURCE } from "../events/events.js";
21
21
  import { errorMessage } from "../text/text.js";
22
- import { dropProviderLeaseKey } from "../concurrency/provider-lease.js";
23
22
  /** In-process waiters per root, woken when any slot is released. Keyed by resolved root so two
24
23
  * roots never wake each other. */
25
24
  const waiters = new Map();
@@ -192,11 +191,9 @@ async function acquire(root, role, purpose, signal, slotBudget) {
192
191
  logSlotWait(root, role, purpose, Date.now() - startedAt);
193
192
  return chosen;
194
193
  }
195
- // The pool-slot wait rule (plans/provider-concurrency.md §5 "No deadlock"): a lease never holds
196
- // a provider key while it waits for a pool slot. Drop the calling lease's key (which never
197
- // waits) before parking here, so this run or verify holds no provider slot whose in-use a
198
- // re-key waiter is waiting to drain; the next run re-takes the key as an ordinary re-key.
199
- await dropProviderLeaseKey();
194
+ // The pool-slot wait needs no permit dropped: a run or verify holds only the one team
195
+ // permit (no provider key any more), so it parks on the busy pool like any other slot
196
+ // waiter.
200
197
  const waiter = registerWaiter(root);
201
198
  try {
202
199
  await raceAbort(waiter.promise, signal);
@@ -2,13 +2,13 @@
2
2
  * The dashboard's GET settings/admin handlers (src/gui/gui-server.ts routes to them):
3
3
  * prompt-text, doctor, config, retire-list, and brief. Each answers its request and touches no
4
4
  * socket beyond its own `res`; the domain work lives one layer down (doctor/doctor.js,
5
- * config/config.js, operator/retire.js), so this module only adapts HTTP onto it. Barrelled
6
- * into gui/gui-endpoints.ts beside the other per-family GET endpoint modules.
5
+ * config/config.js, operator/operator-retire.js), so this module only adapts HTTP onto it.
6
+ * it is barrelled into gui/gui-endpoints.ts beside the other per-family GET endpoint modules.
7
7
  */
8
8
  import { runDoctor } from "../doctor/doctor.js";
9
9
  import { knownRoleIds, loadConfigSafe } from "../config/config.js";
10
10
  import { runnerEnabled } from "../roles/runner-ids.js";
11
- import { artifactPhrase, previewRetire } from "../operator/retire.js";
11
+ import { artifactPhrase, previewRetire } from "../operator/operator-retire.js";
12
12
  import { EDITABLE_CONFIG_KEYS } from "../config/config-editable-keys.js";
13
13
  import { readBrief } from "../brief/brief.js";
14
14
  import { readQueuedPromptBody } from "../inbox/inbox-edit.js";
@@ -4,7 +4,7 @@
4
4
  * question-answer, backlog-file, backlog-cancel, backlog-release, backlog-top, and
5
5
  * backlog-edit — the `checkedBacklogKind` / `checkedEntryPositionAndTitle` validators they share
6
6
  * ride with them. Each handler adapts
7
- * HTTP onto the domain work one layer down (backlog-write, question-answer, backlog-commit)
7
+ * HTTP onto the domain work one layer down (backlog-write, backlog-question-answer, backlog-commit)
8
8
  * and touches no socket beyond its own `res`.
9
9
  */
10
10
  import { backlogHolds, cancelledClaimNote, stillHeldNote } from "../backlog/backlog-eligibility.js";
@@ -15,7 +15,7 @@ import { commitOperatorBacklogEdit } from "../backlog/backlog-commit.js";
15
15
  import { stampedSubject } from "../git/commit-message.js";
16
16
  import { fileEntryWithBody, normalizeEntryBody, OPERATOR_FILE_KINDS, tryCancelEntry, tryEditEntry, bodyStructureProblem } from "../backlog/backlog-write.js";
17
17
  import { tryMoveEntryToTop, tryReleaseEntry } from "../backlog/backlog-release.js";
18
- import { tryAnswerQuestion, UNCLOSED_FENCE_MESSAGE } from "../backlog/question-answer.js";
18
+ import { tryAnswerQuestion, UNCLOSED_FENCE_MESSAGE } from "../backlog/backlog-question-answer.js";
19
19
  import { promptLengthProblem } from "../inbox/inbox-submit.js";
20
20
  import { requestWake, submitRolePromptAndWake, wakeQuestionAsker } from "../operator/operator-intent.js";
21
21
  import { badRequest, okGone, sendJson } from "./gui-http-body.js";
@@ -202,8 +202,8 @@ async function respondBacklogWrite(res, root, kind, position, outcome, verb, sta
202
202
  * `tumwater plan|bug --release <n>` through the non-exiting tryReleaseEntry. Body as
203
203
  * backlog-cancel's. A released entry that is not still held is newly eligible, so the kind's
204
204
  * role is woken (a held one wakes no role and carries `heldNote`); `gone` and `moved` answer
205
- * okGone, and an entry with none of the Refused or
206
- * held-for-operator notes answers status "not_refused" with no write and no wake. */
205
+ * okGone, and an entry with no Refused or Needs review note answers status "not_refused" with no
206
+ * write and no wake. */
207
207
  export async function handleBacklogRelease(req, res, root) {
208
208
  const body = await readPostBody(req, res, '{"kind": "bug", "position": 1, "title": "<heading>"}');
209
209
  if (!body)
@@ -87,6 +87,62 @@ export function handleBacklogMatch(q, res, root) {
87
87
  .map((row) => ({ role: row.role, file: row.file }));
88
88
  sendJson(res, 200, out);
89
89
  }
90
+ /** Normalise an optional `?path=` query arg to `{ path }` (absent → `{ path: undefined }`),
91
+ * answering the shared 400 when the raw value does not name a repo-relative path. Shared by
92
+ * the `?overlaps` and `?role` arms; `null` means the 400 was sent, so the caller returns. */
93
+ function diffPathParam(res, rawPath) {
94
+ if (rawPath === null)
95
+ return { path: undefined };
96
+ const path = normalizeDiffPath(rawPath);
97
+ if (path === null) {
98
+ badRequest(res, "path takes a repo-relative path such as src/cli.ts (as overlaps lists it)");
99
+ return null;
100
+ }
101
+ return { path };
102
+ }
103
+ /** The `?overlaps` view: refuse stat, normalise the path, resolve the comma-separated role
104
+ * list, collect the overlaps and scope them by roles then by path, then send. */
105
+ async function handleDiffOverlaps(q, res, root, rawPath, stat) {
106
+ if (stat) {
107
+ badRequest(res, "overlaps lists paths, not patches; drop stat");
108
+ return;
109
+ }
110
+ const opath = diffPathParam(res, rawPath);
111
+ if (opath === null)
112
+ return;
113
+ const roles = roleListQuery(q, res, root);
114
+ if (roles === null)
115
+ return;
116
+ let view = await collectChangeOverlaps(root);
117
+ if (roles !== undefined)
118
+ view = scopeChangeOverlaps(view, roles);
119
+ if (opath.path !== undefined)
120
+ view = scopeChangeOverlapsToPath(view, opath.path);
121
+ sendJson(res, 200, view);
122
+ }
123
+ /** The role-less team roster: refuse stat and path with their own 400s, then send
124
+ * collectTeamChanges's document. */
125
+ async function handleDiffTeam(res, root, rawPath, stat) {
126
+ if (stat) {
127
+ badRequest(res, "stat needs role (the team roster carries no patch)");
128
+ return;
129
+ }
130
+ if (rawPath !== null) {
131
+ badRequest(res, "path needs role (the team roster carries no patch)");
132
+ return;
133
+ }
134
+ sendJson(res, 200, await collectTeamChanges(root));
135
+ }
136
+ /** The `?role=<id>` per-role view: validate through rejectBadRole, normalise the shared path,
137
+ * then send collectRoleChange with its path and stat options. */
138
+ async function handleDiffRole(res, root, role, rawPath, stat) {
139
+ if (rejectBadRole(root, res, role))
140
+ return;
141
+ const path = diffPathParam(res, rawPath);
142
+ if (path === null)
143
+ return;
144
+ sendJson(res, 200, await collectRoleChange(root, role, { ...(path.path === undefined ? {} : { path: path.path }), ...(stat ? { stat: true } : {}) }));
145
+ }
90
146
  /** Handle GET /api/diff[?role=<id>]: the unmerged-change view the CLI prints from the same
91
147
  * collectors — no `role` → collectTeamChanges (exactly `tumwater diff --json`'s document:
92
148
  * `{ mainBranch, roles }`, no per-role patch halves); `?role=<id>` → collectRoleChange (the
@@ -105,46 +161,10 @@ export function handleBacklogMatch(q, res, root) {
105
161
  export async function handleDiff(q, res, root) {
106
162
  const rawPath = q.get("path");
107
163
  const stat = q.get("stat") !== null;
108
- if (q.get("overlaps") !== null) {
109
- if (stat) {
110
- badRequest(res, "overlaps lists paths, not patches; drop stat");
111
- return;
112
- }
113
- const opath = rawPath === null ? undefined : normalizeDiffPath(rawPath);
114
- if (opath === null) {
115
- badRequest(res, "path takes a repo-relative path such as src/cli.ts (as overlaps lists it)");
116
- return;
117
- }
118
- const roles = roleListQuery(q, res, root);
119
- if (roles === null)
120
- return;
121
- let view = await collectChangeOverlaps(root);
122
- if (roles !== undefined)
123
- view = scopeChangeOverlaps(view, roles);
124
- if (opath !== undefined)
125
- view = scopeChangeOverlapsToPath(view, opath);
126
- sendJson(res, 200, view);
127
- return;
128
- }
164
+ if (q.get("overlaps") !== null)
165
+ return handleDiffOverlaps(q, res, root, rawPath, stat);
129
166
  const role = q.get("role");
130
- if (role === null) {
131
- if (stat) {
132
- badRequest(res, "stat needs role (the team roster carries no patch)");
133
- return;
134
- }
135
- if (rawPath !== null) {
136
- badRequest(res, "path needs role (the team roster carries no patch)");
137
- return;
138
- }
139
- sendJson(res, 200, await collectTeamChanges(root));
140
- return;
141
- }
142
- if (rejectBadRole(root, res, role))
143
- return;
144
- const path = rawPath === null ? undefined : normalizeDiffPath(rawPath);
145
- if (path === null) {
146
- badRequest(res, "path takes a repo-relative path such as src/cli.ts (as overlaps lists it)");
147
- return;
148
- }
149
- sendJson(res, 200, await collectRoleChange(root, role, { ...(path === undefined ? {} : { path }), ...(stat ? { stat: true } : {}) }));
167
+ if (role === null)
168
+ return handleDiffTeam(res, root, rawPath, stat);
169
+ return handleDiffRole(res, root, role, rawPath, stat);
150
170
  }
@@ -32,29 +32,19 @@ export function lanAddresses(interfaces = os.networkInterfaces()) {
32
32
  * /proc/<pid>/cmdline exposes to every local user and what a reload re-runs verbatim — so the
33
33
  * secret must travel by env, never argv. */
34
34
  export const GUI_TOKEN_ENV = "TUMWATER_GUI_TOKEN";
35
- /** The `tumwater gui` command: parse the --port/--all-interfaces/--token flags, start the
36
- * server, print the banner, and serve until Ctrl+C. Lives beside startGui (gui/gui-server.ts) so
37
- * the CLI's gui-specific policy (token validation, the listen-failure messages, the LAN
38
- * announcement) cannot drift from the server it drives. Flag-vocabulary rejection stays in
39
- * cli.ts with the other cases; everything gui-specific after that gate is this function's job.
40
- * A failed listen throws (cli.ts's main catch renders it as the same `tumwater: …` line and
41
- * exit 1 fail() would) so the listen-failure wordings stay assertable in-process. */
42
- export async function cmdGui(root, args, seams = {}) {
43
- const start = seams.startGui ?? startGui;
44
- const lan = seams.lanAddresses ?? lanAddresses;
45
- const print = seams.say ?? say;
46
- const serve = seams.serve ?? (() => new Promise(() => { }));
47
- const portRaw = flagValue(args, "--port");
48
- const port = portRaw !== null ? parsePortFlag(portRaw) : 7180;
49
- const allInterfaces = args.includes("--all-interfaces");
50
- // A valueless or empty --token is a CLI error, not an open server: an operator who
51
- // asked for protection must never silently get none. The validation and its wording
52
- // live in tokenValueProblem, shared with `start --gui` so the two callers cannot
53
- // drift; its flag-looking-value case names `tumwater gui` here.
54
- const problem = tokenValueProblem(args, "tumwater gui");
55
- if (problem)
56
- fail(problem);
57
- const flagToken = flagValue(args, "--token");
35
+ /** Resolve cmdGui's four injectable seams to their runnable forms: the server it starts, the
36
+ * LAN address source the banner names, the printer for the banner and warnings, and the
37
+ * serve-until-Ctrl+C wait. Production callers omit every seam and get the real server,
38
+ * interfaces, stdout, and wait. */
39
+ function resolveGuiSeams(seams) {
40
+ return {
41
+ start: seams.startGui ?? startGui,
42
+ lan: seams.lanAddresses ?? lanAddresses,
43
+ print: seams.say ?? say,
44
+ serve: seams.serve ?? (() => new Promise(() => { })),
45
+ };
46
+ }
47
+ function adoptGuiToken(flagToken, print) {
58
48
  // A direct `tumwater gui --token` puts the secret on its own argv — readable by any local
59
49
  // user and re-run verbatim by reexecSelf onto every reloaded generation — so fold it into
60
50
  // the env and take it off the command line before a redeploy can re-expose it. A child
@@ -66,7 +56,10 @@ export async function cmdGui(root, args, seams = {}) {
66
56
  process.argv.splice(argvToken, 2);
67
57
  print(`--token is visible to every local user in this process's command line (\`ps\`, /proc/<pid>/cmdline) and is re-run onto each reloaded generation — prefer the ${GUI_TOKEN_ENV} env var`);
68
58
  }
69
- const token = flagToken ?? process.env[GUI_TOKEN_ENV] ?? "";
59
+ return flagToken ?? process.env[GUI_TOKEN_ENV] ?? "";
60
+ }
61
+ /** Start the server and translate the two common listen failures into hints naming their fix. */
62
+ async function startGuiOrExplain(start, root, port, allInterfaces, token) {
70
63
  try {
71
64
  await start(root, port, allInterfaces, token);
72
65
  }
@@ -83,6 +76,9 @@ export async function cmdGui(root, args, seams = {}) {
83
76
  throw new Error(`port ${port} could not be opened (permission denied) — ports below 1024 need root; pick an unprivileged port with \`tumwater gui --port <n>\``);
84
77
  throw err;
85
78
  }
79
+ }
80
+ /** Print the localhost banner and, under --all-interfaces, the LAN URLs and exposure wording. */
81
+ function printGuiBanner(print, lan, port, allInterfaces, token) {
86
82
  const tokenSuffix = token ? `/?token=${encodeURIComponent(token)}` : "";
87
83
  print(`tumwater gui at http://127.0.0.1:${port}${tokenSuffix} — Ctrl+C to stop`);
88
84
  if (allInterfaces) {
@@ -95,5 +91,28 @@ export async function cmdGui(root, args, seams = {}) {
95
91
  ? "listening on ALL interfaces — token-protected; prompting the director requires the token"
96
92
  : "listening on ALL interfaces — no auth; anyone reaching it can prompt the director");
97
93
  }
94
+ }
95
+ /** The `tumwater gui` command: parse the --port/--all-interfaces/--token flags, start the
96
+ * server, print the banner, and serve until Ctrl+C. Lives beside startGui (gui/gui-server.ts) so
97
+ * the CLI's gui-specific policy (token validation, the listen-failure messages, the LAN
98
+ * announcement) cannot drift from the server it drives. Flag-vocabulary rejection stays in
99
+ * cli.ts with the other cases; everything gui-specific after that gate is this function's job.
100
+ * A failed listen throws (cli.ts's main catch renders it as the same `tumwater: …` line and
101
+ * exit 1 fail() would) so the listen-failure wordings stay assertable in-process. */
102
+ export async function cmdGui(root, args, seams = {}) {
103
+ const { start, lan, print, serve } = resolveGuiSeams(seams);
104
+ const portRaw = flagValue(args, "--port");
105
+ const port = portRaw !== null ? parsePortFlag(portRaw) : 7180;
106
+ const allInterfaces = args.includes("--all-interfaces");
107
+ // A valueless or empty --token is a CLI error, not an open server: an operator who
108
+ // asked for protection must never silently get none. The validation and its wording
109
+ // live in tokenValueProblem, shared with `start --gui` so the two callers cannot
110
+ // drift; its flag-looking-value case names `tumwater gui` here.
111
+ const problem = tokenValueProblem(args, "tumwater gui");
112
+ if (problem)
113
+ fail(problem);
114
+ const token = adoptGuiToken(flagValue(args, "--token") ?? null, print);
115
+ await startGuiOrExplain(start, root, port, allInterfaces, token);
116
+ printGuiBanner(print, lan, port, allInterfaces, token);
98
117
  await serve(); // Serve until Ctrl+C.
99
118
  }