tumwater 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (446) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +42 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +4 -3
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +402 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +246 -55
  41. package/dist/src/backlog/backlog-cli-view.js +280 -0
  42. package/dist/src/backlog/backlog-cli.js +85 -274
  43. package/dist/src/backlog/backlog-commit.js +4 -5
  44. package/dist/src/backlog/backlog-eligibility.js +175 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +254 -0
  48. package/dist/src/backlog/backlog-release.js +117 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-culprit.js +94 -0
  56. package/dist/src/baseline/main-guardrails.js +209 -0
  57. package/dist/src/baseline/main-red.js +139 -32
  58. package/dist/src/baseline/main-revert.js +133 -0
  59. package/dist/src/baseline/main-watch.js +184 -0
  60. package/dist/src/brief/brief-edit.js +9 -11
  61. package/dist/src/brief/brief.js +31 -51
  62. package/dist/src/budget/budget.js +30 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +1 -2
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +118 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +150 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +308 -590
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-role-command.js +420 -0
  84. package/dist/src/cli/cli-start-summary.js +160 -0
  85. package/dist/src/cli/{cli-run.js → cli-start.js} +165 -237
  86. package/dist/src/cli/completion.js +149 -22
  87. package/dist/src/cli/config-commands.js +41 -18
  88. package/dist/src/cli/follow-view.js +75 -0
  89. package/dist/src/cli/help.js +189 -73
  90. package/dist/src/cli/log-commands.js +148 -82
  91. package/dist/src/cli/question-commands.js +205 -10
  92. package/dist/src/cli.js +106 -43
  93. package/dist/src/collections.js +26 -10
  94. package/dist/src/concurrency/check-permit.js +20 -3
  95. package/dist/src/concurrency/lock.js +17 -13
  96. package/dist/src/concurrency/provider-lease.js +44 -8
  97. package/dist/src/concurrency/semaphore.js +102 -26
  98. package/dist/src/config/config-aliases.js +0 -1
  99. package/dist/src/config/config-editable-keys.js +13 -1
  100. package/dist/src/config/config-fallbacks.js +93 -56
  101. package/dist/src/config/config-field-checks.js +40 -10
  102. package/dist/src/config/config-keys.js +30 -8
  103. package/dist/src/config/config-live.js +63 -24
  104. package/dist/src/config/config-request.js +137 -0
  105. package/dist/src/config/config-schema.js +31 -24
  106. package/dist/src/config/config-selector-checks.js +123 -0
  107. package/dist/src/config/config-validation-agents.js +80 -0
  108. package/dist/src/config/config-validation-model.js +116 -0
  109. package/dist/src/config/config-validation.js +141 -199
  110. package/dist/src/config/config-views.js +127 -53
  111. package/dist/src/config/config-write-core.js +135 -0
  112. package/dist/src/config/config-write.js +131 -264
  113. package/dist/src/config/config.js +48 -38
  114. package/dist/src/config/model-selector.js +34 -34
  115. package/dist/src/doctor/doctor-backlog-bugs.js +287 -0
  116. package/dist/src/doctor/doctor-backlog-plans.js +263 -0
  117. package/dist/src/doctor/doctor-backlog.js +100 -496
  118. package/dist/src/doctor/doctor-check-names.js +81 -0
  119. package/dist/src/doctor/doctor-checks.js +52 -579
  120. package/dist/src/doctor/doctor-claude-checks.js +145 -0
  121. package/dist/src/doctor/doctor-codex-checks.js +279 -0
  122. package/dist/src/doctor/doctor-contention-checks.js +380 -0
  123. package/dist/src/doctor/doctor-holds.js +82 -66
  124. package/dist/src/doctor/doctor-install.js +2 -1
  125. package/dist/src/doctor/doctor-model-checks.js +236 -402
  126. package/dist/src/doctor/doctor-notify.js +3 -4
  127. package/dist/src/doctor/doctor-opencode-checks.js +162 -0
  128. package/dist/src/doctor/doctor-orphans.js +182 -63
  129. package/dist/src/doctor/doctor-project-checks.js +303 -0
  130. package/dist/src/doctor/doctor.js +121 -77
  131. package/dist/src/doctor/image-probe.js +21 -8
  132. package/dist/src/doctor/outcome.js +3 -2
  133. package/dist/src/doctor/probe.js +7 -11
  134. package/dist/src/events/event-format.js +181 -86
  135. package/dist/src/events/event-labels.js +12 -5
  136. package/dist/src/events/event-legacy.js +39 -15
  137. package/dist/src/events/event-read.js +39 -39
  138. package/dist/src/events/event-tally.js +16 -0
  139. package/dist/src/events/event-window.js +60 -24
  140. package/dist/src/events/events.js +30 -13
  141. package/dist/src/events/notify.js +7 -7
  142. package/dist/src/failure/failure-cluster.js +12 -12
  143. package/dist/src/failure/failure-data.js +56 -56
  144. package/dist/src/failure/failure-render.js +31 -23
  145. package/dist/src/failure/failure-state-change.js +17 -12
  146. package/dist/src/failure/time-spend.js +99 -35
  147. package/dist/src/files/files.js +111 -35
  148. package/dist/src/files/json-files.js +3 -3
  149. package/dist/src/files/json-object.js +28 -7
  150. package/dist/src/files/stat-cache.js +2 -2
  151. package/dist/src/files/tail.js +1 -1
  152. package/dist/src/gates/bootstrap-gates.js +4 -5
  153. package/dist/src/gates/budget-gates.js +177 -84
  154. package/dist/src/gates/disk-gate.js +1 -1
  155. package/dist/src/gates/gate-polls.js +108 -58
  156. package/dist/src/gates/maintenance-quota.js +10 -13
  157. package/dist/src/gates/pause-gates.js +28 -10
  158. package/dist/src/gates/readiness.js +7 -3
  159. package/dist/src/gates/role-cap-gates.js +8 -8
  160. package/dist/src/gates/startup-gate.js +94 -32
  161. package/dist/src/gates/streak-gate.js +54 -30
  162. package/dist/src/git/commit-message.js +77 -46
  163. package/dist/src/git/git-diff.js +16 -10
  164. package/dist/src/git/git-run.js +79 -38
  165. package/dist/src/git/git.js +36 -34
  166. package/dist/src/git/slot-salvage.js +253 -0
  167. package/dist/src/git/slots-state.js +6 -6
  168. package/dist/src/git/worktree-pool.js +132 -38
  169. package/dist/src/git/worktree-use.js +4 -11
  170. package/dist/src/git/worktree.js +94 -37
  171. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  172. package/dist/src/gui/gui-args.js +79 -8
  173. package/dist/src/gui/gui-backlog-commands.js +42 -18
  174. package/dist/src/gui/gui-backlog-endpoints.js +155 -0
  175. package/dist/src/gui/gui-command.js +8 -13
  176. package/dist/src/gui/gui-config-commands.js +94 -72
  177. package/dist/src/gui/gui-endpoint-commands.js +4 -3
  178. package/dist/src/gui/gui-endpoint-common.js +4 -4
  179. package/dist/src/gui/gui-endpoints.js +11 -380
  180. package/dist/src/gui/gui-history-endpoints.js +301 -0
  181. package/dist/src/gui/gui-operator-commands.js +108 -13
  182. package/dist/src/gui/gui-prompt-commands.js +11 -14
  183. package/dist/src/gui/gui-report-endpoints.js +90 -0
  184. package/dist/src/gui/gui-server.js +11 -4
  185. package/dist/src/gui/gui-team-commands.js +7 -7
  186. package/dist/src/gui/http-body.js +1 -1
  187. package/dist/src/history/history-data.js +122 -234
  188. package/dist/src/history/history-queued.js +147 -0
  189. package/dist/src/history/history-render.js +42 -22
  190. package/dist/src/history/history.js +193 -49
  191. package/dist/src/inbox/inbox-attachments.js +29 -30
  192. package/dist/src/inbox/inbox-cancel.js +10 -11
  193. package/dist/src/inbox/inbox-edit.js +9 -9
  194. package/dist/src/inbox/inbox-submit.js +18 -18
  195. package/dist/src/inbox/inbox-top.js +25 -9
  196. package/dist/src/inbox/inbox.js +42 -43
  197. package/dist/src/inbox/pending-prompt.js +26 -26
  198. package/dist/src/inbox/prompt-commands.js +62 -49
  199. package/dist/src/inbox/prompt-not-before.js +2 -2
  200. package/dist/src/init/init-templates.js +1 -2
  201. package/dist/src/init/init.js +28 -23
  202. package/dist/src/merge/backlog-conflicts.js +215 -35
  203. package/dist/src/merge/merge-batch.js +14 -8
  204. package/dist/src/merge/merge-cancel.js +53 -0
  205. package/dist/src/merge/merge-check-failures.js +44 -22
  206. package/dist/src/merge/merge-core.js +81 -32
  207. package/dist/src/merge/merge-diff.js +5 -4
  208. package/dist/src/merge/merge-drain.js +150 -11
  209. package/dist/src/merge/merge-git.js +177 -38
  210. package/dist/src/merge/merge-pipeline.js +15 -3
  211. package/dist/src/merge/merge-post-review.js +90 -0
  212. package/dist/src/merge/merge-queue-render.js +49 -11
  213. package/dist/src/merge/merge-queue.js +31 -15
  214. package/dist/src/merge/merge-requeue.js +102 -0
  215. package/dist/src/merge/merge-slot.js +61 -16
  216. package/dist/src/merge/merge-stack.js +33 -11
  217. package/dist/src/merge/merge-to-main.js +86 -27
  218. package/dist/src/merge/merge-top.js +69 -0
  219. package/dist/src/merge/merge-verify.js +12 -10
  220. package/dist/src/notes-cli.js +30 -15
  221. package/dist/src/operator/operator-commands.js +192 -81
  222. package/dist/src/operator/operator-intent.js +69 -42
  223. package/dist/src/operator/operator-requests.js +35 -40
  224. package/dist/src/operator/retire.js +5 -6
  225. package/dist/src/operator/revert.js +142 -34
  226. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  227. package/dist/src/orchestrator/orchestrator-poll.js +320 -0
  228. package/dist/src/orchestrator/orchestrator-scheduling.js +68 -369
  229. package/dist/src/orchestrator/orchestrator.js +128 -369
  230. package/dist/src/orchestrator/retention.js +42 -8
  231. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  232. package/dist/src/orchestrator/scheduling-scope.js +201 -0
  233. package/dist/src/orchestrator/scheduling-skip.js +185 -0
  234. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  235. package/dist/src/orchestrator/startup-migrations.js +11 -7
  236. package/dist/src/paths.js +116 -49
  237. package/dist/src/pi/command-shape.js +12 -1
  238. package/dist/src/pi/pi-args.js +6 -4
  239. package/dist/src/pi/pi-backend.js +6 -6
  240. package/dist/src/pi/pi-bin.js +54 -63
  241. package/dist/src/pi/pi-listed-models.js +53 -0
  242. package/dist/src/pi/pi-models.js +63 -30
  243. package/dist/src/pi/pi-run-result.js +1 -1
  244. package/dist/src/pi/pi-settings.js +1 -1
  245. package/dist/src/pi/pi-stream.js +6 -6
  246. package/dist/src/pi/pi-watchdogs.js +18 -19
  247. package/dist/src/pi-extension/bounded-output.js +5 -3
  248. package/dist/src/pi-extension/context-budget.js +2 -2
  249. package/dist/src/pi-extension/context-shake.js +1 -1
  250. package/dist/src/pi-extension/context-window.js +0 -1
  251. package/dist/src/pi-extension/full-output.js +3 -10
  252. package/dist/src/pi-extension/strip-thinking.js +0 -1
  253. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  254. package/dist/src/pi-extension/write-guard.js +37 -0
  255. package/dist/src/process/launch-services.js +1 -2
  256. package/dist/src/process/node-bin.js +16 -0
  257. package/dist/src/process/process-group.js +8 -18
  258. package/dist/src/process/process.js +41 -2
  259. package/dist/src/process/run-marker.js +18 -6
  260. package/dist/src/process/supervisor.js +22 -21
  261. package/dist/src/prompt/dialect.js +17 -2
  262. package/dist/src/prompt/gate-notes.js +203 -0
  263. package/dist/src/prompt/gate-prompts.js +121 -211
  264. package/dist/src/prompt/notes-backlog.js +181 -0
  265. package/dist/src/prompt/principles.js +1 -2
  266. package/dist/src/prompt/prompt-followup.js +19 -14
  267. package/dist/src/prompt/prompt.js +129 -204
  268. package/dist/src/redeploy/redeploy-policy.js +24 -19
  269. package/dist/src/redeploy/redeploy-probes.js +1 -1
  270. package/dist/src/redeploy/redeploy.js +2 -3
  271. package/dist/src/redeploy/redeployer.js +20 -20
  272. package/dist/src/redeploy/self-reload.js +2 -1
  273. package/dist/src/report/report-data.js +161 -77
  274. package/dist/src/report/report-fold.js +129 -52
  275. package/dist/src/report/report-main-red.js +55 -0
  276. package/dist/src/report/report-render.js +133 -64
  277. package/dist/src/report/report-types.js +1 -0
  278. package/dist/src/report/report.js +26 -9
  279. package/dist/src/request-timeouts.js +2 -2
  280. package/dist/src/review/known-flakes.js +100 -1
  281. package/dist/src/review/post-merge-review.js +106 -0
  282. package/dist/src/review/protected-edits.js +4 -5
  283. package/dist/src/review/review-followup.js +8 -8
  284. package/dist/src/review/review-precheck.js +66 -23
  285. package/dist/src/review/review-run.js +395 -0
  286. package/dist/src/review/review-verdict.js +2 -2
  287. package/dist/src/review/review.js +155 -360
  288. package/dist/src/review/secret-leaks.js +36 -16
  289. package/dist/src/review/suite-rerun.js +0 -1
  290. package/dist/src/roles/legacy-role-migration.js +80 -7
  291. package/dist/src/roles/role-catalog.js +69 -12
  292. package/dist/src/roles/role-guidance.js +55 -20
  293. package/dist/src/roles/role-labels.js +1 -2
  294. package/dist/src/roles/role-render.js +67 -50
  295. package/dist/src/roles/role-view.js +59 -47
  296. package/dist/src/roles/roles.js +31 -31
  297. package/dist/src/roles/runner-ids.js +97 -0
  298. package/dist/src/run/acceptance-greps.js +119 -0
  299. package/dist/src/run/feedback-filings.js +51 -0
  300. package/dist/src/{tick → run}/qa-coverage.js +9 -27
  301. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  302. package/dist/src/run/run-detail-data.js +179 -0
  303. package/dist/src/run/run-detail-render.js +98 -0
  304. package/dist/src/run/run-detail.js +168 -0
  305. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +72 -60
  306. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  307. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +108 -73
  308. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  309. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +108 -50
  310. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  311. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  312. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +91 -56
  313. package/dist/src/run/selfcheck-digest.js +40 -0
  314. package/dist/src/run/stage-backlog-checks.js +249 -0
  315. package/dist/src/run/stage-check.js +172 -0
  316. package/dist/src/run/stage-path-checks.js +730 -0
  317. package/dist/src/{loop → runner}/leftover.js +80 -25
  318. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  319. package/dist/src/runner/revision.js +202 -0
  320. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  321. package/dist/src/runner/runner-outcome.js +107 -0
  322. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  323. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +21 -15
  324. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +218 -240
  325. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  326. package/dist/src/runner/runner-state.js +102 -0
  327. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  328. package/dist/src/scheduling/backoff.js +38 -38
  329. package/dist/src/scheduling/claims.js +35 -22
  330. package/dist/src/scheduling/once-round.js +22 -22
  331. package/dist/src/scheduling/quiet-hours.js +5 -6
  332. package/dist/src/scheduling/scheduling.js +74 -64
  333. package/dist/src/scheduling/work-merged-cache.js +2 -2
  334. package/dist/src/status/status-budget.js +55 -47
  335. package/dist/src/status/status-data.js +43 -16
  336. package/dist/src/status/status-inbox.js +2 -2
  337. package/dist/src/status/status-install.js +0 -1
  338. package/dist/src/status/status-polls.js +28 -28
  339. package/dist/src/status/status-roles.js +39 -13
  340. package/dist/src/team/error-storm.js +8 -8
  341. package/dist/src/team/failure-spread.js +2 -2
  342. package/dist/src/team/orchestrator-info.js +15 -9
  343. package/dist/src/team/reclaim.js +5 -6
  344. package/dist/src/team/team-hold.js +7 -7
  345. package/dist/src/team/team-polls.js +91 -50
  346. package/dist/src/team/team-state.js +8 -2
  347. package/dist/src/text/datetime.js +63 -17
  348. package/dist/src/text/format.js +48 -11
  349. package/dist/src/text/markdown.js +8 -5
  350. package/dist/src/text/phrases.js +57 -23
  351. package/dist/src/text/suggest.js +1 -1
  352. package/dist/src/text/text.js +67 -13
  353. package/dist/src/ui/badges.js +65 -37
  354. package/dist/src/ui/gui/gui-client-backlog.js +292 -0
  355. package/dist/src/ui/gui/gui-client-boot.js +32 -26
  356. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  357. package/dist/src/ui/gui/gui-client-drawer-config.js +212 -0
  358. package/dist/src/ui/gui/gui-client-drawer.js +126 -285
  359. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  360. package/dist/src/ui/gui/gui-client-history.js +434 -67
  361. package/dist/src/ui/gui/gui-client-labels.js +1 -2
  362. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  363. package/dist/src/ui/gui/gui-client-model.js +27 -36
  364. package/dist/src/ui/gui/gui-client-operator.js +55 -51
  365. package/dist/src/ui/gui/gui-client-pending.js +227 -23
  366. package/dist/src/ui/gui/gui-client-report-chart.js +464 -0
  367. package/dist/src/ui/gui/gui-client-report.js +87 -427
  368. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  369. package/dist/src/ui/gui/gui-client-settings.js +56 -4
  370. package/dist/src/ui/gui/gui-client-team.js +25 -465
  371. package/dist/src/ui/gui/gui-client.js +27 -25
  372. package/dist/src/ui/gui/gui-icons.js +0 -2
  373. package/dist/src/ui/gui/gui-page.js +9 -7
  374. package/dist/src/ui/gui/gui-styles.js +34 -34
  375. package/dist/src/ui/progress-data.js +9 -11
  376. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +31 -31
  377. package/dist/src/ui/status-model.js +133 -125
  378. package/dist/src/ui/status-payload.js +136 -49
  379. package/dist/src/ui/status-render.js +101 -82
  380. package/dist/src/ui/team-alerts.js +248 -57
  381. package/dist/src/ui/tone.js +5 -6
  382. package/dist/src/ui/transcript-run.js +58 -0
  383. package/dist/src/ui/transcript-tail.js +28 -20
  384. package/dist/src/ui/transcript.js +63 -23
  385. package/dist/src/ui/tui/tui-app.js +0 -1
  386. package/dist/src/ui/tui/tui-backlog.js +51 -14
  387. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  388. package/dist/src/ui/tui/tui-editor-attach.js +3 -5
  389. package/dist/src/ui/tui/tui-editor-backends.js +132 -0
  390. package/dist/src/ui/tui/tui-editor-brief.js +10 -12
  391. package/dist/src/ui/tui/tui-editor-budget.js +35 -31
  392. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  393. package/dist/src/ui/tui/tui-editor-concurrency.js +24 -0
  394. package/dist/src/ui/tui/tui-editor-config.js +86 -0
  395. package/dist/src/ui/tui/tui-editor-entry.js +36 -47
  396. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  397. package/dist/src/ui/tui/tui-editor-instances.js +37 -0
  398. package/dist/src/ui/tui/tui-editor-instructions.js +136 -0
  399. package/dist/src/ui/tui/tui-editor-interval.js +11 -9
  400. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  401. package/dist/src/ui/tui/tui-editor-notes.js +3 -5
  402. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  403. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  404. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  405. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  406. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  407. package/dist/src/ui/tui/tui-editor-quiet.js +99 -35
  408. package/dist/src/ui/tui/tui-editor-retire.js +4 -6
  409. package/dist/src/ui/tui/tui-editor-role-key.js +76 -0
  410. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  411. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  412. package/dist/src/ui/tui/tui-editor-session.js +60 -34
  413. package/dist/src/ui/tui/tui-editor-slots.js +23 -0
  414. package/dist/src/ui/tui/tui-editors.js +35 -2
  415. package/dist/src/ui/tui/tui-frame.js +56 -11
  416. package/dist/src/ui/tui/tui-history.js +8 -9
  417. package/dist/src/ui/tui/tui-input.js +13 -11
  418. package/dist/src/ui/tui/tui-keymap.js +0 -1
  419. package/dist/src/ui/tui/tui-keys.js +24 -9
  420. package/dist/src/ui/tui/tui-pane-activity.js +14 -11
  421. package/dist/src/ui/tui/tui-pane-backlog.js +171 -126
  422. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  423. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  424. package/dist/src/ui/tui/tui-pane-health.js +58 -8
  425. package/dist/src/ui/tui/tui-pane-history.js +402 -82
  426. package/dist/src/ui/tui/tui-pane-pending.js +308 -13
  427. package/dist/src/ui/tui/tui-pane-queued.js +71 -55
  428. package/dist/src/ui/tui/tui-pane-report.js +46 -14
  429. package/dist/src/ui/tui/tui-pane-session.js +25 -6
  430. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  431. package/dist/src/ui/tui/tui-pane.js +80 -23
  432. package/dist/src/ui/tui/tui-panes.js +100 -30
  433. package/dist/src/ui/tui/tui.js +14 -14
  434. package/dist/src/verdict/fix-claim.js +82 -32
  435. package/dist/src/verdict/no-change.js +4 -4
  436. package/dist/src/verdict/refusal.js +40 -16
  437. package/dist/src/verdict/reply-contract.js +42 -31
  438. package/dist/src/version.js +0 -1
  439. package/package.json +2 -1
  440. package/dist/src/loop/revision.js +0 -70
  441. package/dist/src/roles/loop-ids.js +0 -77
  442. package/dist/src/tick/stage-check.js +0 -419
  443. package/dist/src/tick/telemetry-digest.js +0 -25
  444. package/dist/src/tick/tick-detail-data.js +0 -108
  445. package/dist/src/tick/tick-detail.js +0 -163
  446. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,128 +1,67 @@
1
- // vocabulary: plain
2
- /** The read-only query commands' bodies (status, diff, backlog, questions, role), split out of
1
+ /** The read-only query commands' bodies (status, diff, queue, backlog), split out of
3
2
  * cli.ts's dispatcher: main() routes to a cmd* here exactly as it routes to operator-commands's
4
3
  * cmdAbort or config-commands's cmdConfig, and cli.ts keeps only routing and the help/version
5
4
  * surfaces. Each command owns its argument gate, ready-repo gating decision, and output shape,
6
5
  * with the wording and gating order moved verbatim from main()'s cases. */
7
- import { fail, say, sayJson, sayJsonOrRender, warnIfUncommitted } from "./cli-output.js";
8
- import { flagValue, parseCountFlag, parsePositionFlag, parseRoleFlag, rejectRepeatFlag } from "./cli-args.js";
9
- import { JSON_FLAG, READY_FLAG, rejectEqualsForm, rejectUnknownArgs, ROLE_FLAG, STAT_FLAG } from "./cli-flag-specs.js";
10
- import { requireReadyRepo } from "./cli-repo-gate.js";
11
- import { defaultConfig, knownRoleIds, knownRoleIdsCached, loadConfigSafe } from "../config/config.js";
12
- import { gotSuffix, parsePositiveInt } from "../text/text.js";
13
- import { snapshot } from "../status/status-data.js";
6
+ import { fail, say, sayJson, sayJsonOrRender } from "./cli-output.js";
7
+ import { cancelQueuedMerge } from "../merge/merge-cancel.js";
8
+ import { moveQueuedMergeToTop } from "../merge/merge-top.js";
9
+ import { followView } from "./follow-view.js";
10
+ import { flagValue, parseGrepFlag, parsePositionFlag, parseRoleListScope, rejectRepeatFlag } from "./cli-args.js";
11
+ import { DIFF_PATH_FLAG, FOLLOW_FLAG, hasFollowFlag, JSON_FLAG, READY_FLAG, grepFlagSpec, rejectUnknownArgs, OVERLAPS_FLAG, ROLE_FLAG, STAT_FLAG } from "./cli-flag-specs.js";
12
+ import { refuseTeamWorkspace, requireReadyRepo } from "./cli-repo-gate.js";
13
+ import { scopeSnapshotToRoles, snapshot } from "../status/status-data.js";
14
14
  import { statusPayload } from "../ui/status-payload.js";
15
15
  import { renderStatus } from "../ui/status-render.js";
16
16
  import { backlogPayload } from "../backlog/backlog.js";
17
17
  import { backlogHolds, backlogInProgress } from "../backlog/backlog-eligibility.js";
18
- import { readyBacklog, renderBacklogMarkdown } from "../backlog/backlog-render.js";
19
- import { collectTeamChanges, collectRoleChange } from "../change/change-data.js";
20
- import { renderTeamChange, renderRoleChange } from "../change/change-render.js";
18
+ import { readyBacklog, grepBacklog, renderBacklogMarkdown } from "../backlog/backlog-render.js";
19
+ import { collectChangeOverlaps, scopeChangeOverlaps, scopeChangeOverlapsToPath, collectTeamChanges, collectRoleChange, collectRoleChanges, normalizeDiffPath } from "../change/change-data.js";
20
+ import { renderChangeOverlaps, renderTeamChange, renderRoleChange, renderRoleChanges } from "../change/change-render.js";
21
21
  import { readMergeQueue } from "../merge/merge-queue.js";
22
- import { renderMergeQueue } from "../merge/merge-queue-render.js";
22
+ import { renderMergeQueue, scopeMergeQueue } from "../merge/merge-queue-render.js";
23
+ import { mergeChanges } from "../merge/merge-slot.js";
24
+ import { shortSha } from "../text/format.js";
25
+ import { roleLabel } from "../roles/role-labels.js";
23
26
  import { orchestratorAlive } from "../team/orchestrator-info.js";
24
27
  import { queueInFlight } from "../status/status-polls.js";
25
- import { rolePayload } from "../roles/role-view.js";
26
- import { renderRoleMarkdown } from "../roles/role-render.js";
27
- import { DIRECTOR_ROLE, unknownRoleMessage } from "../roles/roles.js";
28
- import { saveRoleNote } from "../agent/role-notes-write.js";
29
- import { roleNotesPath } from "../paths.js";
30
- import { errorMessage } from "../text/text.js";
31
- import { readPromptFileArg, promptFileValue } from "./cli-command-args.js";
32
- import { commitOperatorBacklogEdit } from "../backlog/backlog-commit.js";
33
- import { stampedSubject } from "../git/commit-message.js";
34
- import { wakeQuestionAsker } from "../operator/operator-intent.js";
35
- import { answerEditorBuffer, answerQuestion, answerQuestionFromText, decisionFromEditorBuffer, sayAnswered, sayQuestionList } from "./question-commands.js";
36
- import { answeredQuestionAt, answeredQuestionCount, answeredQuestionList, answeredQuestionPayload, openQuestionAt, questionListPayload, renderShownQuestion } from "../backlog/question-answer.js";
37
- import { editInEditor } from "./cli-editor.js";
38
- import { canonicalRoleId } from "../roles/legacy-role-ids.js";
39
- import { baseRoleOf } from "../roles/loop-ids.js";
40
- import { setConfigKey, unsetConfigKey } from "../config/config-write.js";
41
- /** `questions answer`'s `--file <path>` flag: the decision comes from the file, lines kept. */
42
- const FILE_SPEC = { names: ["--file"], value: true, valueName: "<path>" };
43
- /** `role`'s notebook-write flags: clear the note, or replace it from a file (`-` for stdin). */
44
- const CLEAR_NOTES_SPEC = { names: ["--clear-notes"] };
45
- const NOTES_FILE_SPEC = { names: ["--notes-file"], value: true, valueName: "<path>" };
46
- /** `role`'s instructions-override flags: clear it, or replace it from a file (`-` for stdin). */
47
- const CLEAR_INSTRUCTIONS_SPEC = { names: ["--clear-instructions"] };
48
- const INSTRUCTIONS_FILE_SPEC = { names: ["--instructions-file"], value: true, valueName: "<path>" };
49
- /** `role`'s quiet-hours write flags: clear the window, or set it to a daily local-time window. */
50
- const CLEAR_QUIET_HOURS_SPEC = { names: ["--clear-quiet-hours"] };
51
- const QUIET_HOURS_SPEC = { names: ["--quiet-hours"], value: true, valueName: "<window>" };
52
- /** `role`'s daily-cap write flags: clear the cap, or set it to a USD amount (0 disables). */
53
- const CLEAR_CAP_SPEC = { names: ["--clear-cap"] };
54
- const CAP_SPEC = { names: ["--cap"], value: true, valueName: "<usd>" };
55
- /** `role`'s min-run-interval write flags: clear it, or set it to a second count (0 is
56
- * back-to-back). */
57
- const CLEAR_MIN_RUN_INTERVAL_SPEC = { names: ["--clear-min-run-interval"] };
58
- const MIN_RUN_INTERVAL_SPEC = { names: ["--min-run-interval"], value: true, valueName: "<seconds>" };
59
- /** `role`'s instances write flags: clear the count, or set it to a parallel-runner count
60
- * (an integer from 1 to 8; only feature and bugfix split). */
61
- const CLEAR_INSTANCES_SPEC = { names: ["--clear-instances"] };
62
- const INSTANCES_SPEC = { names: ["--instances"], value: true, valueName: "<n>" };
63
- /** `role`'s enabled-state write flags: enable or disable the role, written to
64
- * roles.<base>.enabled. */
65
- const ENABLE_SPEC = { names: ["--enable"] };
66
- const DISABLE_SPEC = { names: ["--disable"] };
67
- /** `role`'s model-triple write flags: clear the field, or set it, written to
68
- * roles.<base>.provider/.model/.thinking. model and provider take an id; thinking takes a
69
- * pi `--thinking` level (off|minimal|low|medium|high|xhigh|max). */
70
- const CLEAR_PROVIDER_SPEC = { names: ["--clear-provider"] };
71
- const PROVIDER_SPEC = { names: ["--provider"], value: true, valueName: "<id>" };
72
- const CLEAR_MODEL_SPEC = { names: ["--clear-model"] };
73
- const MODEL_SPEC = { names: ["--model"], value: true, valueName: "<id>" };
74
- const CLEAR_THINKING_SPEC = { names: ["--clear-thinking"] };
75
- const THINKING_SPEC = { names: ["--thinking"], value: true, valueName: "<level>" };
76
- /** Peel `tumwater role <id>`'s positional id off the argument list: the first token that is
77
- * neither a flag nor the value of a --role, --notes-file, --instructions-file,
78
- * --quiet-hours, --cap, --min-run-interval, --instances, --provider, --model or
79
- * --thinking flag. Returns the id (null when absent) and the remaining arguments — flags
80
- * only, ready for rejectUnknownArgs and sayJsonOrRender. The id may also arrive as
81
- * --role <id>, the flag spelling every other role-targeting command shares; the
82
- * collector's caller rejects both spellings at once rather than silently picking one.
83
- * Lives here because it exists only for this one
84
- * command's mixed positional/flag vocabulary. */
85
- function peelRolePositional(args) {
86
- const valueIdx = new Set();
87
- for (const flag of ["--role", "--notes-file", "--instructions-file", "--quiet-hours", "--cap", "--min-run-interval", "--instances", "--provider", "--model", "--thinking"]) {
88
- const at = args.indexOf(flag);
89
- if (at >= 0)
90
- valueIdx.add(at + 1);
91
- }
92
- let id = null;
93
- const rest = [];
94
- for (let i = 0; i < args.length; i++) {
95
- const arg = args[i] ?? ""; // Unreachable fallback: the loop bound guarantees a token here.
96
- // A non-flag token that is not a value-taking flag's value is the positional,
97
- // taken once; everything else — flags and their values alike — rides in rest for
98
- // rejectUnknownArgs and flagValue to claim.
99
- if (!arg.startsWith("-") && id === null && !valueIdx.has(i)) {
100
- id = arg;
101
- continue;
102
- }
103
- rest.push(arg);
104
- }
105
- return { id, rest };
106
- }
107
- /** `tumwater status`: the team snapshot as rendered text or as the JSON document GET
108
- * /api/status serves. */
109
- export async function cmdStatus(root, args) {
110
- rejectUnknownArgs("status", args, [JSON_FLAG]);
28
+ import { roleInScope } from "../roles/runner-ids.js";
29
+ /** `tumwater status [-f/--follow]`: the team snapshot as rendered text or as the JSON
30
+ * document GET /api/status serves. `-f/--follow` repaints the table every couple of seconds
31
+ * until Ctrl+C or the process that started it exits, so a backgrounded follow does not
32
+ * outlive its parent. */
33
+ export async function cmdStatus(root, args, seams = {}) {
34
+ rejectUnknownArgs("status", args, [ROLE_FLAG, JSON_FLAG, FOLLOW_FLAG]);
35
+ const follow = hasFollowFlag(args);
36
+ if (follow && args.includes("--json"))
37
+ fail("status --json prints one document; -f/--follow repaints a live view — drop one of them");
111
38
  await requireReadyRepo(root);
39
+ const scope = parseRoleListScope(root, args);
40
+ if (follow) {
41
+ await followStatus(root, scope, seams);
42
+ return;
43
+ }
112
44
  if (args.includes("--json")) {
113
45
  // Machine-readable team state — the document GET /api/status serves minus the
114
46
  // serving process's own `serverBuildSha`, printed with no server. A query, not a
115
47
  // health verdict: exit 0 on any successful read and let scripts interpret fields
116
48
  // themselves ("running": false is data, not failure).
117
- sayJson(statusPayload(root));
49
+ sayJson(statusPayload(root, Date.now(), scope));
118
50
  }
119
51
  else {
120
- say(renderStatus(root, snapshot(root), process.stdout.isTTY ? process.stdout.columns : undefined));
52
+ const snap = snapshot(root);
53
+ say(renderStatus(root, scope ? scopeSnapshotToRoles(snap, scope) : snap, process.stdout.isTTY ? process.stdout.columns : undefined, scope !== null));
121
54
  }
122
55
  }
123
- /** `tumwater diff`: one line per loop holding pending work, or the full per-role view with
124
- * `--role <id>`. */
125
- export async function cmdDiff(root, args) {
56
+ /** `tumwater status -f`'s live loop: the team snapshot repainted until the parent exits. */
57
+ async function followStatus(root, scope, seams) {
58
+ const tty = seams.tty ?? process.stdout.isTTY;
59
+ await followView(() => renderStatus(root, scope ? scopeSnapshotToRoles(snapshot(root), scope) : snapshot(root), tty ? process.stdout.columns : undefined, scope !== null), seams);
60
+ }
61
+ /** `tumwater diff`: one line per role holding pending work, the files two or more roles touch
62
+ * with `--overlaps` (`--role` keeps those a listed role holds, `--path` those at or under it),
63
+ * or the full per-role view of each role listed with `--role <id>[,<id>...]`. */
64
+ export async function cmdDiff(root, args, seams = {}) {
126
65
  // The repo half of requireReadyRepo still gates: a directory tumwater cannot read yet
127
66
  // (no git, not a repository, no tumwater.json, no commits) has no team to ask about,
128
67
  // and the change view's own degradation would misreport that as "main branch <name>
@@ -130,529 +69,308 @@ export async function cmdDiff(root, args) {
130
69
  // command. Past the gate an absent worktree still degrades to a `no worktree for
131
70
  // <role>` line (exit 0), so the command answers in any initialized directory —
132
71
  // report's rationale.
133
- rejectUnknownArgs("diff", args, [ROLE_FLAG, JSON_FLAG, STAT_FLAG]);
72
+ rejectUnknownArgs("diff", args, [ROLE_FLAG, JSON_FLAG, STAT_FLAG, OVERLAPS_FLAG, DIFF_PATH_FLAG, FOLLOW_FLAG]);
134
73
  await requireReadyRepo(root);
135
- // Absent --role is the team-wide form: one line per loop holding pending work
136
- // (parseRoleFlag returns null only for an absent flag — an empty or unknown value
137
- // already failed above). A named role keeps the full per-role view.
138
- const role = parseRoleFlag(args, knownRoleIdsCached(root));
139
- if (role === null) {
74
+ rejectRepeatFlag(args.filter((a) => a === "--path").length > 1, "--path");
75
+ const follow = hasFollowFlag(args);
76
+ if (follow && args.includes("--json"))
77
+ fail("diff --json prints one document; -f/--follow repaints a live view — drop one of them");
78
+ if (args.includes("--overlaps")) {
140
79
  if (args.includes("--stat"))
141
- fail("diff --stat needs --role <id> (the team roster prints no patch)");
142
- sayJsonOrRender(args, await collectTeamChanges(root), renderTeamChange);
80
+ fail("diff --overlaps prints paths, not patches; drop --stat");
81
+ // Overlaps are computed across every role; --role only filters which entries print.
82
+ const scope = parseRoleListScope(root, args);
83
+ const path = diffPathArg(args);
84
+ // The read and its zero-arg render are factored into one thunk so the follow's frame
85
+ // and the snapshot print exactly the same text from the same reader.
86
+ const readOverlaps = async () => {
87
+ let view = await collectChangeOverlaps(root);
88
+ if (scope === null && path === undefined)
89
+ return { view, render: () => renderChangeOverlaps(view) };
90
+ if (scope !== null)
91
+ view = scopeChangeOverlaps(view, scope);
92
+ if (path !== undefined)
93
+ view = scopeChangeOverlapsToPath(view, path);
94
+ return { view, render: () => renderChangeOverlaps(view, scope ?? undefined, path) };
95
+ };
96
+ if (follow) {
97
+ await followView(async () => (await readOverlaps()).render(), seams);
98
+ return;
99
+ }
100
+ const { view, render } = await readOverlaps();
101
+ sayJsonOrRender(args, view, () => render());
102
+ return;
143
103
  }
144
- else {
145
- const change = await collectRoleChange(root, role, { stat: args.includes("--stat") });
146
- sayJsonOrRender(args, change, renderRoleChange);
104
+ // Absent --role is the team-wide form: one line per role holding pending work
105
+ // (parseRoleListScope returns null only for an absent flag — an empty or unknown piece
106
+ // already failed above). One named role keeps the single per-role view; a list prints each
107
+ // listed role's view in order.
108
+ const roles = parseRoleListScope(root, args);
109
+ const path = roles === null ? undefined : diffPathArg(args);
110
+ // The roster/role reads below are factored into a thunk (view + zero-arg render) so the
111
+ // follow's frame and the snapshot share one reader and print byte-identical text.
112
+ const readChange = async () => {
113
+ if (roles === null) {
114
+ if (args.includes("--stat"))
115
+ fail("diff --stat needs --role <id> (the team roster prints no patch)");
116
+ if (args.includes("--path"))
117
+ fail("diff --path needs --role <id> (the team roster prints no patch)");
118
+ const view = await collectTeamChanges(root);
119
+ return { view, render: () => renderTeamChange(view) };
120
+ }
121
+ if (roles.length === 1) {
122
+ const change = await collectRoleChange(root, roles[0], { stat: args.includes("--stat"), path });
123
+ return { view: change, render: () => renderRoleChange(change) };
124
+ }
125
+ const view = await collectRoleChanges(root, roles, { stat: args.includes("--stat"), path });
126
+ return { view, render: () => renderRoleChanges(view) };
127
+ };
128
+ if (follow) {
129
+ await followView(async () => (await readChange()).render(), seams);
130
+ return;
147
131
  }
132
+ const { view, render } = await readChange();
133
+ sayJsonOrRender(args, view, () => render());
134
+ }
135
+ /** The normalised `--path` value of `diff --role` (undefined when absent): leading `./` pieces
136
+ * and trailing `/` characters stripped, failing on an empty, absolute or `..`-bearing result. */
137
+ function diffPathArg(args) {
138
+ const raw = flagValue(args, "--path");
139
+ if (raw === null || raw === undefined)
140
+ return undefined;
141
+ const norm = normalizeDiffPath(raw);
142
+ if (norm === null)
143
+ fail("diff --path takes a repo-relative path such as src/cli.ts (as diff --overlaps prints it)");
144
+ return norm;
148
145
  }
149
- /** `tumwater queue`: the durable merge queue in execution order, as text or as the
150
- * `{entries, unreadable}` document. */
151
- export async function cmdQueue(root, args) {
146
+ /** `tumwater queue [-f/--follow]`: the durable merge queue in execution order, as text or as
147
+ * the `{entries, unreadable}` document. `-f/--follow` repaints the queue every couple of
148
+ * seconds until Ctrl+C or the process that started it exits, so a backgrounded follow does
149
+ * not outlive its parent (the same rule `status -f` uses). */
150
+ export async function cmdQueue(root, args, seams = {}) {
151
+ // `tumwater queue --cancel <n>` is the read-only command's one destructive mode: route it
152
+ // to its own branch before the read gate, so --cancel never leaks into the view parsing.
153
+ if (args.includes("--cancel")) {
154
+ await cmdQueueCancel(root, args);
155
+ return;
156
+ }
157
+ if (args.includes("--top")) {
158
+ await cmdQueueTop(root, args);
159
+ return;
160
+ }
152
161
  // The repo half of requireReadyRepo gates: like every repo-bound query, a directory
153
162
  // tumwater cannot read yet has no queue to show. Past the gate an absent queue dir reads
154
163
  // as an empty queue (a `merge queue empty` line, exit 0), so no merge ever queued is
155
164
  // data, not an error.
156
- rejectUnknownArgs("queue", args, [JSON_FLAG]);
165
+ rejectUnknownArgs("queue", args, [ROLE_FLAG, JSON_FLAG, FOLLOW_FLAG]);
157
166
  await requireReadyRepo(root);
158
- // The JSON branch prints the reader's own document — raw sha and numeric enqueuedAt, not
159
- // the render's short sha and age — and the render is the collector's view with one clock
160
- // read, so `tumwater queue` and `tumwater status --json`'s mergeQueue cannot drift.
161
- // The in-flight marker rides along only while an orchestrator runs and the marker names a
162
- // queued entry (queueInFlight) — status --json's mergeQueue.inFlight, so the two agree.
163
- const view = readMergeQueue(root);
164
- const inFlight = queueInFlight(root, view.entries, orchestratorAlive(root));
165
- sayJsonOrRender(args, inFlight ? { ...view, inFlight } : view, () => renderMergeQueue(view, Date.now(), inFlight));
166
- }
167
- /** `tumwater backlog`: the planned/open entries as Markdown or as the GUI's JSON payload. */
168
- export async function cmdBacklog(root, args) {
169
- // No requireReadyRepo gate: the entry readers degrade to [] on a missing file, so the
170
- // command prints three empty sections in any directory (report's rationale, not config's).
171
- rejectUnknownArgs("backlog", args, [JSON_FLAG, READY_FLAG]);
172
- // Machine-readable backlog — the three entry arrays the Markdown view renders and the
173
- // GUI's /api/backlog serves (status --json's "print the endpoint's payload" pattern),
174
- // plus `holds.plans`/`holds.bugs` parallel to the entry arrays so a skipped entry's
175
- // reason is data, not inference: a pretty-printed JSON document in every exit-0 case,
176
- // never prose. The payload is a thunk, so whichever branch runs reads the three entry
177
- // files through their stat caches once — the Markdown renderer consumes the same arrays
178
- // the JSON document prints.
179
- // --ready filters both branches through readyBacklog; the Markdown branch adds a count of
180
- // the held entries it hid.
181
- const ready = args.includes("--ready");
182
- let hidden = 0;
183
- sayJsonOrRender(args, () => {
184
- const view = { ...backlogPayload(root), holds: backlogHolds(root), inProgress: backlogInProgress(root) };
185
- if (!ready)
186
- return view;
187
- const kept = readyBacklog(view);
188
- hidden = view.plans.length + view.bugs.length - kept.plans.length - kept.bugs.length;
189
- return kept;
190
- }, (v) => renderBacklogMarkdown(v) + (hidden > 0
191
- ? `\n\n_(${hidden} held entries hidden; run \`tumwater backlog\` to see them)_` : ""));
167
+ const roles = parseRoleListScope(root, args);
168
+ const follow = hasFollowFlag(args);
169
+ if (follow && args.includes("--json"))
170
+ fail("queue --json prints one document; -f/--follow repaints a live view — drop one of them");
171
+ // Scoped read shared by the follow and the one-shot branches, so the render's age and the
172
+ // in-flight marker share one clock read and each frame re-reads the queue. --role keeps
173
+ // the listed roles' entries (a base id taking its instances too) under their whole-queue
174
+ // positions; the in-flight marker is judged against the kept entries only, and
175
+ // `unreadable` stays whole because an unreadable file has no role to attribute it to.
176
+ const readQueue = () => {
177
+ const whole = readMergeQueue(root);
178
+ const scoped = roles ? scopeMergeQueue(whole, roles, (r) => roleInScope(r, roles)) : undefined;
179
+ const view = scoped ? scoped.view : whole;
180
+ return { view, inFlight: queueInFlight(root, view.entries, orchestratorAlive(root)), scope: scoped?.scope };
181
+ };
182
+ if (follow) {
183
+ await followView(() => {
184
+ const { view, inFlight, scope } = readQueue();
185
+ return renderMergeQueue(view, Date.now(), inFlight, scope);
186
+ }, seams);
187
+ return;
188
+ }
189
+ const { view, inFlight, scope } = readQueue();
190
+ sayJsonOrRender(args, inFlight ? { ...view, inFlight } : view, () => renderMergeQueue(view, Date.now(), inFlight, scope));
192
191
  }
193
- /** `tumwater questions`: list open questions, or answer one with `answer <n> <decision>`. */
194
- export async function cmdQuestions(root, args) {
195
- // No requireReadyRepo gate, like backlog: the reader degrades to an empty list on a
196
- // missing QUESTIONS.md, so the command inspects any directory instead of refusing.
197
- // The answer form's decision is free-form prose, so peelPositionals cannot route the
198
- // tokens: a decision word may begin with a single dash (`questions answer 1 "-50% spend
199
- // cap"`), and peelPositionals would hand it to the flag gate as an unknown flag — the
200
- // same masking parsePromptArgs avoids for prompt text by owning only `--`-prefixed
201
- // tokens. So the questions parse scans args itself: a `--json` token before the
202
- // question's number positional is the one flag (repeats refused, equals form named by
203
- // rejectEqualsForm), any other `--`-prefixed token before it is refused as unknown, and
204
- // every remaining token in order is prose — subcommand, position number, then the
205
- // decision words. Past the number positional, tokens are decision words even when one
206
- // is spelled `--json`: unquoted decision prose (`questions answer 1 keep --json output`)
207
- // reaches the command as separate argv tokens, and a flag scan that matched `--json`
208
- // anywhere silently ate the token out of the recorded decision and flipped the command
209
- // into JSON mode. Unknown `--`-prefixed tokens stay refused even there, so a misspelled
210
- // flag is still an error rather than silent decision text.
211
- const words = [];
212
- let jsonFlag = false;
213
- let numbered = false;
214
- let file;
215
- let answeredFlag = false;
216
- let limit;
217
- let grep;
218
- let show;
192
+ /** The `tumwater queue --cancel <n>` branch (PLANS.md): remove the Nth queued merge `tumwater
193
+ * queue` lists and delete its role's merge pin, so next-run leftover recovery cannot silently
194
+ * re-enqueue the change. Parsed as cancelAndAnnounce parses `--cancel` (backlog-cli.ts):
195
+ * `--cancel <n>` the one valued flag, `--json` the one boolean (at most once), `--help`/-h
196
+ * tolerated, anything else refused. --role and -f/--follow are refused by name — a live view
197
+ * repaints a read, and position numbers come from the whole queue, whose numbering is
198
+ * `tumwater queue`'s. An entry a live merge marker names for this sha is refused (wait for it
199
+ * to land, then revert); an out-of-range position fails naming the current depth. */
200
+ async function cmdQueueCancel(root, args) {
201
+ let position = null;
202
+ let json = false;
203
+ const rest = [];
219
204
  for (let i = 0; i < args.length; i++) {
220
205
  const arg = args[i];
221
- if (!numbered && arg === "--show") {
222
- rejectRepeatFlag(show !== undefined, "--show");
223
- show = parsePositionFlag("--show", args[++i], "--show 2");
206
+ if (arg === "--json") {
207
+ rejectRepeatFlag(json, "--json");
208
+ json = true;
224
209
  continue;
225
210
  }
226
- if (!numbered && arg === "--answered") {
227
- rejectRepeatFlag(answeredFlag, "--answered");
228
- answeredFlag = true;
211
+ if (arg === "--cancel") {
212
+ rejectRepeatFlag(position !== null, "--cancel");
213
+ position = parsePositionFlag("--cancel", args[i + 1], "--cancel 2");
214
+ i++;
229
215
  continue;
230
216
  }
231
- if (!numbered && arg === "-n") {
232
- rejectRepeatFlag(limit !== undefined, "-n");
233
- limit = parseCountFlag("-n", args[++i]);
217
+ if (arg === "--help" || arg === "-h")
234
218
  continue;
235
- }
236
- if (!numbered && arg === "--grep") {
237
- rejectRepeatFlag(grep !== undefined, "--grep");
238
- const text = args[++i];
239
- if (text === undefined || text === "")
240
- fail("--grep needs text (usage: tumwater questions --answered --grep <text>)");
241
- grep = text;
242
- continue;
243
- }
244
- if (arg === "--file") {
245
- rejectRepeatFlag(file !== undefined, "--file");
246
- file = promptFileValue(args[++i]);
247
- continue;
248
- }
249
- if (!numbered && arg === "--json") {
250
- rejectRepeatFlag(jsonFlag, "--json");
251
- jsonFlag = true;
252
- continue;
253
- }
254
- if (arg.startsWith("--") && arg !== "--json") {
255
- rejectEqualsForm(arg, [JSON_FLAG, FILE_SPEC, { names: ["--show"], value: true, valueName: "<n>" }, { names: ["--grep"], value: true, valueName: "<text>" }]);
256
- fail(`unknown argument: ${arg} (valid flags for tumwater questions: --json, --file, --answered, --show, --grep)`);
257
- }
258
- if (!numbered) {
259
- // The position is a plain-decimal positive integer (parsePositiveInt's rule, the same
260
- // one --cancel's position honors): bare Number() admitted hex ("0x2"), exponent
261
- // ("1e2"), and whitespace-padded spellings as a valid question number, answering a
262
- // question the operator never named.
263
- if (parsePositiveInt(arg) !== null)
264
- numbered = true;
265
- }
266
- words.push(arg);
267
- }
268
- if (limit !== undefined && !answeredFlag)
269
- fail("-n needs --answered (it limits the answered questions listed)");
270
- if (grep !== undefined && !answeredFlag)
271
- fail("--grep needs --answered (it searches the answered questions)");
272
- if (show !== undefined) {
273
- if (grep !== undefined)
274
- fail("--grep does not apply to --show");
275
- if (words.length > 0)
276
- fail("--show prints one open question; it takes no subcommand");
277
- if (file !== undefined)
278
- fail("--file only applies to questions answer <n>");
279
- if (answeredFlag) {
280
- if (limit !== undefined)
281
- fail("-n does not apply to --show");
282
- const a = answeredQuestionAt(root, show);
283
- if (a === null) {
284
- const count = answeredQuestionCount(root);
285
- const range = count > 0 ? ` — valid: 1-${count}` : "";
286
- fail(`no answered question at position ${show} (${count} in QUESTIONS.md)${range}`);
287
- }
288
- if (jsonFlag)
289
- sayJson(a);
290
- else
291
- say(renderShownQuestion(a));
292
- return;
293
- }
294
- const q = openQuestionAt(root, show);
295
- if (q === null) {
296
- const count = questionListPayload(root).questions.length;
297
- const range = count > 0 ? ` — valid: 1-${count}` : "";
298
- fail(`no open question at position ${show} (${count} in QUESTIONS.md)${range}`);
299
- }
300
- if (jsonFlag)
301
- sayJson(q);
302
- else
303
- say(renderShownQuestion(q));
304
- return;
305
- }
306
- if (answeredFlag) {
307
- if (words.length > 0)
308
- fail("--answered lists answered questions; it takes no subcommand");
309
- if (file !== undefined)
310
- fail("--file only applies to questions answer <n>");
311
- if (jsonFlag)
312
- sayJson(answeredQuestionPayload(root, limit ?? 10, grep));
313
- else
314
- say(answeredQuestionList(root, limit ?? 10, grep));
315
- return;
316
- }
317
- if (words.length === 0 && file !== undefined)
318
- fail("--file only applies to questions answer <n>");
319
- if (words.length === 0) {
320
- sayQuestionList(root, jsonFlag);
321
- return;
219
+ rest.push(arg);
322
220
  }
323
- if (words[0] !== "answer")
324
- fail(`unknown questions subcommand: ${words[0]} (use "answer <n> <decision>", or run \`tumwater questions\` with no subcommand to list them)`);
325
- // The offending token carries the same ` (got "...")` suffix every sibling number parser
326
- // adds (parsePositiveInt's flag callers, --cancel); the bare `answer` form names none.
327
- const raw = words.length > 1 ? words[1] : undefined;
328
- const n = raw === undefined ? null : parsePositiveInt(raw);
329
- if (n === null)
330
- fail(`questions answer needs a positive question number: questions answer <n> "<decision>"${raw === undefined ? "" : gotSuffix(raw)}`);
331
- if (file !== undefined) {
332
- if (words.length > 2)
333
- fail(`unexpected argument ${JSON.stringify(words[2])} — with --file the decision comes from the file`);
334
- const answered = answerQuestionFromText(root, n, readPromptFileArg(file));
335
- await commitAndReportAnswer(root, n, jsonFlag, answered.title, answered.decision);
336
- return;
221
+ // The vocabulary a cancel invocation accepts is exactly --cancel and --json, both consumed
222
+ // above; --role and -f/--follow are refused with the queue-specific wording the tests pin.
223
+ if (rest.includes("--role"))
224
+ fail("queue --cancel cancels by whole-queue position from `tumwater queue`; --role scopes a read only — drop it");
225
+ if (rest.includes("--follow") || rest.includes("-f"))
226
+ fail("queue --cancel removes one entry; -f/--follow repaints a live view — drop it");
227
+ rejectUnknownArgs("queue", rest, []);
228
+ if (position === null)
229
+ fail("--cancel needs a position (usage: tumwater queue --cancel <n>)");
230
+ await requireReadyRepo(root);
231
+ // Refuse the entry a live merge marker names before touching it: it is mid-flight (being
232
+ // reviewed or merged), so cancelling it would race the slot that owns it.
233
+ const view = readMergeQueue(root);
234
+ const inFlight = queueInFlight(root, view.entries, orchestratorAlive(root));
235
+ const target = view.entries[position - 1];
236
+ if (inFlight && target && (target.sha === inFlight.sha || mergeChanges(inFlight).some((c) => c.sha === target.sha))) {
237
+ fail(`queue entry ${position} is being merged right now — wait for it to land, then \`tumwater revert ${shortSha(target.sha)}\``);
337
238
  }
338
- const decision = words.slice(2).join(" ").trim();
339
- if (words.length === 2) {
340
- const open = questionListPayload(root).questions;
341
- const question = open[n - 1];
342
- if (question === undefined) {
343
- const range = open.length > 0 ? ` — valid: 1-${open.length}` : "";
344
- fail(`no question at position ${n} (${open.length} open)${range}`);
345
- }
346
- const edited = decisionFromEditorBuffer(editInEditor(answerEditorBuffer(question.title, question.body), `answer-${n}.md`, "pass a decision or --file <path>"));
347
- if (edited.trim() === "")
348
- fail("questions answer needs a decision: the editor left it blank; nothing changed");
349
- const answered = answerQuestionFromText(root, n, edited, question.title);
350
- await commitAndReportAnswer(root, n, jsonFlag, answered.title, answered.decision);
351
- return;
239
+ const outcome = await cancelQueuedMerge(root, position);
240
+ if (outcome.status === "out-of-range") {
241
+ fail(`no queued merge at position ${position} (merge queue holds ${outcome.depth})`);
352
242
  }
353
- if (decision === "")
354
- fail('questions answer needs a decision: questions answer <n> "<decision>"');
355
- // answerQuestion collapses the free-form decision to one line before writing it; report
356
- // that written text in the --json payload so it matches the file, not the caller's spacing.
357
- const { title, decision: writtenDecision } = answerQuestion(root, n, decision);
358
- await commitAndReportAnswer(root, n, jsonFlag, title, writtenDecision);
359
- }
360
- /** Commit the answered question, report it in the requested output mode, and wake the
361
- * asking role in prose mode — the tail every `questions answer` branch ends with. */
362
- async function commitAndReportAnswer(root, n, jsonFlag, title, decision) {
363
- warnIfUncommitted(await commitOperatorBacklogEdit(root, "QUESTIONS.md", stampedSubject("operator", `Answer question: ${title}`)), "QUESTIONS.md"); // vocabulary-ok: commit prefix stays
364
- sayAnswered(n, title, jsonFlag, decision);
365
- if (!jsonFlag)
366
- sayAskerWake(root, title);
367
- }
368
- /** Prose-mode follow-up to `questions answer`: wake the asking role and print the confirmation. */
369
- function sayAskerWake(root, title) {
370
- const wake = wakeQuestionAsker(root, title);
371
- if (wake !== null)
372
- say(wake);
373
- }
374
- /** Refuse an unknown role id with the shared unknownRoleMessage wording before a per-role
375
- * config write resolves the id. Every `tumwater role` write branch starts with the same
376
- * prelude — load the config (falling back to defaultConfig so built-in roles resolve) and
377
- * throw the unknown-role error unless the id appears — so this keeps that check in one place.
378
- * The caller then derives `baseRoleOf(id)` for the write key. */
379
- function requireKnownRole(root, id) {
380
- const cfg = loadConfigSafe(root).config ?? defaultConfig();
381
- if (!knownRoleIds(cfg).includes(id))
382
- throw new Error(unknownRoleMessage(id, knownRoleIds(cfg)));
383
- }
384
- /** The config-key write the six per-role hold branches (quiet hours, daily cap, min run
385
- * interval, provider, model and thinking) share: with `clear`, or an empty typed value, unset
386
- * `key` under `base`; else set it. `serialize` shapes the value `setConfigKey` stores (it
387
- * JSON-encodes nothing itself) and `display` gives the human phrase for the confirmation
388
- * line. The caller returns right after. The instructions branch keeps its own block: its
389
- * value is a file's text, so an empty value is a legitimate write, not a clear. */
390
- function writeRoleConfigKey(root, base, clear, raw, key, noun, serialize, display) {
391
- if (clear || raw.trim() === "") {
392
- const result = unsetConfigKey(root, key);
393
- if (!result.ok)
394
- fail(result.error);
395
- say(`${base}: ${noun} cleared`);
243
+ if (json) {
244
+ sayJson({ cancelled: { role: outcome.role, commit: outcome.sha, summary: outcome.summary } });
396
245
  }
397
246
  else {
398
- const result = setConfigKey(root, key, serialize(raw));
399
- if (!result.ok)
400
- fail(result.error);
401
- say(`${base}: ${noun} set to ${display(raw)}`);
247
+ say(`cancelled queue entry ${position}: ${roleLabel(outcome.role)} ${shortSha(outcome.sha)} — ${outcome.summary}`);
402
248
  }
403
249
  }
404
- /** `tumwater role <id>`: one loop's standing prompt and next tick's assembled prompt. */
405
- export async function cmdRole(root, args) {
406
- // No requireReadyRepo gate, like backlog: role-view degrades (missing state files →
407
- // fresh defaults, a missing queue directory an empty inbox), so the command inspects
408
- // a repo the team never started in — and a torn one — instead of refusing.
409
- const { id: positional, rest } = peelRolePositional(args);
410
- rejectUnknownArgs("role", rest, [ROLE_FLAG, JSON_FLAG, CLEAR_NOTES_SPEC, NOTES_FILE_SPEC, CLEAR_INSTRUCTIONS_SPEC, INSTRUCTIONS_FILE_SPEC, CLEAR_QUIET_HOURS_SPEC, QUIET_HOURS_SPEC, ENABLE_SPEC, DISABLE_SPEC, CLEAR_CAP_SPEC, CAP_SPEC, CLEAR_MIN_RUN_INTERVAL_SPEC, MIN_RUN_INTERVAL_SPEC, CLEAR_INSTANCES_SPEC, INSTANCES_SPEC, CLEAR_PROVIDER_SPEC, PROVIDER_SPEC, CLEAR_MODEL_SPEC, MODEL_SPEC, CLEAR_THINKING_SPEC, THINKING_SPEC]);
411
- // The id is required: positional (`tumwater role <id>`) or --role <id> (the flag
412
- // spelling every other role-targeting command shares). Both at once is a mistake, not
413
- // a silent pick of one.
414
- const flagId = flagValue(rest, "--role") ?? null;
415
- const typedId = positional ?? flagId;
416
- if (!typedId)
417
- fail("tumwater role needs a role id");
418
- const id = canonicalRoleId(typedId);
419
- if (positional !== null && flagId !== null && flagId !== positional)
420
- fail("give the role id once — as the positional or as --role <id>, not both");
421
- const clear = rest.includes("--clear-notes");
422
- const notesFile = flagValue(rest, "--notes-file");
423
- const clearInst = rest.includes("--clear-instructions");
424
- const instFile = flagValue(rest, "--instructions-file");
425
- const clearQuiet = rest.includes("--clear-quiet-hours");
426
- const clearCap = rest.includes("--clear-cap");
427
- const clearInterval = rest.includes("--clear-min-run-interval");
428
- const clearInstances = rest.includes("--clear-instances");
429
- const clearProvider = rest.includes("--clear-provider");
430
- const clearModel = rest.includes("--clear-model");
431
- const clearThinking = rest.includes("--clear-thinking");
432
- const notesWrite = clear || rest.includes("--notes-file");
433
- const instructionsWrite = clearInst || rest.includes("--instructions-file");
434
- const quietWrite = clearQuiet || rest.includes("--quiet-hours");
435
- const capWrite = clearCap || rest.includes("--cap");
436
- const intervalWrite = clearInterval || rest.includes("--min-run-interval");
437
- const instancesWrite = clearInstances || rest.includes("--instances");
438
- const providerWrite = clearProvider || rest.includes("--provider");
439
- const modelWrite = clearModel || rest.includes("--model");
440
- const thinkingWrite = clearThinking || rest.includes("--thinking");
441
- const enable = rest.includes("--enable");
442
- const disable = rest.includes("--disable");
443
- const writes = [];
444
- if (notesWrite)
445
- writes.push("notes");
446
- if (instructionsWrite)
447
- writes.push("instructions");
448
- if (quietWrite)
449
- writes.push("quiet-hours");
450
- if (instancesWrite)
451
- writes.push("instances");
452
- if (capWrite)
453
- writes.push("cap");
454
- if (intervalWrite)
455
- writes.push("min-run-interval");
456
- if (providerWrite)
457
- writes.push("provider");
458
- if (modelWrite)
459
- writes.push("model");
460
- if (thinkingWrite)
461
- writes.push("thinking");
462
- if (enable)
463
- writes.push("enable");
464
- if (disable)
465
- writes.push("disable");
466
- if (writes.length > 1)
467
- fail(`${writes.join(" and ")} writes cannot share one invocation`);
468
- if (notesWrite) {
469
- if (clear && rest.includes("--notes-file"))
470
- fail("give --clear-notes or --notes-file, not both");
471
- if (rest.includes("--json"))
472
- fail("--json does not apply to a notes write");
473
- if (id === DIRECTOR_ROLE)
474
- fail("the director has no notes");
475
- requireKnownRole(root, id);
476
- const text = clear ? "" : readPromptFileArg(notesFile || fail("--notes-file needs a path"));
477
- try {
478
- say(`${id}: ${saveRoleNote(roleNotesPath(root, id), text)}`);
250
+ /** The `tumwater queue --top <n>` branch (PLANS.md): move the Nth queued merge `tumwater
251
+ * queue` lists to the front of the execution order by renaming its queue file to a stamp
252
+ * below the queue's minimum, so it merges next when ready. `cmdQueueCancel`'s twin: `--top
253
+ * <n>` the one valued flag, `--json` the one boolean (at most once), `--help`/-h tolerated,
254
+ * anything else refused. --role and -f/--follow are refused by name — a live view repaints
255
+ * a read, and position numbers come from the whole queue, whose numbering is
256
+ * `tumwater queue`'s. An entry a live merge marker names for this sha is refused — it is past
257
+ * the queue, so there is nothing to top (unlike `--cancel`, reverting it would undo the change
258
+ * the operator wanted first; the guidance says only that it is already heading in); an
259
+ * out-of-range position fails naming the current depth; position 1 prints the already-first
260
+ * line and exits 0; a concurrently-drained entry prints the gone line and exits 0. */
261
+ async function cmdQueueTop(root, args) {
262
+ let position = null;
263
+ let json = false;
264
+ const rest = [];
265
+ for (let i = 0; i < args.length; i++) {
266
+ const arg = args[i];
267
+ if (arg === "--json") {
268
+ rejectRepeatFlag(json, "--json");
269
+ json = true;
270
+ continue;
479
271
  }
480
- catch (err) {
481
- fail(errorMessage(err));
272
+ if (arg === "--top") {
273
+ rejectRepeatFlag(position !== null, "--top");
274
+ position = parsePositionFlag("--top", args[i + 1], "--top 2");
275
+ i++;
276
+ continue;
482
277
  }
483
- return;
278
+ if (arg === "--help" || arg === "-h")
279
+ continue;
280
+ rest.push(arg);
484
281
  }
485
- // The instructions-override write: the file-shaped twin of the notes flags, but the value
486
- // lands in roles.<base>.instructions (tick-prompt reads config.roles[base]) through the
487
- // same setConfigKey/unsetConfigKey core `config set`/`config unset` use, so validateConfig
488
- // enforces the 4096-char cap and the write is atomic. The director is allowed here (its
489
- // override reaches its own runs), unlike the notes branch's director refusal.
490
- if (instructionsWrite) {
491
- if (clearInst && rest.includes("--instructions-file"))
492
- fail("give --clear-instructions or --instructions-file, not both");
493
- if (rest.includes("--json"))
494
- fail("--json does not apply to an instructions write");
495
- requireKnownRole(root, id);
496
- const base = baseRoleOf(id);
497
- if (clearInst) {
498
- const result = unsetConfigKey(root, `roles.${base}.instructions`);
499
- if (!result.ok)
500
- fail(result.error);
501
- say(`${base}: instructions cleared`);
282
+ // The vocabulary a top invocation accepts is exactly --top and --json, both consumed
283
+ // above; --role and -f/--follow are refused with the queue-specific wording the tests pin.
284
+ if (rest.includes("--role"))
285
+ fail("queue --top moves by whole-queue position from `tumwater queue`; --role scopes a read only — drop it");
286
+ if (rest.includes("--follow") || rest.includes("-f"))
287
+ fail("queue --top moves one entry; -f/--follow repaints a live view — drop it");
288
+ rejectUnknownArgs("queue", rest, []);
289
+ if (position === null)
290
+ fail("--top needs a position (usage: tumwater queue --top <n>)");
291
+ await requireReadyRepo(root);
292
+ // Refuse the entry a live merge marker names before touching it: it is mid-flight (being
293
+ // reviewed or merged), so topping it would race the slot that owns it.
294
+ const view = readMergeQueue(root);
295
+ const inFlight = queueInFlight(root, view.entries, orchestratorAlive(root));
296
+ const target = view.entries[position - 1];
297
+ if (inFlight && target && (target.sha === inFlight.sha || mergeChanges(inFlight).some((c) => c.sha === target.sha))) {
298
+ fail(`queue entry ${position} is being merged right now — it's already heading into the merge, so there's nothing to top`);
299
+ }
300
+ const outcome = moveQueuedMergeToTop(root, position);
301
+ if (outcome.status === "out-of-range") {
302
+ fail(`no queued merge at position ${position} (merge queue holds ${outcome.depth})`);
303
+ }
304
+ if (json) {
305
+ // Every non-failure outcome, `gone` included, exits 0 with a parseable document; the
306
+ // drained entry carries no role/sha/summary, so its JSON names only the position, and
307
+ // an already-first no-op names its own key rather than masquerading as a top.
308
+ if (outcome.status === "gone") {
309
+ sayJson({ gone: { position } });
310
+ }
311
+ else if (outcome.status === "already-first") {
312
+ sayJson({ "already-first": { role: outcome.role, commit: outcome.sha, summary: outcome.summary } });
502
313
  }
503
314
  else {
504
- const text = readPromptFileArg(instFile || fail("--instructions-file needs a path"));
505
- const result = setConfigKey(root, `roles.${base}.instructions`, JSON.stringify(text));
506
- if (!result.ok)
507
- fail(result.error);
508
- say(`${base}: instructions saved (${text.length} chars)`);
315
+ sayJson({ topped: { role: outcome.role, commit: outcome.sha, summary: outcome.summary } });
509
316
  }
510
317
  return;
511
318
  }
512
- // The quiet-hours write: the third write kind, the terminal twin of `config set
513
- // quietHoursPerRole.<base> <window>`. The window lands in quietHoursPerRole.<base> through
514
- // the same setConfigKey/unsetConfigKey core config set/unset use, so checkQuietHours (with
515
- // the typed key as its label) and validateConfig own validation and the write is atomic.
516
- // The director is exempt — its quietHoursPerRole.director entry is inert (the gate and
517
- // doctor both exempt it), like the notes branch's director refusal.
518
- if (quietWrite) {
519
- if (clearQuiet && rest.includes("--quiet-hours"))
520
- fail("give --clear-quiet-hours or --quiet-hours, not both");
521
- if (rest.includes("--json"))
522
- fail("--json does not apply to a quiet-hours write");
523
- requireKnownRole(root, id);
524
- if (id === DIRECTOR_ROLE)
525
- fail("the director has no quiet hours");
526
- const base = baseRoleOf(id);
527
- const window = flagValue(rest, "--quiet-hours") ?? "";
528
- writeRoleConfigKey(root, base, clearQuiet, window, `quietHoursPerRole.${base}`, "quiet hours", (v) => JSON.stringify(v), (v) => v);
319
+ if (outcome.status === "gone") {
320
+ say(`queue entry ${position} was already taken by the merge before it could be moved to the front`);
529
321
  return;
530
322
  }
531
- // The daily-cap write: the terminal twin of `config set maxDailyCostUsdPerRole.<base>
532
- // <usd>`, the two remaining per-role holds' write the dashboard and the TUI already own.
533
- // The value lands in maxDailyCostUsdPerRole.<base> through the same
534
- // setConfigKey/unsetConfigKey core `config set`/`config unset` use, so checkDailyBudgetUsd
535
- // (with the typed key as its label) and validateConfig own validation and the write is
536
- // atomic. The director is exempt — it has no cap, like the notes and quiet-hours branches'
537
- // director refusals.
538
- if (capWrite) {
539
- if (clearCap && rest.includes("--cap"))
540
- fail("give --clear-cap or --cap, not both");
541
- if (rest.includes("--json"))
542
- fail("--json does not apply to a cap write");
543
- requireKnownRole(root, id);
544
- if (id === DIRECTOR_ROLE)
545
- fail("the director has no daily cap");
546
- const base = baseRoleOf(id);
547
- const cap = flagValue(rest, "--cap") ?? "";
548
- writeRoleConfigKey(root, base, clearCap, cap, `maxDailyCostUsdPerRole.${base}`, "daily cap", (v) => v, (v) => v);
549
- return;
550
- }
551
- // The min-run-interval write: the terminal twin of `config set
552
- // roles.<base>.minRunIntervalSeconds <seconds>`, the fourth per-role hold's write. The
553
- // value lands in roles.<base>.minRunIntervalSeconds through the same
554
- // setConfigKey/unsetConfigKey core `config set`/`config unset` use, so validateConfig's
555
- // roles.<id>.minRunIntervalSeconds DURATION_NON_NEGATIVE check owns validation and the
556
- // write is atomic. Unlike the notes/quiet-hours/cap branches there is no director refusal:
557
- // the director ticks on the same clock.
558
- if (intervalWrite) {
559
- if (clearInterval && rest.includes("--min-run-interval"))
560
- fail("give --clear-min-run-interval or --min-run-interval, not both");
561
- if (rest.includes("--json"))
562
- fail("--json does not apply to a min-run-interval write");
563
- requireKnownRole(root, id);
564
- const base = baseRoleOf(id);
565
- const secs = flagValue(rest, "--min-run-interval") ?? "";
566
- writeRoleConfigKey(root, base, clearInterval, secs, `roles.${base}.minRunIntervalSeconds`, "min run interval", (v) => v, (v) => `${v}s`);
567
- return;
568
- }
569
- // The instances write: the terminal twin of `config set roles.<base>.instances <n>`, the
570
- // last per-role hold's write. The count lands in roles.<base>.instances through the same
571
- // setConfigKey/unsetConfigKey core `config set`/`config unset` use, so validateConfig's
572
- // roles.<id>.instances INSTANCE_COUNT check owns the bound and the write is atomic. As
573
- // with min run interval there is no director (or non-instance-role) refusal of its own:
574
- // validateConfig's `!INSTANCE_ROLES.has(id)` branch answers for the director and every
575
- // custom role, and an unset/empty value clears the key.
576
- if (instancesWrite) {
577
- if (clearInstances && rest.includes("--instances"))
578
- fail("give --clear-instances or --instances, not both");
579
- if (rest.includes("--json"))
580
- fail("--json does not apply to an instances write");
581
- const cfg = loadConfigSafe(root).config ?? defaultConfig();
582
- if (!knownRoleIds(cfg).includes(id))
583
- throw new Error(unknownRoleMessage(id, knownRoleIds(cfg)));
584
- const base = baseRoleOf(id);
585
- const n = flagValue(rest, "--instances") ?? "";
586
- writeRoleConfigKey(root, base, clearInstances, n, `roles.${base}.instances`, "instances", (v) => v, (v) => v);
587
- return;
588
- }
589
- // The model-triple writes: the terminal twins of `config set roles.<base>.provider|model|thinking
590
- // <value>` and `config unset` — the three fields configForRole resolves through seamOverrides.
591
- // `tumwater role <id>` renders them as its Model: line. Each lands in roles.<base>.<field>.
592
- // Through setConfigKey/unsetConfigKey, validateConfig's checkModelTripleField ("tier-name"
593
- // shape) owns validation (model accepts a tier name; thinking must be a THINKING_LEVELS level)
594
- // and each write is atomic. No director refusal: accepted exactly as --min-run-interval is.
595
- if (providerWrite) {
596
- if (clearProvider && rest.includes("--provider"))
597
- fail("give --clear-provider or --provider, not both");
598
- if (rest.includes("--json"))
599
- fail("--json does not apply to a provider write");
600
- const cfg = loadConfigSafe(root).config ?? defaultConfig();
601
- if (!knownRoleIds(cfg).includes(id))
602
- throw new Error(unknownRoleMessage(id, knownRoleIds(cfg)));
603
- const base = baseRoleOf(id);
604
- const provider = flagValue(rest, "--provider") ?? "";
605
- writeRoleConfigKey(root, base, clearProvider, provider, `roles.${base}.provider`, "provider", (v) => JSON.stringify(v), (v) => v);
606
- return;
607
- }
608
- if (modelWrite) {
609
- if (clearModel && rest.includes("--model"))
610
- fail("give --clear-model or --model, not both");
611
- if (rest.includes("--json"))
612
- fail("--json does not apply to a model write");
613
- const cfg = loadConfigSafe(root).config ?? defaultConfig();
614
- if (!knownRoleIds(cfg).includes(id))
615
- throw new Error(unknownRoleMessage(id, knownRoleIds(cfg)));
616
- const base = baseRoleOf(id);
617
- const model = flagValue(rest, "--model") ?? "";
618
- writeRoleConfigKey(root, base, clearModel, model, `roles.${base}.model`, "model", (v) => JSON.stringify(v), (v) => v);
619
- return;
323
+ if (outcome.status === "already-first") {
324
+ say(`queue entry ${position} is already first: ${roleLabel(outcome.role)} ${shortSha(outcome.sha)} — ${outcome.summary}`);
620
325
  }
621
- if (thinkingWrite) {
622
- if (clearThinking && rest.includes("--thinking"))
623
- fail("give --clear-thinking or --thinking, not both");
624
- if (rest.includes("--json"))
625
- fail("--json does not apply to a thinking write");
626
- const cfg = loadConfigSafe(root).config ?? defaultConfig();
627
- if (!knownRoleIds(cfg).includes(id))
628
- throw new Error(unknownRoleMessage(id, knownRoleIds(cfg)));
629
- const base = baseRoleOf(id);
630
- const level = flagValue(rest, "--thinking") ?? "";
631
- writeRoleConfigKey(root, base, clearThinking, level, `roles.${base}.thinking`, "thinking", (v) => JSON.stringify(v), (v) => v);
632
- return;
326
+ else {
327
+ say(`moved queue entry ${position} to the front: ${roleLabel(outcome.role)} ${shortSha(outcome.sha)} — ${outcome.summary}`);
633
328
  }
634
- // The enable/disable write: the scoped write for the enabled state `tumwater role <id>`
635
- // already renders, the terminal twin of `config set roles.<base>.enabled true|false`. The
636
- // value lands in roles.<base>.enabled through the same setConfigKey core config set uses, so
637
- // validateConfig's checkBoolean (the roles.<id>. prefix) owns the type and the write stays
638
- // atomic and applies live. The director cannot be disabled: with it disabled loopIds omits
639
- // it and no loop dequeues the operator's inbox, so that refusal is explicit (enabling stays
640
- // allowed). knownRoleIds holds every catalog and custom role whether or not it is enabled, so
641
- // a currently-disabled id resolves; an instance id writes its base role's key.
642
- if (enable || disable) {
643
- if (rest.includes("--json"))
644
- fail("--json does not apply to an enable/disable write");
645
- requireKnownRole(root, id);
646
- if (disable && id === DIRECTOR_ROLE)
647
- fail("the director cannot be disabled");
648
- const base = baseRoleOf(id);
649
- const result = setConfigKey(root, `roles.${base}.enabled`, enable ? "true" : "false");
650
- if (!result.ok)
651
- fail(result.error);
652
- say(`${base}: ${enable ? "enabled" : "disabled"}`);
329
+ }
330
+ /** `tumwater backlog`: the planned/open entries as Markdown or as the GUI's JSON payload. */
331
+ export async function cmdBacklog(root, args, seams = {}) {
332
+ // No requireReadyRepo gate: the entry readers degrade to [] on a missing file, so the
333
+ // command prints three empty sections in any directory (report's rationale, not config's);
334
+ // the team-workspace refusal still applies.
335
+ // --grep filters the three entry arrays to the matches, the finished-entry lists' one
336
+ // matcher (entryMatches) on the active backlog; it composes with --ready (grep first, so
337
+ // the hidden count and the kept entries count only the matches) and with --json. The
338
+ // missing-value wording lives in BACKLOG_GREP_VALUE_ERROR, the flag gate's and this
339
+ // parser's shared string. -f/--follow repaints the same view every couple of seconds
340
+ // until Ctrl+C or the parent process exits, composing with --ready and --grep; it refuses
341
+ // --json (a live view is one repainted document, not one snapshot).
342
+ const BACKLOG_GREP_VALUE_ERROR = "backlog --grep needs text (usage: tumwater backlog --grep <text>)";
343
+ rejectUnknownArgs("backlog", args, [JSON_FLAG, READY_FLAG, grepFlagSpec(BACKLOG_GREP_VALUE_ERROR), FOLLOW_FLAG]);
344
+ refuseTeamWorkspace(root);
345
+ const { rest, pattern } = parseGrepFlag(args, BACKLOG_GREP_VALUE_ERROR);
346
+ const ready = rest.includes("--ready");
347
+ const follow = hasFollowFlag(rest);
348
+ if (follow && rest.includes("--json"))
349
+ fail("backlog --json prints one document; -f/--follow repaints a live view — drop one of them");
350
+ // Machine-readable backlog — the three entry arrays the Markdown view renders and the
351
+ // GUI's /api/backlog serves (status --json's "print the endpoint's payload" pattern),
352
+ // plus `holds.plans`/`holds.bugs` parallel to the entry arrays so a skipped entry's
353
+ // reason is data, not inference: a pretty-printed JSON document in every exit-0 case,
354
+ // never prose. The payload is read once per branch (snapshot or frame) so the Markdown
355
+ // renderer and the JSON document consume the same arrays.
356
+ // --ready filters both branches through readyBacklog; the Markdown branch adds a count of
357
+ // the held entries it hid. The --json/--ready reads go over `rest` (the --grep value token
358
+ // removed), so a flag-shaped pattern like `--grep --json` greps for "--json" rather than
359
+ // flipping this branch into JSON mode.
360
+ const readBacklog = () => {
361
+ const view = { ...backlogPayload(root), holds: backlogHolds(root), inProgress: backlogInProgress(root) };
362
+ const filtered = pattern === null ? view : grepBacklog(view, pattern);
363
+ if (!ready)
364
+ return { view: filtered, hidden: 0 };
365
+ const kept = readyBacklog(filtered);
366
+ return { view: kept, hidden: filtered.plans.length + filtered.bugs.length - kept.plans.length - kept.bugs.length };
367
+ };
368
+ const renderBacklogText = (r) => renderBacklogMarkdown(r.view) + (r.hidden > 0
369
+ ? `\n\n_(${r.hidden} held entries hidden; run \`tumwater backlog\` to see them)_` : "");
370
+ if (follow) {
371
+ await followView(() => renderBacklogText(readBacklog()), seams);
653
372
  return;
654
373
  }
655
- // The unknown-role answer (exit 1, unknownRoleMessage's wording) lives in the
656
- // collector; here the id just has to be non-null for the thunk's types.
657
- sayJsonOrRender(rest, () => rolePayload(root, id), renderRoleMarkdown);
374
+ const result = readBacklog();
375
+ sayJsonOrRender(rest, result.view, () => renderBacklogText(result));
658
376
  }