tumwater 0.2.0 → 0.3.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 (446) 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 +42 -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 +4 -3
  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 +402 -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 +246 -55
  41. package/dist/src/backlog/backlog-cli-view.js +280 -0
  42. package/dist/src/backlog/backlog-cli.js +85 -274
  43. package/dist/src/backlog/backlog-commit.js +4 -5
  44. package/dist/src/backlog/backlog-eligibility.js +175 -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 +254 -0
  48. package/dist/src/backlog/backlog-release.js +117 -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-culprit.js +94 -0
  56. package/dist/src/baseline/main-guardrails.js +209 -0
  57. package/dist/src/baseline/main-red.js +139 -32
  58. package/dist/src/baseline/main-revert.js +133 -0
  59. package/dist/src/baseline/main-watch.js +184 -0
  60. package/dist/src/brief/brief-edit.js +9 -11
  61. package/dist/src/brief/brief.js +31 -51
  62. package/dist/src/budget/budget.js +30 -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 +1 -2
  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 +118 -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 +150 -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 +308 -590
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-role-command.js +420 -0
  84. package/dist/src/cli/cli-start-summary.js +160 -0
  85. package/dist/src/cli/{cli-run.js → cli-start.js} +165 -237
  86. package/dist/src/cli/completion.js +149 -22
  87. package/dist/src/cli/config-commands.js +41 -18
  88. package/dist/src/cli/follow-view.js +75 -0
  89. package/dist/src/cli/help.js +189 -73
  90. package/dist/src/cli/log-commands.js +148 -82
  91. package/dist/src/cli/question-commands.js +205 -10
  92. package/dist/src/cli.js +106 -43
  93. package/dist/src/collections.js +26 -10
  94. package/dist/src/concurrency/check-permit.js +20 -3
  95. package/dist/src/concurrency/lock.js +17 -13
  96. package/dist/src/concurrency/provider-lease.js +44 -8
  97. package/dist/src/concurrency/semaphore.js +102 -26
  98. package/dist/src/config/config-aliases.js +0 -1
  99. package/dist/src/config/config-editable-keys.js +13 -1
  100. package/dist/src/config/config-fallbacks.js +93 -56
  101. package/dist/src/config/config-field-checks.js +40 -10
  102. package/dist/src/config/config-keys.js +30 -8
  103. package/dist/src/config/config-live.js +63 -24
  104. package/dist/src/config/config-request.js +137 -0
  105. package/dist/src/config/config-schema.js +31 -24
  106. package/dist/src/config/config-selector-checks.js +123 -0
  107. package/dist/src/config/config-validation-agents.js +80 -0
  108. package/dist/src/config/config-validation-model.js +116 -0
  109. package/dist/src/config/config-validation.js +141 -199
  110. package/dist/src/config/config-views.js +127 -53
  111. package/dist/src/config/config-write-core.js +135 -0
  112. package/dist/src/config/config-write.js +131 -264
  113. package/dist/src/config/config.js +48 -38
  114. package/dist/src/config/model-selector.js +34 -34
  115. package/dist/src/doctor/doctor-backlog-bugs.js +287 -0
  116. package/dist/src/doctor/doctor-backlog-plans.js +263 -0
  117. package/dist/src/doctor/doctor-backlog.js +100 -496
  118. package/dist/src/doctor/doctor-check-names.js +81 -0
  119. package/dist/src/doctor/doctor-checks.js +52 -579
  120. package/dist/src/doctor/doctor-claude-checks.js +145 -0
  121. package/dist/src/doctor/doctor-codex-checks.js +279 -0
  122. package/dist/src/doctor/doctor-contention-checks.js +380 -0
  123. package/dist/src/doctor/doctor-holds.js +82 -66
  124. package/dist/src/doctor/doctor-install.js +2 -1
  125. package/dist/src/doctor/doctor-model-checks.js +236 -402
  126. package/dist/src/doctor/doctor-notify.js +3 -4
  127. package/dist/src/doctor/doctor-opencode-checks.js +162 -0
  128. package/dist/src/doctor/doctor-orphans.js +182 -63
  129. package/dist/src/doctor/doctor-project-checks.js +303 -0
  130. package/dist/src/doctor/doctor.js +121 -77
  131. package/dist/src/doctor/image-probe.js +21 -8
  132. package/dist/src/doctor/outcome.js +3 -2
  133. package/dist/src/doctor/probe.js +7 -11
  134. package/dist/src/events/event-format.js +181 -86
  135. package/dist/src/events/event-labels.js +12 -5
  136. package/dist/src/events/event-legacy.js +39 -15
  137. package/dist/src/events/event-read.js +39 -39
  138. package/dist/src/events/event-tally.js +16 -0
  139. package/dist/src/events/event-window.js +60 -24
  140. package/dist/src/events/events.js +30 -13
  141. package/dist/src/events/notify.js +7 -7
  142. package/dist/src/failure/failure-cluster.js +12 -12
  143. package/dist/src/failure/failure-data.js +56 -56
  144. package/dist/src/failure/failure-render.js +31 -23
  145. package/dist/src/failure/failure-state-change.js +17 -12
  146. package/dist/src/failure/time-spend.js +99 -35
  147. package/dist/src/files/files.js +111 -35
  148. package/dist/src/files/json-files.js +3 -3
  149. package/dist/src/files/json-object.js +28 -7
  150. package/dist/src/files/stat-cache.js +2 -2
  151. package/dist/src/files/tail.js +1 -1
  152. package/dist/src/gates/bootstrap-gates.js +4 -5
  153. package/dist/src/gates/budget-gates.js +177 -84
  154. package/dist/src/gates/disk-gate.js +1 -1
  155. package/dist/src/gates/gate-polls.js +108 -58
  156. package/dist/src/gates/maintenance-quota.js +10 -13
  157. package/dist/src/gates/pause-gates.js +28 -10
  158. package/dist/src/gates/readiness.js +7 -3
  159. package/dist/src/gates/role-cap-gates.js +8 -8
  160. package/dist/src/gates/startup-gate.js +94 -32
  161. package/dist/src/gates/streak-gate.js +54 -30
  162. package/dist/src/git/commit-message.js +77 -46
  163. package/dist/src/git/git-diff.js +16 -10
  164. package/dist/src/git/git-run.js +79 -38
  165. package/dist/src/git/git.js +36 -34
  166. package/dist/src/git/slot-salvage.js +253 -0
  167. package/dist/src/git/slots-state.js +6 -6
  168. package/dist/src/git/worktree-pool.js +132 -38
  169. package/dist/src/git/worktree-use.js +4 -11
  170. package/dist/src/git/worktree.js +94 -37
  171. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  172. package/dist/src/gui/gui-args.js +79 -8
  173. package/dist/src/gui/gui-backlog-commands.js +42 -18
  174. package/dist/src/gui/gui-backlog-endpoints.js +155 -0
  175. package/dist/src/gui/gui-command.js +8 -13
  176. package/dist/src/gui/gui-config-commands.js +94 -72
  177. package/dist/src/gui/gui-endpoint-commands.js +4 -3
  178. package/dist/src/gui/gui-endpoint-common.js +4 -4
  179. package/dist/src/gui/gui-endpoints.js +11 -380
  180. package/dist/src/gui/gui-history-endpoints.js +301 -0
  181. package/dist/src/gui/gui-operator-commands.js +108 -13
  182. package/dist/src/gui/gui-prompt-commands.js +11 -14
  183. package/dist/src/gui/gui-report-endpoints.js +90 -0
  184. package/dist/src/gui/gui-server.js +11 -4
  185. package/dist/src/gui/gui-team-commands.js +7 -7
  186. package/dist/src/gui/http-body.js +1 -1
  187. package/dist/src/history/history-data.js +122 -234
  188. package/dist/src/history/history-queued.js +147 -0
  189. package/dist/src/history/history-render.js +42 -22
  190. package/dist/src/history/history.js +193 -49
  191. package/dist/src/inbox/inbox-attachments.js +29 -30
  192. package/dist/src/inbox/inbox-cancel.js +10 -11
  193. package/dist/src/inbox/inbox-edit.js +9 -9
  194. package/dist/src/inbox/inbox-submit.js +18 -18
  195. package/dist/src/inbox/inbox-top.js +25 -9
  196. package/dist/src/inbox/inbox.js +42 -43
  197. package/dist/src/inbox/pending-prompt.js +26 -26
  198. package/dist/src/inbox/prompt-commands.js +62 -49
  199. package/dist/src/inbox/prompt-not-before.js +2 -2
  200. package/dist/src/init/init-templates.js +1 -2
  201. package/dist/src/init/init.js +28 -23
  202. package/dist/src/merge/backlog-conflicts.js +215 -35
  203. package/dist/src/merge/merge-batch.js +14 -8
  204. package/dist/src/merge/merge-cancel.js +53 -0
  205. package/dist/src/merge/merge-check-failures.js +44 -22
  206. package/dist/src/merge/merge-core.js +81 -32
  207. package/dist/src/merge/merge-diff.js +5 -4
  208. package/dist/src/merge/merge-drain.js +150 -11
  209. package/dist/src/merge/merge-git.js +177 -38
  210. package/dist/src/merge/merge-pipeline.js +15 -3
  211. package/dist/src/merge/merge-post-review.js +90 -0
  212. package/dist/src/merge/merge-queue-render.js +49 -11
  213. package/dist/src/merge/merge-queue.js +31 -15
  214. package/dist/src/merge/merge-requeue.js +102 -0
  215. package/dist/src/merge/merge-slot.js +61 -16
  216. package/dist/src/merge/merge-stack.js +33 -11
  217. package/dist/src/merge/merge-to-main.js +86 -27
  218. package/dist/src/merge/merge-top.js +69 -0
  219. package/dist/src/merge/merge-verify.js +12 -10
  220. package/dist/src/notes-cli.js +30 -15
  221. package/dist/src/operator/operator-commands.js +192 -81
  222. package/dist/src/operator/operator-intent.js +69 -42
  223. package/dist/src/operator/operator-requests.js +35 -40
  224. package/dist/src/operator/retire.js +5 -6
  225. package/dist/src/operator/revert.js +142 -34
  226. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  227. package/dist/src/orchestrator/orchestrator-poll.js +320 -0
  228. package/dist/src/orchestrator/orchestrator-scheduling.js +68 -369
  229. package/dist/src/orchestrator/orchestrator.js +128 -369
  230. package/dist/src/orchestrator/retention.js +42 -8
  231. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  232. package/dist/src/orchestrator/scheduling-scope.js +201 -0
  233. package/dist/src/orchestrator/scheduling-skip.js +185 -0
  234. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  235. package/dist/src/orchestrator/startup-migrations.js +11 -7
  236. package/dist/src/paths.js +116 -49
  237. package/dist/src/pi/command-shape.js +12 -1
  238. package/dist/src/pi/pi-args.js +6 -4
  239. package/dist/src/pi/pi-backend.js +6 -6
  240. package/dist/src/pi/pi-bin.js +54 -63
  241. package/dist/src/pi/pi-listed-models.js +53 -0
  242. package/dist/src/pi/pi-models.js +63 -30
  243. package/dist/src/pi/pi-run-result.js +1 -1
  244. package/dist/src/pi/pi-settings.js +1 -1
  245. package/dist/src/pi/pi-stream.js +6 -6
  246. package/dist/src/pi/pi-watchdogs.js +18 -19
  247. package/dist/src/pi-extension/bounded-output.js +5 -3
  248. package/dist/src/pi-extension/context-budget.js +2 -2
  249. package/dist/src/pi-extension/context-shake.js +1 -1
  250. package/dist/src/pi-extension/context-window.js +0 -1
  251. package/dist/src/pi-extension/full-output.js +3 -10
  252. package/dist/src/pi-extension/strip-thinking.js +0 -1
  253. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  254. package/dist/src/pi-extension/write-guard.js +37 -0
  255. package/dist/src/process/launch-services.js +1 -2
  256. package/dist/src/process/node-bin.js +16 -0
  257. package/dist/src/process/process-group.js +8 -18
  258. package/dist/src/process/process.js +41 -2
  259. package/dist/src/process/run-marker.js +18 -6
  260. package/dist/src/process/supervisor.js +22 -21
  261. package/dist/src/prompt/dialect.js +17 -2
  262. package/dist/src/prompt/gate-notes.js +203 -0
  263. package/dist/src/prompt/gate-prompts.js +121 -211
  264. package/dist/src/prompt/notes-backlog.js +181 -0
  265. package/dist/src/prompt/principles.js +1 -2
  266. package/dist/src/prompt/prompt-followup.js +19 -14
  267. package/dist/src/prompt/prompt.js +129 -204
  268. package/dist/src/redeploy/redeploy-policy.js +24 -19
  269. package/dist/src/redeploy/redeploy-probes.js +1 -1
  270. package/dist/src/redeploy/redeploy.js +2 -3
  271. package/dist/src/redeploy/redeployer.js +20 -20
  272. package/dist/src/redeploy/self-reload.js +2 -1
  273. package/dist/src/report/report-data.js +161 -77
  274. package/dist/src/report/report-fold.js +129 -52
  275. package/dist/src/report/report-main-red.js +55 -0
  276. package/dist/src/report/report-render.js +133 -64
  277. package/dist/src/report/report-types.js +1 -0
  278. package/dist/src/report/report.js +26 -9
  279. package/dist/src/request-timeouts.js +2 -2
  280. package/dist/src/review/known-flakes.js +100 -1
  281. package/dist/src/review/post-merge-review.js +106 -0
  282. package/dist/src/review/protected-edits.js +4 -5
  283. package/dist/src/review/review-followup.js +8 -8
  284. package/dist/src/review/review-precheck.js +66 -23
  285. package/dist/src/review/review-run.js +395 -0
  286. package/dist/src/review/review-verdict.js +2 -2
  287. package/dist/src/review/review.js +155 -360
  288. package/dist/src/review/secret-leaks.js +36 -16
  289. package/dist/src/review/suite-rerun.js +0 -1
  290. package/dist/src/roles/legacy-role-migration.js +80 -7
  291. package/dist/src/roles/role-catalog.js +69 -12
  292. package/dist/src/roles/role-guidance.js +55 -20
  293. package/dist/src/roles/role-labels.js +1 -2
  294. package/dist/src/roles/role-render.js +67 -50
  295. package/dist/src/roles/role-view.js +59 -47
  296. package/dist/src/roles/roles.js +31 -31
  297. package/dist/src/roles/runner-ids.js +97 -0
  298. package/dist/src/run/acceptance-greps.js +119 -0
  299. package/dist/src/run/feedback-filings.js +51 -0
  300. package/dist/src/{tick → run}/qa-coverage.js +9 -27
  301. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  302. package/dist/src/run/run-detail-data.js +179 -0
  303. package/dist/src/run/run-detail-render.js +98 -0
  304. package/dist/src/run/run-detail.js +168 -0
  305. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +72 -60
  306. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  307. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +108 -73
  308. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  309. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +108 -50
  310. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  311. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  312. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +91 -56
  313. package/dist/src/run/selfcheck-digest.js +40 -0
  314. package/dist/src/run/stage-backlog-checks.js +249 -0
  315. package/dist/src/run/stage-check.js +172 -0
  316. package/dist/src/run/stage-path-checks.js +730 -0
  317. package/dist/src/{loop → runner}/leftover.js +80 -25
  318. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  319. package/dist/src/runner/revision.js +202 -0
  320. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  321. package/dist/src/runner/runner-outcome.js +107 -0
  322. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  323. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +21 -15
  324. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +218 -240
  325. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  326. package/dist/src/runner/runner-state.js +102 -0
  327. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  328. package/dist/src/scheduling/backoff.js +38 -38
  329. package/dist/src/scheduling/claims.js +35 -22
  330. package/dist/src/scheduling/once-round.js +22 -22
  331. package/dist/src/scheduling/quiet-hours.js +5 -6
  332. package/dist/src/scheduling/scheduling.js +74 -64
  333. package/dist/src/scheduling/work-merged-cache.js +2 -2
  334. package/dist/src/status/status-budget.js +55 -47
  335. package/dist/src/status/status-data.js +43 -16
  336. package/dist/src/status/status-inbox.js +2 -2
  337. package/dist/src/status/status-install.js +0 -1
  338. package/dist/src/status/status-polls.js +28 -28
  339. package/dist/src/status/status-roles.js +39 -13
  340. package/dist/src/team/error-storm.js +8 -8
  341. package/dist/src/team/failure-spread.js +2 -2
  342. package/dist/src/team/orchestrator-info.js +15 -9
  343. package/dist/src/team/reclaim.js +5 -6
  344. package/dist/src/team/team-hold.js +7 -7
  345. package/dist/src/team/team-polls.js +91 -50
  346. package/dist/src/team/team-state.js +8 -2
  347. package/dist/src/text/datetime.js +63 -17
  348. package/dist/src/text/format.js +48 -11
  349. package/dist/src/text/markdown.js +8 -5
  350. package/dist/src/text/phrases.js +57 -23
  351. package/dist/src/text/suggest.js +1 -1
  352. package/dist/src/text/text.js +67 -13
  353. package/dist/src/ui/badges.js +65 -37
  354. package/dist/src/ui/gui/gui-client-backlog.js +292 -0
  355. package/dist/src/ui/gui/gui-client-boot.js +32 -26
  356. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  357. package/dist/src/ui/gui/gui-client-drawer-config.js +212 -0
  358. package/dist/src/ui/gui/gui-client-drawer.js +126 -285
  359. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  360. package/dist/src/ui/gui/gui-client-history.js +434 -67
  361. package/dist/src/ui/gui/gui-client-labels.js +1 -2
  362. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  363. package/dist/src/ui/gui/gui-client-model.js +27 -36
  364. package/dist/src/ui/gui/gui-client-operator.js +55 -51
  365. package/dist/src/ui/gui/gui-client-pending.js +227 -23
  366. package/dist/src/ui/gui/gui-client-report-chart.js +464 -0
  367. package/dist/src/ui/gui/gui-client-report.js +87 -427
  368. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  369. package/dist/src/ui/gui/gui-client-settings.js +56 -4
  370. package/dist/src/ui/gui/gui-client-team.js +25 -465
  371. package/dist/src/ui/gui/gui-client.js +27 -25
  372. package/dist/src/ui/gui/gui-icons.js +0 -2
  373. package/dist/src/ui/gui/gui-page.js +9 -7
  374. package/dist/src/ui/gui/gui-styles.js +34 -34
  375. package/dist/src/ui/progress-data.js +9 -11
  376. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +31 -31
  377. package/dist/src/ui/status-model.js +133 -125
  378. package/dist/src/ui/status-payload.js +136 -49
  379. package/dist/src/ui/status-render.js +101 -82
  380. package/dist/src/ui/team-alerts.js +248 -57
  381. package/dist/src/ui/tone.js +5 -6
  382. package/dist/src/ui/transcript-run.js +58 -0
  383. package/dist/src/ui/transcript-tail.js +28 -20
  384. package/dist/src/ui/transcript.js +63 -23
  385. package/dist/src/ui/tui/tui-app.js +0 -1
  386. package/dist/src/ui/tui/tui-backlog.js +51 -14
  387. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  388. package/dist/src/ui/tui/tui-editor-attach.js +3 -5
  389. package/dist/src/ui/tui/tui-editor-backends.js +132 -0
  390. package/dist/src/ui/tui/tui-editor-brief.js +10 -12
  391. package/dist/src/ui/tui/tui-editor-budget.js +35 -31
  392. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  393. package/dist/src/ui/tui/tui-editor-concurrency.js +24 -0
  394. package/dist/src/ui/tui/tui-editor-config.js +86 -0
  395. package/dist/src/ui/tui/tui-editor-entry.js +36 -47
  396. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  397. package/dist/src/ui/tui/tui-editor-instances.js +37 -0
  398. package/dist/src/ui/tui/tui-editor-instructions.js +136 -0
  399. package/dist/src/ui/tui/tui-editor-interval.js +11 -9
  400. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  401. package/dist/src/ui/tui/tui-editor-notes.js +3 -5
  402. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  403. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  404. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  405. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  406. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  407. package/dist/src/ui/tui/tui-editor-quiet.js +99 -35
  408. package/dist/src/ui/tui/tui-editor-retire.js +4 -6
  409. package/dist/src/ui/tui/tui-editor-role-key.js +76 -0
  410. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  411. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  412. package/dist/src/ui/tui/tui-editor-session.js +60 -34
  413. package/dist/src/ui/tui/tui-editor-slots.js +23 -0
  414. package/dist/src/ui/tui/tui-editors.js +35 -2
  415. package/dist/src/ui/tui/tui-frame.js +56 -11
  416. package/dist/src/ui/tui/tui-history.js +8 -9
  417. package/dist/src/ui/tui/tui-input.js +13 -11
  418. package/dist/src/ui/tui/tui-keymap.js +0 -1
  419. package/dist/src/ui/tui/tui-keys.js +24 -9
  420. package/dist/src/ui/tui/tui-pane-activity.js +14 -11
  421. package/dist/src/ui/tui/tui-pane-backlog.js +171 -126
  422. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  423. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  424. package/dist/src/ui/tui/tui-pane-health.js +58 -8
  425. package/dist/src/ui/tui/tui-pane-history.js +402 -82
  426. package/dist/src/ui/tui/tui-pane-pending.js +308 -13
  427. package/dist/src/ui/tui/tui-pane-queued.js +71 -55
  428. package/dist/src/ui/tui/tui-pane-report.js +46 -14
  429. package/dist/src/ui/tui/tui-pane-session.js +25 -6
  430. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  431. package/dist/src/ui/tui/tui-pane.js +80 -23
  432. package/dist/src/ui/tui/tui-panes.js +100 -30
  433. package/dist/src/ui/tui/tui.js +14 -14
  434. package/dist/src/verdict/fix-claim.js +82 -32
  435. package/dist/src/verdict/no-change.js +4 -4
  436. package/dist/src/verdict/refusal.js +40 -16
  437. package/dist/src/verdict/reply-contract.js +42 -31
  438. package/dist/src/version.js +0 -1
  439. package/package.json +2 -1
  440. package/dist/src/loop/revision.js +0 -70
  441. package/dist/src/roles/loop-ids.js +0 -77
  442. package/dist/src/tick/stage-check.js +0 -419
  443. package/dist/src/tick/telemetry-digest.js +0 -25
  444. package/dist/src/tick/tick-detail-data.js +0 -108
  445. package/dist/src/tick/tick-detail.js +0 -163
  446. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,106 +1,44 @@
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
  }
105
43
  /** Set ONE top-level key of tumwater.json from its raw CLI text (`tumwater config set`'s
106
44
  * engine): the key must be a member of TOP_LEVEL_KEYS — the same protection checkKnownKeys
@@ -116,12 +54,15 @@ export function setDailyBudgetUsd(root, value) {
116
54
  * ROLE_ENTRY_KEYS (a did-you-mean suggestion otherwise, via text/suggest.ts's typoSuffix); a
117
55
  * typo'd role id and a type-invalid value are left for validateConfig to reject with its own
118
56
  * 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). */
57
+ * confirm the change; on any failure the file is untouched
58
+ * (see writeConfigMutation). A value equal to the resolved one is a no-op: nothing is written and
59
+ * the result carries `unchanged`. */
120
60
  export function setConfigKey(root, key, rawValue) {
121
61
  key = canonicalConfigKey(key);
122
62
  const parsed = parseConfigKey(key);
123
63
  if (parsed.kind === "error")
124
64
  return fail(parsed.error);
65
+ let unchanged; // set by a mutation callback when value equals the resolved one
125
66
  let value;
126
67
  try {
127
68
  value = JSON.parse(rawValue);
@@ -155,6 +96,10 @@ export function setConfigKey(root, key, rawValue) {
155
96
  if (parsed.kind === "map") {
156
97
  const existing = record[parsed.map] ?? {};
157
98
  oldValue = existing[parsed.role];
99
+ if (isDeepStrictEqual(oldValue, value)) {
100
+ unchanged = true;
101
+ return cfg;
102
+ }
158
103
  const next = { ...cfg, [parsed.map]: { ...existing, [parsed.role]: value } };
159
104
  return preservingFileRoles(root, next);
160
105
  }
@@ -166,19 +111,39 @@ export function setConfigKey(root, key, rawValue) {
166
111
  // there and nothing is written.
167
112
  const existing = modelTierMap(record.model);
168
113
  oldValue = existing[parsed.tier];
114
+ if (isDeepStrictEqual(oldValue, value)) {
115
+ unchanged = true;
116
+ return cfg;
117
+ }
169
118
  const next = { ...cfg, model: { ...existing, [parsed.tier]: value } };
170
119
  return preservingFileRoles(root, next);
171
120
  }
121
+ if (parsed.kind === "section") {
122
+ // Merge one field into the loaded section, keeping its other fields; validateConfig in
123
+ // writeConfigMutation owns the value type, so a bad value fails there and writes nothing.
124
+ const existing = record[parsed.section] ?? {};
125
+ oldValue = existing[parsed.field];
126
+ if (isDeepStrictEqual(oldValue, value)) {
127
+ unchanged = true;
128
+ return cfg;
129
+ }
130
+ const next = { ...cfg, [parsed.section]: { ...existing, [parsed.field]: value } };
131
+ return preservingFileRoles(root, next);
132
+ }
172
133
  if (parsed.kind === "agent") {
173
134
  // Merge one field of one backend's entry, preserving the other field and the other
174
135
  // backends. validateConfig in writeConfigMutation owns the value type, so a bad
175
136
  // value fails there and nothing is written.
176
- const agents = { ...record.backends };
177
- const entry = { ...(agents[parsed.id] ?? {}) };
137
+ const backends = { ...record.backends };
138
+ const entry = { ...(backends[parsed.id] ?? {}) };
178
139
  oldValue = entry[parsed.field];
140
+ if (isDeepStrictEqual(oldValue, value)) {
141
+ unchanged = true;
142
+ return cfg;
143
+ }
179
144
  entry[parsed.field] = value;
180
- agents[parsed.id] = entry;
181
- const next = { ...cfg, backends: agents };
145
+ backends[parsed.id] = entry;
146
+ const next = { ...cfg, backends: backends };
182
147
  // Writers keep one spelling: the new pi form drops its legacy alias so the file can
183
148
  // never hold both (validateConfig's raw pass rejects a file that does).
184
149
  if (parsed.id === "pi") {
@@ -188,6 +153,12 @@ export function setConfigKey(root, key, rawValue) {
188
153
  else
189
154
  delete nextRecord.piArgs;
190
155
  }
156
+ // Setting the nested claude reserve drops the top-level spelling, so the file can never
157
+ // hold both (validateConfig rejects a file that does, mirroring the agentBin/piArgs pair).
158
+ if (parsed.id === "claude" && parsed.field === "windowReserve") {
159
+ const nextRecord = next;
160
+ delete nextRecord.claudeWindowReserve;
161
+ }
191
162
  return preservingFileRoles(root, next);
192
163
  }
193
164
  // Base the merge on the FILE's own roles, not the loaded (catalog-merged) map: an
@@ -199,6 +170,10 @@ export function setConfigKey(root, key, rawValue) {
199
170
  const rawEntryCandidate = rawRoles[parsed.id];
200
171
  const rawEntry = objectOrEmpty(rawEntryCandidate);
201
172
  oldValue = record.roles?.[parsed.id]?.[parsed.field];
173
+ if (isDeepStrictEqual(oldValue, value)) {
174
+ unchanged = true;
175
+ return cfg;
176
+ }
202
177
  const roles = {
203
178
  ...rawRoles,
204
179
  [parsed.id]: { ...rawEntry, [parsed.field]: value },
@@ -210,6 +185,18 @@ export function setConfigKey(root, key, rawValue) {
210
185
  });
211
186
  if (!result.ok)
212
187
  return result;
188
+ if (unchanged)
189
+ return { ok: true, value, oldValue, unchanged: true };
190
+ // A string replaces a whole object tier entry, so its `backend` goes with it.
191
+ if (parsed.kind === "tier" && isJsonObject(oldValue)) {
192
+ const dropped = typeof oldValue.backend === "string" ? `backend ${show(oldValue.backend)}` : "its other keys";
193
+ return {
194
+ ok: true,
195
+ value,
196
+ oldValue,
197
+ note: `model.${parsed.tier} was an object entry — the string replaces it, dropping ${dropped}`,
198
+ };
199
+ }
213
200
  return { ok: true, value, oldValue };
214
201
  }
215
202
  const unknown = unknownConfigKeyError(key);
@@ -240,6 +227,10 @@ export function setConfigKey(root, key, rawValue) {
240
227
  let oldValue;
241
228
  const result = writeConfigMutation(root, (cfg) => {
242
229
  oldValue = cfg[key];
230
+ if (isDeepStrictEqual(oldValue, value)) {
231
+ unchanged = true;
232
+ return cfg;
233
+ }
243
234
  const next = { ...cfg, [key]: value };
244
235
  // `roles` merges per-entry at load (overlayDefaults seeds the catalog and lets the file
245
236
  // override one entry), so an unrelated whole-config write must not re-expand the file's
@@ -251,27 +242,43 @@ export function setConfigKey(root, key, rawValue) {
251
242
  // legacy aliases the new pi form now carries, so the file can never hold both.
252
243
  // Writers keep one spelling: a whole-section `backends` set (the GUI Save path) drops the
253
244
  // 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
245
+ // both — the same collapseBackendAliases rule the whole-config writers (above and in
255
246
  // config.ts) use, so the alias policy cannot drift between the per-key arm and the others.
256
247
  if (key === "backends")
257
- return collapseAgentAliases(next);
248
+ return collapseBackendAliases(next);
258
249
  // Writers keep one spelling: a legacy top-level set drops the matching backends.pi field, so
259
250
  // the file can never hold both (validateConfig's raw pass rejects a file that does).
260
251
  if (key === "agentBin" || key === "piArgs") {
261
252
  const record = next;
262
- const agents = { ...(record.backends ?? {}) };
263
- const entry = { ...(agents.pi ?? {}) };
253
+ const backends = { ...(record.backends ?? {}) };
254
+ const entry = { ...(backends.pi ?? {}) };
264
255
  delete entry[key === "agentBin" ? "bin" : "args"];
265
256
  if (Object.keys(entry).length === 0)
266
- delete agents.pi;
257
+ delete backends.pi;
267
258
  else
268
- agents.pi = entry;
269
- return { ...next, backends: agents };
259
+ backends.pi = entry;
260
+ return { ...next, backends: backends };
261
+ }
262
+ // Writers keep one spelling: setting the top-level `claudeWindowReserve` drops the nested
263
+ // `backends.claude.windowReserve`, so a supported `config set` never writes the rejected pair
264
+ // (validateConfig still refuses a hand-edited file holding both).
265
+ if (key === "claudeWindowReserve") {
266
+ const record = next;
267
+ const backends = { ...(record.backends ?? {}) };
268
+ const entry = { ...(backends.claude ?? {}) };
269
+ delete entry.windowReserve;
270
+ if (Object.keys(entry).length === 0)
271
+ delete backends.claude;
272
+ else
273
+ backends.claude = entry;
274
+ return { ...next, backends: backends };
270
275
  }
271
276
  return next;
272
277
  });
273
278
  if (!result.ok)
274
279
  return result;
280
+ if (unchanged)
281
+ return { ok: true, value, oldValue, unchanged: true };
275
282
  return { ok: true, value, oldValue };
276
283
  }
277
284
  /** Set a role's provider/model/thinking override triple in tumwater.json (`roles.<base>`),
@@ -297,7 +304,8 @@ export function setRoleModelFields(root, base, fields) {
297
304
  // a bogus id "updated".
298
305
  if (!knownRoleIdsCached(root).includes(base))
299
306
  return fail(unknownRoleMessage(base, knownRoleIdsCached(root)));
300
- return writeConfigMutation(root, (cfg) => {
307
+ let unchanged; // set when no field differs from the file
308
+ const result = writeConfigMutation(root, (cfg) => {
301
309
  const rawRolesCandidate = rawConfigObject(root).roles;
302
310
  const rawRoles = objectOrEmpty(rawRolesCandidate);
303
311
  const rawEntryCandidate = rawRoles[base];
@@ -318,8 +326,10 @@ export function setRoleModelFields(root, base, fields) {
318
326
  changed = true;
319
327
  }
320
328
  }
321
- if (!changed)
329
+ if (!changed) {
330
+ unchanged = true;
322
331
  return cfg; // writeConfigMutation skips the write — file bytes identical
332
+ }
323
333
  const nextRoles = { ...rawRoles };
324
334
  // Clearing the entry's last override empties it — never leave an empty `roles.<base>: {}`
325
335
  // (the file-wide no-empty-object invariant unsetConfigKey's `without` keeps); an emptied
@@ -333,51 +343,9 @@ export function setRoleModelFields(root, base, fields) {
333
343
  return without(cfg, "roles");
334
344
  return { ...cfg, roles: nextRoles };
335
345
  });
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);
346
+ if (result.ok && unchanged)
347
+ return { ok: true, unchanged: true };
348
+ return result;
381
349
  }
382
350
  /** Remove ONE value of tumwater.json from its raw CLI text (`tumwater config unset <key>`):
383
351
  * the inverse of setConfigKey. A bare top-level key is deleted (reverting to its default or
@@ -447,24 +415,34 @@ export function unsetConfigKey(root, key) {
447
415
  const next = without(existing, parsed.tier);
448
416
  return withSectionOrDropped(root, cfg, "model", next);
449
417
  }
418
+ if (parsed.kind === "section") {
419
+ // Presence is judged against the FILE: a field only the default carries is a no-op
420
+ // success, and an emptied section leaves the file.
421
+ const rawSection = objectOrEmpty(raw[parsed.section]);
422
+ if (!Object.hasOwn(rawSection, parsed.field))
423
+ return cfg;
424
+ oldValue = rawSection[parsed.field];
425
+ const existing = record[parsed.section] ?? {};
426
+ return withSectionOrDropped(root, cfg, parsed.section, without(existing, parsed.field));
427
+ }
450
428
  if (parsed.kind === "agent") {
451
429
  // Presence is judged against the FILE, so unsetting a field only the default (or the
452
430
  // legacy alias's resolved view) carries is a no-op success. The entry and the `backends`
453
431
  // 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];
432
+ const rawBackends = objectOrEmpty(raw.backends);
433
+ const rawEntryCandidate = rawBackends[parsed.id];
456
434
  const rawEntry = objectOrEmpty(rawEntryCandidate);
457
435
  if (!Object.hasOwn(rawEntry, parsed.field))
458
436
  return cfg; // absent file field: a no-op success
459
437
  oldValue = rawEntry[parsed.field];
460
- const agents = { ...record.backends };
461
- const entry = { ...(agents[parsed.id] ?? {}) };
438
+ const backends = { ...record.backends };
439
+ const entry = { ...(backends[parsed.id] ?? {}) };
462
440
  delete entry[parsed.field];
463
441
  if (Object.keys(entry).length === 0)
464
- delete agents[parsed.id];
442
+ delete backends[parsed.id];
465
443
  else
466
- agents[parsed.id] = entry;
467
- return withSectionOrDropped(root, cfg, "backends", agents);
444
+ backends[parsed.id] = entry;
445
+ return withSectionOrDropped(root, cfg, "backends", backends);
468
446
  }
469
447
  const rawRolesCandidate = raw.roles;
470
448
  const rawRoles = objectOrEmpty(rawRolesCandidate);
@@ -489,114 +467,3 @@ export function unsetConfigKey(root, key) {
489
467
  return result;
490
468
  return { ok: true, oldValue };
491
469
  }
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
- }