tumwater 0.2.0 → 0.4.0

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 (464) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +55 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +6 -4
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +404 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +281 -55
  41. package/dist/src/backlog/backlog-cli-view.js +255 -0
  42. package/dist/src/backlog/backlog-cli.js +91 -335
  43. package/dist/src/backlog/backlog-commit.js +20 -13
  44. package/dist/src/backlog/backlog-eligibility.js +193 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +264 -0
  48. package/dist/src/backlog/backlog-release.js +134 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-guardrails.js +136 -0
  56. package/dist/src/baseline/main-red.js +153 -36
  57. package/dist/src/baseline/main-watch.js +232 -0
  58. package/dist/src/brief/brief-edit.js +12 -12
  59. package/dist/src/brief/brief.js +31 -51
  60. package/dist/src/budget/budget-fallback-view.js +288 -0
  61. package/dist/src/budget/budget-wiring.js +158 -0
  62. package/dist/src/budget/budget.js +31 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +65 -3
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +181 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +183 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +291 -598
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-review-commands.js +113 -0
  84. package/dist/src/cli/cli-role-command.js +525 -0
  85. package/dist/src/cli/cli-start-summary.js +167 -0
  86. package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
  87. package/dist/src/cli/completion.js +169 -22
  88. package/dist/src/cli/config-commands.js +58 -26
  89. package/dist/src/cli/follow-view.js +75 -0
  90. package/dist/src/cli/help.js +258 -82
  91. package/dist/src/cli/log-commands.js +164 -85
  92. package/dist/src/cli/question-commands.js +211 -10
  93. package/dist/src/cli.js +173 -45
  94. package/dist/src/collections.js +56 -13
  95. package/dist/src/concurrency/check-permit.js +20 -3
  96. package/dist/src/concurrency/lock.js +17 -13
  97. package/dist/src/concurrency/provider-lease.js +44 -8
  98. package/dist/src/concurrency/semaphore.js +102 -26
  99. package/dist/src/config/config-aliases.js +0 -1
  100. package/dist/src/config/config-editable-keys.js +16 -1
  101. package/dist/src/config/config-fallbacks.js +94 -56
  102. package/dist/src/config/config-field-checks.js +40 -10
  103. package/dist/src/config/config-keys.js +30 -8
  104. package/dist/src/config/config-live.js +63 -24
  105. package/dist/src/config/config-request.js +137 -0
  106. package/dist/src/config/config-schema.js +31 -24
  107. package/dist/src/config/config-selector-checks.js +123 -0
  108. package/dist/src/config/config-validation-agents.js +80 -0
  109. package/dist/src/config/config-validation-model.js +116 -0
  110. package/dist/src/config/config-validation.js +169 -222
  111. package/dist/src/config/config-views.js +136 -54
  112. package/dist/src/config/config-write-core.js +135 -0
  113. package/dist/src/config/config-write.js +164 -265
  114. package/dist/src/config/config.js +53 -38
  115. package/dist/src/config/model-selector.js +34 -34
  116. package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
  117. package/dist/src/doctor/doctor-backlog-plans.js +398 -0
  118. package/dist/src/doctor/doctor-backlog.js +249 -512
  119. package/dist/src/doctor/doctor-check-names.js +85 -0
  120. package/dist/src/doctor/doctor-checks.js +61 -593
  121. package/dist/src/doctor/doctor-claude-checks.js +142 -0
  122. package/dist/src/doctor/doctor-codex-checks.js +276 -0
  123. package/dist/src/doctor/doctor-contention-checks.js +506 -0
  124. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  125. package/dist/src/doctor/doctor-holds.js +80 -66
  126. package/dist/src/doctor/doctor-install.js +2 -1
  127. package/dist/src/doctor/doctor-model-checks.js +216 -537
  128. package/dist/src/doctor/doctor-notify.js +9 -5
  129. package/dist/src/doctor/doctor-opencode-checks.js +164 -0
  130. package/dist/src/doctor/doctor-orphans.js +182 -63
  131. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  132. package/dist/src/doctor/doctor-project-checks.js +397 -0
  133. package/dist/src/doctor/doctor.js +126 -77
  134. package/dist/src/doctor/image-probe.js +21 -8
  135. package/dist/src/doctor/outcome.js +3 -2
  136. package/dist/src/doctor/probe.js +21 -12
  137. package/dist/src/events/event-format.js +185 -89
  138. package/dist/src/events/event-labels.js +11 -5
  139. package/dist/src/events/event-legacy.js +39 -15
  140. package/dist/src/events/event-read.js +52 -39
  141. package/dist/src/events/event-tally.js +16 -0
  142. package/dist/src/events/event-window.js +80 -26
  143. package/dist/src/events/events.js +30 -13
  144. package/dist/src/events/notify.js +7 -7
  145. package/dist/src/failure/failure-cluster.js +12 -12
  146. package/dist/src/failure/failure-data.js +65 -60
  147. package/dist/src/failure/failure-render.js +29 -21
  148. package/dist/src/failure/failure-state-change.js +17 -12
  149. package/dist/src/failure/time-spend.js +35 -35
  150. package/dist/src/files/file-queue.js +17 -0
  151. package/dist/src/files/files.js +111 -35
  152. package/dist/src/files/json-files.js +3 -3
  153. package/dist/src/files/json-object.js +28 -7
  154. package/dist/src/files/stat-cache.js +2 -2
  155. package/dist/src/files/tail.js +1 -1
  156. package/dist/src/gates/bootstrap-gates.js +4 -5
  157. package/dist/src/gates/budget-gates.js +88 -225
  158. package/dist/src/gates/disk-gate.js +1 -1
  159. package/dist/src/gates/gate-polls.js +101 -143
  160. package/dist/src/gates/maintenance-quota.js +37 -30
  161. package/dist/src/gates/pause-gates.js +28 -10
  162. package/dist/src/gates/readiness.js +7 -3
  163. package/dist/src/gates/role-cap-gates.js +8 -8
  164. package/dist/src/gates/startup-gate.js +94 -32
  165. package/dist/src/gates/streak-gate.js +54 -30
  166. package/dist/src/git/commit-message.js +89 -46
  167. package/dist/src/git/git-diff.js +16 -10
  168. package/dist/src/git/git-run.js +79 -38
  169. package/dist/src/git/git.js +54 -34
  170. package/dist/src/git/slot-salvage.js +253 -0
  171. package/dist/src/git/slots-state.js +6 -6
  172. package/dist/src/git/worktree-pool.js +132 -38
  173. package/dist/src/git/worktree-use.js +4 -11
  174. package/dist/src/git/worktree.js +94 -37
  175. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  176. package/dist/src/gui/gui-args.js +79 -8
  177. package/dist/src/gui/gui-backlog-commands.js +43 -19
  178. package/dist/src/gui/gui-backlog-endpoints.js +156 -0
  179. package/dist/src/gui/gui-command.js +26 -13
  180. package/dist/src/gui/gui-config-commands.js +168 -80
  181. package/dist/src/gui/gui-endpoint-commands.js +5 -4
  182. package/dist/src/gui/gui-endpoint-common.js +4 -4
  183. package/dist/src/gui/gui-endpoints.js +11 -380
  184. package/dist/src/gui/gui-history-endpoints.js +301 -0
  185. package/dist/src/gui/gui-operator-commands.js +235 -13
  186. package/dist/src/gui/gui-prompt-commands.js +26 -20
  187. package/dist/src/gui/gui-report-endpoints.js +97 -0
  188. package/dist/src/gui/gui-server.js +26 -5
  189. package/dist/src/gui/gui-team-commands.js +7 -7
  190. package/dist/src/gui/http-body.js +1 -1
  191. package/dist/src/history/history-data.js +122 -234
  192. package/dist/src/history/history-queued.js +155 -0
  193. package/dist/src/history/history-render.js +42 -22
  194. package/dist/src/history/history.js +190 -49
  195. package/dist/src/inbox/inbox-attachments.js +29 -30
  196. package/dist/src/inbox/inbox-cancel.js +10 -11
  197. package/dist/src/inbox/inbox-edit.js +9 -9
  198. package/dist/src/inbox/inbox-submit.js +18 -18
  199. package/dist/src/inbox/inbox-top.js +28 -17
  200. package/dist/src/inbox/inbox.js +42 -43
  201. package/dist/src/inbox/pending-prompt.js +26 -26
  202. package/dist/src/inbox/prompt-commands.js +62 -49
  203. package/dist/src/inbox/prompt-not-before.js +2 -2
  204. package/dist/src/init/init-templates.js +1 -2
  205. package/dist/src/init/init.js +28 -23
  206. package/dist/src/merge/backlog-conflicts.js +215 -35
  207. package/dist/src/merge/merge-batch.js +14 -8
  208. package/dist/src/merge/merge-cancel.js +53 -0
  209. package/dist/src/merge/merge-check-failures.js +44 -22
  210. package/dist/src/merge/merge-core.js +58 -32
  211. package/dist/src/merge/merge-diff.js +5 -4
  212. package/dist/src/merge/merge-drain.js +45 -19
  213. package/dist/src/merge/merge-git.js +177 -38
  214. package/dist/src/merge/merge-pipeline.js +15 -3
  215. package/dist/src/merge/merge-post-review.js +214 -0
  216. package/dist/src/merge/merge-queue-render.js +48 -11
  217. package/dist/src/merge/merge-queue.js +52 -15
  218. package/dist/src/merge/merge-requeue.js +103 -0
  219. package/dist/src/merge/merge-slot.js +61 -16
  220. package/dist/src/merge/merge-stack.js +27 -11
  221. package/dist/src/merge/merge-to-main.js +77 -26
  222. package/dist/src/merge/merge-top.js +83 -0
  223. package/dist/src/merge/merge-verify.js +75 -18
  224. package/dist/src/notes-cli.js +35 -15
  225. package/dist/src/operator/operator-commands.js +192 -81
  226. package/dist/src/operator/operator-intent.js +69 -42
  227. package/dist/src/operator/operator-requests.js +35 -40
  228. package/dist/src/operator/retire.js +5 -6
  229. package/dist/src/operator/revert.js +142 -34
  230. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  231. package/dist/src/orchestrator/orchestrator-poll.js +352 -0
  232. package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
  233. package/dist/src/orchestrator/orchestrator.js +129 -369
  234. package/dist/src/orchestrator/retention.js +42 -8
  235. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  236. package/dist/src/orchestrator/scheduling-scope.js +245 -0
  237. package/dist/src/orchestrator/scheduling-skip.js +195 -0
  238. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  239. package/dist/src/orchestrator/startup-migrations.js +11 -7
  240. package/dist/src/paths.js +126 -49
  241. package/dist/src/pi/command-shape.js +12 -1
  242. package/dist/src/pi/pi-args.js +6 -4
  243. package/dist/src/pi/pi-backend.js +6 -6
  244. package/dist/src/pi/pi-bin.js +54 -63
  245. package/dist/src/pi/pi-listed-models.js +53 -0
  246. package/dist/src/pi/pi-models.js +63 -30
  247. package/dist/src/pi/pi-run-result.js +1 -1
  248. package/dist/src/pi/pi-settings.js +1 -1
  249. package/dist/src/pi/pi-stream.js +6 -6
  250. package/dist/src/pi/pi-watchdogs.js +18 -19
  251. package/dist/src/pi-extension/bounded-output.js +5 -3
  252. package/dist/src/pi-extension/context-budget.js +2 -2
  253. package/dist/src/pi-extension/context-shake.js +1 -1
  254. package/dist/src/pi-extension/context-window.js +0 -1
  255. package/dist/src/pi-extension/full-output.js +3 -10
  256. package/dist/src/pi-extension/strip-thinking.js +0 -1
  257. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  258. package/dist/src/pi-extension/write-guard.js +37 -0
  259. package/dist/src/process/launch-services.js +1 -2
  260. package/dist/src/process/node-bin.js +16 -0
  261. package/dist/src/process/process-group.js +8 -18
  262. package/dist/src/process/process.js +41 -2
  263. package/dist/src/process/run-marker.js +26 -6
  264. package/dist/src/process/supervisor.js +22 -21
  265. package/dist/src/prompt/dialect.js +17 -2
  266. package/dist/src/prompt/gate-notes.js +203 -0
  267. package/dist/src/prompt/gate-prompts.js +150 -217
  268. package/dist/src/prompt/notes-backlog.js +184 -0
  269. package/dist/src/prompt/principles.js +1 -2
  270. package/dist/src/prompt/prompt-followup.js +19 -14
  271. package/dist/src/prompt/prompt.js +131 -204
  272. package/dist/src/redeploy/redeploy-policy.js +24 -19
  273. package/dist/src/redeploy/redeploy-probes.js +1 -1
  274. package/dist/src/redeploy/redeploy.js +2 -3
  275. package/dist/src/redeploy/redeployer.js +20 -20
  276. package/dist/src/redeploy/self-reload.js +2 -1
  277. package/dist/src/report/report-data.js +188 -124
  278. package/dist/src/report/report-fold.js +130 -54
  279. package/dist/src/report/report-main-red.js +55 -0
  280. package/dist/src/report/report-render.js +136 -65
  281. package/dist/src/report/report-types.js +1 -0
  282. package/dist/src/report/report.js +23 -9
  283. package/dist/src/request-timeouts.js +2 -2
  284. package/dist/src/review/diff-rules.js +53 -0
  285. package/dist/src/review/known-flakes.js +112 -1
  286. package/dist/src/review/post-merge-review-accept.js +51 -0
  287. package/dist/src/review/post-merge-review-render.js +112 -0
  288. package/dist/src/review/post-merge-review.js +239 -0
  289. package/dist/src/review/protected-edits.js +24 -10
  290. package/dist/src/review/quarantine-render.js +65 -0
  291. package/dist/src/review/review-followup.js +8 -8
  292. package/dist/src/review/review-precheck.js +66 -23
  293. package/dist/src/review/review-run.js +395 -0
  294. package/dist/src/review/review-verdict.js +2 -2
  295. package/dist/src/review/review.js +180 -401
  296. package/dist/src/review/secret-leaks.js +36 -16
  297. package/dist/src/review/suite-rerun.js +0 -1
  298. package/dist/src/roles/legacy-role-migration.js +80 -7
  299. package/dist/src/roles/role-catalog.js +127 -12
  300. package/dist/src/roles/role-guidance.js +65 -20
  301. package/dist/src/roles/role-labels.js +7 -5
  302. package/dist/src/roles/role-render.js +108 -56
  303. package/dist/src/roles/role-view.js +95 -56
  304. package/dist/src/roles/roles.js +33 -33
  305. package/dist/src/roles/runner-ids.js +97 -0
  306. package/dist/src/run/acceptance-greps.js +119 -0
  307. package/dist/src/run/ci-parity.js +121 -0
  308. package/dist/src/run/feedback-filings.js +51 -0
  309. package/dist/src/run/qa-coverage.js +125 -0
  310. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  311. package/dist/src/run/run-detail-data.js +175 -0
  312. package/dist/src/run/run-detail-render.js +91 -0
  313. package/dist/src/run/run-detail.js +168 -0
  314. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
  315. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  316. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
  317. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  318. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
  319. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  320. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  321. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
  322. package/dist/src/run/selfcheck-digest.js +40 -0
  323. package/dist/src/run/stage-backlog-checks.js +249 -0
  324. package/dist/src/run/stage-check.js +178 -0
  325. package/dist/src/run/stage-checks-shared.js +65 -0
  326. package/dist/src/run/stage-name-checks.js +204 -0
  327. package/dist/src/run/stage-path-checks.js +231 -0
  328. package/dist/src/run/stage-prose-checks.js +261 -0
  329. package/dist/src/runner/leftover.js +216 -0
  330. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  331. package/dist/src/runner/revision.js +191 -0
  332. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  333. package/dist/src/runner/runner-outcome.js +109 -0
  334. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  335. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
  336. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
  337. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  338. package/dist/src/runner/runner-state.js +103 -0
  339. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  340. package/dist/src/scheduling/backoff.js +38 -38
  341. package/dist/src/scheduling/claim-entries.js +49 -0
  342. package/dist/src/scheduling/claims.js +35 -22
  343. package/dist/src/scheduling/once-round.js +22 -22
  344. package/dist/src/scheduling/quiet-hours.js +5 -6
  345. package/dist/src/scheduling/scheduling.js +74 -64
  346. package/dist/src/scheduling/work-merged-cache.js +2 -2
  347. package/dist/src/status/status-budget.js +55 -47
  348. package/dist/src/status/status-data.js +49 -17
  349. package/dist/src/status/status-inbox.js +2 -2
  350. package/dist/src/status/status-install.js +0 -1
  351. package/dist/src/status/status-polls.js +43 -28
  352. package/dist/src/status/status-roles.js +50 -13
  353. package/dist/src/team/error-storm.js +8 -8
  354. package/dist/src/team/failure-spread.js +2 -2
  355. package/dist/src/team/orchestrator-info.js +15 -9
  356. package/dist/src/team/reclaim.js +7 -8
  357. package/dist/src/team/team-hold.js +7 -7
  358. package/dist/src/team/team-polls.js +91 -50
  359. package/dist/src/team/team-state.js +8 -2
  360. package/dist/src/text/datetime.js +65 -18
  361. package/dist/src/text/format.js +48 -11
  362. package/dist/src/text/markdown.js +8 -5
  363. package/dist/src/text/phrases.js +60 -23
  364. package/dist/src/text/suggest.js +1 -1
  365. package/dist/src/text/text.js +78 -13
  366. package/dist/src/ui/badges.js +99 -38
  367. package/dist/src/ui/gui/gui-client-backlog.js +314 -0
  368. package/dist/src/ui/gui/gui-client-boot.js +89 -61
  369. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  370. package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
  371. package/dist/src/ui/gui/gui-client-drawer.js +155 -289
  372. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  373. package/dist/src/ui/gui/gui-client-history.js +437 -68
  374. package/dist/src/ui/gui/gui-client-labels.js +6 -3
  375. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  376. package/dist/src/ui/gui/gui-client-model.js +27 -36
  377. package/dist/src/ui/gui/gui-client-operator.js +60 -52
  378. package/dist/src/ui/gui/gui-client-pending.js +405 -24
  379. package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
  380. package/dist/src/ui/gui/gui-client-report.js +105 -430
  381. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  382. package/dist/src/ui/gui/gui-client-settings.js +163 -5
  383. package/dist/src/ui/gui/gui-client-team.js +36 -465
  384. package/dist/src/ui/gui/gui-client.js +38 -26
  385. package/dist/src/ui/gui/gui-icons.js +0 -2
  386. package/dist/src/ui/gui/gui-page.js +10 -7
  387. package/dist/src/ui/gui/gui-styles.js +38 -34
  388. package/dist/src/ui/progress-data.js +9 -11
  389. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
  390. package/dist/src/ui/status-model.js +133 -125
  391. package/dist/src/ui/status-payload.js +364 -49
  392. package/dist/src/ui/status-render.js +109 -82
  393. package/dist/src/ui/team-alerts.js +728 -62
  394. package/dist/src/ui/tone.js +12 -6
  395. package/dist/src/ui/transcript-run.js +58 -0
  396. package/dist/src/ui/transcript-tail.js +28 -20
  397. package/dist/src/ui/transcript.js +63 -23
  398. package/dist/src/ui/tui/tui-app.js +0 -1
  399. package/dist/src/ui/tui/tui-backlog.js +53 -14
  400. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  401. package/dist/src/ui/tui/tui-editor-attach.js +4 -12
  402. package/dist/src/ui/tui/tui-editor-backends.js +127 -0
  403. package/dist/src/ui/tui/tui-editor-brief.js +14 -36
  404. package/dist/src/ui/tui/tui-editor-budget.js +36 -39
  405. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  406. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  407. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  408. package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
  409. package/dist/src/ui/tui/tui-editor-config.js +99 -0
  410. package/dist/src/ui/tui/tui-editor-entry.js +39 -64
  411. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  412. package/dist/src/ui/tui/tui-editor-instances.js +46 -0
  413. package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
  414. package/dist/src/ui/tui/tui-editor-interval.js +29 -16
  415. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  416. package/dist/src/ui/tui/tui-editor-notes.js +9 -29
  417. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  418. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  419. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  420. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  421. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  422. package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
  423. package/dist/src/ui/tui/tui-editor-retire.js +5 -13
  424. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  425. package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
  426. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  427. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  428. package/dist/src/ui/tui/tui-editor-session.js +101 -34
  429. package/dist/src/ui/tui/tui-editor-slots.js +24 -0
  430. package/dist/src/ui/tui/tui-editors.js +44 -2
  431. package/dist/src/ui/tui/tui-frame.js +68 -11
  432. package/dist/src/ui/tui/tui-history.js +8 -9
  433. package/dist/src/ui/tui/tui-input.js +13 -11
  434. package/dist/src/ui/tui/tui-keymap.js +0 -1
  435. package/dist/src/ui/tui/tui-keys.js +33 -9
  436. package/dist/src/ui/tui/tui-pane-activity.js +35 -19
  437. package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
  438. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  439. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  440. package/dist/src/ui/tui/tui-pane-health.js +168 -8
  441. package/dist/src/ui/tui/tui-pane-history.js +446 -82
  442. package/dist/src/ui/tui/tui-pane-pending.js +539 -15
  443. package/dist/src/ui/tui/tui-pane-queued.js +78 -57
  444. package/dist/src/ui/tui/tui-pane-report.js +54 -16
  445. package/dist/src/ui/tui/tui-pane-session.js +34 -6
  446. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  447. package/dist/src/ui/tui/tui-pane.js +91 -28
  448. package/dist/src/ui/tui/tui-panes.js +153 -34
  449. package/dist/src/ui/tui/tui.js +18 -15
  450. package/dist/src/verdict/fix-claim.js +129 -33
  451. package/dist/src/verdict/no-change.js +4 -4
  452. package/dist/src/verdict/refusal.js +40 -16
  453. package/dist/src/verdict/reply-contract.js +77 -31
  454. package/dist/src/version.js +0 -1
  455. package/package.json +2 -1
  456. package/dist/src/loop/leftover.js +0 -130
  457. package/dist/src/loop/revision.js +0 -70
  458. package/dist/src/roles/loop-ids.js +0 -77
  459. package/dist/src/tick/qa-coverage.js +0 -108
  460. package/dist/src/tick/stage-check.js +0 -419
  461. package/dist/src/tick/telemetry-digest.js +0 -25
  462. package/dist/src/tick/tick-detail-data.js +0 -108
  463. package/dist/src/tick/tick-detail.js +0 -163
  464. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,107 +1,59 @@
1
- // vocabulary: plain
2
- /** Harness-mediated config WRITES — the mutators behind the operator surfaces (the TUI's
3
- * Ctrl+B editor, the GUI's /api/budget endpoint) and the director's config requests. Each
4
- * one loads fresh (bypassing config.ts's stat cache), validates before writing, and writes
5
- * atomically, because readers poll tumwater.json every ~2 s. The read side — defaults,
6
- * loading, saving, and the role-selection helpers — lives in config.ts, the per-run
7
- * model derivations in config-views.ts, and the config KEY language — how keys are parsed,
8
- * addressed and given per-key value screeners — lives in config-keys.ts (both the write
9
- * verbs here and the read side / doctor share it). */
10
- import fs from "node:fs";
1
+ /** Harness-mediated config WRITES — the four operator-editor mutators behind the operator
2
+ * surfaces (the TUI's Ctrl+B editor, the GUI's /api/budget endpoint, `tumwater config
3
+ * set`/`unset`), addressed by key through config-keys.ts. The shared write MACHINERY these
4
+ * verbs run on — the load → validate → atomic-write idiom, the cross-process lock, and the
5
+ * raw/preserve-role helpers — lives in config-write-core.ts, which the director's staged
6
+ * request (config-request.ts) also consumes; every writer here loads fresh (bypassing
7
+ * config.ts's stat cache), validates before writing, and writes atomically, because readers
8
+ * poll tumwater.json every ~2 s. The read side — defaults, loading, saving, and the
9
+ * role-selection helpers — lives in config.ts, the per-run model derivations in
10
+ * config-views.ts, and the config KEY language — how keys are parsed, addressed and given
11
+ * per-key value screeners — lives in config-keys.ts (both the write verbs here and the read
12
+ * side / doctor share it). */
13
+ import { isDeepStrictEqual } from "node:util";
11
14
  import { modelTierMap } from "./config-schema.js";
12
- import { configPath, configLockPath, configRequestPath, CONFIG_BASENAME } from "../paths.js";
13
- import { errorMessage } from "../text/text.js";
14
- import { writeJsonAtomic } from "../files/json-files.js";
15
15
  import { isJsonObject, objectOrEmpty } from "../files/json-object.js";
16
- import { withStateLock } from "../concurrency/lock.js";
17
16
  import { show } from "./config-field-checks.js";
18
- import { validateConfig } from "./config-validation.js";
19
- import { canonicalConfigKey, normalizeConfigKeys } from "./config-aliases.js";
17
+ import { canonicalConfigKey } from "./config-aliases.js";
20
18
  import { unknownRoleMessage } from "../roles/roles.js";
21
- import { loadConfig, parseJsonConfig, collapseAgentAliases, knownRoleIdsCached } from "./config.js";
19
+ import { loadConfig, collapseBackendAliases, knownRoleIdsCached, } from "./config.js";
22
20
  import { checkDailyBudgetUsd, checkProviderConcurrencyCap, checkQuietHours, parseConfigKey, PER_KEY_VALIDATORS, unknownConfigKeyError, unknownRoleFieldError, } from "./config-keys.js";
23
- /** Serialize a config write through the cross-process lock (paths.ts's configLockPath): the
24
- * CLI, a standalone dashboard's server, and the running team are separate processes, so an
25
- * unguarded read-modify-write lets one writer's whole-file write silently drop another's change
26
- * (the same lost update paused-roles.json already guards against). A lock that cannot be
27
- * acquired within the budget throws rather than writing unlocked — a silently lost config edit
28
- * is worse than a write that reports an error. */
29
- const CONFIG_LOCK_TIMEOUT_MS = 10_000;
30
- function withConfigLock(root, fn) {
31
- // withStateLock creates the state dir the lock sits in (a CLI write may run before any state
32
- // file).
33
- return withStateLock(configLockPath(root), fn, CONFIG_LOCK_TIMEOUT_MS);
34
- }
35
- /** The error arm of the shared write-result union — one home for the `{ ok: false, error }`
36
- * constructor every writer's failure path returns (a broken on-disk file, a validation
37
- * problem, a failed write, a lost lock), so the shape cannot drift between callers. */
38
- function fail(error) {
39
- return { ok: false, error };
40
- }
41
- /** The one load → mutate → validate → atomic-write idiom every top-level config writer
42
- * shares (setDailyBudgetUsd, setConfigKey): a fresh loadConfig that bypasses config.ts's
43
- * stat cache (a writer must see the latest file), the caller's mutation applied in memory,
44
- * validateConfig over the whole merged candidate, then writeJsonAtomic (tmp file + rename)
45
- * because readers poll tumwater.json every ~2 s. The whole load-to-write sequence runs under
46
- * withConfigLock, so two processes racing different keys cannot each write from the same
47
- * pre-race snapshot and lose one update. On any failure — a broken on-disk file, a
48
- * type-invalid candidate, a failed write, or a lock that cannot be acquired — the file is
49
- * left untouched (and no tmp remnant is left behind); the error surfaces as a string so
50
- * callers (both dashboards, the CLI) can print it without try/catch plumbing. */
51
- function writeConfigMutation(root, mutate) {
52
- try {
53
- return withConfigLock(root, () => {
54
- let cfg;
55
- try {
56
- cfg = loadConfig(root); // fresh — bypasses the stat cache on purpose
57
- }
58
- catch (err) {
59
- return fail(errorMessage(err)); // broken file: never overwrite it with defaults
60
- }
61
- const mutated = mutate(cfg);
62
- // An unset that finds nothing to remove returns the loaded config unchanged. Write nothing:
63
- // re-serializing the merged config would reformat tumwater.json (defaults expanded) and
64
- // break the no-op's "changes nothing" contract. The bytes stay identical; validation of an
65
- // already-validated config is unnecessary.
66
- if (mutated === cfg)
67
- return { ok: true };
68
- // Collapse legacy/new pi spellings before writing so a merged config (which always
69
- // carries the default piArgs) never lands both forms in the file.
70
- const candidate = collapseAgentAliases(mutated);
71
- try {
72
- validateConfig(candidate); // throws listing every problem — nothing is written on failure
73
- }
74
- catch (err) {
75
- return fail(errorMessage(err));
76
- }
77
- try {
78
- // The trailing newline is tumwater.json's convention (POSIX text file).
79
- writeJsonAtomic(configPath(root), candidate, true);
80
- }
81
- catch (err) {
82
- return fail(errorMessage(err));
83
- }
84
- return { ok: true };
85
- });
86
- }
87
- catch (err) {
88
- return fail(errorMessage(err)); // lock acquire timeout/failure — nothing written
89
- }
90
- }
21
+ import { writeConfigMutation, fail, rawConfigObject, without, preservingFileRoles, preserveFileRoles, withSectionOrDropped, } from "./config-write-core.js";
91
22
  /** Set the daily cost budget cap in tumwater.json through the shared writer idiom (see
92
23
  * writeConfigMutation), after checkDailyBudgetUsd has screened the value with its own
93
- * actionable message — the one both dashboards flash. */
24
+ * actionable message — the one both dashboards flash. A cap the config already resolves to
25
+ * (an omitted key resolves to the default) returns `unchanged: true` and writes nothing. */
94
26
  export function setDailyBudgetUsd(root, value) {
95
27
  const problem = checkDailyBudgetUsd(value);
96
28
  if (problem)
97
29
  return fail(problem);
98
- return writeConfigMutation(root, (cfg) => {
30
+ let unchanged = false;
31
+ const result = writeConfigMutation(root, (cfg) => {
32
+ if (cfg.maxDailyCostUsd === value) {
33
+ unchanged = true;
34
+ return cfg; // the loaded config itself: writeConfigMutation skips the write
35
+ }
99
36
  const next = { ...cfg, maxDailyCostUsd: value };
100
37
  // A whole-config write: keep the file's own roles section (see preserveFileRoles).
101
38
  preserveFileRoles(root, next);
102
39
  return next;
103
40
  });
41
+ return result.ok && unchanged ? { ok: true, unchanged: true } : result;
104
42
  }
43
+ /** The top-level team keys whose value is a plain positive integer (validateConfig's
44
+ * POSITIVE_INTEGER checks, config-validation.ts): the run-concurrency knobs plus the legacy
45
+ * merge batch cap. For these, an exponent spelling like "4e1" is itself a JSON number (40)
46
+ * that setConfigKey's JSON.parse would read past validateConfig's integer check and silently
47
+ * write — the same silent misread the per-role instances/interval guards, the TUI team-key
48
+ * editors, and the GUI Settings rows refuse — so setConfigKey refuses it, naming the key and
49
+ * writing nothing. The per-provider map (maxConcurrentPerProvider.<provider>) has its own
50
+ * checkProviderConcurrencyCap screen and is not in this set. */
51
+ const PLAIN_INTEGER_TOP_LEVEL_KEYS = new Set([
52
+ "maxConcurrent",
53
+ "mergeBatchMax",
54
+ "maxConcurrentChecks",
55
+ "worktreeSlots",
56
+ ]);
105
57
  /** Set ONE top-level key of tumwater.json from its raw CLI text (`tumwater config set`'s
106
58
  * engine): the key must be a member of TOP_LEVEL_KEYS — the same protection checkKnownKeys
107
59
  * gives the file itself, so `config set modle x` cannot write a dead key the runtime would
@@ -116,12 +68,33 @@ export function setDailyBudgetUsd(root, value) {
116
68
  * ROLE_ENTRY_KEYS (a did-you-mean suggestion otherwise, via text/suggest.ts's typoSuffix); a
117
69
  * typo'd role id and a type-invalid value are left for validateConfig to reject with its own
118
70
  * actionable message. Returns the parsed value and the previous one so the caller can
119
- * confirm the change; on any failure the file is untouched (see writeConfigMutation). */
71
+ * confirm the change; on any failure the file is untouched
72
+ * (see writeConfigMutation). A value equal to the resolved one is a no-op: nothing is written and
73
+ * the result carries `unchanged`. */
120
74
  export function setConfigKey(root, key, rawValue) {
121
75
  key = canonicalConfigKey(key);
122
76
  const parsed = parseConfigKey(key);
123
77
  if (parsed.kind === "error")
124
78
  return fail(parsed.error);
79
+ // Shape, before the JSON.parse, for every plain-positive-integer key this verb can write —
80
+ // the top-level run-concurrency knobs and legacy merge batch cap, plus the integer-bound
81
+ // team-scope keys that arrive as a dotted key (`maxConcurrentPerProvider.<provider>`,
82
+ // `roles.<id>.instances`, the `bootstrap.untilPlansDone` section field): refuse an exponent
83
+ // spelling ("4e1" → 40) that JSON.parse would read past validateConfig's integer check and
84
+ // silently write, naming the key and writing nothing — the twin of the per-role guards, the
85
+ // TUI team-key editors, and the GUI Settings rows. A non-exponent invalid falls through for
86
+ // validateConfig (whose message names the key). Empty clears the key below.
87
+ const trimmed = rawValue.trim();
88
+ const isPlainIntegerKey = (parsed.kind === "top" && PLAIN_INTEGER_TOP_LEVEL_KEYS.has(key)) ||
89
+ (parsed.kind === "map" && parsed.map === "maxConcurrentPerProvider") ||
90
+ (parsed.kind === "role" && parsed.field === "instances") ||
91
+ (parsed.kind === "section" && parsed.section === "bootstrap" && parsed.field === "untilPlansDone");
92
+ if (isPlainIntegerKey &&
93
+ trimmed !== "" &&
94
+ !/^\d*\.?\d+$/.test(trimmed) &&
95
+ /^\d+(\.\d+)?[eE][+-]?\d+$/.test(trimmed))
96
+ return fail(`${key} must be a plain integer — no exponent notation`);
97
+ let unchanged; // set by a mutation callback when value equals the resolved one
125
98
  let value;
126
99
  try {
127
100
  value = JSON.parse(rawValue);
@@ -155,6 +128,10 @@ export function setConfigKey(root, key, rawValue) {
155
128
  if (parsed.kind === "map") {
156
129
  const existing = record[parsed.map] ?? {};
157
130
  oldValue = existing[parsed.role];
131
+ if (isDeepStrictEqual(oldValue, value)) {
132
+ unchanged = true;
133
+ return cfg;
134
+ }
158
135
  const next = { ...cfg, [parsed.map]: { ...existing, [parsed.role]: value } };
159
136
  return preservingFileRoles(root, next);
160
137
  }
@@ -166,19 +143,39 @@ export function setConfigKey(root, key, rawValue) {
166
143
  // there and nothing is written.
167
144
  const existing = modelTierMap(record.model);
168
145
  oldValue = existing[parsed.tier];
146
+ if (isDeepStrictEqual(oldValue, value)) {
147
+ unchanged = true;
148
+ return cfg;
149
+ }
169
150
  const next = { ...cfg, model: { ...existing, [parsed.tier]: value } };
170
151
  return preservingFileRoles(root, next);
171
152
  }
153
+ if (parsed.kind === "section") {
154
+ // Merge one field into the loaded section, keeping its other fields; validateConfig in
155
+ // writeConfigMutation owns the value type, so a bad value fails there and writes nothing.
156
+ const existing = record[parsed.section] ?? {};
157
+ oldValue = existing[parsed.field];
158
+ if (isDeepStrictEqual(oldValue, value)) {
159
+ unchanged = true;
160
+ return cfg;
161
+ }
162
+ const next = { ...cfg, [parsed.section]: { ...existing, [parsed.field]: value } };
163
+ return preservingFileRoles(root, next);
164
+ }
172
165
  if (parsed.kind === "agent") {
173
166
  // Merge one field of one backend's entry, preserving the other field and the other
174
167
  // backends. validateConfig in writeConfigMutation owns the value type, so a bad
175
168
  // value fails there and nothing is written.
176
- const agents = { ...record.backends };
177
- const entry = { ...(agents[parsed.id] ?? {}) };
169
+ const backends = { ...record.backends };
170
+ const entry = { ...(backends[parsed.id] ?? {}) };
178
171
  oldValue = entry[parsed.field];
172
+ if (isDeepStrictEqual(oldValue, value)) {
173
+ unchanged = true;
174
+ return cfg;
175
+ }
179
176
  entry[parsed.field] = value;
180
- agents[parsed.id] = entry;
181
- const next = { ...cfg, backends: agents };
177
+ backends[parsed.id] = entry;
178
+ const next = { ...cfg, backends: backends };
182
179
  // Writers keep one spelling: the new pi form drops its legacy alias so the file can
183
180
  // never hold both (validateConfig's raw pass rejects a file that does).
184
181
  if (parsed.id === "pi") {
@@ -188,6 +185,12 @@ export function setConfigKey(root, key, rawValue) {
188
185
  else
189
186
  delete nextRecord.piArgs;
190
187
  }
188
+ // Setting the nested claude reserve drops the top-level spelling, so the file can never
189
+ // hold both (validateConfig rejects a file that does, mirroring the agentBin/piArgs pair).
190
+ if (parsed.id === "claude" && parsed.field === "windowReserve") {
191
+ const nextRecord = next;
192
+ delete nextRecord.claudeWindowReserve;
193
+ }
191
194
  return preservingFileRoles(root, next);
192
195
  }
193
196
  // Base the merge on the FILE's own roles, not the loaded (catalog-merged) map: an
@@ -199,6 +202,10 @@ export function setConfigKey(root, key, rawValue) {
199
202
  const rawEntryCandidate = rawRoles[parsed.id];
200
203
  const rawEntry = objectOrEmpty(rawEntryCandidate);
201
204
  oldValue = record.roles?.[parsed.id]?.[parsed.field];
205
+ if (isDeepStrictEqual(oldValue, value)) {
206
+ unchanged = true;
207
+ return cfg;
208
+ }
202
209
  const roles = {
203
210
  ...rawRoles,
204
211
  [parsed.id]: { ...rawEntry, [parsed.field]: value },
@@ -210,6 +217,18 @@ export function setConfigKey(root, key, rawValue) {
210
217
  });
211
218
  if (!result.ok)
212
219
  return result;
220
+ if (unchanged)
221
+ return { ok: true, value, oldValue, unchanged: true };
222
+ // A string replaces a whole object tier entry, so its `backend` goes with it.
223
+ if (parsed.kind === "tier" && isJsonObject(oldValue)) {
224
+ const dropped = typeof oldValue.backend === "string" ? `backend ${show(oldValue.backend)}` : "its other keys";
225
+ return {
226
+ ok: true,
227
+ value,
228
+ oldValue,
229
+ note: `model.${parsed.tier} was an object entry — the string replaces it, dropping ${dropped}`,
230
+ };
231
+ }
213
232
  return { ok: true, value, oldValue };
214
233
  }
215
234
  const unknown = unknownConfigKeyError(key);
@@ -240,6 +259,10 @@ export function setConfigKey(root, key, rawValue) {
240
259
  let oldValue;
241
260
  const result = writeConfigMutation(root, (cfg) => {
242
261
  oldValue = cfg[key];
262
+ if (isDeepStrictEqual(oldValue, value)) {
263
+ unchanged = true;
264
+ return cfg;
265
+ }
243
266
  const next = { ...cfg, [key]: value };
244
267
  // `roles` merges per-entry at load (overlayDefaults seeds the catalog and lets the file
245
268
  // override one entry), so an unrelated whole-config write must not re-expand the file's
@@ -251,30 +274,46 @@ export function setConfigKey(root, key, rawValue) {
251
274
  // legacy aliases the new pi form now carries, so the file can never hold both.
252
275
  // Writers keep one spelling: a whole-section `backends` set (the GUI Save path) drops the
253
276
  // legacy agentBin/piArgs aliases the new pi form now carries, so the file can never hold
254
- // both — the same collapseAgentAliases rule the whole-config writers (above and in
277
+ // both — the same collapseBackendAliases rule the whole-config writers (above and in
255
278
  // config.ts) use, so the alias policy cannot drift between the per-key arm and the others.
256
279
  if (key === "backends")
257
- return collapseAgentAliases(next);
280
+ return collapseBackendAliases(next);
258
281
  // Writers keep one spelling: a legacy top-level set drops the matching backends.pi field, so
259
282
  // the file can never hold both (validateConfig's raw pass rejects a file that does).
260
283
  if (key === "agentBin" || key === "piArgs") {
261
284
  const record = next;
262
- const agents = { ...(record.backends ?? {}) };
263
- const entry = { ...(agents.pi ?? {}) };
285
+ const backends = { ...(record.backends ?? {}) };
286
+ const entry = { ...(backends.pi ?? {}) };
264
287
  delete entry[key === "agentBin" ? "bin" : "args"];
265
288
  if (Object.keys(entry).length === 0)
266
- delete agents.pi;
289
+ delete backends.pi;
267
290
  else
268
- agents.pi = entry;
269
- return { ...next, backends: agents };
291
+ backends.pi = entry;
292
+ return { ...next, backends: backends };
293
+ }
294
+ // Writers keep one spelling: setting the top-level `claudeWindowReserve` drops the nested
295
+ // `backends.claude.windowReserve`, so a supported `config set` never writes the rejected pair
296
+ // (validateConfig still refuses a hand-edited file holding both).
297
+ if (key === "claudeWindowReserve") {
298
+ const record = next;
299
+ const backends = { ...(record.backends ?? {}) };
300
+ const entry = { ...(backends.claude ?? {}) };
301
+ delete entry.windowReserve;
302
+ if (Object.keys(entry).length === 0)
303
+ delete backends.claude;
304
+ else
305
+ backends.claude = entry;
306
+ return { ...next, backends: backends };
270
307
  }
271
308
  return next;
272
309
  });
273
310
  if (!result.ok)
274
311
  return result;
312
+ if (unchanged)
313
+ return { ok: true, value, oldValue, unchanged: true };
275
314
  return { ok: true, value, oldValue };
276
315
  }
277
- /** Set a role's provider/model/thinking override triple in tumwater.json (`roles.<base>`),
316
+ /** Set a role's provider/model/thinking/backend override set in tumwater.json (`roles.<base>`),
278
317
  * the write behind the dashboard role drawer's Model overrides editor: one writeConfigMutation
279
318
  * rebuilds `roles.<base>` from the FILE's own `roles` section (the `rawConfigObject(root).roles` /
280
319
  * `objectOrEmpty` idiom setConfigKey's role branch uses, so the write never re-expands the
@@ -297,7 +336,8 @@ export function setRoleModelFields(root, base, fields) {
297
336
  // a bogus id "updated".
298
337
  if (!knownRoleIdsCached(root).includes(base))
299
338
  return fail(unknownRoleMessage(base, knownRoleIdsCached(root)));
300
- return writeConfigMutation(root, (cfg) => {
339
+ let unchanged; // set when no field differs from the file
340
+ const result = writeConfigMutation(root, (cfg) => {
301
341
  const rawRolesCandidate = rawConfigObject(root).roles;
302
342
  const rawRoles = objectOrEmpty(rawRolesCandidate);
303
343
  const rawEntryCandidate = rawRoles[base];
@@ -318,8 +358,10 @@ export function setRoleModelFields(root, base, fields) {
318
358
  changed = true;
319
359
  }
320
360
  }
321
- if (!changed)
361
+ if (!changed) {
362
+ unchanged = true;
322
363
  return cfg; // writeConfigMutation skips the write — file bytes identical
364
+ }
323
365
  const nextRoles = { ...rawRoles };
324
366
  // Clearing the entry's last override empties it — never leave an empty `roles.<base>: {}`
325
367
  // (the file-wide no-empty-object invariant unsetConfigKey's `without` keeps); an emptied
@@ -333,51 +375,9 @@ export function setRoleModelFields(root, base, fields) {
333
375
  return without(cfg, "roles");
334
376
  return { ...cfg, roles: nextRoles };
335
377
  });
336
- }
337
- /** A shallow copy of `obj` without `key` — the copy each `unsetConfigKey` branch edits
338
- * before it decides whether the removal emptied its container, and the shape the branch then
339
- * returns when it did (an emptied map, the last field of a `roles.<id>` entry, an emptied
340
- * `model` map), so tumwater.json never keeps an empty object. */
341
- function without(obj, key) {
342
- const copy = { ...obj };
343
- delete copy[key];
344
- return copy;
345
- }
346
- /** The raw parsed tumwater.json as a plain object — the pre-defaults view `unsetConfigKey`'s
347
- * presence check needs, because the merged config always carries defaults a top-level key or a
348
- * `roles.<id>.<field>` (e.g. the seeded `minRunIntervalSeconds`) may never have been set to.
349
- * Read inside writeConfigMutation's lock; a missing or unparsable file is `{}` (loadConfig has
350
- * already rejected a broken file before a mutation runs, and an absent file is the bare
351
- * defaults — there is nothing to retract). */
352
- function rawConfigObject(root) {
353
- const { raw } = parseJsonConfig(configPath(root), CONFIG_BASENAME);
354
- return objectOrEmpty(raw);
355
- }
356
- /** Reinstall the raw tumwater.json's `roles` section onto a whole-config write candidate (or
357
- * drop it when the file had none), so a bare `config set`/`unset` of an unrelated key never
358
- * re-expands the file's roles to the built-in catalog. `roles` merges per-entry at load, so
359
- * this is about the bytes the operator can read and edit, not the resolved view: omission
360
- * still cannot disable a role, and a partial section stays exactly as written. */
361
- function preserveFileRoles(root, candidate) {
362
- const record = candidate;
363
- const rawRoles = rawConfigObject(root).roles;
364
- if (rawRoles === undefined)
365
- delete record.roles;
366
- else
367
- record.roles = rawRoles;
368
- }
369
- /** preserveFileRoles, returning the candidate so a whole-config write branch can
370
- * `return preservingFileRoles(root, next)` after building it. */
371
- function preservingFileRoles(root, candidate) {
372
- preserveFileRoles(root, candidate);
373
- return candidate;
374
- }
375
- /** The config with top-level `key` set to `section`, or removed when `section` is empty — the
376
- * unset shape for the per-role maps, `model` tiers and `backends`, which never leave an empty
377
- * object in the file. A whole-config write, so the file's own roles section is kept. */
378
- function withSectionOrDropped(root, cfg, key, section) {
379
- const next = Object.keys(section).length === 0 ? without(cfg, key) : { ...cfg, [key]: section };
380
- return preservingFileRoles(root, next);
378
+ if (result.ok && unchanged)
379
+ return { ok: true, unchanged: true };
380
+ return result;
381
381
  }
382
382
  /** Remove ONE value of tumwater.json from its raw CLI text (`tumwater config unset <key>`):
383
383
  * the inverse of setConfigKey. A bare top-level key is deleted (reverting to its default or
@@ -447,24 +447,34 @@ export function unsetConfigKey(root, key) {
447
447
  const next = without(existing, parsed.tier);
448
448
  return withSectionOrDropped(root, cfg, "model", next);
449
449
  }
450
+ if (parsed.kind === "section") {
451
+ // Presence is judged against the FILE: a field only the default carries is a no-op
452
+ // success, and an emptied section leaves the file.
453
+ const rawSection = objectOrEmpty(raw[parsed.section]);
454
+ if (!Object.hasOwn(rawSection, parsed.field))
455
+ return cfg;
456
+ oldValue = rawSection[parsed.field];
457
+ const existing = record[parsed.section] ?? {};
458
+ return withSectionOrDropped(root, cfg, parsed.section, without(existing, parsed.field));
459
+ }
450
460
  if (parsed.kind === "agent") {
451
461
  // Presence is judged against the FILE, so unsetting a field only the default (or the
452
462
  // legacy alias's resolved view) carries is a no-op success. The entry and the `backends`
453
463
  // section are dropped when they empty, so the file never keeps an empty object.
454
- const rawAgents = objectOrEmpty(raw.backends);
455
- const rawEntryCandidate = rawAgents[parsed.id];
464
+ const rawBackends = objectOrEmpty(raw.backends);
465
+ const rawEntryCandidate = rawBackends[parsed.id];
456
466
  const rawEntry = objectOrEmpty(rawEntryCandidate);
457
467
  if (!Object.hasOwn(rawEntry, parsed.field))
458
468
  return cfg; // absent file field: a no-op success
459
469
  oldValue = rawEntry[parsed.field];
460
- const agents = { ...record.backends };
461
- const entry = { ...(agents[parsed.id] ?? {}) };
470
+ const backends = { ...record.backends };
471
+ const entry = { ...(backends[parsed.id] ?? {}) };
462
472
  delete entry[parsed.field];
463
473
  if (Object.keys(entry).length === 0)
464
- delete agents[parsed.id];
474
+ delete backends[parsed.id];
465
475
  else
466
- agents[parsed.id] = entry;
467
- return withSectionOrDropped(root, cfg, "backends", agents);
476
+ backends[parsed.id] = entry;
477
+ return withSectionOrDropped(root, cfg, "backends", backends);
468
478
  }
469
479
  const rawRolesCandidate = raw.roles;
470
480
  const rawRoles = objectOrEmpty(rawRolesCandidate);
@@ -489,114 +499,3 @@ export function unsetConfigKey(root, key) {
489
499
  return result;
490
500
  return { ok: true, oldValue };
491
501
  }
492
- /** Consume the director's config-write request file (plans/portability.md §3/7). After its pi
493
- * run the director may leave `.tumwater-config-request.json` at its worktree root:
494
- * `{ "customRoles": [ { "name", "task" }, … ] }` — the whole array, replacing the current one.
495
- * Applied atomically to the live config with setDailyBudgetUsd's idiom (fresh loadConfig that
496
- * bypasses the stat cache, validateConfig, then writeJsonAtomic — readers poll every ~2 s), so
497
- * the orchestrator's live reload starts the new loop with no commit, no review gate, and no
498
- * merge, and the request file never enters a diff.
499
- *
500
- * The permitted key set is enforced here, not in prose: `customRoles` is accepted and every
501
- * other top-level key is collected into `ignored` and dropped (the caller logs the warning that
502
- * names them). Validation runs BEFORE any write and nothing here dereferences a request entry
503
- * first — a structurally malformed entry (`[null]`, a non-string name) is left for
504
- * validateConfig to reject, so on failure nothing is written and the previous config stays
505
- * live. The request file is deleted on EVERY path (including every failure), so a malformed
506
- * request cannot retry forever and the file cannot survive into `git add -A`.
507
- *
508
- * Roles entries for custom loops the request removes are stripped before validation, from the
509
- * FILE's own `roles` section rather than the catalog-merged map (the rule every other
510
- * whole-config write follows, see preserveFileRoles): loadConfig seeds one `roles.<name>` per
511
- * current custom loop and validateConfig rejects an id that is neither a catalog role nor a
512
- * requested custom-loop name, so without the strip a removal would never apply. Only
513
- * structurally valid names feed the strip — a malformed entry stays in the array for
514
- * validateConfig to reject.
515
- *
516
- * Returns null when no request file exists. `applied` names the custom loops now live (empty
517
- * unless the write happened); `ignored` lists the discarded top-level keys; `error`, when set,
518
- * names why nothing (or only part of the work) was done — the caller turns it and `ignored`
519
- * into warning events. */
520
- export function applyConfigRequest(root, wt) {
521
- const requestFile = configRequestPath(wt);
522
- let raw;
523
- try {
524
- raw = fs.readFileSync(requestFile, "utf8");
525
- }
526
- catch {
527
- return null; // no request this tick
528
- }
529
- const ignored = [];
530
- let applied = [];
531
- let error;
532
- try {
533
- const unnormalized = JSON.parse(raw);
534
- if (!isJsonObject(unnormalized))
535
- throw new Error(`config request must be a JSON object (got ${show(unnormalized)})`);
536
- // Old key spellings read as the new ones; a request setting both fails with the pair named.
537
- const normalized = normalizeConfigKeys(unnormalized);
538
- if (normalized.problems.length > 0)
539
- throw new Error(normalized.problems.join("; "));
540
- const parsed = normalized.raw;
541
- for (const key of Object.keys(parsed)) {
542
- if (key !== "customRoles")
543
- ignored.push(key);
544
- }
545
- const loops = parsed.customRoles;
546
- if (!Array.isArray(loops))
547
- throw new Error(`config request's customRoles must be an array (got ${show(loops)})`);
548
- // The load-to-write sequence is one critical section under withConfigLock: a concurrent
549
- // `config set`/dashboard write between the load and this write must not be dropped by the
550
- // whole-file overwrite (see writeConfigMutation).
551
- withConfigLock(root, () => {
552
- const current = loadConfig(root); // fresh — bypasses the stat cache: a writer sees the latest file
553
- // Strip roles.<id> entries for custom loops this request removes (see docstring).
554
- // Structurally valid names only; anything else stays for validateConfig to reject — never
555
- // dereference a request entry before validation.
556
- const requestedNames = new Set();
557
- for (const entry of loops) {
558
- if (isJsonObject(entry) && typeof entry.name === "string")
559
- requestedNames.add(entry.name);
560
- }
561
- // Base the roles rewrite on the FILE's own section, not the loaded (catalog-merged)
562
- // map: this whole-config write must not re-expand a partial section to every built-in
563
- // (preserveFileRoles' rule). Only the removed custom loops' entries are stripped, and
564
- // only from what the file actually holds.
565
- const rawRolesCandidate = rawConfigObject(root).roles;
566
- const roles = isJsonObject(rawRolesCandidate)
567
- ? { ...(rawRolesCandidate) }
568
- : {};
569
- for (const c of current.customRoles) {
570
- if (!requestedNames.has(c.name))
571
- delete roles[c.name];
572
- }
573
- const candidate = { ...current, customRoles: loops };
574
- const candidateRecord = candidate;
575
- if (rawRolesCandidate === undefined)
576
- delete candidateRecord.roles;
577
- else
578
- candidateRecord.roles = roles;
579
- // Collapse legacy/new pi spellings before writing so a merged config (which always
580
- // carries the default piArgs) never lands both forms in the file — the next load's raw
581
- // pass rejects a file holding both `piArgs` and `backends.pi.args`.
582
- const collapsed = collapseAgentAliases(candidate);
583
- validateConfig(collapsed); // throws listing every problem — nothing is written on failure
584
- writeJsonAtomic(configPath(root), collapsed, true);
585
- applied = collapsed.customRoles.map((c) => c.name);
586
- });
587
- }
588
- catch (err) {
589
- applied = []; // no write happened, or it must not be reported as applied
590
- error = errorMessage(err);
591
- }
592
- // Delete the request on EVERY path — success, rejection, malformed JSON — so it can never be
593
- // staged by commitAll, reach a review gate, or retry forever. A failed unlink is surfaced as
594
- // an error so the caller logs it (the file would otherwise survive into the diff).
595
- try {
596
- fs.unlinkSync(requestFile);
597
- }
598
- catch (err) {
599
- error ??= `config request file could not be deleted: ${errorMessage(err)}`;
600
- }
601
- return { applied, ignored, error };
602
- }