tumwater 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -13,12 +13,28 @@
13
13
  import { isDeepStrictEqual } from "node:util";
14
14
  import { modelTierMap } from "./config-schema.js";
15
15
  import { isJsonObject, objectOrEmpty } from "../files/json-object.js";
16
+ import { isExponentSpelling } from "../text/text.js";
16
17
  import { show } from "./config-field-checks.js";
17
18
  import { canonicalConfigKey } from "./config-aliases.js";
18
19
  import { unknownRoleMessage } from "../roles/roles.js";
19
20
  import { loadConfig, collapseBackendAliases, knownRoleIdsCached, } from "./config.js";
20
21
  import { checkDailyBudgetUsd, checkProviderConcurrencyCap, checkQuietHours, parseConfigKey, PER_KEY_VALIDATORS, unknownConfigKeyError, unknownRoleFieldError, } from "./config-keys.js";
21
22
  import { writeConfigMutation, fail, rawConfigObject, without, preservingFileRoles, preserveFileRoles, withSectionOrDropped, } from "./config-write-core.js";
23
+ /** Drop `field` from the `backends.<backend>` entry of the config `next`, dropping the whole
24
+ * backend entry when it empties. The one home of the "remove a nested backend field, drop the
25
+ * backend when empty" step the legacy `agentBin`/`piArgs` collapse and the `claudeWindowReserve`
26
+ * collapse share, so the empty-entry rule cannot drift between the two. */
27
+ function dropNestedBackendField(next, backend, field) {
28
+ const record = next;
29
+ const backends = { ...(record.backends ?? {}) };
30
+ const entry = { ...(backends[backend] ?? {}) };
31
+ delete entry[field];
32
+ if (Object.keys(entry).length === 0)
33
+ delete backends[backend];
34
+ else
35
+ backends[backend] = entry;
36
+ return { ...next, backends: backends };
37
+ }
22
38
  /** Set the daily cost budget cap in tumwater.json through the shared writer idiom (see
23
39
  * writeConfigMutation), after checkDailyBudgetUsd has screened the value with its own
24
40
  * actionable message — the one both dashboards flash. A cap the config already resolves to
@@ -72,37 +88,21 @@ const PLAIN_INTEGER_TOP_LEVEL_KEYS = new Set([
72
88
  * (see writeConfigMutation). A value equal to the resolved one is a no-op: nothing is written and
73
89
  * the result carries `unchanged`. */
74
90
  export function setConfigKey(root, key, rawValue) {
75
- key = canonicalConfigKey(key);
76
- const parsed = parseConfigKey(key);
77
- if (parsed.kind === "error")
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
98
- let value;
99
- try {
100
- value = JSON.parse(rawValue);
101
- }
102
- catch {
103
- value = rawValue; // not JSON: the literal string, so `set model gpt-5` needs no quotes
104
- }
105
- if (parsed.kind !== "top") {
91
+ // The helpers are declared here — above the body — so they close over `root`; the two
92
+ // write dispatches (writeDottedKey / writeTopLevelKey) each own one writeConfigMutation
93
+ // and the per-kind merge groups close over that call's `value`/`unchanged`/`oldValue`.
94
+ const exponentRefusal = (parsed, key, trimmed) => {
95
+ const isPlainIntegerKey = (parsed.kind === "top" && PLAIN_INTEGER_TOP_LEVEL_KEYS.has(key)) ||
96
+ (parsed.kind === "map" && parsed.map === "maxConcurrentPerProvider") ||
97
+ (parsed.kind === "role" && parsed.field === "instances") ||
98
+ (parsed.kind === "section" && parsed.section === "bootstrap" && parsed.field === "untilPlansDone");
99
+ if (isPlainIntegerKey && trimmed !== "" && isExponentSpelling(trimmed))
100
+ return `${key} must be a plain integer — no exponent notation`;
101
+ return null;
102
+ };
103
+ // One named handler per parsed dotted-key kind: the shape/validator screen, then one
104
+ // writeConfigMutation whose callback is the `switch (parsed.kind)` over the merge helpers.
105
+ const writeDottedKey = (parsed, value) => {
106
106
  const fieldError = parsed.kind === "role" ? unknownRoleFieldError(parsed.id, parsed.field) : null;
107
107
  if (fieldError)
108
108
  return fail(fieldError);
@@ -122,77 +122,78 @@ export function setConfigKey(root, key, rawValue) {
122
122
  if (parsed.kind === "tier" && (typeof value !== "string" || value === "")) {
123
123
  return fail(`model.${parsed.tier} must be a model id (e.g. \`config set model.strong huggingface/zai-org/GLM-5.3-Flash:together:low\`) — got ${show(value)}`);
124
124
  }
125
+ // The two merge groups close over these locals of the one writeConfigMutation below.
125
126
  let oldValue;
126
- const result = writeConfigMutation(root, (cfg) => {
127
- const record = cfg;
128
- if (parsed.kind === "map") {
129
- const existing = record[parsed.map] ?? {};
130
- oldValue = existing[parsed.role];
131
- if (isDeepStrictEqual(oldValue, value)) {
132
- unchanged = true;
133
- return cfg;
134
- }
135
- const next = { ...cfg, [parsed.map]: { ...existing, [parsed.role]: value } };
136
- return preservingFileRoles(root, next);
127
+ let unchanged;
128
+ const mergeMapEntry = (record, parsed) => {
129
+ const existing = record[parsed.map] ?? {};
130
+ oldValue = existing[parsed.role];
131
+ if (isDeepStrictEqual(oldValue, value)) {
132
+ unchanged = true;
133
+ return record;
137
134
  }
138
- if (parsed.kind === "tier") {
139
- // Setting a tier on a string `model` promotes it to the map form rather than
140
- // overwriting the bare string — the other tiers' models survive (modelTierMap owns
141
- // that reading). validateConfig in writeConfigMutation owns the
142
- // type truth of the merged map, so the cast is safe: a type-invalid merge fails
143
- // there and nothing is written.
144
- const existing = modelTierMap(record.model);
145
- oldValue = existing[parsed.tier];
146
- if (isDeepStrictEqual(oldValue, value)) {
147
- unchanged = true;
148
- return cfg;
149
- }
150
- const next = { ...cfg, model: { ...existing, [parsed.tier]: value } };
151
- return preservingFileRoles(root, next);
135
+ const next = { ...record, [parsed.map]: { ...existing, [parsed.role]: value } };
136
+ return preservingFileRoles(root, next);
137
+ };
138
+ const mergeTier = (record, parsed) => {
139
+ // Setting a tier on a string `model` promotes it to the map form rather than
140
+ // overwriting the bare string — the other tiers' models survive (modelTierMap owns
141
+ // that reading). validateConfig in writeConfigMutation owns the
142
+ // type truth of the merged map, so the cast is safe: a type-invalid merge fails
143
+ // there and nothing is written.
144
+ const existing = modelTierMap(record.model);
145
+ oldValue = existing[parsed.tier];
146
+ if (isDeepStrictEqual(oldValue, value)) {
147
+ unchanged = true;
148
+ return record;
152
149
  }
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);
150
+ const next = { ...record, model: { ...existing, [parsed.tier]: value } };
151
+ return preservingFileRoles(root, next);
152
+ };
153
+ const mergeSection = (record, parsed) => {
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 record;
164
161
  }
165
- if (parsed.kind === "agent") {
166
- // Merge one field of one backend's entry, preserving the other field and the other
167
- // backends. validateConfig in writeConfigMutation owns the value type, so a bad
168
- // value fails there and nothing is written.
169
- const backends = { ...record.backends };
170
- const entry = { ...(backends[parsed.id] ?? {}) };
171
- oldValue = entry[parsed.field];
172
- if (isDeepStrictEqual(oldValue, value)) {
173
- unchanged = true;
174
- return cfg;
175
- }
176
- entry[parsed.field] = value;
177
- backends[parsed.id] = entry;
178
- const next = { ...cfg, backends: backends };
179
- // Writers keep one spelling: the new pi form drops its legacy alias so the file can
180
- // never hold both (validateConfig's raw pass rejects a file that does).
181
- if (parsed.id === "pi") {
182
- const nextRecord = next;
183
- if (parsed.field === "bin")
184
- delete nextRecord.agentBin;
185
- else
186
- delete nextRecord.piArgs;
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
- }
194
- return preservingFileRoles(root, next);
162
+ const next = { ...record, [parsed.section]: { ...existing, [parsed.field]: value } };
163
+ return preservingFileRoles(root, next);
164
+ };
165
+ const mergeAgent = (record, parsed) => {
166
+ // Merge one field of one backend's entry, preserving the other field and the other
167
+ // backends. validateConfig in writeConfigMutation owns the value type, so a bad
168
+ // value fails there and nothing is written.
169
+ const backends = { ...record.backends };
170
+ const entry = { ...(backends[parsed.id] ?? {}) };
171
+ oldValue = entry[parsed.field];
172
+ if (isDeepStrictEqual(oldValue, value)) {
173
+ unchanged = true;
174
+ return record;
175
+ }
176
+ entry[parsed.field] = value;
177
+ backends[parsed.id] = entry;
178
+ const next = { ...record, backends: backends };
179
+ // Writers keep one spelling: the new pi form drops its legacy alias so the file can
180
+ // never hold both (validateConfig's raw pass rejects a file that does).
181
+ if (parsed.id === "pi") {
182
+ const nextRecord = next;
183
+ if (parsed.field === "bin")
184
+ delete nextRecord.agentBin;
185
+ else
186
+ delete nextRecord.piArgs;
195
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
+ }
194
+ return preservingFileRoles(root, next);
195
+ };
196
+ const mergeRoleEntry = (record, parsed) => {
196
197
  // Base the merge on the FILE's own roles, not the loaded (catalog-merged) map: an
197
198
  // unrelated dotted write must not re-expand the operator's partial section to every
198
199
  // built-in (see preserveFileRoles). A role absent from the file gets a fresh entry with
@@ -204,7 +205,7 @@ export function setConfigKey(root, key, rawValue) {
204
205
  oldValue = record.roles?.[parsed.id]?.[parsed.field];
205
206
  if (isDeepStrictEqual(oldValue, value)) {
206
207
  unchanged = true;
207
- return cfg;
208
+ return record;
208
209
  }
209
210
  const roles = {
210
211
  ...rawRoles,
@@ -213,7 +214,22 @@ export function setConfigKey(root, key, rawValue) {
213
214
  // The merged entry is only partially typed here (the value is unknown until
214
215
  // validation); validateConfig below owns the type truth, so the cast is safe —
215
216
  // a type-invalid merge fails there and nothing is written.
216
- return { ...cfg, roles };
217
+ return { ...record, roles };
218
+ };
219
+ const result = writeConfigMutation(root, (cfg) => {
220
+ const record = cfg;
221
+ switch (parsed.kind) {
222
+ case "map":
223
+ return mergeMapEntry(record, parsed);
224
+ case "tier":
225
+ return mergeTier(record, parsed);
226
+ case "section":
227
+ return mergeSection(record, parsed);
228
+ case "agent":
229
+ return mergeAgent(record, parsed);
230
+ case "role":
231
+ return mergeRoleEntry(record, parsed);
232
+ }
217
233
  });
218
234
  if (!result.ok)
219
235
  return result;
@@ -230,88 +246,102 @@ export function setConfigKey(root, key, rawValue) {
230
246
  };
231
247
  }
232
248
  return { ok: true, value, oldValue };
233
- }
234
- const unknown = unknownConfigKeyError(key);
235
- if (unknown)
236
- return fail(unknown);
237
- // Legacy `provider` / `fallbackModel` are accepted to FIX an existing value (a config
238
- // that already has one keeps the old meaning), but `config set` never adds them to a
239
- // config that lacks them — the new-form writers emit `model` and `fallback` only.
240
- if (key === "provider" || key === "fallbackModel") {
241
- const absent = (() => {
242
- try {
243
- const c = loadConfig(root); // fresh — bypasses the stat cache on purpose
244
- return c[key] === undefined;
245
- }
246
- catch {
247
- return false; // a broken file fails in writeConfigMutation with its own message
248
- }
249
- })();
250
- if (absent)
251
- return fail(`config set ${key}: this config has no ${key}, and a writer never adds legacy ${key} — set \`${key === "provider" ? "model" : "fallback"}\` instead`);
252
- }
253
- const perKeyValidator = PER_KEY_VALIDATORS[key];
254
- if (perKeyValidator) {
255
- const problem = perKeyValidator(value);
256
- if (problem)
257
- return fail(problem);
258
- }
259
- let oldValue;
260
- const result = writeConfigMutation(root, (cfg) => {
261
- oldValue = cfg[key];
262
- if (isDeepStrictEqual(oldValue, value)) {
263
- unchanged = true;
264
- return cfg;
249
+ };
250
+ // One named handler for a top-level key: the unknown-key refusal, the legacy
251
+ // provider/fallbackModel absence refusal, the per-key validator, then one writeConfigMutation
252
+ // whose callback applies the legacy/whole-section spelling collapses.
253
+ const writeTopLevelKey = (key, value) => {
254
+ const unknown = unknownConfigKeyError(key);
255
+ if (unknown)
256
+ return fail(unknown);
257
+ // Legacy `provider` / `fallbackModel` are accepted to FIX an existing value (a config
258
+ // that already has one keeps the old meaning), but `config set` never adds them to a
259
+ // config that lacks them — the new-form writers emit `model` and `fallback` only.
260
+ if (key === "provider" || key === "fallbackModel") {
261
+ const absent = (() => {
262
+ try {
263
+ const c = loadConfig(root); // fresh — bypasses the stat cache on purpose
264
+ return c[key] === undefined;
265
+ }
266
+ catch {
267
+ return false; // a broken file fails in writeConfigMutation with its own message
268
+ }
269
+ })();
270
+ if (absent)
271
+ return fail(`config set ${key}: this config has no ${key}, and a writer never adds legacy ${key} — set \`${key === "provider" ? "model" : "fallback"}\` instead`);
265
272
  }
266
- const next = { ...cfg, [key]: value };
267
- // `roles` merges per-entry at load (overlayDefaults seeds the catalog and lets the file
268
- // override one entry), so an unrelated whole-config write must not re-expand the file's
269
- // own roles section to the catalog — that would silently rewrite what the operator wrote.
270
- // When the key IS `roles` the value is the replacement, so it is left alone.
271
- if (key !== "roles")
272
- preserveFileRoles(root, next);
273
- // Writers keep one spelling: a whole-section `backends` set (the GUI Save path) drops the
274
- // legacy aliases the new pi form now carries, so the file can never hold both.
275
- // Writers keep one spelling: a whole-section `backends` set (the GUI Save path) drops the
276
- // legacy agentBin/piArgs aliases the new pi form now carries, so the file can never hold
277
- // both — the same collapseBackendAliases rule the whole-config writers (above and in
278
- // config.ts) use, so the alias policy cannot drift between the per-key arm and the others.
279
- if (key === "backends")
280
- return collapseBackendAliases(next);
273
+ const perKeyValidator = PER_KEY_VALIDATORS[key];
274
+ if (perKeyValidator) {
275
+ const problem = perKeyValidator(value);
276
+ if (problem)
277
+ return fail(problem);
278
+ }
279
+ let oldValue;
280
+ let unchanged;
281
281
  // Writers keep one spelling: a legacy top-level set drops the matching backends.pi field, so
282
282
  // the file can never hold both (validateConfig's raw pass rejects a file that does).
283
- if (key === "agentBin" || key === "piArgs") {
284
- const record = next;
285
- const backends = { ...(record.backends ?? {}) };
286
- const entry = { ...(backends.pi ?? {}) };
287
- delete entry[key === "agentBin" ? "bin" : "args"];
288
- if (Object.keys(entry).length === 0)
289
- delete backends.pi;
290
- else
291
- backends.pi = entry;
292
- return { ...next, backends: backends };
293
- }
283
+ const dropLegacyPiAlias = (next, key) => dropNestedBackendField(next, "pi", key === "agentBin" ? "bin" : "args");
294
284
  // Writers keep one spelling: setting the top-level `claudeWindowReserve` drops the nested
295
285
  // `backends.claude.windowReserve`, so a supported `config set` never writes the rejected pair
296
286
  // (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 };
307
- }
308
- return next;
309
- });
310
- if (!result.ok)
311
- return result;
312
- if (unchanged)
313
- return { ok: true, value, oldValue, unchanged: true };
314
- return { ok: true, value, oldValue };
287
+ const dropNestedReserve = (next) => dropNestedBackendField(next, "claude", "windowReserve");
288
+ const result = writeConfigMutation(root, (cfg) => {
289
+ oldValue = cfg[key];
290
+ if (isDeepStrictEqual(oldValue, value)) {
291
+ unchanged = true;
292
+ return cfg;
293
+ }
294
+ const next = { ...cfg, [key]: value };
295
+ // `roles` merges per-entry at load (overlayDefaults seeds the catalog and lets the file
296
+ // override one entry), so an unrelated whole-config write must not re-expand the file's
297
+ // own roles section to the catalog — that would silently rewrite what the operator wrote.
298
+ // When the key IS `roles` the value is the replacement, so it is left alone.
299
+ if (key !== "roles")
300
+ preserveFileRoles(root, next);
301
+ // Writers keep one spelling: a whole-section `backends` set (the GUI Save path) drops the
302
+ // legacy agentBin/piArgs aliases the new pi form now carries, so the file can never hold
303
+ // both — the same collapseBackendAliases rule the whole-config writers (above and in
304
+ // config.ts) use, so the alias policy cannot drift between the per-key arm and the others.
305
+ if (key === "backends")
306
+ return collapseBackendAliases(next);
307
+ if (key === "agentBin" || key === "piArgs")
308
+ return dropLegacyPiAlias(next, key);
309
+ if (key === "claudeWindowReserve")
310
+ return dropNestedReserve(next);
311
+ return next;
312
+ });
313
+ if (!result.ok)
314
+ return result;
315
+ if (unchanged)
316
+ return { ok: true, value, oldValue, unchanged: true };
317
+ return { ok: true, value, oldValue };
318
+ };
319
+ key = canonicalConfigKey(key);
320
+ const parsed = parseConfigKey(key);
321
+ if (parsed.kind === "error")
322
+ return fail(parsed.error);
323
+ // Shape, before the JSON.parse, for every plain-positive-integer key this verb can write —
324
+ // the top-level run-concurrency knobs and legacy merge batch cap, plus the integer-bound
325
+ // team-scope keys that arrive as a dotted key (`maxConcurrentPerProvider.<provider>`,
326
+ // `roles.<id>.instances`, the `bootstrap.untilPlansDone` section field): refuse an exponent
327
+ // spelling ("4e1" → 40) that JSON.parse would read past validateConfig's integer check and
328
+ // silently write, naming the key and writing nothing — the twin of the per-role guards, the
329
+ // TUI team-key editors, and the GUI Settings rows. A non-exponent invalid falls through for
330
+ // validateConfig (whose message names the key). Empty clears the key below.
331
+ const trimmed = rawValue.trim();
332
+ const refusal = exponentRefusal(parsed, key, trimmed);
333
+ if (refusal)
334
+ return fail(refusal);
335
+ let value;
336
+ try {
337
+ value = JSON.parse(rawValue);
338
+ }
339
+ catch {
340
+ value = rawValue; // not JSON: the literal string, so `set model gpt-5` needs no quotes
341
+ }
342
+ if (parsed.kind !== "top")
343
+ return writeDottedKey(parsed, value);
344
+ return writeTopLevelKey(key, value);
315
345
  }
316
346
  /** Set a role's provider/model/thinking/backend override set in tumwater.json (`roles.<base>`),
317
347
  * the write behind the dashboard role drawer's Model overrides editor: one writeConfigMutation
@@ -299,7 +299,8 @@ export function loadConfigCached(root) {
299
299
  /** The live resolved config for `root`: the stat-cached read (loadConfigCached) with a broken
300
300
  * or vanished file degrading to the defaults — the one home of that fallback, shared by every
301
301
  * mid-run reader that must keep working when tumwater.json is briefly broken (the baseline
302
- * check's per-call config, the red-main gate, the stall threshold), so a change to the policy
302
+ * check's per-call config, the main-red heal prompt (`mainRedHealNote`), the
303
+ * stall threshold), so a change to the policy
303
304
  * (what degrades, what logs) cannot drift between them. Missing is degraded here too, unlike
304
305
  * loadConfigCached's own contract — callers of this helper want A config, never an incident. */
305
306
  export function liveConfig(root) {