tumwater 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -12,16 +12,13 @@
12
12
  * which re-decide; `signal` aborts a waiter. */
13
13
  import path from "node:path";
14
14
  import { loadConfig, slotCount } from "../config/config.js";
15
- import { worktreesDir, slotWorktreePath } from "../paths.js";
16
- import { loadRunnerState } from "../runner/runner-state-load.js";
17
- import { DIRECTOR_ROLE } from "../roles/roles.js";
15
+ import { slotWorktreePath, isCanonicalSlotDir } from "../paths.js";
18
16
  import { readSlotsState, slotForDir, updateSlotsState } from "./slots-state.js";
19
17
  import { salvageSlotLeftovers } from "./slot-salvage.js";
20
18
  import { beginWorktreeUse } from "./worktree-use.js";
21
19
  import { ensureDetachedWorktree, removeWorktreeDir } from "./worktree.js";
22
20
  import { logEventBestEffort, warnEventBestEffort, tryOrWarn, TUMWATER_SOURCE } from "../events/events.js";
23
21
  import { errorMessage } from "../text/text.js";
24
- import { subdirectoryNames } from "../files/files.js";
25
22
  import { dropProviderLeaseKey } from "../concurrency/provider-lease.js";
26
23
  /** In-process waiters per root, woken when any slot is released. Keyed by resolved root so two
27
24
  * roots never wake each other. */
@@ -229,12 +226,6 @@ function liveCount(root) {
229
226
  return Number.MAX_SAFE_INTEGER;
230
227
  }
231
228
  }
232
- /** A slot dir is canonical when it is exactly a `_slot-<n>` checkout under the worktrees dir.
233
- * A migrated legacy `<role>` dir is not, which marks it for retirement once its resume finishes
234
- * (retireLegacyRoleWorktrees and releaseSlot). */
235
- function isCanonicalSlotDir(dir) {
236
- return /^_slot-\d+$/.test(path.basename(dir));
237
- }
238
229
  /** Slot dirs per root whose release write to slots.json failed in this process; the next claim
239
230
  * clears their stale lease. */
240
231
  const unsavedReleases = new Map();
@@ -316,116 +307,6 @@ export async function removeDroppedSlots(root, dirs, remove = removeWorktreeDir)
316
307
  }
317
308
  }
318
309
  }
319
- /** One-time startup migration (plans/worktree-pool.md "Legacy role worktrees", part 4c/5):
320
- * retire the pre-pool `.tumwater/worktrees/<role>` checkouts now that runs lease pooled slots.
321
- * A resumable enabled role keeps its legacy dir as a pinned slot so its pending resume runs in
322
- * the same cwd; every other legacy dir is removed (its branch keeps any commit), and pins for
323
- * roles outside `configured` are cleared. `configured` is every role id the config's roles
324
- * enable (`runnerIds`), never a `--once --role` filter: a scoped run must leave the other roles'
325
- * legacy dirs and pins alone, or it silently loses their pending resumes. It runs after
326
- * `runOrchestrator` constructs every
327
- * `RoleRunner` but before any run can call `leaseSlot`: the constructor infers `resumePending`
328
- * from the on-disk `running` flag only in memory (it does not save it), so `loadRunnerState` here
329
- * still reports the on-disk flags. *Removing* a dir is best-effort and logged: the slot records
330
- * are already updated, so a leaked directory is only a leak the next prune collects. */
331
- export async function retireLegacyRoleWorktrees(root, configured) {
332
- const dir = worktreesDir(root);
333
- // A missing worktrees dir (a team that has not started) yields no names to migrate; the pin
334
- // retirement pass below still runs over whatever slots.json holds.
335
- const names = subdirectoryNames(dir, (name) => name !== DIRECTOR_ROLE && !name.startsWith("_"));
336
- const configuredSet = new Set(configured);
337
- const toRemove = new Set();
338
- updateSlotsState(root, (state) => {
339
- for (const role of names) {
340
- const legacyDir = path.join(dir, role);
341
- const legacy = loadRunnerState(root, role);
342
- const resumable = legacy.resumePending === true || legacy.running === true;
343
- const ownRecord = state.slots.find((slot) => slot.dir === legacyDir);
344
- if (resumable && configuredSet.has(role)) {
345
- const owned = state.slots.find((slot) => slot.pinnedFor === role ||
346
- (slot.lease?.role === role && slot.lease.purpose === "run"));
347
- if (owned !== undefined) {
348
- // The resume happens in the owned slot: drop the legacy record and remove its dir.
349
- if (owned.dir !== legacyDir) {
350
- state.slots = state.slots.filter((slot) => slot.dir !== legacyDir);
351
- toRemove.add(legacyDir);
352
- }
353
- if (owned.pinnedFor !== role)
354
- owned.pinnedAt = Date.now();
355
- owned.pinnedFor = role;
356
- }
357
- else if (ownRecord !== undefined) {
358
- // Already migrated (a restart before the resume ran): keep it pinned and inert,
359
- // preserving the pin's original age for the doctor's stuck-pin warning.
360
- ownRecord.lease = null;
361
- if (ownRecord.pinnedFor !== role)
362
- ownRecord.pinnedAt = Date.now();
363
- ownRecord.pinnedFor = role;
364
- ownRecord.lastRole = null;
365
- ownRecord.lastReleasedAt = null;
366
- }
367
- else {
368
- state.slots.push({
369
- dir: legacyDir,
370
- lease: null,
371
- pinnedFor: role,
372
- pinnedAt: Date.now(),
373
- lastRole: null,
374
- lastReleasedAt: null,
375
- });
376
- }
377
- }
378
- else {
379
- state.slots = state.slots.filter((slot) => slot.dir !== legacyDir);
380
- toRemove.add(legacyDir);
381
- }
382
- }
383
- // A paused or removed role must not leak a permanently pinned checkout.
384
- for (const slot of [...state.slots]) {
385
- if (slot.pinnedFor === null || configuredSet.has(slot.pinnedFor))
386
- continue;
387
- slot.pinnedFor = null;
388
- slot.pinnedAt = null;
389
- if (!isCanonicalSlotDir(slot.dir)) {
390
- state.slots = state.slots.filter((s) => s !== slot);
391
- toRemove.add(slot.dir);
392
- }
393
- }
394
- });
395
- for (const victim of toRemove) {
396
- try {
397
- await removeWorktreeDir(root, victim);
398
- }
399
- catch (err) {
400
- warnEventBestEffort(root, TUMWATER_SOURCE, `could not remove legacy role workspace ${victim}: ${errorMessage(err)}`);
401
- }
402
- }
403
- }
404
- /** Startup pass for runs the previous process died in (crash, kill -9, power loss): such a run's
405
- * lease is still in slots.json with the dead pid, and its RoleRunner reads the persisted
406
- * `running` flag as a resume that must run in the same checkout, the cwd its session used. Pin
407
- * each such slot for its role, as the graceful shutdown's abort pin would have, unless the role
408
- * already holds a pin; the next claim then clears the dead lease (marking the run unsettled) and
409
- * the resume leases the pinned slot with its edits in place. Only configured roles count, as in
410
- * retireLegacyRoleWorktrees, so a removed role never leaks a pin. Like that pass, it runs before
411
- * any run can lease, while loadRunnerState still reports the on-disk flags. */
412
- export function pinCrashedRuns(root, configured) {
413
- const configuredSet = new Set(configured);
414
- updateSlotsState(root, (state) => {
415
- for (const slot of state.slots) {
416
- const lease = slot.lease;
417
- if (lease === null || lease.pid === process.pid || lease.purpose !== "run")
418
- continue;
419
- if (!configuredSet.has(lease.role) || state.slots.some((s) => s.pinnedFor === lease.role))
420
- continue;
421
- const runner = loadRunnerState(root, lease.role);
422
- if (runner.running !== true && runner.resumePending !== true)
423
- continue;
424
- slot.pinnedFor = lease.role;
425
- slot.pinnedAt = Date.now();
426
- }
427
- });
428
- }
429
310
  /** Lease a pooled slot for `role`, prepared at `ref`. Resolves once a slot is held and reset to
430
311
  * `ref`, any uncommitted edits its last holder left saved first (salvageSlotLeftovers), as work
431
312
  * to hand back when that holder was a run that never settled them; rejects on a `signal` abort
@@ -14,7 +14,7 @@ import { readBrief } from "../brief/brief.js";
14
14
  import { readQueuedPromptBody } from "../inbox/inbox-edit.js";
15
15
  import { rejectBadQueueFileName, rejectBadRole } from "./gui-args.js";
16
16
  import { DIRECTOR_ROLE } from "../roles/roles.js";
17
- import { okGone, sendJson } from "./http-body.js";
17
+ import { okGone, sendJson } from "./gui-http-body.js";
18
18
  /** Handle GET /api/prompt-text?file=<name>[&role=<id>]: one queued prompt's editable body —
19
19
  * the text an edit replaces, for the dashboard's per-row edit affordance (PLANS.md
20
20
  * 2026-10-11). The prompt is addressed by its queue-file basename (the /api/prompt-cancel
@@ -44,9 +44,8 @@ export function handlePromptText(q, res, root) {
44
44
  /** Handle GET /api/doctor: the same DoctorReport `tumwater doctor --json` prints, so the
45
45
  * Settings view's Health check card and the CLI cannot disagree. Read-only — the checks write
46
46
  * nothing (runDoctor's own contract) — and it works whether or not the team is running. `run`
47
- * exists so tests can skip the host probes; it never probes images ("never") — the explicit
48
- * re-probe is POST /api/doctor-reprobe; a throw reaches the server's outer 500. */
49
- export async function handleDoctor(res, root, run = (r) => runDoctor(r, undefined, undefined, "never")) {
47
+ * exists so tests can skip the host probes; a throw reaches the server's outer 500. */
48
+ export async function handleDoctor(res, root, run = (r) => runDoctor(r)) {
50
49
  sendJson(res, 200, await run(root));
51
50
  }
52
51
  /** Handle GET /api/config: the Settings view's resolved values for exactly
@@ -16,7 +16,7 @@ import { queueFileNameProblem } from "../inbox/inbox-cancel.js";
16
16
  import { DIRECTOR_ROLE } from "../roles/roles.js";
17
17
  import { gotSuffix, parseNonNegativeInt, parsePositiveInt, splitCommaListUnique } from "../text/text.js";
18
18
  import { typoSuffix } from "../text/suggest.js";
19
- import { badRequest, sendJson } from "./http-body.js";
19
+ import { badRequest, sendJson } from "./gui-http-body.js";
20
20
  /** The role ids a role-targeting endpoint accepts: when tumwater.json parses, catalog +
21
21
  * customRoles (knownRoleIds); a transiently broken file falls back to the built-in catalog
22
22
  * rather than refusing every id — the rule config.knownRoleIdsCached owns. Shared by
@@ -18,7 +18,7 @@ import { tryMoveEntryToTop, tryReleaseEntry } from "../backlog/backlog-release.j
18
18
  import { tryAnswerQuestion, UNCLOSED_FENCE_MESSAGE } from "../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
- import { badRequest, okGone, sendJson } from "./http-body.js";
21
+ import { badRequest, okGone, sendJson } from "./gui-http-body.js";
22
22
  import { collapseWhitespace, errorMessage, gotSuffix } from "../text/text.js";
23
23
  import { readPostBody } from "./gui-endpoint-common.js";
24
24
  /** Shared 400-validation for an entry-target body's `kind` — "bug" or "plan" — the one field
@@ -183,11 +183,26 @@ function heldAt(root, kind, position) {
183
183
  const holds = backlogHolds(root);
184
184
  return (kind === "bug" ? holds.bugs : holds.plans)[position - 1] ?? null;
185
185
  }
186
+ /** Commit a successful backlog write and send the drawer's success response — the same
187
+ * post-write tail the release, move-to-top, and edit handlers repeat: commit the file change on
188
+ * main, re-read the entry's hold (its position may have moved), and send the 200 with the
189
+ * commit and a `heldNote` naming the hold when one still applies (a held entry is not newly
190
+ * eligible, so the caller must not wake a role — the release handler reads the returned hold
191
+ * to decide). `verb` names the action in the commit subject (`Release`/`Prioritize`/`Edit`),
192
+ * `status` is the response's status, and `extra` carries the success body fields only one
193
+ * handler has (release's `lifted`), spread before `committed` so that body's key order stays
194
+ * intact. Returns the hold it read. */
195
+ async function respondBacklogWrite(res, root, kind, position, outcome, verb, status, extra = {}) {
196
+ const committed = await commitOperatorBacklogEdit(root, outcome.file, stampedSubject("operator", `${verb} ${kind}: ${outcome.title}`)); // vocabulary-ok: the commit prefix stays tumwater(operator):
197
+ const hold = heldAt(root, kind, position);
198
+ sendJson(res, 200, { ok: true, status, file: outcome.file, title: outcome.title, ...extra, committed, ...(hold === null ? {} : { heldNote: stillHeldNote(hold) }) });
199
+ return hold;
200
+ }
186
201
  /** Handle POST /api/backlog-release: the entry drawer's release — the same lock and splice as
187
202
  * `tumwater plan|bug --release <n>` through the non-exiting tryReleaseEntry. Body as
188
203
  * backlog-cancel's. A released entry that is not still held is newly eligible, so the kind's
189
204
  * role is woken (a held one wakes no role and carries `heldNote`); `gone` and `moved` answer
190
- * okGone, and an entry with none of the Refused, Waiting-for-the-next-failure, or
205
+ * okGone, and an entry with none of the Refused or
191
206
  * held-for-operator notes answers status "not_refused" with no write and no wake. */
192
207
  export async function handleBacklogRelease(req, res, root) {
193
208
  const body = await readPostBody(req, res, '{"kind": "bug", "position": 1, "title": "<heading>"}');
@@ -204,11 +219,9 @@ export async function handleBacklogRelease(req, res, root) {
204
219
  if (outcome === null)
205
220
  return;
206
221
  if (outcome.status === "released") {
207
- const committed = await commitOperatorBacklogEdit(root, outcome.file, stampedSubject("operator", `Release ${kind}: ${outcome.title}`)); // vocabulary-ok: the commit prefix stays tumwater(operator):
208
- const hold = heldAt(root, kind, position);
222
+ const hold = await respondBacklogWrite(res, root, kind, position, outcome, "Release", "released", { lifted: outcome.lifted });
209
223
  if (hold === null)
210
224
  requestWake(root, [OPERATOR_FILE_KINDS[kind].role]);
211
- sendJson(res, 200, { ok: true, status: "released", file: outcome.file, title: outcome.title, lifted: outcome.lifted, committed, ...(hold === null ? {} : { heldNote: stillHeldNote(hold) }) });
212
225
  }
213
226
  else if (outcome.status === "not_refused") {
214
227
  sendJson(res, 200, { ok: true, status: "not_refused", title: outcome.title });
@@ -237,9 +250,7 @@ export async function handleBacklogTop(req, res, root) {
237
250
  if (outcome === null)
238
251
  return;
239
252
  if (outcome.status === "moved_to_top") {
240
- const committed = await commitOperatorBacklogEdit(root, outcome.file, stampedSubject("operator", `Prioritize ${kind}: ${outcome.title}`)); // vocabulary-ok: the commit prefix stays tumwater(operator):
241
- const hold = heldAt(root, kind, 1);
242
- sendJson(res, 200, { ok: true, status: "moved_to_top", file: outcome.file, title: outcome.title, committed, ...(hold === null ? {} : { heldNote: stillHeldNote(hold) }) });
253
+ await respondBacklogWrite(res, root, kind, 1, outcome, "Prioritize", "moved_to_top");
243
254
  }
244
255
  else if (outcome.status === "already_first") {
245
256
  sendJson(res, 200, { ok: true, status: "already_first", title: outcome.title });
@@ -284,9 +295,7 @@ export async function handleBacklogEdit(req, res, root) {
284
295
  if (outcome === null)
285
296
  return;
286
297
  if (outcome.status === "edited") {
287
- const committed = await commitOperatorBacklogEdit(root, outcome.file, stampedSubject("operator", `Edit ${kind}: ${outcome.title}`)); // vocabulary-ok: the commit prefix stays tumwater(operator):
288
- const hold = heldAt(root, kind, position);
289
- sendJson(res, 200, { ok: true, status: "edited", file: outcome.file, title: outcome.title, committed, ...(hold === null ? {} : { heldNote: stillHeldNote(hold) }) });
298
+ await respondBacklogWrite(res, root, kind, position, outcome, "Edit", "edited");
290
299
  }
291
300
  else if (outcome.status === "unchanged") {
292
301
  sendJson(res, 200, { ok: true, status: "unchanged", file: outcome.file, title: outcome.title });
@@ -5,12 +5,12 @@
5
5
  * inbox/inbox.ts, change/change-data.ts), so this module only adapts HTTP onto it. Barrelled
6
6
  * into gui/gui-endpoints.ts beside the other per-family GET endpoint modules.
7
7
  */
8
- import { operatorHeldEntries, refusalNote, waitingNote } from "../backlog/backlog-eligibility.js";
8
+ import { refusalNote } from "../backlog/backlog-eligibility.js";
9
9
  import { answeredQuestionEntries, entryMatches, donePlanEntries, fixedBugEntries, openBugEntries, openQuestionEntries, plannedPlanEntries } from "../backlog/backlog.js";
10
10
  import { collectChangeOverlaps, scopeChangeOverlaps, scopeChangeOverlapsToPath, collectTeamChanges, collectRoleChange, normalizeDiffPath } from "../change/change-data.js";
11
11
  import { listWideScope, queuedListRows } from "../inbox/inbox.js";
12
12
  import { intQuery, rejectBadRole, roleListQuery } from "./gui-args.js";
13
- import { badRequest, sendJson } from "./http-body.js";
13
+ import { badRequest, sendJson } from "./gui-http-body.js";
14
14
  import { typoSuffix } from "../text/suggest.js";
15
15
  /** The /api/backlog `file` values and the open-section reader each addresses — one table so
16
16
  * the on-demand dispatch and the "valid values" list in both 400s cannot drift apart. A Map,
@@ -36,10 +36,6 @@ const BACKLOG_FILE_VALUES = BACKLOG_FILE_NAMES.join(", ");
36
36
  * order, unlike done/fixed) — zero-based.
37
37
  * Unknown/missing file, missing or bad index, and out-of-range index
38
38
  * → 400 JSON error via sendJson.
39
- * On a held-for-operator entry (the entry in
40
- * operatorHeldEntries' selection, file and index both matching), the 200 payload adds
41
- * `heldForOperator: true` so the drawer shows its Release button there too; every other
42
- * entry's payload carries no such key.
43
39
  * The query arrives pre-parsed (gui/gui-server.ts's parseRequestTarget), like every
44
40
  * GET-data handler here. */
45
41
  export function handleBacklog(q, res, root) {
@@ -66,8 +62,6 @@ export function handleBacklog(q, res, root) {
66
62
  title: entry.title,
67
63
  body: entry.body,
68
64
  refused: refusalNote(entry.body) !== null,
69
- waiting: waitingNote(entry.body) !== null,
70
- ...(operatorHeldEntries(root).some((e) => e.file === file && e.index === index) ? { heldForOperator: true } : {}),
71
65
  });
72
66
  }
73
67
  /** Handle GET /api/backlog-match?grep=<text>: for each /api/backlog `file`, the ascending
@@ -15,7 +15,7 @@ import { testNotifyHook } from "../cli/notify-command.js";
15
15
  import { rejectBadRole, requireConfigKey, requireRoleField, requireStringField } from "./gui-args.js";
16
16
  import { baseRoleOf } from "../roles/runner-ids.js";
17
17
  import { DIRECTOR_ROLE } from "../roles/roles.js";
18
- import { badRequest, sendJson } from "./http-body.js";
18
+ import { badRequest, sendJson } from "./gui-http-body.js";
19
19
  import { readPostBody } from "./gui-endpoint-common.js";
20
20
  /** Handle POST /api/budget: the dashboard's budget-badge editor saves the daily cost cap —
21
21
  * same body discipline as /api/prompt (readJsonObject), and the same shared validation rule +
@@ -173,40 +173,28 @@ export async function handleRoleInterval(req, res, root) {
173
173
  return;
174
174
  }
175
175
  const base = baseRoleOf(role);
176
- // A null, empty or whitespace-only value clears the override, so the role falls back to the
177
- // team-wide minRunIntervalSeconds (unsetConfigKey, like the CLI's config unset of the key).
178
- if (value === null || (typeof value === "string" && value.trim() === "")) {
179
- // unsetConfigKey's no-op map path (a role the file never holds, an unknown id included)
180
- // skips validateConfig, so without this the set branch's unknown-role refusal would never
181
- // fire on a clear and a bogus id would answer 200 "cleared" — the explicit check mirrors
182
- // the CLI's knownRoleIds guard and the role-quiet-hours clear branch.
183
- if (rejectBadRole(root, res, role))
176
+ // A non-null, non-blank value must pass the shared per-key screen (the finite
177
+ // 0..MAX_DURATION_SECONDS rule, naming the per-role dotted key so a rejected write tells the
178
+ // operator which key the drawer would set) before the write; a null/blank value is the clear
179
+ // path (writeRoleJsonKey), so the role falls back to the team-wide interval.
180
+ if (value !== null && !(typeof value === "string" && value.trim() === "")) {
181
+ const problem = checkMinRunInterval(value, `roles.${base}.minRunIntervalSeconds`);
182
+ if (problem) {
183
+ badRequest(res, problem);
184
184
  return;
185
- const clearResult = unsetConfigKey(root, `roles.${base}.minRunIntervalSeconds`);
186
- if (!clearResult.ok)
187
- return configKeyError(res, `roles.${base}.minRunIntervalSeconds`, clearResult);
188
- const message = clearResult.oldValue === undefined
189
- ? `${base}: min run interval is not set in tumwater.json, so nothing was cleared`
190
- : `${base}: min run interval cleared`;
191
- sendJson(res, 200, { ok: true, role: base, message });
192
- return;
193
- }
194
- // The shared rule (finite 0..MAX_DURATION_SECONDS), naming the per-role dotted key so a
195
- // rejected write tells the operator which key the drawer would set.
196
- const problem = checkMinRunInterval(value, `roles.${base}.minRunIntervalSeconds`);
197
- if (problem) {
198
- badRequest(res, problem);
199
- return;
185
+ }
200
186
  }
201
- // setConfigKey takes the raw CLI text; a JSON round-trip of an already-parsed value is
202
- // exact, so the shared per-key validator + whole-config validateConfig apply unchanged.
203
- const result = setConfigKey(root, `roles.${base}.minRunIntervalSeconds`, JSON.stringify(value));
204
- if (!result.ok)
205
- return configKeyError(res, `roles.${base}.minRunIntervalSeconds`, result);
206
- const message = result.unchanged
207
- ? `${base}: min run interval is already ${value}s, so tumwater.json was not written`
208
- : `${base}: min run interval set to ${value}s`;
209
- sendJson(res, 200, { ok: true, role: base, minRunIntervalSeconds: value, message });
187
+ // The set-or-clear tail shared with the other role JSON-key handlers: clears through
188
+ // unsetConfigKey and sets through the CLI's own setConfigKey dotted path, so the shared
189
+ // per-key validator (above) and whole-config validateConfig (which rejects an unknown role
190
+ // id) own the final truth. The clear arm carries rejectBadRole because unsetConfigKey's
191
+ // no-op map path skips validateConfig — the guard the set branch gets free from
192
+ // setConfigKey's unknown-role refusal (mirrors the CLI's knownRoleIds guard).
193
+ writeRoleJsonKey(res, root, base, `roles.${base}.minRunIntervalSeconds`, "min run interval", value, (v) => `${base}: min run interval set to ${v}s`, {
194
+ unchangedMessage: (v) => `${base}: min run interval is already ${v}s, so tumwater.json was not written`,
195
+ guardClear: () => rejectBadRole(root, res, role),
196
+ extra: { minRunIntervalSeconds: value },
197
+ });
210
198
  }
211
199
  /** Handle POST /api/role-instances: the role drawer's instance-count editor sets or clears one
212
200
  * role's parallel-runner count — the count that lets `feature-2`…`feature-N` run. Same body
@@ -233,34 +221,17 @@ export async function handleRoleInstances(req, res, root) {
233
221
  return;
234
222
  }
235
223
  const base = baseRoleOf(role);
236
- // A null, empty or whitespace-only value clears the key, so the role falls back to the
237
- // default 1 (unsetConfigKey, like the CLI's config unset of the key).
238
- if (value === null || (typeof value === "string" && value.trim() === "")) {
239
- // unsetConfigKey's no-op map path (a role the file never holds, an unknown id included)
240
- // skips validateConfig, so without this the set branch's unknown-role refusal would never
241
- // fire on a clear and a bogus id would answer 200 "cleared" — the explicit check mirrors
242
- // the CLI's knownRoleIds guard and the role-interval clear branch.
243
- if (rejectBadRole(root, res, role))
244
- return;
245
- const clearResult = unsetConfigKey(root, `roles.${base}.instances`);
246
- if (!clearResult.ok)
247
- return configKeyError(res, `roles.${base}.instances`, clearResult);
248
- const message = clearResult.oldValue === undefined
249
- ? `${base}: instances is not set in tumwater.json, so nothing was cleared`
250
- : `${base}: instances cleared`;
251
- sendJson(res, 200, { ok: true, role: base, message });
252
- return;
253
- }
254
- // setConfigKey takes the raw CLI text; a JSON round-trip of an already-parsed value is
255
- // exact, so the shared per-key validator + whole-config validateConfig (which owns which
256
- // roles may split and the 1–8 integer bound) apply unchanged.
257
- const result = setConfigKey(root, `roles.${base}.instances`, JSON.stringify(value));
258
- if (!result.ok)
259
- return configKeyError(res, `roles.${base}.instances`, result);
260
- const message = result.unchanged
261
- ? `${base}: instances is already ${value}, so tumwater.json was not written`
262
- : `${base}: instances set to ${value}`;
263
- sendJson(res, 200, { ok: true, role: base, instances: value, message });
224
+ // The set-or-clear tail shared with the other role JSON-key handlers: clears through
225
+ // unsetConfigKey and sets through the CLI's own setConfigKey dotted path, so whole-candidate
226
+ // validateConfig owns the final truth (the INSTANCE_COUNT bound and which roles may split;
227
+ // an unknown id is refused there). The clear arm carries rejectBadRole because
228
+ // unsetConfigKey's no-op map path skips validateConfig — the guard the set branch gets free
229
+ // from setConfigKey's unknown-role refusal (mirrors the CLI's knownRoleIds guard).
230
+ writeRoleJsonKey(res, root, base, `roles.${base}.instances`, "instances", value, (v) => `${base}: instances set to ${v}`, {
231
+ unchangedMessage: (v) => `${base}: instances is already ${v}, so tumwater.json was not written`,
232
+ guardClear: () => rejectBadRole(root, res, role),
233
+ extra: { instances: value },
234
+ });
264
235
  }
265
236
  /** Handle POST /api/role-model: the role drawer's Model overrides editor sets or clears one
266
237
  * role's provider/model/thinking/backend set (roles.<base>.provider/.model/.thinking/
@@ -337,7 +308,7 @@ export async function handleRoleInstructions(req, res, root) {
337
308
  // 4096-char cap and the unknown-role check in writeConfigMutation. Unlike quiet hours the
338
309
  // clear arm carries no role guard (the director may clear, and the unknown-role clear gap is
339
310
  // pre-existing).
340
- writeRoleJsonKey(res, root, base, `roles.${base}.instructions`, "instructions", text, (v) => JSON.stringify(v), (v) => `${base}: instructions saved (${v.length} chars)`);
311
+ writeRoleJsonKey(res, root, base, `roles.${base}.instructions`, "instructions", text, (v) => `${base}: instructions saved (${String(v).length} chars)`);
341
312
  }
342
313
  /** The shared write behind POST /api/role-enable and POST /api/role-disable: the Settings
343
314
  * card's Enable button and the role drawer's two-click Disable both write
@@ -416,7 +387,7 @@ export async function handleRoleQuietHours(req, res, root) {
416
387
  // branch's unknown-role refusal would never fire on a clear and a bogus id would answer 200
417
388
  // "cleared" (mirrors the CLI's knownRoleIds guard). The set arm runs setConfigKey raw, its
418
389
  // map branch checking the dotted key as the label and validateConfig owning the unknown role.
419
- writeRoleJsonKey(res, root, base, `quietHoursPerRole.${base}`, "quiet hours", window, (v) => JSON.stringify(v), (v) => `${base}: quiet hours set to ${v}`, () => rejectBadRole(root, res, role));
390
+ writeRoleJsonKey(res, root, base, `quietHoursPerRole.${base}`, "quiet hours", window, (v) => `${base}: quiet hours set to ${v}`, { guardClear: () => rejectBadRole(root, res, role) });
420
391
  }
421
392
  /** The shared 400 for a failed config mutation — the set and unset write paths both nest the
422
393
  * write's error under the key it names, so the Settings view can tell which field failed and a
@@ -431,16 +402,18 @@ function configKeyError(res, key, result) {
431
402
  function unchangedFlag(result) {
432
403
  return result.unchanged ? { unchanged: true } : {};
433
404
  }
434
- /** The set-or-clear write the two per-role JSON-config-key handlers (quiet hours, standing
435
- * instructions) share: an empty/whitespace `raw` unsets `key` (after `guardClear`, a per-call
436
- * check the clear arm runs first — quiet hours' unknown-role guard; instructions has none),
437
- * else sets it via setConfigKey (which JSON-encodes nothing, so `serialize` shapes the stored
438
- * value). `noun` names the hold in the "cleared" 200 and `setMessage` is the exact set-arm 200
439
- * message, threaded so each handler keeps its own wording. Always writes the response, so the
440
- * caller never falls through to further output. */
441
- function writeRoleJsonKey(res, root, base, key, noun, raw, serialize, setMessage, guardClear) {
442
- if (raw.trim() === "") {
443
- if (guardClear?.())
405
+ /** The set-or-clear write every per-role JSON-config-key handler (quiet hours, standing
406
+ * instructions, min run interval, instance count) shares: an empty/whitespace/`null` `raw`
407
+ * unsets `key` (after `guardClear`, a per-call check the clear arm runs first — the numeric
408
+ * keys' and quiet hours' unknown-role guard; instructions has none), else sets it via
409
+ * setConfigKey (which JSON-encodes nothing, so the JSON.stringify below shapes the stored
410
+ * value). `noun` names the hold in the "cleared" 200, `setMessage` is the exact set-arm 200
411
+ * message, and `unchangedMessage` overrides the default unchanged wording — threaded so each
412
+ * handler keeps its own wording. Always writes the response, so the caller never falls
413
+ * through. */
414
+ function writeRoleJsonKey(res, root, base, key, noun, raw, setMessage, options = {}) {
415
+ if (raw === null || (typeof raw === "string" && raw.trim() === "")) {
416
+ if (options.guardClear?.())
444
417
  return;
445
418
  const result = unsetConfigKey(root, key);
446
419
  if (!result.ok)
@@ -453,12 +426,14 @@ function writeRoleJsonKey(res, root, base, key, noun, raw, serialize, setMessage
453
426
  }
454
427
  return;
455
428
  }
456
- const result = setConfigKey(root, key, serialize(raw));
429
+ const result = setConfigKey(root, key, JSON.stringify(raw));
457
430
  if (!result.ok)
458
431
  configKeyError(res, key, result);
459
432
  else {
460
- const message = result.unchanged ? `${base}: ${noun} unchanged, so tumwater.json was not written` : setMessage(raw);
461
- sendJson(res, 200, { ok: true, role: base, message });
433
+ const message = result.unchanged
434
+ ? (options.unchangedMessage ?? (() => `${base}: ${noun} unchanged, so tumwater.json was not written`))(raw)
435
+ : setMessage(raw);
436
+ sendJson(res, 200, { ok: true, role: base, message, ...options.extra });
462
437
  }
463
438
  }
464
439
  /** Handle POST /api/config-set: the Settings view's per-key Save — one curated key set to
@@ -10,10 +10,12 @@
10
10
  * question-answer (gui/gui-backlog-commands), config set/unset + budget + notify-test
11
11
  * (gui/gui-config-commands), team pause/role-pause/wake (gui/gui-team-commands),
12
12
  * and operator lifecycle restart/reclaim/abort/reset-counters/stop-schedule/role-notes/
13
- * retire/brief-edit (gui/gui-operator-commands).
13
+ * retire/brief-edit (gui/gui-operator-commands), and flake-quarantine release
14
+ * (gui/gui-quarantine-commands).
14
15
  */
15
16
  export { handlePrompt, handlePromptRole, handlePromptCancel, handlePromptEdit, handlePromptTop } from "./gui-prompt-commands.js";
16
17
  export { handleQuestionAnswer, handleBacklogFile, handleBacklogCancel, handleBacklogRelease, handleBacklogTop, handleBacklogEdit } from "./gui-backlog-commands.js";
17
18
  export { handleConfigSet, handleConfigUnset, handleBudget, handleRoleCap, handleRoleReviewAfterMerge, handleRoleEnable, handleRoleDisable, handleRoleInstances, handleRoleInterval, handleRoleInstructions, handleRoleModel, handleRoleQuietHours, handleNotifyTest } from "./gui-config-commands.js";
18
19
  export { handlePause, handlePauseRole, handleWake, handleWakeCancel } from "./gui-team-commands.js";
19
- export { handleResetCounters, handleRestart, handleReclaim, handleKillOrphans, handleDropBrokenPins, handleCommitBacklog, handleResyncInstall, handleRetireLeftovers, handleDoctorReprobe, handleAbort, handleStopSchedule, handleRoleNotes, handleRetire, handleRevert, handleQueueCancel, handleQueueTop, handlePostReviewAccept, handleBriefEdit } from "./gui-operator-commands.js";
20
+ export { handleResetCounters, handleRestart, handleReclaim, handleKillOrphans, handleDropBrokenPins, handleCommitBacklog, handleResyncInstall, handleRetireLeftovers, handleArchiveBacklog, handleAbort, handleAbortAll, handleStopSchedule, handleRoleNotes, handleRetire, handleRevert, handleQueueCancel, handleQueueTop, handlePostReviewAccept, handleBriefEdit } from "./gui-operator-commands.js";
21
+ export { handleQuarantineRelease } from "./gui-quarantine-commands.js";
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * The request-body helpers shared by the dashboard's per-domain POST command modules
3
- * (gui-prompt-commands, gui-backlog-commands, gui-config-commands, gui-team-commands, and
4
- * gui-operator-commands, all barrelled into gui/gui-endpoint-commands.ts): readPostBody /
3
+ * (gui-prompt-commands, gui-backlog-commands, gui-config-commands, gui-team-commands,
4
+ * gui-operator-commands, and gui-quarantine-commands, all barrelled into
5
+ * gui/gui-endpoint-commands.ts): readPostBody /
5
6
  * readRoleBody — the body-discipline-and-role-check pair each mutating handler guards its
6
7
  * inputs with — and sendRequestResult, the shared boolean-claiming marker reply. The
7
8
  * per-domain `checked*` validators live with their domain's module, not here.
8
9
  */
9
10
  import { rejectBadRole } from "./gui-args.js";
10
- import { readJsonObject, sendJson } from "./http-body.js";
11
+ import { readJsonObject, sendJson } from "./gui-http-body.js";
11
12
  /** Every POST handler's shared opening: readJsonObject reads the body and — when it is
12
13
  * oversized, malformed, or not a JSON object — has already written the 413/400 itself. Its
13
14
  * null return therefore means the response is sent and the handler must stop without touching
@@ -17,7 +17,7 @@ import { renderRunDetail, runDetailPatch, runDetailStat, runTranscriptLines } fr
17
17
  import { resolveRevertTarget } from "../operator/revert.js";
18
18
  import { rolePayload } from "../roles/role-view.js";
19
19
  import { clampCount, intQuery, rejectBadRole, sinceMsQuery, untilMsQuery } from "./gui-args.js";
20
- import { badRequest, sendJson } from "./http-body.js";
20
+ import { badRequest, sendJson } from "./gui-http-body.js";
21
21
  import { typoSuffix } from "../text/suggest.js";
22
22
  import { gotSuffix, splitCommaList, splitCommaListUnique } from "../text/text.js";
23
23
  import { newestRunRow, runNotFoundLastMessage, runNotFoundMessage } from "../run/run-detail.js";
@@ -98,6 +98,31 @@ function resultClassesQuery(q, res) {
98
98
  * (the `history --result` rule, parsed by resultClassesQuery like /api/run's), after `grep` and
99
99
  * before `model`; an unknown class is a 400. Reads files directly, so it works whether
100
100
  * or not the team is running. */
101
+ /** Parse the `sinceMs`-window prologue the two history endpoints share. A present `sinceMs`
102
+ * (with no rival `n`) yields `{ kind: "window", sinceMs, untilMs }`; an absent `sinceMs`
103
+ * yields `{ kind: "count" }`. Any 400 — the rival-shapes rejection when both `sinceMs` and
104
+ * `n` are present, or a `sinceMs`/`untilMs` that fails to parse or caps (sinceMsQuery,
105
+ * untilMsQuery) — is already sent and yields null, so the caller returns early. Shared by
106
+ * handleHistory and handleLogs so the rival-shapes message and the parse-and-cap discipline
107
+ * cannot drift. */
108
+ function windowOrCount(q, res) {
109
+ if (q.get("sinceMs") !== null) {
110
+ if (q.get("n") !== null) {
111
+ badRequest(res, "sinceMs cannot be combined with n (a count and a window are rival shapes)");
112
+ return null;
113
+ }
114
+ const sinceMs = sinceMsQuery(q, res, LOGS_SINCE_MAX_MS, (ms) => `sinceMs is capped at ${humanSeconds(LOGS_SINCE_MAX_MS / 1000)} (got ${ms})`);
115
+ if (sinceMs === null)
116
+ return null;
117
+ const untilMs = untilMsQuery(q, res, sinceMs);
118
+ if (untilMs === null)
119
+ return null;
120
+ return { kind: "window", sinceMs, untilMs };
121
+ }
122
+ if (untilMsQuery(q, res, undefined) === null)
123
+ return null; // a count has no window end to move
124
+ return { kind: "count" };
125
+ }
101
126
  export function handleHistory(q, res, root) {
102
127
  const role = q.get("role") || null; // "" and absent both read all roles
103
128
  const grep = q.get("grep") || null; // "" and absent both filter nothing
@@ -110,24 +135,16 @@ export function handleHistory(q, res, root) {
110
135
  const modelPieces = splitCommaListUnique(q.get("model") ?? "");
111
136
  const onModels = (rows) => (modelPieces.length ? runRowsOnModels(rows, modelPieces) : rows);
112
137
  const summary = q.get("summary") !== null; // the flag's presence, whatever its value
113
- if (q.get("sinceMs") !== null) {
114
- if (q.get("n") !== null) {
115
- badRequest(res, "sinceMs cannot be combined with n (a count and a window are rival shapes)");
116
- return;
117
- }
118
- const sinceMs = sinceMsQuery(q, res, LOGS_SINCE_MAX_MS, (ms) => `sinceMs is capped at ${humanSeconds(LOGS_SINCE_MAX_MS / 1000)} (got ${ms})`);
119
- if (sinceMs === null)
120
- return;
121
- const untilMs = untilMsQuery(q, res, sinceMs);
122
- if (untilMs === null)
123
- return;
138
+ const windowCtl = windowOrCount(q, res);
139
+ if (windowCtl === null)
140
+ return;
141
+ if (windowCtl.kind === "window") {
142
+ const { sinceMs, untilMs } = windowCtl;
124
143
  const { rows, covered } = readRunRowsSince(root, sinceMs, role, untilMs ?? null);
125
144
  const matched = onModels(inClasses(grep === null ? rows : runRowsMatchingGrep(rows, grep)));
126
145
  sendJson(res, 200, { rows: matched, covered, note: covered ? "" : SPARSE_WINDOW_NOTE, ...(summary ? { summary: summarizeRunRows(matched), modelSummary: summarizeRunRowsByModel(matched) } : {}) });
127
146
  return;
128
147
  }
129
- if (untilMsQuery(q, res, undefined) === null)
130
- return; // a count has no window end to move
131
148
  const parsed = intQuery(q, res, "n", "non-negative", HISTORY_DEFAULT_RUNS);
132
149
  if (parsed === null)
133
150
  return;
@@ -169,17 +186,11 @@ export function handleLogs(q, res, root) {
169
186
  const grep = q.get("grep") || null; // "" and absent both filter nothing
170
187
  const needle = grep === null ? null : grep.toLowerCase();
171
188
  const keep = (e) => (role === null || eventRole(e) === role) && (needle === null || eventMatchesGrep(e, needle));
172
- if (q.get("sinceMs") !== null) {
173
- if (q.get("n") !== null) {
174
- badRequest(res, "sinceMs cannot be combined with n (a count and a window are rival shapes)");
175
- return;
176
- }
177
- const sinceMs = sinceMsQuery(q, res, LOGS_SINCE_MAX_MS, (ms) => `sinceMs is capped at ${humanSeconds(LOGS_SINCE_MAX_MS / 1000)} (got ${ms})`);
178
- if (sinceMs === null)
179
- return;
180
- const untilMs = untilMsQuery(q, res, sinceMs);
181
- if (untilMs === null)
182
- return;
189
+ const windowCtl = windowOrCount(q, res);
190
+ if (windowCtl === null)
191
+ return;
192
+ if (windowCtl.kind === "window") {
193
+ const { sinceMs, untilMs } = windowCtl;
183
194
  const { cutoff, events: windowEvents, covered } = readEventsSince(root, sinceMs);
184
195
  const events = untilMs === undefined ? windowEvents : windowEvents.filter((e) => e.ts < cutoff + sinceMs - untilMs);
185
196
  sendJson(res, 200, {
@@ -189,8 +200,6 @@ export function handleLogs(q, res, root) {
189
200
  });
190
201
  return;
191
202
  }
192
- if (untilMsQuery(q, res, undefined) === null)
193
- return; // a count has no window end to move
194
203
  const parsed = intQuery(q, res, "n", "non-negative", LOGS_SEARCH_EVENTS);
195
204
  if (parsed === null)
196
205
  return;