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
@@ -1,8 +1,9 @@
1
- /** The director's staged config-write request — one verb split out of config-write.ts (the
1
+ /** The role's staged config-write request — one verb split out of config-write.ts (the
2
2
  * operator-editor mutators stay there, addressed by key through config-keys.ts). The director
3
- * stages a JSON request file in its worktree; this module consumes it: parse the request,
4
- * keep only its `customRoles` array, apply it as a whole-config write, and delete the file on
5
- * every path. A request is shaped and lives differently from the interactive `config set` /
3
+ * (and, for a declared project check, the feature role) stages a JSON request file in its
4
+ * worktree; this module consumes it: parse the request, keep only its `customRoles` array and
5
+ * a declared `check.command`, apply it as a whole-config write, and delete the file on every
6
+ * path. A request is shaped and lives differently from the interactive `config set` /
6
7
  * `unset` verbs (a staged file that must never reach a diff, not direct CLI text), so the
7
8
  * runtime (run-verdict.ts) imports this module rather than the whole editor. It
8
9
  * still shares the write machinery with the editor — withConfigLock and rawConfigObject from
@@ -10,9 +11,10 @@
10
11
  * config-validation.ts, writeJsonAtomic from json-files.js — because a request is a
11
12
  * whole-config write too. */
12
13
  import fs from "node:fs";
14
+ import path from "node:path";
13
15
  import { isDeepStrictEqual } from "node:util";
14
16
  import { configPath, configRequestPath } from "../paths.js";
15
- import { errorMessage } from "../text/text.js";
17
+ import { errorMessage, isNonBlankString } from "../text/text.js";
16
18
  import { readTextOrNull } from "../files/files.js";
17
19
  import { writeJsonAtomic } from "../files/json-files.js";
18
20
  import { isJsonObject } from "../files/json-object.js";
@@ -21,21 +23,124 @@ import { show } from "./config-field-checks.js";
21
23
  import { validateConfig } from "./config-validation.js";
22
24
  import { normalizeConfigKeys } from "./config-aliases.js";
23
25
  import { collapseBackendAliases, loadConfig } from "./config.js";
24
- /** Consume the director's config-write request file (plans/portability.md §3/7). After its pi
26
+ import { BUILD_CHECK_TIMEOUT_MS, gateCommandOf, } from "../build/build-check-detect.js";
27
+ import { runBuildCheck } from "../build/build-check.js";
28
+ import { CHECK_TIER, withCheckPermit } from "../concurrency/check-permit.js";
29
+ async function defaultCheckGuard(wt, check, config) {
30
+ // Take the same process-wide check permit every full-suite runner takes (runScopedBuildCheck's
31
+ // gate/merge/batch scopes and checkMainBaseline), so a declaration burst cannot stack suites
32
+ // on the host beside the authors' own runs. runBuildCheck's own arguments stand: the same
33
+ // install-first step and upward PATH resolution the gate's run uses; the candidate BuildCheck
34
+ // carries the config-resolved timeout, so checkTimeoutMs enforces it here exactly as the
35
+ // pre-check would.
36
+ return withCheckPermit(config, CHECK_TIER.other, () => runBuildCheck(wt, check));
37
+ }
38
+ /** The candidates the guard verifies before a declared `check.command` is written: the
39
+ * declared command itself (the feature's fail-closed guarantee — a request whose own command
40
+ * fails must not be written), plus, when a `check.gateCommand` is set and differs, that gate
41
+ * command too (the gate scope's pre-check — runScopedBuildCheck — swaps in `check.gateCommand`
42
+ * at scope "gate", so the guard must not read green on a command the gate never runs). Each is
43
+ * run at `check.cwd` resolved against the worktree (default `.`), under the config-resolved
44
+ * timeout. A preserved cwd must be honored or the guard runs in the wrong tree — a false green
45
+ * or false red that defeats the fail-closed purpose. */
46
+ function guardChecks(current, wt, declared) {
47
+ const c = current.check;
48
+ const configuredMs = typeof c?.timeoutSeconds === "number" && c.timeoutSeconds > 0 ? c.timeoutSeconds * 1000 : undefined;
49
+ const base = {
50
+ kind: "command",
51
+ cwd: path.resolve(wt, isNonBlankString(c?.cwd) ? c.cwd : "."),
52
+ timeoutMs: configuredMs ?? BUILD_CHECK_TIMEOUT_MS,
53
+ };
54
+ const gate = gateCommandOf(current);
55
+ if (gate !== undefined && gate !== declared) {
56
+ return [{ ...base, command: gate }, { ...base, command: declared }];
57
+ }
58
+ return [{ ...base, command: declared }];
59
+ }
60
+ /** How a non-passed guard outcome is named in the rejection error. */
61
+ function describeGuardFailure(o) {
62
+ if (o.status === "failed")
63
+ return "failed";
64
+ if (o.skipReason === "timeout")
65
+ return "timed out";
66
+ if (o.skipReason === "install")
67
+ return "could not install dependencies";
68
+ return "could not run";
69
+ }
70
+ /** Build the whole-config write candidate a request would produce from a given `current`
71
+ * config — the request's customRoles array (or the live one when absent) and, when a
72
+ * check.command is declared, the check section with only `command` replaced — and return
73
+ * whether that candidate differs from `current` at all. Shared by the pre-write validation
74
+ * (which must reject an invalid request BEFORE the declared check runs, so a request pairing a
75
+ * valid check.command with an invalid customRoles entry never spawns the check) and the locked
76
+ * write, so the two can never build different candidates. Roles stripping is folded in: the
77
+ * standalone validation has to reject what the write would reject, and the write has to strip
78
+ * what the validation sees. */
79
+ function buildCandidate(root, current, entries, checkCommand) {
80
+ const customRoles = entries === undefined ? current.customRoles : entries;
81
+ const checkChanged = checkCommand !== undefined && (current.check?.command ?? "") !== checkCommand;
82
+ const unchanged = !checkChanged && isDeepStrictEqual(customRoles, current.customRoles);
83
+ // Strip roles.<id> entries for custom roles this request removes (see applyConfigRequest's
84
+ // docstring): structurally valid names only; anything else stays for validateConfig to
85
+ // reject — never dereference a request entry before validation.
86
+ const requestedNames = new Set();
87
+ for (const entry of customRoles) {
88
+ if (isJsonObject(entry) && typeof entry.name === "string")
89
+ requestedNames.add(entry.name);
90
+ }
91
+ // Base the roles rewrite on the FILE's own section, not the loaded (catalog-merged) map:
92
+ // this whole-config write must not re-expand a partial section to every built-in
93
+ // (preserveFileRoles' rule). Only the removed custom roles' entries are stripped, and only
94
+ // from what the file actually holds.
95
+ const rawRolesCandidate = rawConfigObject(root).roles;
96
+ const roles = isJsonObject(rawRolesCandidate) ? { ...(rawRolesCandidate) } : {};
97
+ for (const c of current.customRoles) {
98
+ if (!requestedNames.has(c.name))
99
+ delete roles[c.name];
100
+ }
101
+ const candidate = { ...current, customRoles };
102
+ if (checkChanged && checkCommand !== undefined) {
103
+ // Preserve the rest of the check section (gateCommand/mergeCommand/timeoutSeconds);
104
+ // only `command` is declared by the request.
105
+ candidate.check = { ...current.check, command: checkCommand };
106
+ }
107
+ if (rawRolesCandidate === undefined)
108
+ delete candidate.roles;
109
+ else
110
+ candidate.roles = roles;
111
+ return { candidate: candidate, changed: !unchanged };
112
+ }
113
+ /** Consume the role's config-write request file (plans/portability.md §3/7). After its pi
25
114
  * run the director may leave `.tumwater-config-request.json` at its worktree root:
26
- * `{ "customRoles": [ { "name", "task" }, … ] }` — the whole array, replacing the current one.
115
+ * `{ "customRoles": [ { "name", "task" }, … ] }` — the whole array, replacing the current one;
116
+ * the feature role (only) may leave the same file carrying a `check.command` to declare the
117
+ * project's verification command, which is observed green in the worktree before it is written.
27
118
  * Applied atomically to the live config, sharing the editor's trap (fresh loadConfig that
28
119
  * bypasses the stat cache, validateConfig, then writeJsonAtomic — readers poll every ~2 s), so
29
120
  * the orchestrator's live reload starts the new role with no commit, no review
30
121
  * gate, and no merge, and the request file never enters a diff.
31
122
  *
32
- * The permitted key set is enforced here, not in prose: `customRoles` is accepted and every
33
- * other top-level key is collected into `ignored` and dropped (the caller logs the warning that
34
- * names them). Validation runs BEFORE any write and nothing here dereferences a request entry
35
- * first — a structurally malformed entry (`[null]`, a non-string name) is left for
36
- * validateConfig to reject, so on failure nothing is written and the previous config stays
37
- * live. The request file is deleted on EVERY path (including every failure), so a malformed
38
- * request cannot retry forever and the file cannot survive into `git add -A`.
123
+ * The permitted key set is enforced here, not in prose: `customRoles` and a `check` object are
124
+ * accepted and every other top-level key is collected into `ignored` and dropped (the caller
125
+ * logs the warning that names them). Inside `check` only `command` is read — a non-blank
126
+ * string that declares the project's check; every other sub-key (and a blank `command`) is
127
+ * collected into `ignored` and dropped, so a role can declare a check but never turn the
128
+ * safety gates off (`gateCommand: ""`, `mergeCommand: ""`, `timeoutSeconds`). Validation runs
129
+ * BEFORE any write and nothing here dereferences a request entry first — a structurally
130
+ * malformed entry (`[null]`, a non-string name) is left for validateConfig to reject, so on
131
+ * failure nothing is written and the previous config stays live. The request file is deleted
132
+ * on EVERY path (including every failure), so a malformed request cannot retry forever and the
133
+ * file cannot survive into `git add -A`.
134
+ *
135
+ * A declared `check.command` is verified before it is written: the declared command is run
136
+ * through runBuildCheck in the requesting worktree — and, when a `check.gateCommand` is set
137
+ * and differs, that gate command (which the gate scope runs) is verified first too — each at
138
+ * `check.cwd` resolved against it (default `.`), under the config-resolved timeout, the same
139
+ * install-first step, and the same process-wide check permit (`maxConcurrentChecks`) the gate's
140
+ * pre-check takes — and only a `passed` outcome for every candidate writes. A failed, timed-out,
141
+ * or skipped run writes nothing and returns an error naming what was observed — the role's own
142
+ * claim is not the evidence, because a command that could never pass would park the whole queue
143
+ * behind a red main no role can repair (part 1/2).
39
144
  *
40
145
  * Roles entries for custom roles the request removes are stripped before validation, from the
41
146
  * FILE's own `roles` section rather than the catalog-merged map (the rule every other
@@ -46,11 +151,11 @@ import { collapseBackendAliases, loadConfig } from "./config.js";
46
151
  * validateConfig to reject.
47
152
  *
48
153
  * Returns null when no request file exists. `applied` names the custom roles now live (empty
49
- * unless the write happened); `ignored` lists the discarded top-level keys; `error`, when set,
50
- * names why nothing (or only part of the work) was done — the caller turns it and `ignored`
51
- * into warning events. `unchanged` is set when the request's array equals the live one;
52
- * nothing is written. */
53
- export function applyConfigRequest(root, wt) {
154
+ * unless the write happened); `ignored` lists the discarded keys (top-level keys and, named
155
+ * `check.<key>`, the check sub-keys that were dropped); `error`, when set, names why nothing
156
+ * (or only part of the work) was done — the caller turns it and `ignored` into warning events.
157
+ * `unchanged` is set when the request changes nothing; nothing is written. */
158
+ export async function applyConfigRequest(root, wt, runCheck = defaultCheckGuard) {
54
159
  const requestFile = configRequestPath(wt);
55
160
  const raw = readTextOrNull(requestFile);
56
161
  if (raw === null)
@@ -69,48 +174,68 @@ export function applyConfigRequest(root, wt) {
69
174
  throw new Error(normalized.problems.join("; "));
70
175
  const parsed = normalized.raw;
71
176
  for (const key of Object.keys(parsed)) {
72
- if (key !== "customRoles")
177
+ if (key !== "customRoles" && key !== "check")
73
178
  ignored.push(key);
74
179
  }
75
- const entries = parsed.customRoles;
76
- if (!Array.isArray(entries))
77
- throw new Error(`config request's customRoles must be an array (got ${show(entries)})`);
180
+ // A check request is read only for its `command` (a non-blank string). Every other
181
+ // sub-key — and a blank `command` — is dropped into `ignored`, so it can never turn the
182
+ // safety gates off; a blank/absent command simply declares nothing.
183
+ let checkCommand;
184
+ if (parsed.check !== undefined) {
185
+ const checkReq = parsed.check;
186
+ if (!isJsonObject(checkReq))
187
+ throw new Error(`config request's check must be an object (got ${show(checkReq)})`);
188
+ for (const key of Object.keys(checkReq)) {
189
+ if (key !== "command")
190
+ ignored.push(`check.${key}`);
191
+ }
192
+ if (isNonBlankString(checkReq.command))
193
+ checkCommand = checkReq.command;
194
+ else if (checkReq.command !== undefined)
195
+ ignored.push("check.command");
196
+ }
197
+ const entriesRaw = parsed.customRoles;
198
+ if (entriesRaw !== undefined && !Array.isArray(entriesRaw))
199
+ throw new Error(`config request's customRoles must be an array (got ${show(entriesRaw)})`);
200
+ const entries = entriesRaw;
201
+ // Verified declaration (part 1/2): a declared check is fail-closed — the gate's pre-check
202
+ // rejects a red run and a failure that reproduces on main parks every queued pin behind a
203
+ // red main no role can repair by code — so the declared command must be observed green in
204
+ // the requesting worktree BEFORE it is written. Only a `passed` outcome writes; failed,
205
+ // timed-out, or skipped writes nothing and returns an error naming what was observed. Each
206
+ // candidate is run at `check.cwd` resolved against the worktree (default `.`) under the
207
+ // config-resolved timeout, and when a `check.gateCommand` is set and differs the gate
208
+ // command is verified first too — the declared command is the fail-closed promise, the
209
+ // gate command is what the gate scope will actually run — so a green here is a green at
210
+ // the gate for every command the write leaves live.
211
+ if (checkCommand !== undefined) {
212
+ // Read the current config only for the guard — the write reloads fresh under the lock.
213
+ const currentForGuard = loadConfig(root);
214
+ // Pre-validate what the request would write BEFORE the declared check runs: a request
215
+ // that pairs a valid check.command with an invalid customRoles entry must be rejected
216
+ // without ever spawning the check (it would fail validation anyway). The locked write
217
+ // re-loads fresh and re-validates, so a config change between here and the write is
218
+ // still caught.
219
+ const { candidate: preCandidate } = buildCandidate(root, currentForGuard, entries, checkCommand);
220
+ validateConfig(collapseBackendAliases(preCandidate));
221
+ for (const check of guardChecks(currentForGuard, wt, checkCommand)) {
222
+ const outcome = await runCheck(wt, check, currentForGuard);
223
+ if (outcome.status !== "passed") {
224
+ throw new Error(`check.command not declared: \`${outcome.script}\` ${describeGuardFailure(outcome)} in the worktree`);
225
+ }
226
+ }
227
+ }
78
228
  // The load-to-write sequence is one critical section under withConfigLock: a concurrent
79
229
  // `config set`/dashboard write between the load and this write must not be dropped by the
80
230
  // whole-file overwrite (see writeConfigMutation).
81
231
  withConfigLock(root, () => {
82
232
  const current = loadConfig(root); // fresh — bypasses the stat cache: a writer sees the latest file
83
- // Same as the live array: write nothing, like writeConfigMutation's no-op skip.
84
- if (isDeepStrictEqual(entries, current.customRoles)) {
233
+ const { candidate, changed } = buildCandidate(root, current, entries, checkCommand);
234
+ // Nothing the request would change: write nothing, like writeConfigMutation's no-op skip.
235
+ if (!changed) {
85
236
  unchanged = true;
86
237
  return;
87
238
  }
88
- // Strip roles.<id> entries for custom roles this request removes (see docstring).
89
- // Structurally valid names only; anything else stays for validateConfig to reject — never
90
- // dereference a request entry before validation.
91
- const requestedNames = new Set();
92
- for (const entry of entries) {
93
- if (isJsonObject(entry) && typeof entry.name === "string")
94
- requestedNames.add(entry.name);
95
- }
96
- // Base the roles rewrite on the FILE's own section, not the loaded (catalog-merged)
97
- // map: this whole-config write must not re-expand a partial section to every built-in
98
- // (preserveFileRoles' rule). Only the removed custom roles' entries are stripped, and
99
- // only from what the file actually holds.
100
- const rawRolesCandidate = rawConfigObject(root).roles;
101
- const roles = isJsonObject(rawRolesCandidate)
102
- ? { ...(rawRolesCandidate) }
103
- : {};
104
- for (const c of current.customRoles) {
105
- if (!requestedNames.has(c.name))
106
- delete roles[c.name];
107
- }
108
- const candidate = { ...current, customRoles: entries };
109
- const candidateRecord = candidate;
110
- if (rawRolesCandidate === undefined)
111
- delete candidateRecord.roles;
112
- else
113
- candidateRecord.roles = roles;
114
239
  // Collapse legacy/new pi spellings before writing so a merged config (which always
115
240
  // carries the default piArgs) never lands both forms in the file — the next load's raw
116
241
  // pass rejects a file holding both `piArgs` and `backends.pi.args`.
@@ -0,0 +1,271 @@
1
+ /** The per-role and cross-field section rules of tumwater.json validation: the `customRoles`
2
+ * collect-and-check block, the `roles.<id>` entry block, `review.afterMerge`'s name check,
3
+ * the per-role daily cost caps and quiet hours, `maxConcurrentPerProvider`,
4
+ * `claudeWindowReserve` and its mutually-exclusive spelling pair, and the `idleBackoff`
5
+ * cross-field pair. Extracted from the single validateConfig gate (config-validation.ts) so a
6
+ * per-role or cross-field edit lands as a per-theme diff instead of a whole-gate one; this
7
+ * module runs once, at the gate's end, after every other section has appended its problems,
8
+ * so the overall problem order is unchanged. These rules sit on top of the generic machinery
9
+ * in config-field-checks.ts; the helpers passed in are the closures the gate binds once (the
10
+ * problems-bound checkers and the shared model-triple/window-reserve sequences), and the
11
+ * cross-cutting `customNames` set — collected in the customRoles block, consumed by the
12
+ * roles.<id>, afterMerge, cap and quiet-hour blocks — is owned here, so validateConfig
13
+ * passes no set across the boundary. */
14
+ import { CUSTOM_ROLE_KEYS, CUSTOM_NAME_RE, CUSTOM_TASK_MAX_CHARS, ROLE_ENTRY_KEYS, ROLE_INSTRUCTIONS_MAX_CHARS, } from "./config-schema.js";
15
+ import { DIRECTOR_ROLE, allRoleIds } from "../roles/roles.js";
16
+ import { INSTANCE_ROLES, baseRoleOf } from "../roles/runner-ids.js";
17
+ import { isJsonObject } from "../files/json-object.js";
18
+ import { isNonBlankString } from "../text/text.js";
19
+ import { parseQuietHours } from "../scheduling/quiet-hours.js";
20
+ import { TUMWATER_SOURCE } from "../events/events.js";
21
+ import { DOLLAR_CAP, MAX_DURATION_SECONDS, POSITIVE_INTEGER, checkKnownKeys, checkKnownRoleId, checkNumberField, show, } from "./config-field-checks.js";
22
+ const DURATION_NON_NEGATIVE = {
23
+ ok: (n) => n >= 0 && n <= MAX_DURATION_SECONDS,
24
+ what: `a number of 0 or more, at most ${MAX_DURATION_SECONDS}`,
25
+ };
26
+ /** A per-role instance count (`roles.feature.instances`): an integer from 1 to 8. Bounded so
27
+ * a typo like `instances: 800` cannot spawn hundreds of runners, and integer so
28
+ * `feature-1.5` — which no role id can name — is refused. */
29
+ const INSTANCE_COUNT = {
30
+ ok: (n) => Number.isInteger(n) && n >= 1 && n <= 8,
31
+ what: "an integer from 1 to 8",
32
+ };
33
+ /** Validate one per-role map section (`maxDailyCostUsdPerRole`, `quietHoursPerRole`): the
34
+ * value must be an object mapping role ids to entries, every key must name a known role
35
+ * (checkKnownRoleId's guard — a typo'd id would silently no-op the section), and each entry
36
+ * goes through `checkValue` with its role id. `expectation` is the value description the
37
+ * type-error message quotes. One home for the section shape the two dotted per-role keys
38
+ * share, so a third per-role map reuses it instead of re-copying the loop. */
39
+ function checkPerRoleMap(r, key, expectation, customNames, problems, checkValue) {
40
+ if (!(key in r))
41
+ return;
42
+ const map = r[key];
43
+ if (!isJsonObject(map)) {
44
+ problems.push(`${key} must be an object mapping role ids to ${expectation} (got ${show(map)})`);
45
+ return;
46
+ }
47
+ for (const [id, value] of Object.entries(map)) {
48
+ if (!checkKnownRoleId(key, id, customNames, problems))
49
+ continue;
50
+ checkValue(id, value);
51
+ }
52
+ }
53
+ /** Validate the per-role and cross-field sections that close the gate: `customRoles`, the
54
+ * `roles.<id>` entries, `review.afterMerge`'s role names, the per-role caps and quiet hours,
55
+ * `maxConcurrentPerProvider`, `claudeWindowReserve` and the `idleBackoff` cross-field pair,
56
+ * each appended to `problems` in the order the gate produced. The single call site in
57
+ * validateConfig is the point where these problems were appended, so the order across the
58
+ * whole pass is unchanged. */
59
+ export function validateRolesAndCrossField(r, problems, helpers) {
60
+ const customNames = validateCustomRoles(r, problems, helpers);
61
+ validateRoleEntries(r, problems, helpers, customNames);
62
+ validateReviewAfterMerge(r, problems, customNames);
63
+ validatePerRoleCostCaps(r, problems, customNames);
64
+ validatePerRoleQuietHours(r, problems, customNames);
65
+ validateMaxConcurrentPerProvider(r, problems);
66
+ validateClaudeWindowReserve(r, problems, helpers);
67
+ validateIdleBackoff(r, problems);
68
+ }
69
+ /** Collect the `customRoles` names and refuse this block's bad shapes, appending to
70
+ * `problems` and returning the set of accepted names for the later per-role sections. Names
71
+ * become worktree dirs and git refs (plans/user-defined-loops.md), so they are validated
72
+ * strictly — a colliding name would silently shadow a built-in's prompt (roleById wins in
73
+ * runPrompt), validation makes that impossible instead of subtle. The collected names feed
74
+ * the roles cross-check: an id under `roles` is valid when it is in the catalog OR listed as
75
+ * a customRoles name. */
76
+ function validateCustomRoles(r, problems, helpers) {
77
+ const { checkPromptLength } = helpers;
78
+ const customNames = new Set();
79
+ if ("customRoles" in r) {
80
+ const cl = r.customRoles;
81
+ if (!Array.isArray(cl)) {
82
+ problems.push(`customRoles must be an array of { name, task } entries (got ${show(cl)})`);
83
+ }
84
+ else {
85
+ cl.forEach((entry, i) => {
86
+ const where = `customRoles[${i}]`;
87
+ if (!isJsonObject(entry)) {
88
+ problems.push(`${where} must be an object with keys name and task (got ${show(entry)})`);
89
+ return;
90
+ }
91
+ checkKnownKeys(entry, CUSTOM_ROLE_KEYS, where, problems);
92
+ const name = entry.name;
93
+ if (typeof name !== "string") {
94
+ problems.push(`${where}.name must be a string matching /^[a-z0-9][a-z0-9_-]{0,31}$/ (got ${show(name)})`);
95
+ }
96
+ else if (!CUSTOM_NAME_RE.test(name)) {
97
+ problems.push(`${where}.name "${name}" is not a valid role name: start with a lowercase letter or digit, then at most 31 more of [a-z0-9_-]`);
98
+ }
99
+ else if (name === TUMWATER_SOURCE) {
100
+ problems.push(`${where}.name "${name}" is reserved: events tumwater writes about itself carry that source, so a role's events would be indistinguishable from them — pick another name`);
101
+ }
102
+ else if (allRoleIds().includes(name)) {
103
+ problems.push(`${where}.name "${name}" collides with a built-in role id — pick another name`);
104
+ }
105
+ else if (baseRoleOf(name) !== name) {
106
+ problems.push(`${where}.name "${name}" would shadow the ${baseRoleOf(name)} role's instance ids — pick another name`);
107
+ }
108
+ else if (customNames.has(name)) {
109
+ problems.push(`${where}.name "${name}" is duplicated in customRoles — names must be unique`);
110
+ }
111
+ else {
112
+ customNames.add(name);
113
+ }
114
+ const task = entry.task;
115
+ // A whitespace-only task is as inert as an empty one — it rides into every one of
116
+ // this role's run prefills as blank text, so the role has nothing to do and runs
117
+ // no_change forever. Reject both shapes with the same message; the model-triple
118
+ // checkString rule uses the same trim-based emptiness test.
119
+ if (!isNonBlankString(task)) {
120
+ problems.push(`${where}.task must be a non-empty string (got ${show(task)})`);
121
+ }
122
+ else {
123
+ checkPromptLength(`${where}.task`, task, CUSTOM_TASK_MAX_CHARS);
124
+ }
125
+ });
126
+ }
127
+ }
128
+ return customNames;
129
+ }
130
+ /** Validate the `roles.<id>` entry block: the object shape, `checkKnownRoleId`, the per-entry
131
+ * settings checks, and the INSTANCE_ROLES instances rule. */
132
+ function validateRoleEntries(r, problems, helpers, customNames) {
133
+ const { checkString, checkBoolean, checkNumber, checkPromptLength, checkModelTripleEntry } = helpers;
134
+ if ("roles" in r) {
135
+ const roles = r.roles;
136
+ if (!isJsonObject(roles)) {
137
+ problems.push(`roles must be an object mapping role ids to settings (got ${show(roles)})`);
138
+ }
139
+ else {
140
+ for (const [id, rc] of Object.entries(roles)) {
141
+ // An id outside the catalog and customRoles cannot work: runPrompt has no prompt for
142
+ // it and the role would error every run forever (checkKnownRoleId).
143
+ if (!checkKnownRoleId("roles", id, customNames, problems))
144
+ continue;
145
+ if (!isJsonObject(rc)) {
146
+ problems.push(`roles.${id} must be an object (got ${show(rc)})`);
147
+ continue;
148
+ }
149
+ const o = rc;
150
+ checkKnownKeys(o, ROLE_ENTRY_KEYS, `roles.${id}`, problems);
151
+ // Empty is a deliberate "no extra instructions". A non-empty value rides into every
152
+ // one of this role's run prefills, so it is capped like a custom role's task.
153
+ checkString(o, `roles.${id}.`, "instructions");
154
+ checkPromptLength(`roles.${id}.instructions`, o.instructions, ROLE_INSTRUCTIONS_MAX_CHARS);
155
+ checkModelTripleEntry(o, `roles.${id}.`, `roles.${id}`);
156
+ checkBoolean(o, `roles.${id}.`, "enabled");
157
+ checkNumber(o, `roles.${id}.`, "minRunIntervalSeconds", DURATION_NON_NEGATIVE);
158
+ // Only feature and bugfix may split (src/roles/runner-ids.ts INSTANCE_ROLES); an
159
+ // instances count under any other role would never spawn a runner, so reject it
160
+ // rather than let the operator believe it did something.
161
+ if ("instances" in o && !INSTANCE_ROLES.has(id)) {
162
+ problems.push(`roles.${id}.instances is only valid for ${[...INSTANCE_ROLES].join(" and ")} (got ${show(o.instances)})`);
163
+ }
164
+ else {
165
+ checkNumber(o, `roles.${id}.`, "instances", INSTANCE_COUNT);
166
+ }
167
+ }
168
+ }
169
+ }
170
+ }
171
+ /** Validate `review.afterMerge`'s role names: a typo'd id would silently keep that role
172
+ * reviewed before merging; the director is refused because its changes always merge on a
173
+ * review first. */
174
+ function validateReviewAfterMerge(r, problems, customNames) {
175
+ if (isJsonObject(r.review) && Array.isArray(r.review.afterMerge)) {
176
+ r.review.afterMerge.forEach((id, i) => {
177
+ if (typeof id !== "string" || id.trim() === "")
178
+ return; // Already reported by checkStringArray.
179
+ if (id === DIRECTOR_ROLE)
180
+ problems.push(`review.afterMerge[${i}] must not be "${DIRECTOR_ROLE}" — its changes are always reviewed before merging`);
181
+ else
182
+ checkKnownRoleId("review.afterMerge", id, customNames, problems);
183
+ });
184
+ }
185
+ }
186
+ /** Validate the per-role daily cost caps (src/gates/role-cap-gates.ts): each key must name a
187
+ * known role — built-in or customRoles — because a typo'd id would silently no-op the cap,
188
+ * the exact silent-ignore class the `roles.<id>` check exists to prevent (the customNames set
189
+ * is fully collected by this point, so a custom role can be capped). Each value is a spend
190
+ * threshold with the NON_NEGATIVE_OR_DISABLED semantics: 0 disables that role's cap. */
191
+ function validatePerRoleCostCaps(r, problems, customNames) {
192
+ checkPerRoleMap(r, "maxDailyCostUsdPerRole", "USD caps", customNames, problems, (id) => {
193
+ // Same MAX_SAFE_INTEGER bound as maxDailyCostUsd's DOLLAR_CAP rule (BUGS.md
194
+ // 2026-10-02): a finite-but-unrepresentable per-role cap is an effectively
195
+ // uncapped budget for that role — DOLLAR_CAP is that rule, so the predicate
196
+ // and its wording have one home. checkPerRoleMap narrowed the map, so the cast
197
+ // is sound and checkNumberField sees the entry's value typed as before.
198
+ checkNumberField(problems, r.maxDailyCostUsdPerRole, "maxDailyCostUsdPerRole.", id, DOLLAR_CAP);
199
+ });
200
+ }
201
+ /** Validate the per-role quiet hours (src/scheduling/quiet-hours.ts): the same known-role gate
202
+ * as the caps — a typo'd role id would silently no-op the window. Each value must parse as a
203
+ * quiet-hours window (an empty string means off for that role, the same disablement the
204
+ * team-wide key takes), and the message names the key and the offending id/value verbatim. */
205
+ function validatePerRoleQuietHours(r, problems, customNames) {
206
+ checkPerRoleMap(r, "quietHoursPerRole", '"HH:MM-HH:MM" windows', customNames, problems, (id, value) => {
207
+ if (typeof value !== "string") {
208
+ problems.push(`quietHoursPerRole.${id} must be a "HH:MM-HH:MM" string like "23:00-07:00" (an empty string means off) (got ${show(value)})`);
209
+ return;
210
+ }
211
+ if (value.trim() === "")
212
+ return; // empty = off for that role
213
+ const parsed = parseQuietHours(value, `quietHoursPerRole.${id}`);
214
+ if (!parsed.ok)
215
+ problems.push(parsed.error);
216
+ });
217
+ }
218
+ /** Validate per-provider concurrency caps (plans/provider-concurrency.md §1): a map from
219
+ * provider key to a positive integer. Unlike the per-role maps, an unknown provider name is
220
+ * NOT a validation error — providers live in pi's models.json, which validation does not
221
+ * read, and a fallback's provider may only come into use later (doctor warns in part 5/5).
222
+ * Each value must be a positive integer under a non-empty key. */
223
+ function validateMaxConcurrentPerProvider(r, problems) {
224
+ if (r.maxConcurrentPerProvider !== undefined) {
225
+ const map = r.maxConcurrentPerProvider;
226
+ if (!isJsonObject(map)) {
227
+ problems.push(`maxConcurrentPerProvider must be an object mapping provider names to concurrency caps (got ${show(map)})`);
228
+ }
229
+ else {
230
+ for (const [provider, value] of Object.entries(map)) {
231
+ if (provider.trim() === "") {
232
+ problems.push(`maxConcurrentPerProvider.<empty> — a provider key must not be empty`);
233
+ continue;
234
+ }
235
+ if (typeof value !== "number" || !Number.isFinite(value) || !POSITIVE_INTEGER.ok(value)) {
236
+ problems.push(`maxConcurrentPerProvider.${provider} must be ${POSITIVE_INTEGER.what} (got ${show(value)})`);
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ /** Validate `claudeWindowReserve` (the subscription-window reserve: a map from window type to
243
+ * a fraction, 0 < f <= 1) and its mutually-exclusive spelling pair with
244
+ * `backends.claude.windowReserve`. */
245
+ function validateClaudeWindowReserve(r, problems, helpers) {
246
+ const { checkWindowReserve } = helpers;
247
+ // The subscription-window reserve: a map from window type to a fraction, 0 < f <= 1.
248
+ if (r.claudeWindowReserve !== undefined)
249
+ checkWindowReserve("claudeWindowReserve", r.claudeWindowReserve);
250
+ // The reserve's two spellings are mutually exclusive (plans/codex-backend.md part 2b/9): the
251
+ // nested key is the current one; `claudeWindowReserve` is kept as claude's older spelling. A
252
+ // file holding both is ambiguous about which drives the window, so it is rejected naming both.
253
+ if (r.claudeWindowReserve !== undefined &&
254
+ isJsonObject(r.backends) &&
255
+ isJsonObject(r.backends.claude) &&
256
+ r.backends.claude.windowReserve !== undefined)
257
+ problems.push("claudeWindowReserve and backends.claude.windowReserve set the same value — keep one (backends.claude.windowReserve is the current spelling; claudeWindowReserve is its old name)");
258
+ }
259
+ /** Validate the `idleBackoff` cross-field pair (judged on the merged config, where both sides
260
+ * are always present; on a raw file only when the file itself names both): scheduleBackoff
261
+ * clamps every idle wait — the first included — to min(initialSeconds, maxSeconds), so a
262
+ * smaller max silently discards the configured initial wait. Name both values so one edit
263
+ * fixes the pair. */
264
+ function validateIdleBackoff(r, problems) {
265
+ const backoff = r.idleBackoff;
266
+ if (isJsonObject(backoff) &&
267
+ typeof backoff.initialSeconds === "number" &&
268
+ typeof backoff.maxSeconds === "number" &&
269
+ backoff.maxSeconds < backoff.initialSeconds)
270
+ problems.push(`idleBackoff.maxSeconds (${show(backoff.maxSeconds)}) must be ≥ idleBackoff.initialSeconds (${show(backoff.initialSeconds)}) — every idle wait is clamped to the smaller, so the smaller max would silently shorten the configured first wait`);
271
+ }