tumwater 0.4.2 → 0.4.4

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 (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -54,6 +54,43 @@ function isCheckScript(s, key) {
54
54
  const v = s[key];
55
55
  return isNonBlankString(v);
56
56
  }
57
+ /** Resolve the configured check timeout to cap the run: `check.timeoutSeconds` × 1000 when it is a
58
+ * positive number, else undefined. check.timeoutSeconds bounds the npm walk-up's check too: it is
59
+ * how a repo whose suite outgrows 300 s under load says so (the team's own gate runs were timing
60
+ * out there). Same result as today for every config shape (absent config, absent `check`, a
61
+ * non-number, zero and a positive number). */
62
+ function configuredCheckTimeoutMs(config) {
63
+ const seconds = config?.check?.timeoutSeconds;
64
+ return typeof seconds === "number" && seconds > 0 ? seconds * 1000 : undefined;
65
+ }
66
+ /** A configured `check.command` (plans/portability.md §6/7) wins over the walk-up: the right way to
67
+ * verify a repo is a property of the repo, and the walk cannot know it — a Python, Rust, or Go repo
68
+ * has no npm install for the walk to find, which silently turned every safety gate off there. Null
69
+ * when the command is blank (validation rejects one, but a degraded default config could still
70
+ * carry it), so a blank command falls through to the walk-up instead of disabling the check. */
71
+ function configuredCommandCheck(startDir, check, timeoutMs) {
72
+ if (!isNonBlankString(check.command))
73
+ return null;
74
+ return {
75
+ kind: "command",
76
+ command: check.command,
77
+ cwd: path.resolve(startDir, isNonBlankString(check.cwd) ? check.cwd : "."),
78
+ timeoutMs: timeoutMs ?? BUILD_CHECK_TIMEOUT_MS,
79
+ };
80
+ }
81
+ /** The npm walk-up: climb to the nearest ancestor holding both package.json and node_modules (the
82
+ * FIRST qualifying directory is the project — if its package.json has neither script, there is no
83
+ * check, and an unrelated ancestor further up must never be used), prefer `test` then `typecheck`
84
+ * then `build`, and stamp the configured timeout onto the npm result. The walk is required:
85
+ * tumwater worktrees live under `<repo>/.tumwater/worktrees/<role>` with no install of their own
86
+ * (node_modules is gitignored — it exists only where someone ran npm install), so a literal
87
+ * startDir check would silently disable the pre-check forever in dogfood. Returns null when no
88
+ * ancestor within `maxLevels` qualifies or the file is missing/unreadable/malformed. */
89
+ function detectNpmCheck(startDir, maxLevels, timeoutMs) {
90
+ const root = walkUp(startDir, maxLevels, (dir) => (hasInstall(dir) ? dir : null));
91
+ const found = root === null ? null : buildCheckFrom(root);
92
+ return found !== null && found.kind === "npm" && timeoutMs !== undefined ? { ...found, timeoutMs } : found;
93
+ }
57
94
  /** Find the project's deterministic check from `startDir` — at most `maxLevels` ancestors
58
95
  * (default 5) — to the nearest directory containing BOTH a package.json and a node_modules/
59
96
  * directory, then preferring scripts.test over scripts.typecheck and scripts.build (npm convention:
@@ -71,21 +108,13 @@ function isCheckScript(s, key) {
71
108
  * be used). Returns null when no ancestor qualifies or the file is missing/unreadable/malformed —
72
109
  * detection never throws into the gate. */
73
110
  export function detectBuildCheck(startDir, config, maxLevels = WALK_UP_LEVELS) {
74
- const c = config?.check;
75
- // check.timeoutSeconds bounds the npm walk-up's check too: it is how a repo whose suite
76
- // outgrows 300 s under load says so (the team's own gate runs were timing out there).
77
- const configuredMs = typeof c?.timeoutSeconds === "number" && c.timeoutSeconds > 0 ? c.timeoutSeconds * 1000 : undefined;
78
- if (c && isNonBlankString(c.command)) {
79
- return {
80
- kind: "command",
81
- command: c.command,
82
- cwd: path.resolve(startDir, isNonBlankString(c.cwd) ? c.cwd : "."),
83
- timeoutMs: configuredMs ?? BUILD_CHECK_TIMEOUT_MS,
84
- };
111
+ const timeoutMs = configuredCheckTimeoutMs(config);
112
+ if (config?.check) {
113
+ const command = configuredCommandCheck(startDir, config.check, timeoutMs);
114
+ if (command !== null)
115
+ return command;
85
116
  }
86
- const root = walkUp(startDir, maxLevels, (dir) => (hasInstall(dir) ? dir : null));
87
- const found = root === null ? null : buildCheckFrom(root);
88
- return found !== null && found.kind === "npm" && configuredMs !== undefined ? { ...found, timeoutMs: configuredMs } : found;
117
+ return detectNpmCheck(startDir, maxLevels, timeoutMs);
89
118
  }
90
119
  /** The configured per-change gate command (`check.gateCommand`, PLANS.md Land-queue speed 3e) when
91
120
  * it is a non-blank string, else undefined — off. Opt-in: when no `mergeCommand` is declared, a
@@ -13,8 +13,8 @@ import { logEventBestEffort } from "../events/events.js";
13
13
  * full-suite check. The one vocabulary the three predicates that recognize a full-suite check
14
14
  * select on — isPassingFullCheck, isRedFullCheck, and the status polls' newest-merge-check
15
15
  * scan (status-polls.ts) — so they cannot drift on which scopes count. The deadline-killed
16
- * failure costing in time-spend.ts is a narrower pair (merge/batch only, with a status and a
17
- * timeout required), so it keeps its own check. `full` is the event's `full` field: false
16
+ * failure costing in failure-time-spend.ts is a narrower pair (merge/batch only, with a status
17
+ * and a timeout required), so it keeps its own check. `full` is the event's `full` field: false
18
18
  * (a check.mergeCommand run) is never a full-suite check. */
19
19
  export function isFullCheckScope(scope, full) {
20
20
  // A merge or batch run of check.mergeCommand carries `full: false` (buildCheckEvent): it ran
@@ -125,6 +125,16 @@ export function failureHeadline(tail) {
125
125
  export function describeCheck(check) {
126
126
  return check.kind === "npm" ? `\`npm run ${check.script}\`` : `\`${check.command}\``;
127
127
  }
128
+ /** The single-line headline of a red check — the failureHeadline when the tail has one to
129
+ * offer (a test name or marker plus a non-framing message), else the check's describeCheck
130
+ * command name — the name the gate, merge, and review warnings print for a failed check.
131
+ * One definition for every red that names itself to a human: review-precheck's two
132
+ * flake/failure warnings, merge-to-main's merge-retry quarantine warning, and
133
+ * merge-check-failures's kept-pin headline record and attributeRedCheck absent-tail headline.
134
+ */
135
+ export function redCheckHeadline(check, tail) {
136
+ return failureHeadline(tail) ?? describeCheck(check);
137
+ }
128
138
  /** The machine-generated reasons a red check hands its author: the headline joined to the rest of
129
139
  * the clipped tail, so the compiler error sits right after it in the injected next-run note. The
130
140
  * headline is failureHeadline's — the first line that is not a stack frame — not outputTail[0]: a
@@ -32,9 +32,7 @@ function resolveScopedCheck(wt, scope, config, timeoutMs, sampleLoad) {
32
32
  /** Run the resolved check under one permit, with the one clean retry for a verdict-less death —
33
33
  * the one-permit-two-attempt rule. Prices the first attempt as its own build_check event and
34
34
  * returns the final outcome and its run duration. */
35
- async function runScopedCheckWithRetry(root, role, scope, wt, runCheck, timeoutMs, config, sampleSleep, install, permitWait, cheaper) {
36
- // One permit covers both attempts (the killed-check retry re-runs under the permit it
37
- // holds); durationMs prices the run itself, not the wait for a permit.
35
+ async function runScopedCheckWithRetry(root, role, scope, wt, runCheck, timeoutMs, config, sampleSleep, install, permitWait, cheaper, signal) {
38
36
  let durationMs = 0;
39
37
  const raw = await withCheckPermit(config, MERGE_SCOPES.has(scope) ? CHECK_TIER.merge : CHECK_TIER.other, async () => {
40
38
  // One permit covers both attempts (the killed-check retry re-runs under the permit it
@@ -45,7 +43,7 @@ async function runScopedCheckWithRetry(root, role, scope, wt, runCheck, timeoutM
45
43
  // outcome stands (runWithCleanRetry). The verdict-less first attempt is priced as its own
46
44
  // event (the feed must answer how long a check took), then the final event below records
47
45
  // the retry's classified outcome.
48
- const { outcome, durationMs: d } = await runWithCleanRetry(() => runBuildCheck(wt, runCheck, timeoutMs, undefined, sampleSleep, install), (first, dur) => logEventBestEffort(root, buildCheckEvent(role, scope, first, dur, cheaper)));
46
+ const { outcome, durationMs: d } = await runWithCleanRetry(() => runBuildCheck(wt, runCheck, timeoutMs, undefined, sampleSleep, install, signal), (first, dur) => logEventBestEffort(root, buildCheckEvent(role, scope, first, dur, cheaper)));
49
47
  durationMs = d;
50
48
  return outcome;
51
49
  }, {
@@ -59,6 +57,23 @@ async function runScopedCheckWithRetry(root, role, scope, wt, runCheck, timeoutM
59
57
  * unverified reject; anything else passes through. Never logs — the caller warns (see
60
58
  * warnScopedCheck). Returns the unverified reason only when a rule fired. */
61
59
  function unverifiedOutcomeFor(scope, check, raw, effectiveMs, load, durationMs) {
60
+ const sleepFailed = sleptThroughRun(raw);
61
+ if (!unverifiedSkipFor(scope, raw, sleepFailed))
62
+ return { outcome: raw, unverifiedReason: null };
63
+ const unverifiedReason = unverifiedReasonFor(scope, raw, effectiveMs, load, durationMs, sleepFailed);
64
+ return { outcome: unverifiedFailOutcome(check, raw, unverifiedReason), unverifiedReason };
65
+ }
66
+ /** True when the failed run slept past the tolerance — the slept-through rule. */
67
+ function sleptThroughRun(raw) {
68
+ // A retry that ALSO slept made no clean verdict either: it is recorded as unverified — not
69
+ // attributed to the change, not cached as a red baseline — rather than read as a second
70
+ // deterministic failure (BUGS.md 2026-09-30). A retry that passed stands: the tree went green
71
+ // under the project's own check, even if the host napped through parts of it.
72
+ return raw.status === "failed" && (raw.run?.sleptMs ?? 0) > SLEEP_SPAN_TOLERANCE_MS;
73
+ }
74
+ /** Whether a merge scope classifies this raw outcome as a deterministic unverified reject — the
75
+ * merge-scope skip rule. */
76
+ function unverifiedSkipFor(scope, raw, sleepFailed) {
62
77
  // A signal kill at a merge scope, or a timeout whose deadline fired on time (real slowness, a
63
78
  // genuinely hung or oversized suite), made no verdict about the tree — and this is the check
64
79
  // whose whole job is to catch a semantic conflict before it merges, so it rejects
@@ -67,20 +82,19 @@ function unverifiedOutcomeFor(scope, check, raw, effectiveMs, load, durationMs)
67
82
  // slept, so a second verdict-less attempt is recorded as unverified rather than read again as
68
83
  // weather (BUGS.md 2026-09-28). no-npm and a broken toolchain still say nothing about the tree,
69
84
  // and the gate scope still proceeds to the model reviewer, which the merge path's own check backs
70
- // up. A retry that ALSO slept made no clean verdict either: it is recorded as unverified — not
71
- // attributed to the change, not cached as a red baseline — rather than read as a second
72
- // deterministic failure (BUGS.md 2026-09-30). A retry that passed stands: the tree went green
73
- // under the project's own check, even if the host napped through parts of it.
74
- const sleepFailed = raw.status === "failed" && (raw.run?.sleptMs ?? 0) > SLEEP_SPAN_TOLERANCE_MS;
85
+ // up.
75
86
  // A failed dependency install is the same at a merge scope: nothing ran against the tree as its
76
87
  // lockfile pins it, so it must not merge (BUGS.md 2026-10-01).
77
- const unverifiedSkip = MERGE_SCOPES.has(scope) &&
78
- (((raw.status === "skipped" &&
88
+ return (MERGE_SCOPES.has(scope) &&
89
+ ((raw.status === "skipped" &&
79
90
  (raw.skipReason === "timeout" || raw.skipReason === "killed" || raw.skipReason === "install")) ||
80
91
  sleepFailed));
81
- if (!unverifiedSkip)
82
- return { outcome: raw, unverifiedReason: null };
83
- const unverifiedReason = raw.skipReason === "install"
92
+ }
93
+ /** The unverified reason phrase for the rule's four arms — the reason chain, with the optional
94
+ * starved-host tail. The reason names the starved host only when the load says the team was the
95
+ * check's competition (a late-deadline wording already names its own cause). */
96
+ function unverifiedReasonFor(scope, raw, effectiveMs, load, durationMs, sleepFailed) {
97
+ return raw.skipReason === "install"
84
98
  ? installFailedPhrase(SCOPE_WORDS[scope].label, raw.install, UNVERIFIED_TREE)
85
99
  : raw.skipReason === "killed"
86
100
  ? killedPhrase(SCOPE_WORDS[scope].label, raw.killedBy ? { signal: raw.killedBy, durationMs } : undefined, UNVERIFIED_TREE)
@@ -90,7 +104,11 @@ function unverifiedOutcomeFor(scope, check, raw, effectiveMs, load, durationMs)
90
104
  // An on-time kill (the late-deadline wording already names its own cause) names the
91
105
  // starved host when the load average says the team was the check's competition.
92
106
  (raw.run?.deadlineLateMs ?? 0) > DEADLINE_LATE_TOLERANCE_MS ? "" : loadedHostPhrase(load)}`;
93
- const outcome = {
107
+ }
108
+ /** Assemble the deterministic `failed` outcome that carries the unverified reason — the
109
+ * failed-outcome assembly. */
110
+ function unverifiedFailOutcome(check, raw, unverifiedReason) {
111
+ return {
94
112
  status: "failed",
95
113
  script: checkScriptName(check),
96
114
  outputTail: [unverifiedReason],
@@ -98,12 +116,16 @@ function unverifiedOutcomeFor(scope, check, raw, effectiveMs, load, durationMs)
98
116
  unverified: true,
99
117
  ...(raw.install ? { install: raw.install } : {}),
100
118
  };
101
- return { outcome, unverifiedReason };
102
119
  }
103
120
  /** Warn for the two verdict-less roads: a skipped check (buildCheckSkipWarning) or an unverified
104
121
  * merge reject. Reads the scope word table and the killed signal. */
105
122
  function warnScopedCheck(root, role, scope, outcome, effectiveMs, durationMs, unverifiedReason) {
106
123
  if (outcome.status === "skipped") {
124
+ // A check the harness stopped on an abort signal is neither weather nor a skip to warn
125
+ // about: the caller (the gate pre-check) fails closed as aborted on its way out, so no
126
+ // "proceeding to model review" warning may suggest the check still stands.
127
+ if (outcome.skipReason === "aborted")
128
+ return;
107
129
  // Environmental — deliberately NOT fail-closed, so a hung build script cannot wedge every
108
130
  // code run into the 3-strike discard (gate) or a merge behind the merge lock.
109
131
  const w = SCOPE_WORDS[scope];
@@ -142,11 +164,11 @@ function warnScopedCheck(root, role, scope, outcome, effectiveMs, durationMs, un
142
164
  * merge and batch scopes, and those events then carry `full: false`. The red-main baseline, which
143
165
  * detects on its own, keeps running check.command. `permitWait` hears
144
166
  * about a wait for the check permit (the merge cell's "waiting for a check slot"). Never throws. */
145
- export async function runScopedBuildCheck(root, role, scope, wt, config, timeoutMs = BUILD_CHECK_TIMEOUT_MS, sampleSleep = sampleSleepClock, install = npmInstall, permitWait, sampleLoad = sampleHostLoad) {
167
+ export async function runScopedBuildCheck(root, role, scope, wt, config, timeoutMs = BUILD_CHECK_TIMEOUT_MS, sampleSleep = sampleSleepClock, install = npmInstall, permitWait, sampleLoad = sampleHostLoad, signal) {
146
168
  const r = resolveScopedCheck(wt, scope, config, timeoutMs, sampleLoad);
147
169
  if (!r)
148
170
  return null;
149
- const { raw, durationMs } = await runScopedCheckWithRetry(root, role, scope, wt, r.runCheck, timeoutMs, config, sampleSleep, install, permitWait, r.cheaper);
171
+ const { raw, durationMs } = await runScopedCheckWithRetry(root, role, scope, wt, r.runCheck, timeoutMs, config, sampleSleep, install, permitWait, r.cheaper, signal);
150
172
  const { outcome, unverifiedReason } = unverifiedOutcomeFor(scope, r.check, raw, r.effectiveMs, r.load, durationMs);
151
173
  logEventBestEffort(root, buildCheckEvent(role, scope, outcome, durationMs, r.cheaper));
152
174
  warnScopedCheck(root, role, scope, outcome, r.effectiveMs, durationMs, unverifiedReason);
@@ -72,7 +72,7 @@ export function checkScriptName(check) {
72
72
  * branch state — which is what this check exists for; build-check.test.ts pins the
73
73
  * no-node_modules-worktree resolution. A configured command's cwd is resolved by
74
74
  * detectBuildCheck against the worktree the detection started from. */
75
- export async function runBuildCheck(wt, check, timeoutMs = BUILD_CHECK_TIMEOUT_MS, killGraceMs = KILL_GRACE_MS, sampleSleep = sampleSleepClock, install = npmInstall) {
75
+ export async function runBuildCheck(wt, check, timeoutMs = BUILD_CHECK_TIMEOUT_MS, killGraceMs = KILL_GRACE_MS, sampleSleep = sampleSleepClock, install = npmInstall, signal) {
76
76
  const script = checkScriptName(check);
77
77
  const effectiveMs = checkTimeoutMs(check, timeoutMs);
78
78
  // Environmental probe first: a toolchain broken below the project (git exiting 69 on an
@@ -105,13 +105,13 @@ export async function runBuildCheck(wt, check, timeoutMs = BUILD_CHECK_TIMEOUT_M
105
105
  if (r)
106
106
  installed = { packages: r.packages, durationMs: r.durationMs };
107
107
  }
108
- const outcome = await runDeclaredCheck(wt, check, script, effectiveMs, killGraceMs, sampleSleep);
108
+ const outcome = await runDeclaredCheck(wt, check, script, effectiveMs, killGraceMs, sampleSleep, signal);
109
109
  return installed ? { ...outcome, install: installed } : outcome;
110
110
  }
111
111
  /** runBuildCheck's run-and-classify step, once the probe and any owed install are done: spawn
112
- * the declared check as a process group under its deadline, bracketed by sleep samples, and
113
- * classify what it did. */
114
- async function runDeclaredCheck(wt, check, script, effectiveMs, killGraceMs, sampleSleep) {
112
+ * the declared check as a process group under its deadline — or, when `signal` fires, stopped
113
+ * by the harness as its own abort — bracketed by sleep samples, and classify what it did. */
114
+ async function runDeclaredCheck(wt, check, script, effectiveMs, killGraceMs, sampleSleep, signal) {
115
115
  // Sleep evidence brackets the spawn: the opening sample is taken while the host is provably
116
116
  // awake (this code is running), so any sleep the closing sample can attribute began inside
117
117
  // the run's window. A platform with no readable clock leaves run.sleptMs unset — no evidence
@@ -123,12 +123,14 @@ async function runDeclaredCheck(wt, check, script, effectiveMs, killGraceMs, sam
123
123
  timeoutMs: effectiveMs,
124
124
  killGraceMs,
125
125
  maxBuffer: EXEC_MAX_BUFFER,
126
+ signal,
126
127
  })
127
128
  : await runScriptGroup("npm", ["run", check.script], {
128
129
  cwd: wt,
129
130
  timeoutMs: effectiveMs,
130
131
  killGraceMs,
131
132
  maxBuffer: EXEC_MAX_BUFFER,
133
+ signal,
132
134
  });
133
135
  // Spawn failed before anything ran — the runner is missing from PATH.
134
136
  if (r.spawnError)
@@ -143,6 +145,12 @@ async function runDeclaredCheck(wt, check, script, effectiveMs, killGraceMs, sam
143
145
  // deadline actually fired, which the warning reports when it was late.
144
146
  if (r.timedOut)
145
147
  return { status: "skipped", script, skipReason: "timeout", run };
148
+ // The harness stopped the tree on an abort signal (a user abort or a shutdown) — its own
149
+ // stop, not weather and not a verdict about the tree: classify it separately from a timeout
150
+ // and from an external killed signal so runScopedBuildCheck neither retries it nor warns it
151
+ // as a skip.
152
+ if (r.aborted)
153
+ return { status: "skipped", script, skipReason: "aborted", run };
146
154
  // The tree died on a signal the harness did NOT send (runScriptGroup resolves its own
147
155
  // timeout with timedOut already set, so a signal reaching this line is external — another
148
156
  // run's `pkill`, an operator's cleanup): the outcome says nothing about the tree, but it is
@@ -213,7 +213,8 @@ export function swapDist(root, dist, mainHead) {
213
213
  * swap can close the window, because the survivor is the supervisor and it exits on the dead
214
214
  * generation; the next `tumwater start` then cannot even load `dist/src/cli.js` and fails with
215
215
  * MODULE_NOT_FOUND, so the repo is wedged until someone renames `dist.prev` back by hand. The
216
- * supervisor calls this before it exits (src/cli/cli-start.ts, via superviseStart's onDone): while
216
+ * supervisor calls this before it exits (src/cli/cli-start-supervisor.ts, via superviseStart's
217
+ * onDone): while
217
218
  * it is still up, it renames the preserved old build back into place so the next boot works.
218
219
  *
219
220
  * Only acts when `dist` is missing and a `dist.prev` exists — both conditions together are the
@@ -228,11 +229,12 @@ export function recoverInterruptedSwap(root, dist) {
228
229
  fs.renameSync(prev, dist); // Atomic: dist is either the restored old build or still missing.
229
230
  return true;
230
231
  }
231
- /** The supervisor's end-of-supervision repair (src/cli/cli-start.ts wires this into
232
+ /** The supervisor's end-of-supervision repair (src/cli/cli-start-supervisor.ts wires this into
232
233
  * superviseStart's onDone): recover dist/ after an interrupted swap and, either way, put the
233
234
  * outcome on the event feed. Kept beside recoverInterruptedSwap so the CLI wiring is one call.
234
- * cli-start.ts imports this module statically, so the code is in memory before a swap can move
235
- * dist/ away — recovering via an import at call time would fail ERR_MODULE_NOT_FOUND exactly
235
+ * cli-start-supervisor.ts imports this module statically, so the code is in memory before a
236
+ * swap can move dist/ away — recovering via an import at call time would fail
237
+ * ERR_MODULE_NOT_FOUND exactly
236
238
  * when it is needed, the window this closes. `dist` defaults to this process's own dist
237
239
  * (build-info.distDir, a pure path computation captured at load), overridable so a test can
238
240
  * point it at a temp root. A failed restore is reported, never thrown: the hook runs on the
@@ -9,7 +9,7 @@
9
9
  import { knownRoleIdsCached, loadConfigSafe } from "../config/config.js";
10
10
  import { aheadOfMain, branchExists, countCommitsInRange, currentBranch, targetBranch } from "../git/git.js";
11
11
  import { gitTry } from "../git/git-run.js";
12
- import { aheadOfMainDiff, aheadOfMainFiles, changedFiles } from "../git/git-diff.js";
12
+ import { aheadOfMainDiff, aheadOfMainFiles, changedFiles, DIFF_MAX_BYTES } from "../git/git-diff.js";
13
13
  import { compareCodeUnits } from "../collections.js";
14
14
  import { isAbsolute } from "node:path";
15
15
  import { branchName } from "../paths.js";
@@ -44,9 +44,6 @@ export async function collectRoleChanges(root, roles, opts = {}) {
44
44
  const views = await Promise.all(roles.map((role) => collectRoleChangeAt(root, role, scope, opts)));
45
45
  return { mainBranch: scope.mainBranch, roles: views };
46
46
  }
47
- /** The uncommitted patch's cap, matching aheadOfMainDiff's — one shared bound so neither
48
- * half of the view can print unbounded text. */
49
- const DIFF_MAX_BYTES = 200_000;
50
47
  /** Collect the change the role holds: both halves of the view, or a degraded state
51
48
  * when the worktree or the base branch is missing. Never throws on a degraded team — a
52
49
  * query about a role that has not run yet must answer, not fail (report's rationale). */
@@ -72,6 +72,15 @@ function failStrayArg(args, reason, ...claimed) {
72
72
  if (extra !== undefined)
73
73
  fail(`unexpected argument ${JSON.stringify(extra)} — ${reason}`);
74
74
  }
75
+ /** The two positions a valued flag claims for a free-form-prompt parser's stray-argument
76
+ * accounting: the flag token and its value — the flag and its value are never prompt content.
77
+ * Empty when the flag is absent, so a negative index never over-claims a real token. The one
78
+ * shape every valued-flag pair claims for the parsePromptFlags/parsePromptArgs stray-argument
79
+ * accounting — `--role`, `--grep`, `--at` in parsePromptFlags and `--file` in editPromptArgs
80
+ * ("claimed like --role's": each site is this same pair) — so no site can drift from the others. */
81
+ function pairClaim(flagIndex) {
82
+ return flagIndex >= 0 ? [flagIndex, flagIndex + 1] : [];
83
+ }
75
84
  /** Read a `--file <path>` prompt file: fail naming the file when it cannot be read. A raw
76
85
  * ENOENT/EISDIR names the path but not its role, so the message says this was the --file
77
86
  * prompt. Shared by the init and prompt parsers' path branches; the prompt parser's stdin
@@ -141,25 +150,22 @@ function rejectDuplicateFlags(args, flags) {
141
150
  for (const flag of flags)
142
151
  rejectRepeatFlag(args.filter((a) => a === flag).length > 1, flag);
143
152
  }
144
- /** `tumwater init` argument handling. Init's positionals are free-form prompt text, so
145
- * rejectUnknownArgs (which rejects ANY unconsumed token) can't be used wholesale. The rules
146
- * instead: a double-dash token must be a known flag from INIT_FLAG_SPECS (`--file`,
147
- * `--branch`, `--template`, `--adopt`, `--dry-run` or `--list-templates`), each given at most
148
- * once; with `--file` present nothing but the other flags may accompany it; a `--branch
149
- * <name>` pair and the valueless flags are never prompt content; single-dash positionals are
150
- * prompt content, not flags. Without these checks a misspelled --file would be baked into the
151
- * brief text — injected into every run of every role until someone edits the project
153
+ /** The flag-vocabulary gate for `tumwater init`: every double-dash token must be a known
154
+ * INIT_FLAG_SPECS flag, each given at most once. Without it a misspelled --file would be baked
155
+ * into the brief text — injected into every run of every role until someone edits the project
152
156
  * brief. */
153
- export function parseInitArgs(args) {
157
+ function assertInitFlags(args) {
154
158
  const known = INIT_FLAG_SPECS.flatMap((s) => s.names);
155
159
  // The unknown-double-dash gate reads the specs, so the list its error names admits exactly
156
160
  // what the duplicate-flag check below accepts — one vocabulary, one message renderer.
157
161
  rejectUnknownDoubleDash("init", args, INIT_FLAG_SPECS);
158
162
  rejectDuplicateFlags(args, known);
159
- const branch = parseBranchFlag(args);
160
- const adopt = args.includes("--adopt");
161
- const dryRun = args.includes("--dry-run");
162
- const listTemplates = args.includes("--list-templates");
163
+ }
164
+ /** Resolve `--template <id>` from the argument list, refusing an empty id, an id unknown to
165
+ * the catalog, and a `--template` given alongside `--list-templates`. Validated here so an
166
+ * unknown id fails before initProject runs any side effect — before a half-seeded repo can
167
+ * result from a typo'd id. */
168
+ function resolveInitTemplate(args, listTemplates) {
163
169
  // Validate the template id here, so an unknown one fails before initProject runs any side
164
170
  // effect — and before a half-seeded repo can result from a typo'd id.
165
171
  const templateFlag = args.indexOf("--template");
@@ -175,29 +181,40 @@ export function parseInitArgs(args) {
175
181
  if (listTemplates && templateFlag >= 0) {
176
182
  fail("--list-templates takes no --template — it lists the catalog, it does not use one");
177
183
  }
184
+ return template;
185
+ }
186
+ /** The `--file` branch of `parseInitArgs`: when the flag is present the prompt comes from the
187
+ * file, so every other positional is claimed (refused as stray) and the file's exact contents
188
+ * are read as the prompt — unless `--list-templates` conflicts, which takes no prompt at all.
189
+ * Returns null when no `--file` flag is present. */
190
+ function resolveInitFileArg(args, base) {
178
191
  const fileFlag = args.indexOf("--file");
179
- if (fileFlag >= 0) {
180
- const file = promptFileValue(args[fileFlag + 1]);
181
- const claimed = [fileFlag, fileFlag + 1];
182
- const branchFlag = args.indexOf("--branch");
183
- if (branchFlag >= 0)
184
- claimed.push(branchFlag, branchFlag + 1);
185
- if (templateFlag >= 0)
186
- claimed.push(templateFlag, templateFlag + 1);
187
- for (const flag of INIT_BOOLEAN_FLAGS) {
188
- if (args.includes(flag))
189
- claimed.push(args.indexOf(flag));
190
- }
191
- failStrayArg(args, "with --file the prompt comes from the file", ...claimed);
192
- const contents = readPromptFileChecked(file);
193
- if (listTemplates) {
194
- fail(LIST_TEMPLATES_PROMPT_ERROR);
195
- }
196
- return { prompt: contents, branch, template, listTemplates, adopt, dryRun };
192
+ if (fileFlag < 0)
193
+ return null;
194
+ const file = promptFileValue(args[fileFlag + 1]);
195
+ const claimed = [fileFlag, fileFlag + 1];
196
+ const branchFlag = args.indexOf("--branch");
197
+ if (branchFlag >= 0)
198
+ claimed.push(branchFlag, branchFlag + 1);
199
+ const templateFlag = args.indexOf("--template");
200
+ if (templateFlag >= 0)
201
+ claimed.push(templateFlag, templateFlag + 1);
202
+ for (const flag of INIT_BOOLEAN_FLAGS) {
203
+ if (args.includes(flag))
204
+ claimed.push(args.indexOf(flag));
205
+ }
206
+ failStrayArg(args, "with --file the prompt comes from the file", ...claimed);
207
+ const contents = readPromptFileChecked(file);
208
+ if (base.listTemplates) {
209
+ fail(LIST_TEMPLATES_PROMPT_ERROR);
197
210
  }
198
- // Everything except the --branch/--template pairs and the booleans is prompt text; the join
199
- // keeps single-dash tokens as content, exactly as before — with no flag present this is the
200
- // old args.join(" ").
211
+ return { ...base, prompt: contents };
212
+ }
213
+ /** Reconstruct the positional prompt text for `tumwater init`: everything except the
214
+ * `--branch`/`--template` pairs and the valueless flags is prompt text. The join keeps
215
+ * single-dash tokens as content, exactly as before — with no flag present this is the old
216
+ * args.join(" "). */
217
+ function joinInitPrompt(args) {
201
218
  const promptTokens = [];
202
219
  for (let i = 0; i < args.length; i++) {
203
220
  const arg = args[i];
@@ -206,11 +223,29 @@ export function parseInitArgs(args) {
206
223
  else if (!INIT_BOOLEAN_FLAGS.includes(arg))
207
224
  promptTokens.push(arg);
208
225
  }
209
- const prompt = promptTokens.join(" ");
210
- if (listTemplates && prompt.trim() !== "") {
226
+ return promptTokens.join(" ");
227
+ }
228
+ /** `tumwater init` argument handling. Init's positionals are free-form prompt text, so
229
+ * rejectUnknownArgs (which rejects ANY unconsumed token) can't be used wholesale. The rules
230
+ * instead: a double-dash token must be a known flag from INIT_FLAG_SPECS, each given at most
231
+ * once; with `--file` present nothing but the other flags may accompany it; a `--branch
232
+ * <name>` pair and the valueless flags are never prompt content; single-dash positionals are
233
+ * prompt content, not flags. */
234
+ export function parseInitArgs(args) {
235
+ assertInitFlags(args);
236
+ const branch = parseBranchFlag(args);
237
+ const adopt = args.includes("--adopt");
238
+ const dryRun = args.includes("--dry-run");
239
+ const listTemplates = args.includes("--list-templates");
240
+ const template = resolveInitTemplate(args, listTemplates);
241
+ const base = { branch, template, listTemplates, adopt, dryRun };
242
+ const fromFile = resolveInitFileArg(args, base);
243
+ if (fromFile !== null)
244
+ return fromFile;
245
+ const prompt = joinInitPrompt(args);
246
+ if (listTemplates && prompt.trim() !== "")
211
247
  fail(LIST_TEMPLATES_PROMPT_ERROR);
212
- }
213
- return { prompt, branch, template, listTemplates, adopt, dryRun };
248
+ return { ...base, prompt };
214
249
  }
215
250
  /** The stdin prompt read once per process: cli.ts pre-parses prompt's args and cmdPrompt
216
251
  * re-parses them, and a second readFileSync(0) on a drained pipe would see an empty prompt. */
@@ -306,7 +341,7 @@ function parsePromptRole(args, roleFlag) {
306
341
  const role = roleRaw ?? null;
307
342
  // The flag and its value are never prompt content: --role is a scope, not text. Only claimed
308
343
  // when actually present — negative indexes would over-claim real tokens.
309
- const roleClaim = roleFlag >= 0 ? [roleFlag, roleFlag + 1] : [];
344
+ const roleClaim = pairClaim(roleFlag);
310
345
  return { role, roleClaim };
311
346
  }
312
347
  /** The `--json` list-only refusal: in enqueue or cancel mode it must never silently ride
@@ -325,7 +360,7 @@ function parsePromptGrep(args, scan) {
325
360
  if (scan.grepFlag >= 0 && (!grepRaw || grepRaw.startsWith("--"))) {
326
361
  fail("--grep needs text (usage: tumwater prompt --list --grep <text>)");
327
362
  }
328
- const grepClaim = scan.grepFlag >= 0 ? [scan.grepFlag, scan.grepFlag + 1] : [];
363
+ const grepClaim = pairClaim(scan.grepFlag);
329
364
  return { grepRaw, grepClaim };
330
365
  }
331
366
  /** The queue-only-mode refusals: --file and --at have nothing to mean except in the enqueue
@@ -365,7 +400,7 @@ function parsePromptDeferral(args, atFlag) {
365
400
  // An edit keeps the target prompt's existing deferral exactly as it was (the marker is
366
401
  // plumbing the edit carries over), so a --at alongside it has nothing to mean.
367
402
  // The pair is claimed like --role's: the flag and its value are never prompt content.
368
- const atClaim = atFlag >= 0 ? [atFlag, atFlag + 1] : [];
403
+ const atClaim = pairClaim(atFlag);
369
404
  let atDelayMs = null;
370
405
  if (atFlag >= 0) {
371
406
  atDelayMs = parseDurationFlag("--at", args[atFlag + 1]);
@@ -415,13 +450,19 @@ function listPromptArgs(args, f) {
415
450
  failStrayArg(args, "with --list there is no prompt text", f.listFlag, ...f.jsonFlags, ...f.roleClaim, ...f.grepClaim);
416
451
  return { mode: "list", role: f.role, json: f.jsonFlags.length > 0, grep: f.grepRaw ?? null };
417
452
  }
453
+ /** The shared `--cancel <n>` / `--top <n>` mode: each takes exactly one 1-based position
454
+ * and refuses any other token — these modes have no prompt text. Shared so the two cannot
455
+ * drift; `mode` drives both the refusal wording and the returned mode. */
456
+ function positionOnlyPromptArgs(args, f, mode, flag, flagIndex) {
457
+ const n = parsePositionFlag(flag, args[flagIndex + 1], `${flag} 2`);
458
+ // The position is the only token this mode may carry; anything else alongside it would be
459
+ // prompt text, and this mode has none.
460
+ failStrayArg(args, `with ${flag} there is no prompt text`, flagIndex, flagIndex + 1, ...f.roleClaim);
461
+ return { mode, role: f.role, position: n };
462
+ }
418
463
  /** `--cancel <n>`: remove one entry by its 1-based queue position. */
419
464
  function cancelPromptArgs(args, f) {
420
- const n = parsePositionFlag("--cancel", args[f.cancelFlag + 1], "--cancel 2");
421
- // The position is the only token --cancel may carry; anything else alongside it would be
422
- // prompt text, and this mode has none.
423
- failStrayArg(args, "with --cancel there is no prompt text", f.cancelFlag, f.cancelFlag + 1, ...f.roleClaim);
424
- return { mode: "cancel", role: f.role, position: n };
465
+ return positionOnlyPromptArgs(args, f, "cancel", "--cancel", f.cancelFlag);
425
466
  }
426
467
  /** `--edit <n>`: rewrite one entry's text in place — the replacement is the --file contents,
427
468
  * the tokens after the position, or (with neither) an editor open in the caller. */
@@ -432,7 +473,7 @@ function editPromptArgs(args, f) {
432
473
  // a mistyped command would edit prompt N with only the trailing fragment — so they are
433
474
  // refused here by the same stray-argument wording the sibling modes use; tokens after the
434
475
  // position are the replacement text, minus any claimed attach pair (none: edit in $EDITOR).
435
- const fileClaim = f.fileFlag >= 0 ? [f.fileFlag, f.fileFlag + 1] : [];
476
+ const fileClaim = pairClaim(f.fileFlag);
436
477
  for (let i = 0; i < f.editFlag; i++) {
437
478
  if (f.roleClaim.includes(i) || f.attachClaims.includes(i) || fileClaim.includes(i))
438
479
  continue;
@@ -457,11 +498,7 @@ function editPromptArgs(args, f) {
457
498
  }
458
499
  /** `--top <n>`: move one entry to the head of its role's queue. */
459
500
  function topPromptArgs(args, f) {
460
- const n = parsePositionFlag("--top", args[f.topFlag + 1], "--top 2");
461
- // The position is the only token --top may carry; anything else alongside it would be
462
- // prompt text, and this mode has none.
463
- failStrayArg(args, "with --top there is no prompt text", f.topFlag, f.topFlag + 1, ...f.roleClaim);
464
- return { mode: "top", role: f.role, position: n };
501
+ return positionOnlyPromptArgs(args, f, "top", "--top", f.topFlag);
465
502
  }
466
503
  /** `--file <path>`: enqueue the file's (or stdin's) whole contents as the prompt. */
467
504
  function filePromptArgs(args, f) {
@@ -0,0 +1,75 @@
1
+ import { completionConfigKeys, completionModels, completionRoleIds, completionScript, configValueWords, SUPPORTED_SHELLS, } from "./completion.js";
2
+ import { say, fail } from "./cli-output.js";
3
+ import { gotSuffix } from "../text/text.js";
4
+ import { typoSuffix } from "../text/suggest.js";
5
+ /** `tumwater completion`'s command body — the shell-completion script and its --roles/
6
+ * --config-keys/--config-value/--models listing shapes. The completion data lives in
7
+ * src/cli/completion.ts (the spec parser, the completionScript emitter and the read-only
8
+ * listing helpers); this module answers a `completion` invocation, split out of cli.ts so the
9
+ * dispatcher stays the pure shared sequence and this command body sits beside its data the
10
+ * way every other command body does. Imported lazily by cli.ts like each command body. */
11
+ function printCompletionRoles(root) {
12
+ for (const id of completionRoleIds(root))
13
+ say(id);
14
+ }
15
+ function printCompletionConfigKeys(root) {
16
+ for (const key of completionConfigKeys(root))
17
+ say(key);
18
+ }
19
+ function printCompletionConfigValue(key) {
20
+ // The vocabulary `config set <key> <value>` completes for a discrete-valued key
21
+ // (boolean/backend/model/thinking); free-form keys print nothing, so a `set` on one
22
+ // completes nothing instead of offering a wrong vocabulary.
23
+ for (const word of configValueWords(key))
24
+ say(word);
25
+ }
26
+ function printCompletionModels(root) {
27
+ for (const m of completionModels(root))
28
+ say(m);
29
+ }
30
+ const COMPLETION_LISTING_SHAPES = {
31
+ "--roles": "roles",
32
+ "--config-keys": "config-keys",
33
+ "--models": "models",
34
+ };
35
+ function completionListShape(args) {
36
+ if (args.length === 1 && args[0] in COMPLETION_LISTING_SHAPES)
37
+ return COMPLETION_LISTING_SHAPES[args[0]];
38
+ if (args.length === 2 && args[0] === "--config-value")
39
+ return "config-value";
40
+ return null;
41
+ }
42
+ function completionShell(args) {
43
+ const shell = args[0];
44
+ // A wrong word count names the count, not a bad shell. A single near-miss shell word gets
45
+ // the same typoSuffix `logs --type`/`history --result` give, so `completion zs` reads
46
+ // "did you mean `zsh`?" while a far-off value (`tcsh`) stays hint-free.
47
+ if (args.length === 0)
48
+ fail("completion needs a shell: bash, zsh, or fish");
49
+ if (args.length > 1)
50
+ fail(`completion takes one shell: bash, zsh, or fish (got ${args.length} words: ${args.join(" ")})`);
51
+ if (!SUPPORTED_SHELLS.includes(shell))
52
+ fail(`completion takes one shell: bash, zsh, or fish${gotSuffix(shell)}${typoSuffix(shell ?? "", SUPPORTED_SHELLS)}`);
53
+ return shell;
54
+ }
55
+ /** Answer a `tumwater completion` invocation. Works outside a repo (no requireReadyRepo). */
56
+ export async function cmdCompletion(root, args) {
57
+ const shape = completionListShape(args);
58
+ if (shape === "roles") {
59
+ printCompletionRoles(root);
60
+ return;
61
+ }
62
+ if (shape === "config-keys") {
63
+ printCompletionConfigKeys(root);
64
+ return;
65
+ }
66
+ if (shape === "config-value") {
67
+ printCompletionConfigValue(args[1]);
68
+ return;
69
+ }
70
+ if (shape === "models") {
71
+ printCompletionModels(root);
72
+ return;
73
+ }
74
+ say(completionScript(completionShell(args)));
75
+ }