tumwater 0.1.2 → 0.2.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 (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -1,9 +1,12 @@
1
1
  import { changedConfigKeys, loadConfigCached } from "./config.js";
2
2
  import { loopIds } from "../roles/loop-ids.js";
3
- import { configForRole, fleetModelLabel, modelSelectorField } from "./config-views.js";
4
- import { bestEffortEventLogger } from "../events/events.js";
3
+ import { configForRole, teamModelLabel, modelSelectorField } from "./config-views.js";
4
+ import { seamsInUse } from "./config-fallbacks.js";
5
+ import { bestEffortEventLogger, TUMWATER_SOURCE } from "../events/events.js";
5
6
  import { LoopRunner } from "../loop/loop.js";
6
7
  import { configPath } from "../paths.js";
8
+ import { seedClaudeAccount } from "../agent/claude/claude-account.js";
9
+ import { agentMissingMessage, findAgentBinary, resolveAgentBin } from "../pi/pi-bin.js";
7
10
  /** A live config reloader over the given wiring; poll it once per scheduler cycle (see
8
11
  * LiveConfigReload for the contract). */
9
12
  export function newLiveConfigReload(deps) {
@@ -21,9 +24,17 @@ export function newLiveConfigReload(deps) {
21
24
  // The cap last applied to the semaphore (live-resized on each reload), so a change logs
22
25
  // exactly one event per distinct value — not once per poll.
23
26
  let lastMaxConcurrent = Math.max(1, deps.config.maxConcurrent);
27
+ // The per-provider caps last applied to the semaphore (live-resized on each reload), so a
28
+ // change logs exactly one event per distinct value — not once per poll. Seeded from the
29
+ // startup config, so the first poll of an unchanged file emits nothing.
30
+ let lastKeyCaps = deps.config.maxConcurrentPerProvider ?? {};
31
+ // Roots this process has already attempted a live-edit claude-account seed for (see the
32
+ // reload block below), so repeated polls of a config that keeps a claude seam cannot respawn
33
+ // `claude auth status` on every cycle.
34
+ const liveClaudeSeedAttempted = new Set();
24
35
  // Best-effort event logging: poll() runs inside the orchestrator's poll loop
25
36
  // (src/orchestrator/orchestrator.ts), which has no catch, so an unwritable feed (ENOSPC,
26
- // EACCES, the path replaced by a directory) would otherwise end the fleet — and, worse, skip
37
+ // EACCES, the path replaced by a directory) would otherwise end the team — and, worse, skip
27
38
  // the config application that follows the emission. bestEffortEventLogger (events.ts) holds
28
39
  // the shared "logging must not throw" policy.
29
40
  const { emit, warn } = bestEffortEventLogger(deps.root);
@@ -31,8 +42,8 @@ export function newLiveConfigReload(deps) {
31
42
  poll() {
32
43
  const reloaded = loadConfigCached(deps.root);
33
44
  // A missing file keeps the last-known-good config too, and never reloads as defaults: the
34
- // fleet was started on a real file (startup refuses to run without one), so its vanishing
35
- // mid-run is an incident — a landing's fast-forward deleted it on 2026-09-22 and the fleet
45
+ // team was started on a real file (startup refuses to run without one), so its vanishing
46
+ // mid-run is an incident — a merge's fast-forward deleted it on 2026-09-22 and the team
36
47
  // silently ran 8.6 h on defaults (BUGS.md 2026-09-23). One warning per vanish, not per
37
48
  // poll; its return logs one line, then the normal reload below diffs it against the
38
49
  // retained config — so config_changed names only what the returned file really changed.
@@ -54,11 +65,11 @@ export function newLiveConfigReload(deps) {
54
65
  // changed (maxConcurrent and sessionRetentionDays have their own events elsewhere).
55
66
  const changedKeys = changedConfigKeys(prevLive, reloaded.config);
56
67
  if (changedKeys.length > 0) {
57
- emit({ loop: "harness", type: "config_changed", keys: changedKeys });
68
+ emit({ source: TUMWATER_SOURCE, type: "config_changed", keys: changedKeys });
58
69
  // A real model-wiring change gets its own event naming the new selector(s), which the
59
70
  // key list cannot show; a non-model edit (instructions, an interval) emits nothing.
60
- const fromModel = fleetModelLabel(prevLive);
61
- const toModel = fleetModelLabel(reloaded.config);
71
+ const fromModel = teamModelLabel(prevLive);
72
+ const toModel = teamModelLabel(reloaded.config);
62
73
  const roleDiffs = changedKeys
63
74
  .filter((key) => key.startsWith("roles."))
64
75
  .map((key) => {
@@ -70,13 +81,34 @@ export function newLiveConfigReload(deps) {
70
81
  .filter((d) => d.from !== d.to);
71
82
  if (fromModel !== toModel || roleDiffs.length > 0)
72
83
  emit({
73
- loop: "harness",
84
+ source: TUMWATER_SOURCE,
74
85
  type: "model_changed",
75
86
  from: fromModel,
76
87
  to: toModel,
77
88
  ...(roleDiffs.length > 0 ? { roles: roleDiffs } : {}),
78
89
  });
79
90
  }
91
+ // A live edit that routes a seam to a backend the previous config did not use: the
92
+ // team keeps running, but that backend must be verified and (for claude) its account
93
+ // seeded, exactly as startup would — so a claude reviewer added to a pi team does not
94
+ // start uncheckable. A missing binary is a warning event, never a crash; a present one
95
+ // logs its verdict as a backend_binary event. The seed is attempted once per root.
96
+ const prevAgents = new Set(seamsInUse(prevLive).map((s) => s.agent ?? "pi"));
97
+ for (const agent of new Set(seamsInUse(reloaded.config).map((s) => s.agent ?? "pi"))) {
98
+ if (prevAgents.has(agent))
99
+ continue;
100
+ const resolved = resolveAgentBin(reloaded.config, agent);
101
+ if (findAgentBinary(resolved)) {
102
+ emit({ source: TUMWATER_SOURCE, type: "backend_binary", agent, found: true });
103
+ }
104
+ else {
105
+ warn(`newly routed ${agent} backend: ${agentMissingMessage(resolved, agent)}`);
106
+ }
107
+ if (agent === "claude" && !liveClaudeSeedAttempted.has(deps.root)) {
108
+ liveClaudeSeedAttempted.add(deps.root);
109
+ seedClaudeAccount(deps.root, reloaded.config);
110
+ }
111
+ }
80
112
  prevLive = reloaded.config;
81
113
  for (const r of deps.runners)
82
114
  r.config = reloaded.config;
@@ -86,9 +118,29 @@ export function newLiveConfigReload(deps) {
86
118
  const newMaxConcurrent = Math.max(1, reloaded.config.maxConcurrent);
87
119
  if (newMaxConcurrent !== lastMaxConcurrent) {
88
120
  deps.semaphore.setCapacity(newMaxConcurrent);
89
- emit({ loop: "harness", type: "max_concurrent_changed", from: lastMaxConcurrent, to: newMaxConcurrent });
121
+ emit({ source: TUMWATER_SOURCE, type: "max_concurrent_changed", from: lastMaxConcurrent, to: newMaxConcurrent });
90
122
  lastMaxConcurrent = newMaxConcurrent;
91
123
  }
124
+ // Live-resize the per-provider caps (plans/provider-concurrency.md §7): an edit
125
+ // re-applies the whole map to the pool, and each changed provider fires its own
126
+ // `provider_concurrency_changed` event. setKeyCaps never preempts — shrinking only
127
+ // withholds future grants on that key until releases drain it under the new cap.
128
+ const nextKeyCaps = reloaded.config.maxConcurrentPerProvider ?? {};
129
+ deps.semaphore.setKeyCaps(nextKeyCaps);
130
+ for (const provider of new Set([...Object.keys(lastKeyCaps), ...Object.keys(nextKeyCaps)])) {
131
+ const from = lastKeyCaps[provider];
132
+ const to = nextKeyCaps[provider];
133
+ if (from !== to) {
134
+ emit({
135
+ source: TUMWATER_SOURCE,
136
+ type: "provider_concurrency_changed",
137
+ provider,
138
+ from: from ?? 0,
139
+ to: to ?? 0,
140
+ });
141
+ }
142
+ }
143
+ lastKeyCaps = nextKeyCaps;
92
144
  const nowEnabled = loopIds(reloaded.config);
93
145
  // Enabling a role — or raising `roles.<id>.instances` — mid-run starts it: create the
94
146
  // runner for each newly present loop id (its persisted state survives). A lowered
@@ -103,10 +155,10 @@ export function newLiveConfigReload(deps) {
103
155
  }
104
156
  for (const role of prevEnabled)
105
157
  if (!nowEnabled.includes(role))
106
- warn(`role ${role} disabled — stopping ticks`);
158
+ warn(`role ${role} disabled — no new runs`);
107
159
  for (const role of nowEnabled)
108
160
  if (!prevEnabled.has(role))
109
- warn(`role ${role} enabled — starting ticks`);
161
+ warn(`role ${role} enabled — starting runs`);
110
162
  prevEnabled = new Set(nowEnabled);
111
163
  lastConfigError = null;
112
164
  }
@@ -1,3 +1,4 @@
1
+ // vocabulary: plain
1
2
  /** The tumwater.json config schema shape: the types `config-validation.ts` validates a
2
3
  * loaded config into and every config read site consumes, plus the key allow-lists and
3
4
  * value shapes (`TOP_LEVEL_KEYS` and friends) that say what a valid file may hold at each
@@ -5,7 +6,7 @@
5
6
  * its default (config.ts), and its allow-list entry sit beside each other. Split out of the former
6
7
  * types.ts grab-bag — the runtime types a tick produces now live beside their consumers
7
8
  * (TickResult and TickOutcome in tick-outcome.ts, LoopState in loop-state.ts, HarnessEvent
8
- * in events.ts, LandingEntry in landing-queue.ts, and the pi-run types in run-agent.ts and
9
+ * in events.ts, MergeEntry in merge-queue.ts, and the pi-run types in run-agent.ts and
9
10
  * pi-run-result.ts) — so the schema the file on disk is validated against and the runtime
10
11
  * state the harness runs with
11
12
  * are two layers, changed for different reasons by different readers: a tumwater.json knob
@@ -17,11 +18,13 @@ import { objectOrEmpty } from "../files/json-object.js";
17
18
  * `model` map inherits `default`'s model; `fallback` map values may also be `"pause"`
18
19
  * (each tier's own entry is consulted when that tier runs at the cap; part 5a/8). */
19
20
  export const MODEL_TIERS = ["small", "default", "strong"];
21
+ /** The keys an object fallback step may carry (validation's checkKnownKeys list). */
22
+ export const FALLBACK_STEP_KEYS = ["model", "maxDailyCostUsd"];
20
23
  /** The agent backends an `agent` value may name (plans/agent-backends.md "Config"). The
21
24
  * registry (src/agent/agent-backend.ts) is the runtime source, but the schema reserves these
22
25
  * ids so validation rejects a typo'd backend at load time instead of falling back to pi. The
23
26
  * agent id doubles as the selector provider namespace: `claude/<id>` always routes to claude. */
24
- export const AGENT_IDS = ["pi", "claude"];
27
+ export const AGENT_IDS = ["pi", "claude", "opencode"];
25
28
  /** The tier-map key list validation feeds checkKnownKeys: a `model` or `fallback` map whose
26
29
  * keys are not exactly these tiers is a typo that would silently never resolve. */
27
30
  export const TIER_MAP_KEYS = MODEL_TIERS;
@@ -41,32 +44,34 @@ export function modelTierMap(model) {
41
44
  /** Every key tumwater.json may hold, by level. Anything else is a typo that would be
42
45
  * silently ignored at runtime — the intended setting falls back to its default with no
43
46
  * warning — so validation (config-validation.ts's checkKnownKeys, fed from these tables)
44
- * fails fast instead (e.g. `tickTimeoutSecondss` does nothing). Kept in sync with the
47
+ * fails fast instead (e.g. `runTimeoutSecondss` does nothing). Kept in sync with the
45
48
  * interfaces above in this same file: adding a key edits the interface and its level's
46
49
  * list side by side. */
47
50
  export const TOP_LEVEL_KEYS = [
48
51
  "provider",
49
52
  "model",
50
53
  "thinking",
51
- "agent",
54
+ "backend",
52
55
  "baseBranch",
53
56
  "agentBin",
54
- "agents",
57
+ "backends",
55
58
  "check",
56
59
  "piArgs",
57
60
  "maxConcurrent",
58
- "landBatchMax",
61
+ "maxConcurrentPerProvider",
62
+ "claudeWindowReserve",
63
+ "mergeBatchMax",
59
64
  "maxConcurrentChecks",
60
65
  "worktreeSlots",
61
- "minTickIntervalSeconds",
62
- "tickTimeoutSeconds",
66
+ "minRunIntervalSeconds",
67
+ "runTimeoutSeconds",
63
68
  "quietTimeoutSeconds",
64
69
  "toolCallStallSeconds",
65
70
  "logMaxBytes",
66
71
  "sessionRetentionDays",
67
72
  "maxDailyCostUsd",
68
73
  "maxDailyCostUsdPerRole",
69
- "maintenancePerWorkLanding",
74
+ "maintenancePerWorkMerge",
70
75
  "quietHours",
71
76
  "quietHoursPerRole",
72
77
  "diskHoldGB",
@@ -81,13 +86,17 @@ export const TOP_LEVEL_KEYS = [
81
86
  "autoRestart",
82
87
  "bootstrap",
83
88
  "review",
84
- "customLoops",
89
+ "customRoles",
85
90
  "roles",
86
91
  ];
87
92
  export const BACKOFF_KEYS = ["initialSeconds", "factor", "maxSeconds"];
88
- /** The keys an `agents.<id>` entry may hold; validation feeds checkKnownKeys so a typo'd
89
- * field (`agents.claude.binary`) is a load error rather than a silently inert entry. */
93
+ /** The keys an `backends.<id>` entry may hold; validation feeds checkKnownKeys so a typo'd
94
+ * field (`backends.claude.binary`) is a load error rather than a silently inert entry.
95
+ * `configDir` is claude-only (CLAUDE_AGENT_KEYS), so it is not silently inert on pi/opencode. */
90
96
  export const AGENT_KEYS = ["bin", "args"];
97
+ /** The keys a claude `backends.claude` entry may hold — the shared agent keys plus `configDir`,
98
+ * the one per-backend field only the claude adapter reads. */
99
+ export const CLAUDE_AGENT_KEYS = [...AGENT_KEYS, "configDir"];
91
100
  /** The keys tumwater.json's `bootstrap` section may hold. */
92
101
  export const BOOTSTRAP_KEYS = ["untilPlansDone"];
93
102
  export const ROLE_ENTRY_KEYS = [
@@ -96,7 +105,7 @@ export const ROLE_ENTRY_KEYS = [
96
105
  "provider",
97
106
  "model",
98
107
  "thinking",
99
- "minTickIntervalSeconds",
108
+ "minRunIntervalSeconds",
100
109
  "instances",
101
110
  ];
102
111
  export const REVIEW_KEYS = ["enabled", "exemptPaths", "provider", "model", "thinking", "timeoutSeconds"];
@@ -110,7 +119,7 @@ export const CHECK_KEYS = ["command", "gateCommand", "cwd", "timeoutSeconds"];
110
119
  export const MODEL_TRIPLE_KEYS = ["provider", "model", "thinking"];
111
120
  /** pi's accepted `--thinking` levels (pi's own `--help`). pi WARNS and falls back to its own
112
121
  * default on any other value rather than failing, so a misspelled level would silently run the
113
- * fleet at the wrong reasoning depth — the same silent-ignore class validation exists to
122
+ * team at the wrong reasoning depth — the same silent-ignore class validation exists to
114
123
  * catch. Kept in sync with pi's CLI (dist/cli/args.js VALID_THINKING_LEVELS). */
115
124
  export const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
116
125
  /** pi flags the harness passes itself (src/pi/pi-args.ts's piArgs: `--print --mode json
@@ -121,17 +130,20 @@ export const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high
121
130
  * `--continue` resumes a session the harness did not choose. Each flag maps to why it is
122
131
  * refused; the model triple points at the setting that owns it. */
123
132
  export const HARNESS_PI_FLAGS = new Map([
124
- ["--print", "the harness already runs pi non-interactively"],
125
- ["-p", "the harness already runs pi non-interactively"],
126
- ["--mode", "the harness requires pi's json output mode"],
127
- ["--session-dir", "the harness owns each role's session directory"],
128
- ["--continue", "the harness decides when to resume a session"],
129
- ["-c", "the harness decides when to resume a session"],
130
- ["-n", "the harness names the session"],
131
- ["--name", "the harness names the session"],
133
+ ["--print", "tumwater already runs pi non-interactively"],
134
+ ["-p", "tumwater already runs pi non-interactively"],
135
+ ["--mode", "tumwater requires pi's json output mode"],
136
+ ["--session-dir", "tumwater owns each role's session directory"],
137
+ ["--continue", "tumwater decides when to resume a session"],
138
+ ["-c", "tumwater decides when to resume a session"],
139
+ ["-n", "tumwater names the session"],
140
+ ["--name", "tumwater names the session"],
132
141
  ["--provider", "set the top-level or per-role `provider` instead"],
133
142
  ["--model", "set the top-level or per-role `model` instead"],
134
143
  ["--thinking", "set the top-level or per-role `thinking` instead"],
144
+ // The harness passes --no-extensions itself (pi-args.ts's piArgs) so pi cannot load discovered
145
+ // extensions; a piArgs entry that re-enabled them would put operator MCP servers back in.
146
+ ["--no-extensions", "tumwater skips pi's discovered extensions itself"],
135
147
  ]);
136
148
  export const CUSTOM_LOOP_KEYS = ["name", "task"];
137
149
  /** A custom loop's name becomes a worktree dir and a git ref, so it is validated strictly:
@@ -1,13 +1,19 @@
1
- import { AGENT_IDS, AGENT_KEYS, BACKOFF_KEYS, BOOTSTRAP_KEYS, CHECK_KEYS, CUSTOM_LOOP_KEYS, CUSTOM_NAME_RE, CUSTOM_TASK_MAX_CHARS, HARNESS_PI_FLAGS, MODEL_TRIPLE_KEYS, REVIEW_KEYS, ROLE_ENTRY_KEYS, TOP_LEVEL_KEYS, ROLE_INSTRUCTIONS_MAX_CHARS, TIER_MAP_KEYS, } from "./config-schema.js";
1
+ // vocabulary: plain
2
+ import { AGENT_IDS, AGENT_KEYS, CLAUDE_AGENT_KEYS, BACKOFF_KEYS, BOOTSTRAP_KEYS, CHECK_KEYS, CUSTOM_LOOP_KEYS, CUSTOM_NAME_RE, CUSTOM_TASK_MAX_CHARS, FALLBACK_STEP_KEYS, HARNESS_PI_FLAGS, MODEL_TRIPLE_KEYS, REVIEW_KEYS, ROLE_ENTRY_KEYS, TOP_LEVEL_KEYS, ROLE_INSTRUCTIONS_MAX_CHARS, TIER_MAP_KEYS, } from "./config-schema.js";
2
3
  import { allRoleIds } from "../roles/roles.js";
3
4
  import { INSTANCE_ROLES, baseRoleOf } from "../roles/loop-ids.js";
4
5
  import { isJsonObject } from "../files/json-object.js";
6
+ import { expandHomeTilde } from "../files/files.js";
7
+ import os from "node:os";
8
+ import path from "node:path";
5
9
  import { isNonBlankString, tooLongMessage } from "../text/text.js";
10
+ import { typoSuffix } from "../text/suggest.js";
6
11
  import { PREFILL_REASON } from "../text/phrases.js";
7
12
  import { parseQuietHours } from "../scheduling/quiet-hours.js";
8
13
  import { isAgentId, parseModelSelector } from "./model-selector.js";
9
- import { HARNESS_CLAUDE_FLAGS } from "../agent/claude/claude-args.js";
10
- import { AT_LEAST_ONE, checkBooleanField, checkKnownKeys, checkKnownRoleId, checkModelTripleField, checkNumberField, checkObjectSection, checkReservedArgs, checkStringArrayField, checkStringField, NON_NEGATIVE, NON_NEGATIVE_OR_DISABLED, DOLLAR_CAP, POSITIVE, POSITIVE_INTEGER, show, typeName, } from "./config-field-checks.js";
14
+ import { TUMWATER_SOURCE } from "../events/events.js";
15
+ import { reservedPassthroughFlag } from "../agent/backend-traits.js";
16
+ import { AT_LEAST_ONE, checkBooleanField, checkKnownKeys, checkKnownRoleId, checkModelTripleField, checkNumberField, checkObjectSection, checkReservedArgs, checkStringArrayField, checkStringField, NON_NEGATIVE, NON_NEGATIVE_OR_DISABLED, DOLLAR_CAP, MAX_DURATION_SECONDS, POSITIVE, POSITIVE_INTEGER, show, typeName, } from "./config-field-checks.js";
11
17
  /** Schema validation for tumwater.json: validateConfig, the gate every load and save passes
12
18
  * through (config.ts's load/save, config-write.ts's budget editing and director config
13
19
  * requests), judged against the key allow-lists and value shapes that live beside the types
@@ -18,12 +24,6 @@ import { AT_LEAST_ONE, checkBooleanField, checkKnownKeys, checkKnownRoleId, chec
18
24
  * unknown-key and known-role guards, the NumberRule table, and the per-field type checkers —
19
25
  * lives in config-field-checks.ts; this module is the section-by-section rules plus the
20
26
  * cross-field checks, and the one export gate (validateConfig) for them. */
21
- /** The upper bound every seconds field that feeds a ×1000 duration shares: 2147483 s
22
- * × 1000 stays inside node's signed-32-bit setTimeout/setInterval range (2^31−1 ms,
23
- * about 24.8 days). Above it node clamps the delay to 1ms — a one-zero typo like
24
- * `"tickTimeoutSeconds": 1e300` became Infinity ms, and every pi run died the moment it
25
- * started (BUGS.md 2026-10-03). */
26
- const MAX_DURATION_SECONDS = 2147483;
27
27
  const DURATION_POSITIVE = {
28
28
  ok: (n) => n > 0 && n <= MAX_DURATION_SECONDS,
29
29
  what: `a number greater than 0, at most ${MAX_DURATION_SECONDS}`,
@@ -64,7 +64,7 @@ function checkPerRoleMap(r, key, expectation, customNames, problems, checkValue)
64
64
  }
65
65
  }
66
66
  /** Validate tumwater.json values, so a typo fails fast with an actionable message instead
67
- * of misbehaving at runtime — e.g. a non-numeric tickTimeoutSeconds becomes NaN and kills
67
+ * of misbehaving at runtime — e.g. a non-numeric runTimeoutSeconds becomes NaN and kills
68
68
  * every pi run instantly, a non-numeric logMaxBytes rotates the event log on every write, an
69
69
  * unknown role id (a misspelled entry under `roles`) spawns a phantom loop that errors every
70
70
  * tick, and an unknown key is silently ignored so the intended setting never takes effect.
@@ -113,9 +113,9 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
113
113
  // The agent backend for bare/no-model selectors (plans/agent-backends.md "Config"): a
114
114
  // non-blank id from the schema's reserved set. An unknown id would fall back to pi at run
115
115
  // time (src/agent/run-agent.ts), silently running the wrong backend against a named one.
116
- checkString(r, "", "agent", false);
117
- if (typeof r.agent === "string" && !isAgentId(r.agent))
118
- problems.push(`agent must name a backend (${AGENT_IDS.join(", ")}; got ${show(r.agent)})`);
116
+ checkString(r, "", "backend", false);
117
+ if (typeof r.backend === "string" && !isAgentId(r.backend))
118
+ problems.push(`backend must name a backend (${AGENT_IDS.join(", ")}; got ${show(r.backend)})${typoSuffix(r.backend, AGENT_IDS)}`);
119
119
  checkStringArray(r, "", "piArgs");
120
120
  // A piArgs entry that repeats a harness-managed flag is appended after the harness's own and
121
121
  // pi's parser is last-wins, so it silently overrides it (see HARNESS_PI_FLAGS). Name the
@@ -127,25 +127,29 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
127
127
  // that repeats a flag the backend's own harness invocation sets is appended last and wins,
128
128
  // so it silently overrides the harness — refused here for the two backends whose reserved
129
129
  // sets are known (claude-args.ts HARNESS_CLAUDE_FLAGS, config-schema.ts HARNESS_PI_FLAGS).
130
- checkObjectSection(r, "agents", problems, (o) => {
130
+ checkObjectSection(r, "backends", problems, (o) => {
131
131
  for (const [id, entry] of Object.entries(o)) {
132
- const prefix = `agents.${id}`;
132
+ const prefix = `backends.${id}`;
133
133
  if (!isJsonObject(entry)) {
134
134
  problems.push(`${prefix} must be an object with optional bin and args (got ${show(entry)})`);
135
135
  continue;
136
136
  }
137
- checkKnownKeys(entry, AGENT_KEYS, prefix, problems);
137
+ checkKnownKeys(entry, id === "claude" ? CLAUDE_AGENT_KEYS : AGENT_KEYS, prefix, problems);
138
138
  checkString(entry, `${prefix}.`, "bin", false);
139
139
  checkStringArray(entry, `${prefix}.`, "args");
140
+ // claude's per-backend config dir (plans/notes-command.md "Keeping funes out of team runs"):
141
+ // a leading `~` expands; the value must be an absolute path after expansion, or claude would
142
+ // resolve a relative CLAUDE_CONFIG_DIR from the tick's cwd and write transcripts somewhere
143
+ // unexpected. Only claude accepts the key (CLAUDE_AGENT_KEYS), so it is inert nowhere.
144
+ if (id === "claude") {
145
+ checkString(entry, `${prefix}.`, "configDir", false);
146
+ if (typeof entry.configDir === "string" && entry.configDir !== "")
147
+ if (!path.isAbsolute(expandHomeTilde(entry.configDir, os.homedir())))
148
+ problems.push(`${prefix}.configDir must be an absolute path after ~ expansion (got ${show(entry.configDir)})`);
149
+ }
140
150
  if (!Array.isArray(entry.args))
141
151
  continue; // Already reported by checkStringArray.
142
- checkReservedArgs(problems, entry.args, `${prefix}.args`, (flag) => id === "claude"
143
- ? HARNESS_CLAUDE_FLAGS.has(flag)
144
- ? "it is harness-managed"
145
- : undefined
146
- : id === "pi"
147
- ? HARNESS_PI_FLAGS.get(flag)
148
- : undefined);
152
+ checkReservedArgs(problems, entry.args, `${prefix}.args`, (flag) => reservedPassthroughFlag(id, flag));
149
153
  }
150
154
  });
151
155
  // Legacy back-compat (plans/agent-backends.md "Back-compat"): a file must not spell one pi
@@ -154,21 +158,21 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
154
158
  // value from the default. The effective value resolves through config-views.ts
155
159
  // resolvedAgentConfig; a load-time mirror into `agents` would instead leak a duplicate
156
160
  // spelling into every later config write.
157
- if (rawFile && isJsonObject(r.agents)) {
158
- const pi = r.agents.pi;
161
+ if (rawFile && isJsonObject(r.backends)) {
162
+ const pi = r.backends.pi;
159
163
  if (isJsonObject(pi)) {
160
164
  if (r.agentBin !== undefined && pi.bin !== undefined)
161
- problems.push("agentBin and agents.pi.bin set the same value — keep one (agents.pi.bin is the current spelling; agentBin is its legacy alias)");
165
+ problems.push("agentBin and backends.pi.bin set the same value — keep one (backends.pi.bin is the current spelling; agentBin is its old name)");
162
166
  if (r.piArgs !== undefined && pi.args !== undefined)
163
- problems.push("piArgs and agents.pi.args set the same value — keep one (agents.pi.args is the current spelling; piArgs is its legacy alias)");
167
+ problems.push("piArgs and backends.pi.args set the same value — keep one (backends.pi.args is the current spelling; piArgs is its old name)");
164
168
  }
165
169
  }
166
170
  checkNumber(r, "", "maxConcurrent", POSITIVE_INTEGER);
167
- checkNumber(r, "", "landBatchMax", POSITIVE_INTEGER);
171
+ checkNumber(r, "", "mergeBatchMax", POSITIVE_INTEGER);
168
172
  checkNumber(r, "", "maxConcurrentChecks", POSITIVE_INTEGER);
169
173
  checkNumber(r, "", "worktreeSlots", POSITIVE_INTEGER);
170
- checkNumber(r, "", "minTickIntervalSeconds", DURATION_NON_NEGATIVE);
171
- checkNumber(r, "", "tickTimeoutSeconds", DURATION_POSITIVE);
174
+ checkNumber(r, "", "minRunIntervalSeconds", DURATION_NON_NEGATIVE);
175
+ checkNumber(r, "", "runTimeoutSeconds", DURATION_POSITIVE);
172
176
  checkNumber(r, "", "quietTimeoutSeconds", DURATION_OR_DISABLED);
173
177
  checkNumber(r, "", "toolCallStallSeconds", DURATION_OR_DISABLED);
174
178
  checkNumber(r, "", "logMaxBytes", POSITIVE);
@@ -176,7 +180,7 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
176
180
  checkNumber(r, "", "maxDailyCostUsd", DOLLAR_CAP);
177
181
  // Work ratio 1a/4: the maintenance allowance ratio is a number of 0 or more (a larger value
178
182
  // effectively disables the cap, so there is no separate off switch).
179
- checkNumber(r, "", "maintenancePerWorkLanding", NON_NEGATIVE);
183
+ checkNumber(r, "", "maintenancePerWorkMerge", NON_NEGATIVE);
180
184
  // Disk floor (plans/disk-floor.md, part 1/4): 0 disables the hold, so only a negative
181
185
  // value or a non-number is a problem.
182
186
  checkNumber(r, "", "diskHoldGB", NON_NEGATIVE);
@@ -281,19 +285,26 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
281
285
  if (isJsonObject(f)) {
282
286
  checkKnownKeys(f, TIER_MAP_KEYS, "fallback", problems);
283
287
  for (const [tier, v] of Object.entries(f)) {
284
- if (!isNonBlankString(v))
285
- problems.push(`fallback.${tier} must be a non-empty selector string or "pause" (got ${show(v)})`);
288
+ if (Array.isArray(v))
289
+ checkFallbackList(`fallback.${tier}`, v, problems);
290
+ else if (isJsonObject(v))
291
+ checkFallbackStep(`fallback.${tier}`, v, problems);
292
+ else if (!isNonBlankString(v))
293
+ problems.push(`fallback.${tier} must be a non-empty model id or "pause" (got ${show(v)})`);
286
294
  else if (v !== "pause")
287
295
  // An entry is a pure selector (config-views' fallbackSelectorFields parses it with
288
296
  // no legacy provider in scope); "pause" names no model and stays valid.
289
297
  checkSelectorValue(`fallback.${tier}`, v, undefined, problems);
290
298
  }
291
299
  }
300
+ else if (Array.isArray(f)) {
301
+ checkFallbackList("fallback", f, problems);
302
+ }
292
303
  else if (!isNonBlankString(f)) {
293
- problems.push(`fallback must be a selector string or a map by tier (got ${show(f)})`);
304
+ problems.push(`fallback must be a model id or a map by tier (got ${show(f)})`);
294
305
  }
295
306
  else if (f === "pause") {
296
- problems.push(`fallback "pause" is only valid as a tier map value — a string fallback must name a selector`);
307
+ problems.push(`fallback "pause" is only valid as a tier map value — a string fallback must name a model id`);
297
308
  }
298
309
  else {
299
310
  // A selector-string fallback still parses to halves: report an empty one.
@@ -311,13 +322,46 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
311
322
  /** A selector string that parses to an empty provider or model half (`"/id"`, `"p/"`) passes
312
323
  * the non-empty-string rule but never reaches pi: piArgs skips an empty value when it builds
313
324
  * its flags (config-field-checks' model-triple note), and fallbackPair drops an empty half, so
314
- * the fleet silently runs pi's default model — the same silent-ignore class the empty-string
325
+ * the team silently runs pi's default model — the same silent-ignore class the empty-string
315
326
  * rules exist to prevent. Checked only when no legacy provider is in scope: under one, the
316
327
  * whole string is the id (config-views' parse), so a `/` in it is ordinary text and the parse
317
328
  * cannot produce an empty half. */
318
329
  function checkSelectorHalves(obj, prefix, key, legacyProvider, problems) {
319
330
  checkSelectorValue(`${prefix}${key}`, obj[key], legacyProvider, problems);
320
331
  }
332
+ /** Validate a `fallback` list (a tier's ordered chain): non-empty, each element a step — a
333
+ * non-blank model id that is not `"pause"` (a whole-tier value only) and parses to both
334
+ * halves, or a `{ model, maxDailyCostUsd? }` object (checkFallbackStep). */
335
+ function checkFallbackList(name, list, problems) {
336
+ if (list.length === 0)
337
+ problems.push(`${name} list must not be empty — name at least one model id`);
338
+ list.forEach((el, i) => {
339
+ const at = `${name}[${i}]`;
340
+ if (isJsonObject(el))
341
+ checkFallbackStep(at, el, problems);
342
+ else if (!isNonBlankString(el))
343
+ problems.push(`${name} list element ${i} must be a non-empty model id or a step object (got ${show(el)})`);
344
+ else if (el === "pause")
345
+ problems.push(`${name} list element ${i} is "pause" — "pause" is only valid as a whole-tier value`);
346
+ else
347
+ checkSelectorValue(at, el, undefined, problems);
348
+ });
349
+ }
350
+ /** Validate one object step `{ model, maxDailyCostUsd? }`: only those keys, `model` a non-blank
351
+ * selector (not `"pause"`) and `maxDailyCostUsd`, when present, a positive finite number. */
352
+ function checkFallbackStep(at, step, problems) {
353
+ checkKnownKeys(step, FALLBACK_STEP_KEYS, at, problems);
354
+ const m = step.model;
355
+ if (!isNonBlankString(m))
356
+ problems.push(`${at}.model must be a non-empty model id (got ${show(m)})`);
357
+ else if (m === "pause")
358
+ problems.push(`${at}.model is "pause" — "pause" is only valid as a whole-tier value`);
359
+ else
360
+ checkSelectorValue(`${at}.model`, m, undefined, problems);
361
+ const cap = step.maxDailyCostUsd;
362
+ if (cap !== undefined && !(typeof cap === "number" && Number.isFinite(cap) && cap > 0))
363
+ problems.push(`${at}.maxDailyCostUsd must be a positive number (got ${show(cap)})`);
364
+ }
321
365
  /** Report one selector string that parses to an empty provider or model half. `name` is the
322
366
  * config path the message names — `model`, a map entry's `model.small`, a section's
323
367
  * `roles.feature.model`. Split out of checkSelectorHalves so the map forms, which enumerate
@@ -337,16 +381,16 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
337
381
  // they are validated strictly — a colliding name would silently shadow a built-in's prompt
338
382
  // (roleById wins in tickPrompt), validation makes that impossible instead of subtle. The
339
383
  // collected names feed the roles cross-check below: an id under `roles` is valid when it is
340
- // in the catalog OR listed as a customLoops name.
384
+ // in the catalog OR listed as a customRoles name.
341
385
  const customNames = new Set();
342
- if ("customLoops" in r) {
343
- const cl = r.customLoops;
386
+ if ("customRoles" in r) {
387
+ const cl = r.customRoles;
344
388
  if (!Array.isArray(cl)) {
345
- problems.push(`customLoops must be an array of { name, task } entries (got ${show(cl)})`);
389
+ problems.push(`customRoles must be an array of { name, task } entries (got ${show(cl)})`);
346
390
  }
347
391
  else {
348
392
  cl.forEach((entry, i) => {
349
- const where = `customLoops[${i}]`;
393
+ const where = `customRoles[${i}]`;
350
394
  if (!isJsonObject(entry)) {
351
395
  problems.push(`${where} must be an object with keys name and task (got ${show(entry)})`);
352
396
  return;
@@ -357,16 +401,19 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
357
401
  problems.push(`${where}.name must be a string matching /^[a-z0-9][a-z0-9_-]{0,31}$/ (got ${show(name)})`);
358
402
  }
359
403
  else if (!CUSTOM_NAME_RE.test(name)) {
360
- problems.push(`${where}.name "${name}" is not a valid loop name: start with a lowercase letter or digit, then at most 31 more of [a-z0-9_-]`);
404
+ 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_-]`);
405
+ }
406
+ else if (name === TUMWATER_SOURCE) {
407
+ 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`);
361
408
  }
362
409
  else if (allRoleIds().includes(name)) {
363
410
  problems.push(`${where}.name "${name}" collides with a built-in role id — pick another name`);
364
411
  }
365
412
  else if (baseRoleOf(name) !== name) {
366
- problems.push(`${where}.name "${name}" would shadow the ${baseRoleOf(name)} loop's instance ids — pick another name`);
413
+ problems.push(`${where}.name "${name}" would shadow the ${baseRoleOf(name)} role's instance ids — pick another name`);
367
414
  }
368
415
  else if (customNames.has(name)) {
369
- problems.push(`${where}.name "${name}" is duplicated in customLoops — names must be unique`);
416
+ problems.push(`${where}.name "${name}" is duplicated in customRoles — names must be unique`);
370
417
  }
371
418
  else {
372
419
  customNames.add(name);
@@ -392,7 +439,7 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
392
439
  }
393
440
  else {
394
441
  for (const [id, rc] of Object.entries(roles)) {
395
- // An id outside the catalog and customLoops cannot work: tickPrompt has no prompt for
442
+ // An id outside the catalog and customRoles cannot work: tickPrompt has no prompt for
396
443
  // it and the loop would error every tick forever (checkKnownRoleId).
397
444
  if (!checkKnownRoleId("roles", id, customNames, problems))
398
445
  continue;
@@ -411,7 +458,7 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
411
458
  checkModelTriple(o, `roles.${id}.`, "tier-name");
412
459
  checkSelectorHalves(o, `roles.${id}.`, "model", o.provider ?? r.provider, problems);
413
460
  checkBoolean(o, `roles.${id}.`, "enabled");
414
- checkNumber(o, `roles.${id}.`, "minTickIntervalSeconds", DURATION_NON_NEGATIVE);
461
+ checkNumber(o, `roles.${id}.`, "minRunIntervalSeconds", DURATION_NON_NEGATIVE);
415
462
  // Only feature and bugfix may split (src/roles/loop-ids.ts INSTANCE_ROLES); an
416
463
  // instances count under any other role would never spawn a runner, so reject it
417
464
  // rather than let the operator believe it did something.
@@ -425,7 +472,7 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
425
472
  }
426
473
  }
427
474
  // Per-role daily cost caps (src/gates/role-cap-gates.ts): each key must name a known role —
428
- // built-in or customLoops — because a typo'd id would silently no-op the cap, the exact
475
+ // built-in or customRoles — because a typo'd id would silently no-op the cap, the exact
429
476
  // silent-ignore class the `roles.<id>` check exists to prevent (the customNames set above
430
477
  // is fully collected by this point, so a custom loop can be capped). Each value is a
431
478
  // spend threshold with the NON_NEGATIVE_OR_DISABLED semantics: 0 disables that role's cap.
@@ -440,7 +487,7 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
440
487
  // Per-role quiet hours (src/scheduling/quiet-hours.ts): the same known-role gate as the
441
488
  // caps — a typo'd role id would silently no-op the window. Each value must parse as a
442
489
  // quiet-hours window (an empty string means off for that role, the same disablement the
443
- // fleet-wide key takes), and the message names the key and the offending id/value verbatim.
490
+ // team-wide key takes), and the message names the key and the offending id/value verbatim.
444
491
  checkPerRoleMap(r, "quietHoursPerRole", '"HH:MM-HH:MM" windows', customNames, problems, (id, value) => {
445
492
  if (typeof value !== "string") {
446
493
  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)})`);
@@ -452,6 +499,43 @@ export function validateConfig(raw, label = "tumwater.json", rawFile = false) {
452
499
  if (!parsed.ok)
453
500
  problems.push(parsed.error);
454
501
  });
502
+ // Per-provider concurrency caps (plans/provider-concurrency.md §1): a map from provider
503
+ // key to a positive integer. Unlike the per-role maps above, an unknown provider name is
504
+ // NOT a validation error — providers live in pi's models.json, which validation does not
505
+ // read, and a fallback's provider may only come into use later (doctor warns in part 5/5).
506
+ // Each value must be a positive integer under a non-empty key.
507
+ if (r.maxConcurrentPerProvider !== undefined) {
508
+ const map = r.maxConcurrentPerProvider;
509
+ if (!isJsonObject(map)) {
510
+ problems.push(`maxConcurrentPerProvider must be an object mapping provider names to concurrency caps (got ${show(map)})`);
511
+ }
512
+ else {
513
+ for (const [provider, value] of Object.entries(map)) {
514
+ if (provider.trim() === "") {
515
+ problems.push(`maxConcurrentPerProvider.<empty> — a provider key must not be empty`);
516
+ continue;
517
+ }
518
+ if (typeof value !== "number" || !Number.isFinite(value) || !POSITIVE_INTEGER.ok(value)) {
519
+ problems.push(`maxConcurrentPerProvider.${provider} must be ${POSITIVE_INTEGER.what} (got ${show(value)})`);
520
+ }
521
+ }
522
+ }
523
+ }
524
+ // The subscription-window reserve: a map from window type to a fraction, 0 < f <= 1.
525
+ if (r.claudeWindowReserve !== undefined) {
526
+ const map = r.claudeWindowReserve;
527
+ if (!isJsonObject(map)) {
528
+ problems.push(`claudeWindowReserve must be an object mapping window types to fractions (got ${show(map)})`);
529
+ }
530
+ else {
531
+ for (const [type, value] of Object.entries(map)) {
532
+ if (type.trim() === "")
533
+ problems.push(`claudeWindowReserve.<empty> — a window type must not be empty`);
534
+ else if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 1)
535
+ problems.push(`claudeWindowReserve.${type} must be a fraction above 0 and at most 1 (got ${show(value)})`);
536
+ }
537
+ }
538
+ }
455
539
  // Cross-field (judged on the merged config, where both sides are always present; on a raw
456
540
  // file only when the file itself names both): scheduleBackoff clamps every idle wait — the
457
541
  // first included — to min(initialSeconds, maxSeconds), so a smaller max silently discards