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,3 +1,4 @@
1
+ // vocabulary: plain
1
2
  /** One loop's read-only inspection payload — the collector behind `tumwater role <id>`:
2
3
  * what the loop IS (catalog entry or custom, enabled, paused, tier), what it RUNS ON
3
4
  * (resolved provider/model, the budget fallback pair), what it CARRIES (the instructions
@@ -7,29 +8,59 @@
7
8
  * without the CLI and one renderer consumes exactly this shape. Every read degrades like
8
9
  * backlogPayload does — a missing tumwater.json is the defaults, a missing state file a
9
10
  * fresh state, a missing queue directory an empty inbox — so the command works with the
10
- * fleet stopped and never throws on a torn repo. */
11
- import { defaultConfig, enabledRoleIds, isCustomRole, knownRoleIds, loadConfigSafe } from "../config/config.js";
11
+ * team stopped and never throws on a torn repo. */
12
+ import { defaultConfig, enabledRoleIds, isCustomRole, knownRoleIds, loadConfigSafe, parseJsonConfig } from "../config/config.js";
12
13
  import { DIRECTOR_ROLE, customRole, roleById, roleTier, unknownRoleMessage } from "./roles.js";
13
14
  import { queuedRolePromptCount } from "../inbox/inbox.js";
14
- import { pausedRoles } from "../fleet/fleet-state.js";
15
+ import { pausedRoles } from "../team/team-state.js";
15
16
  import { loadLoopState } from "../loop/loop-state.js";
17
+ import { baseRoleOf, loopIds, loopPaused } from "./loop-ids.js";
18
+ import { roleCapVerdicts } from "../gates/role-cap-gates.js";
19
+ import { yieldMultiplierFor } from "../scheduling/backoff.js";
20
+ import { quietHoursStatus } from "../scheduling/quiet-hours.js";
16
21
  import { assembleTickPrompt } from "../tick/tick-prompt.js";
17
- import { configForRole, fallbackPair, modelFallbackView, resolvedModelFields, roleSeamTier, } from "../config/config-views.js";
18
- import { fallbackModelPair, piModelsPath } from "../pi/pi-models.js";
22
+ import { configForRole, resolvedModelFields, reviewRunConfig, roleSeamTier, seamConcurrencyKey } from "../config/config-views.js";
23
+ import { fallbackPair, modelFallbackView } from "../config/config-fallbacks.js";
24
+ import { fallbackModelPair, piModelsPath, seamContextWindow } from "../pi/pi-models.js";
25
+ import { readOpencodeCache } from "../agent/opencode/opencode-catalog.js";
26
+ import { formatModelSelector } from "../config/model-selector.js";
27
+ import { roleImageInfo } from "../agent/image-input.js";
28
+ import { readImageProbeCache } from "../doctor/image-probe.js";
29
+ import { piSettingsPath, readPiDefaultModel } from "../pi/pi-settings.js";
19
30
  import { claudeWindowEntries, readClaudeAccount } from "../agent/claude/claude-account.js";
20
- import { roleNotesPath } from "../paths.js";
31
+ import { CONFIG_BASENAME, configPath, roleNotesPath } from "../paths.js";
21
32
  import { readTextOrNull } from "../files/files.js";
33
+ import { eventRole, eventUsage, readEvents } from "../events/event-read.js";
34
+ import { finiteNumber, objectOrEmpty } from "../files/json-object.js";
35
+ import { canonicalRoleId } from "./legacy-role-ids.js";
36
+ import { orchestratorAlive, readOrchestratorInfo } from "../team/orchestrator-info.js";
37
+ /** The roles.<base>.<field> value the FILE actually holds, or undefined when the file never
38
+ * set that field. Needed only for the seeded per-role fields: overlayDefaults fills every
39
+ * built-in role's minRunIntervalSeconds from config.ts's catalog, so the merged config cannot
40
+ * tell an operator-set override from a seeded default. This reads the same raw file view
41
+ * unsetConfigKey judges presence against (configPath + CONFIG_BASENAME + objectOrEmpty), so
42
+ * the override tag and the drawer's clear path agree with what unsetting can actually remove. */
43
+ export function rawRoleField(root, roleId, field) {
44
+ const { raw } = parseJsonConfig(configPath(root), CONFIG_BASENAME);
45
+ const rawRoles = objectOrEmpty(raw).roles;
46
+ const entry = rawRoles && typeof rawRoles === "object" ? rawRoles[roleId] : undefined;
47
+ const obj = entry && typeof entry === "object" ? entry : undefined;
48
+ if (!obj)
49
+ return undefined;
50
+ return obj[field];
51
+ }
22
52
  /** Collect one loop's inspection payload. `modelsPath` overrides pi's model definitions location
23
53
  * — a test seam, like status/status-data.ts's snapshot — consulted only when a fallback pair is
24
54
  * configured (the freeness verdict needs the definitions; nothing else does). */
25
- export function rolePayload(root, role, modelsPath = piModelsPath()) {
55
+ export function rolePayload(root, typedRole, modelsPath = piModelsPath(), now = new Date()) {
56
+ const role = canonicalRoleId(typedRole);
26
57
  // loadConfigSafe (not loadConfigCached's hold-last-good machinery — this is a one-shot
27
58
  // command, not a poller): a broken or missing file degrades to the defaults, so the
28
59
  // command still answers for built-in roles on a torn repo.
29
60
  const { config } = loadConfigSafe(root);
30
61
  const cfg = config ?? defaultConfig();
31
62
  const catalog = roleById(role);
32
- const customEntry = cfg.customLoops.find((c) => c.name === role);
63
+ const customEntry = cfg.customRoles.find((c) => c.name === role);
33
64
  // The director is inspectable like any loop but has no catalog entry: a pseudo-Role whose
34
65
  // find text is empty (rolePayload nulls it below — the director's queued prompts ARE its
35
66
  // work), so the unknown-role throw only fires for ids nothing can answer.
@@ -44,20 +75,132 @@ export function rolePayload(root, role, modelsPath = piModelsPath()) {
44
75
  throw new Error(unknownRoleMessage(role, knownRoleIds(cfg)));
45
76
  }
46
77
  const effective = configForRole(cfg, role);
47
- // The claude billing answer, read only for a claude seam (the account file is claude's):
48
- // its subscription window's primary type rides along for the renderer's label.
49
- const account = effective.agent === "claude" ? readClaudeAccount(root) : null;
78
+ // The claude billing answer, read when a claude seam is in play — the role's OWN seam, or
79
+ // the review gate's when the reviewer runs claude (PLANS.md "Mixed claude + pi fleets, part
80
+ // 3/4" gap 7): a pi author on a claude reviewer still shows where its changes' spend lands.
81
+ // Its subscription window's primary type rides along for the renderer's label. The account
82
+ // file is team-global, so either seam reads the same answer.
83
+ const claudeInPlay = effective.agent === "claude" || (cfg.review.enabled && reviewRunConfig(cfg).agent === "claude");
84
+ const account = claudeInPlay ? readClaudeAccount(root) : null;
85
+ const info = readOrchestratorInfo(root);
86
+ const chainStep = (orchestratorAlive(root, info) && info?.chain?.[roleSeamTier(cfg, role)]) || null;
50
87
  const billingWindow = account?.billing === "subscription" ? claudeWindowEntries(account)[0]?.type : undefined;
88
+ // pi's own saved default for a pi seam that resolves no model, read once beside the models
89
+ // catalog this collector already takes as a seam (piSettingsPath). The config's `thinking`
90
+ // wins over the default's because tumwater passes it to pi as `--thinking`, mirroring the
91
+ // snapshot's modelFromPi rule. Any other seam stays unchanged and carries no `piDefault`.
92
+ const runsPiWithoutAModel = (effective.agent === undefined || effective.agent === "pi") &&
93
+ effective.provider === undefined &&
94
+ effective.model === undefined;
95
+ const piDefault = runsPiWithoutAModel ? readPiDefaultModel(piSettingsPath(modelsPath), root) : null;
96
+ // The role's provider key and its cap (plans/provider-concurrency.md §8): the same pure
97
+ // key the orchestrator's admission resolves for this role, answered from the same
98
+ // piDefault seam the orchestrator carries, so `tumwater role <id>` names the exact key
99
+ // whose cap bounds the role. The cap is the map's entry for that key, absent when no
100
+ // `maxConcurrentPerProvider` map is set or the key has no entry (then the role is bounded
101
+ // only by the team total, and the cap is omitted).
102
+ const providerKey = seamConcurrencyKey(effective, piDefault);
103
+ const providerCap = providerKey !== undefined ? cfg.maxConcurrentPerProvider?.[providerKey] : undefined;
51
104
  // The fallback story the operator sees: the declared pair, but the price-resolved pair when
52
105
  // one exists (a map with no `default` entry borrows another tier's free pair) and the
53
106
  // freeness verdict from the same read. `fallbackFree` false keeps the declared pair visible
54
107
  // so a refused fallback is still named.
55
108
  const declaredFallback = fallbackPair(cfg);
56
- const freeFallback = declaredFallback ? fallbackModelPair(cfg, modelsPath, readClaudeAccount(root)) : null;
109
+ // The opencode catalog answer (part 6b/7), read the way the budget gate reads it — always,
110
+ // from the same team-global cache the startup gate seeds — so the fallback free-ness verdict
111
+ // prices opencode pairs against the same snapshot as the gate. A conditional read cannot
112
+ // decide up front which pair the verdict will price: `fallbackModelPair` may resolve to a
113
+ // free pair BORROWED from another tier (a map whose `default` entry is priced borrows a
114
+ // free pair elsewhere), which routes to opencode even when the role's own seam, the declared
115
+ // default pair, and the review gate are not opencode. A null catalog would read that borrowed
116
+ // opencode pair NOT free, so this view and the gate would disagree; reading always (as the
117
+ // gate does) keeps them on one answer. OpenCode's `catalog` is threaded the way claude's
118
+ // `account` is: absent, an opencode pair reads NOT free.
119
+ const opencodeCatalog = readOpencodeCache(root);
120
+ const freeFallback = declaredFallback ? fallbackModelPair(cfg, modelsPath, readClaudeAccount(root), opencodeCatalog) : null;
57
121
  const fallback = freeFallback ?? declaredFallback;
58
122
  // One state read serves both the model-fallback episode and the next-tick preview below.
59
123
  const state = loadLoopState(root, role);
60
124
  const episode = state.modelFallback;
125
+ // The loop's recent tick outcomes, read from the event log (readEvents) so the command
126
+ // answers with the team stopped and never touches live state. readEvents returns the log's
127
+ // newest events in file (chronological) order, so filtering to this role's run_end events
128
+ // and taking the last 8 yields the newest 8, reversed to newest-first. Durations and
129
+ // summaries ride along only when the event carried them (a torn or empty line drops them),
130
+ // and an empty result is omitted so a never-ticked loop carries no `recentOutcomes` at all.
131
+ // One event-log read serves both per-loop facts — the recent outcomes and the spend sum — so
132
+ // the two cannot drift on what they cover: filtered to this role's run_end events once, then
133
+ // the outcome projection keeps the newest valid 8 while the usage fold sums the whole window.
134
+ const events = readEvents(root);
135
+ const tickEnds = events.filter((ev) => ev.type === "run_end" && eventRole(ev) === role);
136
+ // The event log is read without a shape filter, so a torn or hand-edited run_end line can
137
+ // carry a non-numeric ts/durationMs or an empty result — the same foreign shape every other
138
+ // consumer guards with finiteNumber. A line with no usable ts or result is as unusable as
139
+ // an absent one (the same "a torn line carries no usable timestamp" rule eventTick/tickStartMap
140
+ // apply), so it contributes no outcome; a non-numeric durationMs is dropped like a missing one.
141
+ const recentOutcomes = tickEnds
142
+ .filter((ev) => {
143
+ return finiteNumber(ev.ts, null) !== null && typeof ev.result === "string" && ev.result !== "";
144
+ })
145
+ .slice(-8)
146
+ .map((ev) => ({
147
+ ts: ev.ts,
148
+ result: ev.result,
149
+ ...(finiteNumber(ev.durationMs, null) !== null ? { durationMs: ev.durationMs } : {}),
150
+ ...(typeof ev.summary === "string" && ev.summary !== "" ? { summary: ev.summary } : {}),
151
+ }))
152
+ .reverse();
153
+ // The loop's own spend over the same retained window, summed per-run_end through the shared
154
+ // eventUsage guard (a torn or hand-edited run_end line contributes nothing: a non-numeric
155
+ // field reads as 0, and a line without a usable ts is as unusable as an absent one, matching
156
+ // recentOutcomes's drop rule so a torn tick never silently spends). Omitted when all three
157
+ // sums are 0, keeping a never-costly loop's JSON unchanged (the report's zero-means-absent
158
+ // convention).
159
+ const usage = tickEnds.reduce((acc, ev) => {
160
+ if (finiteNumber(ev.ts, null) === null)
161
+ return acc;
162
+ const u = eventUsage(ev);
163
+ acc.tokens += u.tokens;
164
+ acc.costUsd += u.costUsd;
165
+ acc.notionalCostUsd += u.notionalCostUsd;
166
+ return acc;
167
+ }, { tokens: 0, costUsd: 0, notionalCostUsd: 0 });
168
+ // The per-role daily cap, answered by the verdict the scheduler's gate shares (roleCapVerdicts)
169
+ // over the base role's instances, so the page cannot disagree with the hold. A cap of 0
170
+ // disables (roleCapPaused's rule) and the director is exempt, so neither carries the field.
171
+ const base = baseRoleOf(role);
172
+ // The roles.<base>.minRunIntervalSeconds override, resolved through the base role like the
173
+ // cap, quiet hours and instructions — an instance id shows (and can edit) the override its
174
+ // runs carry. Presence is judged against the FILE (rawRoleField), not the merged cfg:
175
+ // overlayDefaults seeds every built-in role's minRunIntervalSeconds from config.ts's catalog,
176
+ // so the merged value would make steward/qa/... always look overridden and the drawer's
177
+ // clear a no-op. undefined when unset, so the spread below drops it (the payload stays
178
+ // byte-identical when there is no override).
179
+ const rawInterval = rawRoleField(root, base, "minRunIntervalSeconds");
180
+ const minRunIntervalOverride = typeof rawInterval === "number" ? rawInterval : undefined;
181
+ // The raw provider/model/thinking the FILE holds (roles.<base>), distinct from the resolved
182
+ // provider/model/thinking fields configForRole merges with the top level — the drawer's
183
+ // Model overrides editor prefills from these, empty when unset. Presence is judged against
184
+ // the FILE (rawRoleField) like minRunIntervalOverride, so a config that relies on the top
185
+ // level or a tier name carries no override. Each is spread only when a string, so a file
186
+ // with no overrides yields a payload byte-identical to today's.
187
+ const providerOverrideRaw = rawRoleField(root, base, "provider");
188
+ const modelOverrideRaw = rawRoleField(root, base, "model");
189
+ const thinkingOverrideRaw = rawRoleField(root, base, "thinking");
190
+ const providerOverride = typeof providerOverrideRaw === "string" ? providerOverrideRaw : undefined;
191
+ const modelOverride = typeof modelOverrideRaw === "string" ? modelOverrideRaw : undefined;
192
+ const thinkingOverride = typeof thinkingOverrideRaw === "string" ? thinkingOverrideRaw : undefined;
193
+ const capUsd = cfg.maxDailyCostUsdPerRole?.[base];
194
+ let dailyCap;
195
+ if (role !== DIRECTOR_ROLE && typeof capUsd === "number" && Number.isFinite(capUsd) && capUsd > 0) {
196
+ const ids = loopIds(cfg).filter((id) => baseRoleOf(id) === base);
197
+ if (!ids.includes(role))
198
+ ids.push(role);
199
+ const runners = ids.map((id) => ({ role: id, state: id === role ? state : loadLoopState(root, id) }));
200
+ const verdict = roleCapVerdicts(runners, cfg.maxDailyCostUsdPerRole, now.getTime()).get(base);
201
+ if (verdict)
202
+ dailyCap = { capUsd, spentUsd: verdict.spentUsd, over: verdict.over, roleIds: verdict.roleIds };
203
+ }
61
204
  // The role's notebook, read for display. The same degradation as every other read here: a
62
205
  // missing, empty, or unreadable file is simply "no note yet". The director has none.
63
206
  let note = null;
@@ -65,26 +208,68 @@ export function rolePayload(root, role, modelsPath = piModelsPath()) {
65
208
  const text = readTextOrNull(roleNotesPath(root, role));
66
209
  note = text !== null && text.trim() !== "" ? text : null;
67
210
  }
211
+ // The windows the scheduler applies (gates/gate-polls.ts), through the same parser and
212
+ // predicate; the director is exempt from both gates.
213
+ const quietHours = [];
214
+ if (role !== DIRECTOR_ROLE) {
215
+ const own = quietHoursStatus(cfg.quietHoursPerRole?.[baseRoleOf(role)], now);
216
+ if (own.window !== null)
217
+ quietHours.push({ scope: "role", window: own.window, inWindow: own.inWindow });
218
+ const team = quietHoursStatus(cfg.quietHours, now);
219
+ if (team.window !== null)
220
+ quietHours.push({ scope: "fleet", window: team.window, inWindow: team.inWindow }); // vocabulary-ok: persisted --json scope value
221
+ }
222
+ // The resolved primary seam's declared context window. A run currently on the model
223
+ // fallback may use a different window, which is the fallback's own documented state.
224
+ const contextWindow = seamContextWindow(configForRole(cfg, role), root, modelsPath);
68
225
  return {
69
226
  id: role,
70
227
  title: resolved.title,
71
228
  custom: isCustomRole(cfg, role),
72
229
  enabled: enabledRoleIds(cfg).includes(role),
73
- paused: pausedRoles(root).includes(role),
230
+ paused: loopPaused(new Set(pausedRoles(root)), role),
74
231
  tier: roleTier(role),
75
232
  modelTier: roleSeamTier(cfg, role),
76
233
  ...resolvedModelFields(effective.provider, effective.model, effective.thinking),
77
- agent: effective.agent ?? "pi",
234
+ ...(piDefault
235
+ ? {
236
+ piDefault: formatModelSelector({
237
+ ...piDefault,
238
+ thinking: effective.thinking ?? piDefault.thinking,
239
+ }),
240
+ }
241
+ : {}),
242
+ backend: effective.agent ?? "pi",
243
+ ...(providerKey !== undefined ? { providerKey } : {}),
244
+ ...(providerCap !== undefined ? { providerCap } : {}),
78
245
  fallback,
79
246
  fallbackFree: freeFallback !== null,
80
247
  modelFallback: modelFallbackView(episode, cfg, role),
81
248
  ...(account !== null ? { billing: account.billing } : {}),
82
249
  ...(billingWindow !== undefined ? { billingWindow } : {}),
83
- minTickIntervalSeconds: effective.minTickIntervalSeconds,
84
- instructions: cfg.roles[role]?.instructions ?? null,
250
+ chainStep,
251
+ ...roleImageInfo(cfg, role, modelsPath, readImageProbeCache(root)),
252
+ minRunIntervalSeconds: effective.minRunIntervalSeconds,
253
+ ...(minRunIntervalOverride !== undefined ? { minRunIntervalOverride } : {}),
254
+ ...(providerOverride !== undefined ? { providerOverride } : {}),
255
+ ...(modelOverride !== undefined ? { modelOverride } : {}),
256
+ ...(thinkingOverride !== undefined ? { thinkingOverride } : {}),
257
+ // The configured parallel-runner count (roles.<base>.instances), present only when the
258
+ // config sets it — unlike minRunIntervalSeconds no catalog seed fills built-ins' counts,
259
+ // so the merged cfg tells presence exactly. Resolved through the base role like the
260
+ // instructions and min-run-interval override.
261
+ ...(cfg.roles[base]?.instances !== undefined ? { instances: cfg.roles[base].instances } : {}),
262
+ // The roles.<base>.instructions override verbatim — resolved through the base role like
263
+ // the cap and quiet hours, so an instance id shows (and can edit) the override its runs
264
+ // carry (tick-prompt reads config.roles[base]); null when unset.
265
+ instructions: cfg.roles[baseRoleOf(role)]?.instructions ?? null,
85
266
  find: role === DIRECTOR_ROLE ? null : resolved.find,
86
- note,
267
+ notes: note,
87
268
  inboxCount: queuedRolePromptCount(root, role),
269
+ // The resolved primary seam's declared context window in tokens, or null when it cannot
270
+ // be known (a non-pi seam, an unresolvable provider/model pair, or a model with no
271
+ // declared window) — the same seam `- Next run:` previews. Independent of the fallback.
272
+ contextWindow,
88
273
  // The preview seam: the queued prompt (if any) is read, not consumed, so an inspection
89
274
  // never costs the loop its queued work — the property the tick-prompt test pins.
90
275
  nextPrompt: assembleTickPrompt({
@@ -93,6 +278,20 @@ export function rolePayload(root, role, modelsPath = piModelsPath()) {
93
278
  role,
94
279
  state,
95
280
  preview: true,
281
+ // The compact prompt every run gets (loop-tick.ts), so the preview is the real prompt.
282
+ compact: true,
96
283
  })?.prompt ?? null,
284
+ // The loop's schedule — surfaced read-only from the same persisted state rolePayload
285
+ // already loaded for the model-fallback episode and the next-tick preview, so no new
286
+ // read path exists and the collector stays one loadLoopState per role. `lastRunEndedAt`
287
+ // is omitted (JSON unchanged) until the loop has actually ticked.
288
+ nextRunAt: state.nextRunAt,
289
+ backoffSeconds: state.backoffSeconds,
290
+ yieldMultiplier: yieldMultiplierFor(state),
291
+ ...(state.lastRunEndedAt !== undefined ? { lastRunEndedAt: state.lastRunEndedAt } : {}),
292
+ ...(quietHours.length > 0 ? { quietHours } : {}),
293
+ ...(recentOutcomes.length > 0 ? { recentOutcomes } : {}),
294
+ ...(usage.tokens !== 0 || usage.costUsd !== 0 || usage.notionalCostUsd !== 0 ? { usage } : {}),
295
+ ...(dailyCap ? { dailyCap } : {}),
97
296
  };
98
297
  }
@@ -1,3 +1,4 @@
1
+ // vocabulary: plain
1
2
  /** The role registries and classification helpers over the built-in role catalog. The catalog
2
3
  * itself — each loop's identity and its "find something to do" prompt text — lives in
3
4
  * role-catalog.ts (split out 2026-10-01, organize); this module re-exports ROLES and Role so
@@ -10,24 +11,25 @@ import { typoSuffix } from "../text/suggest.js";
10
11
  export { ROLES };
11
12
  /** The director loop's id — a target that is not in ROLES (allRoleIds appends it) and is
12
13
  * driven by operator prompts, not a find prompt: an un-targeted `tumwater prompt` queues
13
- * there. It keeps running through a fleet-wide `pause` (which stops only the role loops);
14
+ * there. It keeps running through a team-wide `pause` (which stops only the role loops);
14
15
  * an un-targeted `stop` signals the whole orchestrator, director included. */
15
16
  export const DIRECTOR_ROLE = "director";
16
17
  /** The bugfix loop's id. It is the one work role that also defers like a maintenance role while its
17
- * backlog (BUGS.md `## Open`) is empty — see deferTick in src/scheduling/scheduling.ts. */
18
+ * backlog (BUGS.md `## Open`) is empty — see deferTick in src/scheduling/scheduling.ts. With open
19
+ * bugs it never defers, but pollRunnerReasons stands it down while all of them are held. */
18
20
  export const BUGFIX_ROLE = "bugfix";
19
21
  /** Work-tier roles (need-based prioritization): they
20
- * ship work — feature and bugfix land code on main, plan feeds them — so their due ticks are
22
+ * ship work — feature and bugfix merge code on main, plan feeds them — so their due ticks are
21
23
  * never deferred and slot allocation always orders them ahead of maintenance. */
22
24
  const WORK_ROLES = new Set(["feature", "bugfix", "plan"]);
23
25
  /** Observer roles (plans/observer-roles.md 1/2): a role whose product is an observation, not a
24
26
  * commit, and for which `no_change` means "checked, all well" rather than "found nothing to do".
25
27
  * The idle ladder's premise — a loop that keeps finding nothing stops burning model time — does
26
- * not hold for these, so their no_change tick schedules at `minTickIntervalSeconds` and leaves
28
+ * not hold for these, so their no_change tick schedules at `minRunIntervalSeconds` and leaves
27
29
  * `backoffSeconds` at 0 (src/scheduling/backoff.ts). The error ladder still applies in full, and
28
30
  * they are removed from DEFERRABLE_ROLES because their input (the running product for `qa`, the
29
- * event log for `telemetry`) is not a function of whether main moved. */
30
- export const OBSERVER_ROLES = new Set(["qa", "telemetry"]);
31
+ * event log for `selfcheck`) is not a function of whether main moved. */
32
+ export const OBSERVER_ROLES = new Set(["qa", "selfcheck"]);
31
33
  /** The built-in maintenance roles that author code on their own cadence — the eight members
32
34
  * DEFERRABLE_ROLES and BASELINE_BLOCKED_ROLES share. Both sets list them because both charter
33
35
  * the same roles (defer-until-needed scheduling, red-main blocking), so the list lives once
@@ -36,15 +38,16 @@ const CODE_MAINTENANCE_ROLES = [
36
38
  "organize",
37
39
  "coverage",
38
40
  "clean",
39
- "dry",
41
+ "dedupe",
40
42
  "perf",
41
43
  "security",
42
44
  "robustness",
43
45
  "improve",
44
46
  ];
45
47
  /** Maintenance-tier roles (need-based prioritization): exactly the ten built-ins whose due
46
- * ticks are deferrable while no feature/bugfix/director/human commit has landed on main since
47
- * their last tick and that tick did nothing. Unknown/custom roles are deliberately NOT in this
48
+ * ticks are deferrable while no feature, bugfix, director, operator or human commit has merged
49
+ * into main since their last tick and that tick did nothing. Unknown/custom roles are
50
+ * deliberately NOT in this
48
51
  * set — the harness cannot judge what an arbitrary custom role needs, so they never defer (they
49
52
  * still sort into tier 1 for fairOrder via roleTier). Observers are excluded: an unmoved tree
50
53
  * says nothing about whether the product or the event log has something new to report. */
@@ -78,8 +81,8 @@ export function roleTier(role) {
78
81
  * by the gate's pre-check, so spending an authoring run on it is pure waste. Exempt: director
79
82
  * (human prompts outrank autonomous gates, like the budget gate), bugfix (the designated healer
80
83
  * — its tick runs the suite per "leave the project working" and can fix a red main through the
81
- * existing pre-merge gate; blocking it would leave only humans able to unblock the fleet), and
82
- * plan/readme/steward/qa/telemetry (markdown-only charter — their diffs are exempt from the build
84
+ * existing pre-merge gate; blocking it would leave only humans able to unblock the team), and
85
+ * plan/readme/steward/qa/selfcheck (markdown-only charter — their diffs are exempt from the build
83
86
  * pre-check via review.exemptPaths, so a red main does not block them). */
84
87
  export const BASELINE_BLOCKED_ROLES = new Set([
85
88
  "feature",
@@ -90,8 +93,8 @@ export const BASELINE_BLOCKED_ROLES = new Set([
90
93
  export function baselineBlocked(role) {
91
94
  return BASELINE_BLOCKED_ROLES.has(baseRoleOf(role));
92
95
  }
93
- /** Work ratio 4/4: which commit tier a loop's base role lands into, for the work vs maintenance
94
- * split the report and the "Landed today" tile show and the maintenance allowance (Work ratio
96
+ /** Work ratio 4/4: which commit tier a loop's base role merges into, for the work vs maintenance
97
+ * split the report and the "Merged today" tile show and the maintenance allowance (Work ratio
95
98
  * 1a/4, src/gates/maintenance-quota.ts) counts. The one home of the split. `work` is the
96
99
  * shipping tier (feature, bugfix, director); `maintenance` is code upkeep plus readme. Every
97
100
  * other role (plan, steward, observers, custom) is neither and counts only toward the total
@@ -129,7 +132,7 @@ export function unknownRoleMessage(role, validIds) {
129
132
  * role-specific find-something-to-do text, and the title is what identifies the loop inside
130
133
  * its own prompt (`You are the "<name>" loop (user-defined loop)`) and commit context.
131
134
  * Custom loops run the `default` tier — the harness cannot judge what an arbitrary loop's
132
- * work needs, so it gets the fleet's ordinary model. */
135
+ * work needs, so it gets the team's ordinary model. */
133
136
  export function customRole(name, task) {
134
- return { id: name, title: "user-defined loop", find: task, tier: "default" };
137
+ return { id: name, title: "custom role", find: task, tier: "default" };
135
138
  }
@@ -11,12 +11,12 @@ import { yieldScaledRole } from "../roles/roles.js";
11
11
  * unit-tested without touching a disk. */
12
12
  /** Clear a loop's backoff so its next orchestrator poll finds it immediately due: zero
13
13
  * backoffSeconds (so the next no_change/error tick restarts the backoff ladder from the
14
- * bottom instead of climbing from wherever the fleet parked) and pull nextRunAt forward to
14
+ * bottom instead of climbing from wherever the team parked) and pull nextRunAt forward to
15
15
  * now. Pure: returns a new state and preserves everything else — counters (an
16
16
  * observation-window reset is zeroCounters' job), wake tracking (lastMainHead), and the
17
17
  * last-result fields. Scheduling operation, not observation-window reset: it exists so an
18
18
  * operator who fixed what the loops were failing on can say "try again" (BUGS.md
19
- * 2026-09-15: the fleet had no wake lever). Also stamps wokenAt, which is what lets
19
+ * 2026-09-15: the team had no wake lever). Also stamps wokenAt, which is what lets
20
20
  * isEligible honor the demand over the role's min-tick interval (LoopState.wokenAt). */
21
21
  export function clearBackoff(s, now) {
22
22
  // wokenAt marks the wake as newer than the current gap window's opening tick, which is
@@ -27,7 +27,7 @@ export function clearBackoff(s, now) {
27
27
  }
28
28
  /** Re-apply a wake that was consumed while the just-ended tick was still in flight. The tick
29
29
  * holds the same state object wake() mutated in place, but applyTickOutcome's own schedule
30
- * overwrites the wake: lastTickEndedAt is re-stamped past wokenAt (so the min-gap exemption
30
+ * overwrites the wake: lastRunEndedAt is re-stamped past wokenAt (so the min-gap exemption
31
31
  * reads stale) and nextRunAt is scheduled a fresh gap or backoff out — the operator's "try
32
32
  * again now" silently waits out the whole interval. Called by the tick's end-save after
33
33
  * applyTickOutcome (src/tick/tick-apply.ts), it re-applies the demand exactly like a wake
@@ -40,22 +40,22 @@ export function clearBackoff(s, now) {
40
40
  export function restoreMidTickWake(s) {
41
41
  if (s.resumePending)
42
42
  return false;
43
- if (s.wokenAt === undefined || s.wokenAt <= (s.lastTickStartedAt ?? 0))
43
+ if (s.wokenAt === undefined || s.wokenAt <= (s.lastRunStartedAt ?? 0))
44
44
  return false;
45
45
  // clearBackoff stamps wokenAt = now, which must read NEWER than the end-save's
46
- // lastTickEndedAt for isEligible's exemption to fire — both are Date.now() reads, so a
46
+ // lastRunEndedAt for isEligible's exemption to fire — both are Date.now() reads, so a
47
47
  // same-millisecond tie would swallow the demand; floor it just past the end stamp.
48
- Object.assign(s, clearBackoff(s, Math.max(Date.now(), (s.lastTickEndedAt ?? 0) + 1)));
48
+ Object.assign(s, clearBackoff(s, Math.max(Date.now(), (s.lastRunEndedAt ?? 0) + 1)));
49
49
  return true;
50
50
  }
51
51
  /** Yield-scaled clocks: how long a role's own recent
52
52
  * results stretch its min-tick gap. The ring (LoopState.recentOutcomes) holds one char per
53
- * COUNTED tick — a landing (`changed`/`queued`) is `L`, every other counted result is `n`;
53
+ * COUNTED tick — a merge (`changed`/`queued`) is `L`, every other counted result is `n`;
54
54
  * the error class (`error`/`aborted`/`quiet_killed`) is no yield evidence either way and is
55
55
  * not recorded at all, so a run of backend failures neither stretches a role's clock nor
56
56
  * resets it. */
57
57
  export const YIELD_RING = 20;
58
- const YIELD_LAND = "L";
58
+ const YIELD_MERGE = "L";
59
59
  /** Results that never enter the ring. */
60
60
  const UNCOUNTED_RESULTS = new Set(["error", "aborted", "quiet_killed"]);
61
61
  /** Record one finished tick's result on the state's yield ring, oldest entries falling off
@@ -64,21 +64,21 @@ const UNCOUNTED_RESULTS = new Set(["error", "aborted", "quiet_killed"]);
64
64
  export function pushYieldOutcome(s, result) {
65
65
  if (UNCOUNTED_RESULTS.has(result))
66
66
  return;
67
- const ch = result === "changed" || result === "queued" ? YIELD_LAND : "n";
67
+ const ch = result === "changed" || result === "queued" ? YIELD_MERGE : "n";
68
68
  s.recentOutcomes = ((s.recentOutcomes ?? "") + ch).slice(-YIELD_RING);
69
69
  }
70
70
  const YIELD_MAX = 8;
71
- /** The multiplier on a scalable role's minTickIntervalSeconds gap its recent yield earns:
72
- * 1 while any of the last 10 counted ticks landed, otherwise doubling per 5 further empty
71
+ /** The multiplier on a scalable role's minRunIntervalSeconds gap its recent yield earns:
72
+ * 1 while any of the last 10 counted ticks merged, otherwise doubling per 5 further empty
73
73
  * ticks — 10 empties ×2, 15 ×4, 20 ×8 — capped at 8. `recent` is the ring's chars, oldest
74
74
  * first, as pushYieldOutcome recorded them. Pure; unit-tested beside the other ladder math.
75
- * A landing inside the last 10 resets the multiplier to 1 even when older empties remain in
76
- * the ring: one landing is the evidence the role's clock should trust, and the ring's older
77
- * half only matters once the landing has aged out of the recent window. */
75
+ * A merge inside the last 10 resets the multiplier to 1 even when older empties remain in
76
+ * the ring: one merge is the evidence the role's clock should trust, and the ring's older
77
+ * half only matters once the merge has aged out of the recent window. */
78
78
  export function yieldMultiplier(recent) {
79
- if (recent.slice(-10).includes(YIELD_LAND))
79
+ if (recent.slice(-10).includes(YIELD_MERGE))
80
80
  return 1;
81
- const empty = recent.filter((c) => c !== YIELD_LAND).length;
81
+ const empty = recent.filter((c) => c !== YIELD_MERGE).length;
82
82
  if (empty < 10)
83
83
  return 1;
84
84
  return Math.min(YIELD_MAX, 2 ** (Math.floor((empty - 10) / 5) + 1));
@@ -92,6 +92,16 @@ export function yieldMultiplier(recent) {
92
92
  export function yieldMultiplierFor(s) {
93
93
  return yieldScaledRole(s.role) ? yieldMultiplier((s.recentOutcomes ?? "").split("")) : 1;
94
94
  }
95
+ /** The `×N` yield-multiplier suffix a next-run display rides: `×4` when the multiplier is
96
+ * above 1, else `""` — the quiet scalable loop's stretched effective gap made visible (the
97
+ * multiplier gates the scheduled gap in isEligible, so a `now`-due cell at ×4 is a role
98
+ * waiting out its gap, not a broken clock). Shared by the status table's `next run` cell
99
+ * (status-render.ts's nextRunCell) and the role view's Schedule line (role-render.ts) so the
100
+ * two node surfaces cannot drift on how an above-1 multiplier is worded; the GUI renders the
101
+ * same rule from its own separate-runtime copy (gui-client.ts's fmtNextRun). */
102
+ export function yieldMultiplierSuffix(mult) {
103
+ return mult > 1 ? ` ×${mult}` : "";
104
+ }
95
105
  /** Next step of a backoff ladder: initial (capped) on the first step, then multiplied, capped. */
96
106
  export function nextBackoffSeconds(current, ladder) {
97
107
  const { initialSeconds, factor, maxSeconds } = ladder;
@@ -108,16 +118,16 @@ export function scheduleBackoff(s, ladder) {
108
118
  s.nextRunAt = Date.now() + s.backoffSeconds * 1000;
109
119
  }
110
120
  /** Schedule the loop's next tick at its minimum interval — the role-resolved config's
111
- * minTickIntervalSeconds (a per-role slow clock), converted seconds→ms here so every outcome
121
+ * minRunIntervalSeconds (a per-role slow clock), converted seconds→ms here so every outcome
112
122
  * arm that shares this cadence cannot drift apart. The productive-side counterpart of
113
123
  * scheduleBackoff: together the two own the whole "when does this loop run again" decision. */
114
124
  export function scheduleAtMinInterval(s, cfg) {
115
- s.nextRunAt = Date.now() + cfg.minTickIntervalSeconds * 1000;
125
+ s.nextRunAt = Date.now() + cfg.minRunIntervalSeconds * 1000;
116
126
  }
117
127
  /** Backoff ladder for failed ticks (`error` results). The idle ladder prices hour-long model
118
128
  * runs — its cap exists so a loop that keeps finding nothing stops burning model time. A tick
119
129
  * that fails (a broken toolchain, a dead pi subprocess) often never reaches the model, so it
120
- * climbs this short ladder, capped in minutes: one broken `git` must not park a fleet for the
130
+ * climbs this short ladder, capped in minutes: one broken `git` must not park a team for the
121
131
  * idle ladder's 10-hour sleep (BUGS.md, the 2026-09-15 outage). One ladder, one sensible
122
132
  * default, no knob: the cap is the point. */
123
133
  export const ERROR_BACKOFF = { initialSeconds: 30, factor: 2, maxSeconds: 600 };
@@ -1,13 +1,14 @@
1
+ // vocabulary: plain
1
2
  /** Work-instance claims (plans/parallel-work-instances.md "Claims", part 4/7): which backlog
2
3
  * entry each runner of a multi-instance base role may take, whether it must drop the entry, and
3
4
  * which entry a staged diff moved. The pure policies live here — assignment is the first free
4
5
  * entry in file order, release is `left` / `ineligible` / `disabled` / `stale` — so
5
6
  * orchestrator-scheduling.ts and tick-stage.ts share one definition and the rules are unit
6
- * tested without a fleet. The claim itself is persisted on LoopState, so it survives a crash,
7
+ * tested without a team. The claim itself is persisted on LoopState, so it survives a crash,
7
8
  * resume, revision and leftover recovery. */
8
9
  import path from "node:path";
9
10
  import { hasOutstandingWork } from "../loop/loop-state.js";
10
- import { eligibleEntries, entryKey } from "../backlog/backlog-eligibility.js";
11
+ import { backlogHolds, eligibleEntries, entryKey } from "../backlog/backlog-eligibility.js";
11
12
  import { actionableEntryRanges, actionableEntryRangesFor } from "../backlog/backlog-structure.js";
12
13
  import { readTextOrNull } from "../files/files.js";
13
14
  import { changeBaseRev, fileContentAt } from "../git/git.js";
@@ -24,6 +25,15 @@ export const CLAIM_IDLE_MAX_MS = 24 * 60 * 60 * 1000;
24
25
  export function planBacklogTarget(config) {
25
26
  return configuredInstances(config, "feature") + 1;
26
27
  }
28
+ /** Does PLANS.md already hold the plan loop's target (planBacklogTarget eligible plans), with no
29
+ * plan marked needs-review or needs-replan? The charter makes splitting or replanning a marked
30
+ * plan outrank the stop, so a marked entry keeps the plan loop running; otherwise a plan tick can
31
+ * only end in TUMWATER_NOTHING_TO_DO and the scheduler defers it (deferTickReason). */
32
+ export function planBacklogFull(root, config) {
33
+ if (eligibleEntries(root, "feature").length < planBacklogTarget(config))
34
+ return false;
35
+ return !backlogHolds(root).plans.some((h) => h === "needs-review" || h === "needs-replan");
36
+ }
27
37
  /** The backlog file and section a role's entries live under: PLANS.md's `## Planned` for
28
38
  * feature, BUGS.md's `## Open` for bugfix. An instance id resolves through its base role. */
29
39
  function roleSection(role) {
@@ -59,15 +69,15 @@ export function assignNext(free) {
59
69
  return free[0] ?? null;
60
70
  }
61
71
  /** Why a runner must drop its claim, or null when it keeps it. A runner is idle when it is not
62
- * running, has no queued landing, no outstanding revision and no pending resume; `disabled`
63
- * and `stale` only release an idle runner, while a claim whose entry left or turned ineligible
64
- * is released outright (there is nothing left to do). */
72
+ * running, has no queued merge, no outstanding revision and no pending resume; `disabled`,
73
+ * `paused` and `stale` only release an idle runner, while a claim whose entry left or turned
74
+ * ineligible is released outright (there is nothing left to do). */
65
75
  export function claimReleaseReason(runner, ctx) {
66
76
  const claim = runner.state.claim;
67
77
  if (!claim)
68
78
  return null;
69
79
  const idle = !runner.state.running &&
70
- !ctx.hasQueuedLanding &&
80
+ !ctx.hasQueuedMerge &&
71
81
  !hasOutstandingWork(runner.state);
72
82
  if (!ctx.listedKeys.has(claim.key))
73
83
  return "left";
@@ -75,12 +85,14 @@ export function claimReleaseReason(runner, ctx) {
75
85
  return "ineligible";
76
86
  if (!ctx.enabled && idle)
77
87
  return "disabled";
88
+ if (ctx.paused && idle)
89
+ return "paused";
78
90
  if (idle && ctx.now - claim.at > CLAIM_IDLE_MAX_MS)
79
91
  return "stale";
80
92
  return null;
81
93
  }
82
94
  /** The entries `headMd` moved out of `section` that `baseMd` still listed, in base file order.
83
- * A moved entry's key matches whether it landed into `## Done`/`## Fixed` or was renamed; a key
95
+ * A moved entry's key matches whether it merged into `## Done`/`## Fixed` or was renamed; a key
84
96
  * absent from the head section means it is no longer actionable there. */
85
97
  export function movedOutEntries(baseMd, headMd, section) {
86
98
  const headKeys = new Set(actionableEntryRanges(headMd, section).map((e) => entryKey(e.title)));
@@ -102,7 +114,7 @@ export async function stagedMovedEntries(wt, mainBranch, role) {
102
114
  /** How many permits to withhold from tier ≥ 1 acquirers this poll so a work loop that has work
103
115
  * to take can claim one without waiting out a maintenance tick (plans/parallel-work-instances.md
104
116
  * "Priority headroom", part 7/7). Only the claim-taking roles (feature, bugfix) count: an idle
105
- * one — not running, with no queued landing — counts when it either holds an eligible claim or
117
+ * one — not running, with no queued merge — counts when it either holds an eligible claim or
106
118
  * its base role still has an unclaimed eligible entry. Returns min(floor(maxConcurrent / 3),
107
119
  * the count), so an entirely busy work tier reserves nothing. */
108
120
  export function workReserveCount(root, runners, opts) {
@@ -137,7 +149,11 @@ export function workReserveCount(root, runners, opts) {
137
149
  continue;
138
150
  if (!opts.enabled.has(r.role))
139
151
  continue;
140
- if (r.state.running || opts.queuedLandingRoles.has(r.role))
152
+ if (r.state.running || opts.queuedMergeRoles.has(r.role))
153
+ continue;
154
+ // A work loop whose provider is full (capped and at its cap) cannot use a reserved permit
155
+ // anyway, so it must not withhold one from another provider's maintenance tick (§6).
156
+ if (opts.providerHasHeadroom !== undefined && !opts.providerHasHeadroom(r))
141
157
  continue;
142
158
  const eligible = eligibleFor(base);
143
159
  const holdsClaim = r.state.claim !== undefined && eligible.has(r.state.claim.key);