tumwater 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -5,34 +5,11 @@ import { MERGE_SCOPES, SCOPE_WORDS, buildCheckEvent, buildCheckSkipWarning, runW
5
5
  import { CHECK_TIER, withCheckPermit } from "../concurrency/check-permit.js";
6
6
  import { sampleHostLoad, sampleSleepClock } from "./host-sleep.js";
7
7
  import { npmInstall } from "./dep-install.js";
8
- /** Run the project's declared check for a named scope — the detect → run → build_check event →
9
- * skip-warning sequence the review gate's pre-check (scope "gate"), the merge path's in-lock
10
- * re-check (scope "merge"), and the batch merger's one check over the whole stacked tree (scope
11
- * "batch") previously each ran inline, kept in one place so the event's shape and the skip warning
12
- * cannot drift between them. Split out of build-check.ts — which keeps the check's execution and
13
- * outcome classification (runBuildCheck, BuildCheckOutcome) — because orchestration is its own
14
- * concern: the permit, the event emission, the verdict-less retry, the merge-scope unverified
15
- * remapping, and the skip warning. Every run is an event with its cost: the deterministic checks
16
- * are where the team's compute goes after the authoring run, and "how long does npm test take per
17
- * merge" must be answerable from the feed, not by timing it by hand. Returns null when no check is
18
- * declared (nothing to run — the caller passes, exactly as before this split), otherwise the
19
- * declared check plus its classified outcome. A "skipped" outcome also logs its standard warning
20
- * here (wording keyed on the scope, the timeout as actually set — and, when its deadline fired
21
- * late, as actually enforced); "failed" and "passed" are the caller's to decide (deterministic
22
- * reject vs. verifiedHead / baseline seeding). A timeout or signal kill at a merge scope is
23
- * remapped to a deterministic "failed" — the tree is unverified, so it must not merge. A check
24
- * killed by a signal the harness did not send is retried once at any scope — the first run's death
25
- * says nothing about the tree (it is another run's `pkill`), so one verdict from a clean attempt is
26
- * owed before the skip is honored; a timeout whose deadline demonstrably fired late (the harness's
27
- * own host-sleep evidence) earns that same one retry at any scope, and a failed run the host slept
28
- * through does too; each attempt is priced as its own build_check event, carrying when the check
29
- * itself ran (buildCheckRunFields). A configured `check.gateCommand` replaces the command at scope
30
- * "gate" only (same cwd and timeout); the in-lock command (Fast merge "Defaults", mergeCommandOf) —
31
- * `check.mergeCommand` when set, else `check.gateCommand`, else the full check — replaces it at the
32
- * merge and batch scopes, and those events then carry `full: false`. The red-main baseline, which
33
- * detects on its own, keeps running check.command. `permitWait` hears
34
- * about a wait for the check permit (the merge cell's "waiting for a check slot"). Never throws. */
35
- export async function runScopedBuildCheck(root, role, scope, wt, config, timeoutMs = BUILD_CHECK_TIMEOUT_MS, sampleSleep = sampleSleepClock, install = npmInstall, permitWait, sampleLoad = sampleHostLoad) {
8
+ /** Resolve which command to detect for a scope and the effective deadline it runs under — the
9
+ * command-swap/detect rule (gateCommandOf/mergeCommandOf over detectBuildCheck), the
10
+ * merge-scope starved deadline, and the cheaper flag. Returns null when no check is declared
11
+ * (nothing to run). */
12
+ function resolveScopedCheck(wt, scope, config, timeoutMs, sampleLoad) {
36
13
  const swapped = scope === "gate" ? gateCommandOf(config) : MERGE_SCOPES.has(scope) ? mergeCommandOf(config) : undefined;
37
14
  const check = detectBuildCheck(wt, swapped === undefined ? config : { check: { ...config?.check, command: swapped } });
38
15
  if (!check)
@@ -50,6 +27,12 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
50
27
  ? starvedDeadlineMs(checkTimeoutMs(check, timeoutMs), load)
51
28
  : checkTimeoutMs(check, timeoutMs);
52
29
  const runCheck = { ...check, timeoutMs: effectiveMs };
30
+ return { check, cheaper, runCheck, effectiveMs, load };
31
+ }
32
+ /** Run the resolved check under one permit, with the one clean retry for a verdict-less death —
33
+ * the one-permit-two-attempt rule. Prices the first attempt as its own build_check event and
34
+ * returns the final outcome and its run duration. */
35
+ async function runScopedCheckWithRetry(root, role, scope, wt, runCheck, timeoutMs, config, sampleSleep, install, permitWait, cheaper) {
53
36
  // One permit covers both attempts (the killed-check retry re-runs under the permit it
54
37
  // holds); durationMs prices the run itself, not the wait for a permit.
55
38
  let durationMs = 0;
@@ -70,6 +53,12 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
70
53
  granted: () => permitWait?.granted?.(),
71
54
  waited: (waitedMs, parked) => logCheckWaitEvent(root, role, scope, waitedMs, parked),
72
55
  }, `${role} ${scope}`);
56
+ return { raw, durationMs };
57
+ }
58
+ /** Classify the raw outcome: the merge-scope skip and slept-through rules become a deterministic
59
+ * unverified reject; anything else passes through. Never logs — the caller warns (see
60
+ * warnScopedCheck). Returns the unverified reason only when a rule fired. */
61
+ function unverifiedOutcomeFor(scope, check, raw, effectiveMs, load, durationMs) {
73
62
  // A signal kill at a merge scope, or a timeout whose deadline fired on time (real slowness, a
74
63
  // genuinely hung or oversized suite), made no verdict about the tree — and this is the check
75
64
  // whose whole job is to catch a semantic conflict before it merges, so it rejects
@@ -89,6 +78,8 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
89
78
  (((raw.status === "skipped" &&
90
79
  (raw.skipReason === "timeout" || raw.skipReason === "killed" || raw.skipReason === "install")) ||
91
80
  sleepFailed));
81
+ if (!unverifiedSkip)
82
+ return { outcome: raw, unverifiedReason: null };
92
83
  const unverifiedReason = raw.skipReason === "install"
93
84
  ? installFailedPhrase(SCOPE_WORDS[scope].label, raw.install, UNVERIFIED_TREE)
94
85
  : raw.skipReason === "killed"
@@ -99,17 +90,19 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
99
90
  // An on-time kill (the late-deadline wording already names its own cause) names the
100
91
  // starved host when the load average says the team was the check's competition.
101
92
  (raw.run?.deadlineLateMs ?? 0) > DEADLINE_LATE_TOLERANCE_MS ? "" : loadedHostPhrase(load)}`;
102
- const outcome = unverifiedSkip
103
- ? {
104
- status: "failed",
105
- script: checkScriptName(check),
106
- outputTail: [unverifiedReason],
107
- run: raw.run,
108
- unverified: true,
109
- ...(raw.install ? { install: raw.install } : {}),
110
- }
111
- : raw;
112
- logEventBestEffort(root, buildCheckEvent(role, scope, outcome, durationMs, cheaper));
93
+ const outcome = {
94
+ status: "failed",
95
+ script: checkScriptName(check),
96
+ outputTail: [unverifiedReason],
97
+ run: raw.run,
98
+ unverified: true,
99
+ ...(raw.install ? { install: raw.install } : {}),
100
+ };
101
+ return { outcome, unverifiedReason };
102
+ }
103
+ /** Warn for the two verdict-less roads: a skipped check (buildCheckSkipWarning) or an unverified
104
+ * merge reject. Reads the scope word table and the killed signal. */
105
+ function warnScopedCheck(root, role, scope, outcome, effectiveMs, durationMs, unverifiedReason) {
113
106
  if (outcome.status === "skipped") {
114
107
  // Environmental — deliberately NOT fail-closed, so a hung build script cannot wedge every
115
108
  // code run into the 3-strike discard (gate) or a merge behind the merge lock.
@@ -118,8 +111,44 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
118
111
  ? { signal: outcome.killedBy, durationMs }
119
112
  : undefined, outcome.run, outcome.install));
120
113
  }
121
- else if (unverifiedSkip) {
114
+ else if (unverifiedReason) {
122
115
  warnEventBestEffort(root, role, `${unverifiedReason}; rejecting the merge`);
123
116
  }
124
- return { check, outcome };
117
+ }
118
+ /** Run the project's declared check for a named scope — the detect → run → build_check event →
119
+ * skip-warning sequence the review gate's pre-check (scope "gate"), the merge path's in-lock
120
+ * re-check (scope "merge"), and the batch merger's one check over the whole stacked tree (scope
121
+ * "batch") previously each ran inline, kept in one place so the event's shape and the skip warning
122
+ * cannot drift between them. Split out of build-check.ts — which keeps the check's execution and
123
+ * outcome classification (runBuildCheck, BuildCheckOutcome) — because orchestration is its own
124
+ * concern: the permit, the event emission, the verdict-less retry, the merge-scope unverified
125
+ * remapping, and the skip warning. Every run is an event with its cost: the deterministic checks
126
+ * are where the team's compute goes after the authoring run, and "how long does npm test take per
127
+ * merge" must be answerable from the feed, not by timing it by hand. Returns null when no check is
128
+ * declared (nothing to run — the caller passes, exactly as before this split), otherwise the
129
+ * declared check plus its classified outcome. A "skipped" outcome also logs its standard warning
130
+ * here (wording keyed on the scope, the timeout as actually set — and, when its deadline fired
131
+ * late, as actually enforced); "failed" and "passed" are the caller's to decide (deterministic
132
+ * reject vs. verifiedHead / baseline seeding). A timeout or signal kill at a merge scope is
133
+ * remapped to a deterministic "failed" — the tree is unverified, so it must not merge. A check
134
+ * killed by a signal the harness did not send is retried once at any scope — the first run's death
135
+ * says nothing about the tree (it is another run's `pkill`), so one verdict from a clean attempt is
136
+ * owed before the skip is honored; a timeout whose deadline demonstrably fired late (the harness's
137
+ * own host-sleep evidence) earns that same one retry at any scope, and a failed run the host slept
138
+ * through does too; each attempt is priced as its own build_check event, carrying when the check
139
+ * itself ran (buildCheckRunFields). A configured `check.gateCommand` replaces the command at scope
140
+ * "gate" only (same cwd and timeout); the in-lock command (Fast merge "Defaults", mergeCommandOf) —
141
+ * `check.mergeCommand` when set, else `check.gateCommand`, else the full check — replaces it at the
142
+ * merge and batch scopes, and those events then carry `full: false`. The red-main baseline, which
143
+ * detects on its own, keeps running check.command. `permitWait` hears
144
+ * 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) {
146
+ const r = resolveScopedCheck(wt, scope, config, timeoutMs, sampleLoad);
147
+ if (!r)
148
+ return null;
149
+ const { raw, durationMs } = await runScopedCheckWithRetry(root, role, scope, wt, r.runCheck, timeoutMs, config, sampleSleep, install, permitWait, r.cheaper);
150
+ const { outcome, unverifiedReason } = unverifiedOutcomeFor(scope, r.check, raw, r.effectiveMs, r.load, durationMs);
151
+ logEventBestEffort(root, buildCheckEvent(role, scope, outcome, durationMs, r.cheaper));
152
+ warnScopedCheck(root, role, scope, outcome, r.effectiveMs, durationMs, unverifiedReason);
153
+ return { check: r.check, outcome };
125
154
  }
@@ -10,6 +10,7 @@ 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
12
  import { aheadOfMainDiff, aheadOfMainFiles, changedFiles } from "../git/git-diff.js";
13
+ import { compareCodeUnits } from "../collections.js";
13
14
  import { isAbsolute } from "node:path";
14
15
  import { branchName } from "../paths.js";
15
16
  import { isUsableWorktree } from "../git/worktree.js";
@@ -152,7 +153,8 @@ async function cappedWorkingDiff(wt, spec = []) {
152
153
  return (`[uncommitted diff truncated: the full diff is ${full.length} bytes; showing --stat]\n\n` + stat);
153
154
  }
154
155
  /** The paths two or more distinct roles hold: a role's duplicate path counts once, roles stay
155
- * in holdings order, paths sort by plain `<` comparison. */
156
+ * in holdings order, paths sort in code-unit order (`compareCodeUnits`, the locale-independent
157
+ * order a plain `<` comparison gives). */
156
158
  export function fileOverlaps(holdings) {
157
159
  const byPath = new Map();
158
160
  for (const { role, files } of holdings) {
@@ -166,7 +168,7 @@ export function fileOverlaps(holdings) {
166
168
  }
167
169
  return [...byPath.entries()]
168
170
  .filter(([, roles]) => roles.length >= 2)
169
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
171
+ .sort(([a], [b]) => compareCodeUnits(a, b))
170
172
  .map(([path, roles]) => ({ path, roles }));
171
173
  }
172
174
  /** Keep only the overlaps naming a listed role; each kept entry still names every holder. */
@@ -341,6 +341,15 @@ export function durationLabel(ms) {
341
341
  }
342
342
  return rest === 0 ? parts.join("") : `${ms}ms`;
343
343
  }
344
+ /** The human phrase for a two-ended time window — `between 2h and 1h ago` — the shape
345
+ * `--since/--until` windows share with the history TUI and `history`'s bounded empties.
346
+ * Unbounded windows keep their own "last …"/"in …" phrasing; only the two-ended form is
347
+ * folded here so the three sites cannot drift. The two bounds are labeled in caller order
348
+ * (older before newer at every current site), and each is run through durationLabel, so a
349
+ * phrase always echoes a form the parser accepts. Lives beside durationLabel for that reason. */
350
+ export function betweenWindow(older, newer) {
351
+ return `between ${durationLabel(older)} and ${durationLabel(newer)} ago`;
352
+ }
344
353
  /** The shared over-cap check for duration-valued flags: fail with the one message shape —
345
354
  * `<flag> is capped at <max label> (got <value label>)` — so every capped duration names
346
355
  * both bounds in the same phrasing and the sites cannot drift apart (the same idiom the
@@ -247,19 +247,19 @@ const PROMPT_FLAG_SPECS = [
247
247
  { names: ["--attach"], value: true, valueName: "<path>" },
248
248
  { names: ["--grep"], value: true, valueName: "<text>" },
249
249
  ];
250
- /** `tumwater prompt` argument handling, following parseInitArgs' pattern. Like init's,
251
- * positionals are free-form prompt content — but a double-dash token must be a real flag
252
- * (`--role <id>`, `--list`, `--json`, `--grep <text>`, `--cancel <n>`, `--edit <n>`,
253
- * `--top <n>`, `--file <path>`, `--at <duration>`), or
254
- * it would be baked into the queued prompt (the same class of bug parseInitArgs fixed: today
255
- * `tumwater prompt --foo text` enqueues "--foo text"). Single-dash positionals remain prompt
256
- * content. `--list`, `--cancel`, `--edit` and `--top` are mutually exclusive and may
257
- * not combine with positional text; `--role` is accepted in every mode and is never
258
- * prompt content. `--at <duration>` (PLANS.md "tumwater prompt --at <duration>") only queues
259
- * a prompt: it defers delivery — parsed here with parseDurationFlag, riding the enqueue as
260
- * the queue file's not-before marker — so the read-only and destructive modes refuse it
261
- * rather than silently ignore it. */
262
- export function parsePromptArgs(args) {
250
+ /** The six `--list`/`--cancel`/`--edit`/`--top` mode pairs that may not combine, in today's
251
+ * exact order. */
252
+ const PROMPT_MODE_EXCLUSIONS = [
253
+ ["--list", "--cancel"],
254
+ ["--list", "--edit"],
255
+ ["--cancel", "--edit"],
256
+ ["--list", "--top"],
257
+ ["--cancel", "--top"],
258
+ ["--edit", "--top"],
259
+ ];
260
+ /** The flag-vocabulary gate and the once-check: the unknown-double-dash gate, the ten index
261
+ * collections and rejectDuplicateFlags's once-check all ride here, one scan, one home. */
262
+ function scanPromptFlags(args) {
263
263
  // The unknown-double-dash gate reads PROMPT_FLAG_SPECS, so the list its error names admits
264
264
  // exactly what the duplicate-flag check below accepts — one vocabulary, one message renderer.
265
265
  rejectUnknownDoubleDash("prompt", args, PROMPT_FLAG_SPECS);
@@ -276,21 +276,30 @@ export function parsePromptArgs(args) {
276
276
  // --attach is repeatable: every occurrence claims itself and its value token, like --role's
277
277
  // single pair, so the pairs ride out of the prompt text in every mode.
278
278
  const attachFlags = args.flatMap((a, i) => (a === "--attach" ? [i] : []));
279
+ const roleFlag = args.indexOf("--role");
279
280
  const known = PROMPT_FLAG_SPECS.flatMap((s) => s.names).filter((f) => f !== "--attach"); // repeatable: the once-check skips it
280
281
  rejectDuplicateFlags(args, known);
281
- if (listFlag >= 0 && cancelFlag >= 0)
282
- fail("--list and --cancel are mutually exclusive");
283
- if (listFlag >= 0 && editFlag >= 0)
284
- fail("--list and --edit are mutually exclusive");
285
- if (cancelFlag >= 0 && editFlag >= 0)
286
- fail("--cancel and --edit are mutually exclusive");
287
- if (listFlag >= 0 && topFlag >= 0)
288
- fail("--list and --top are mutually exclusive");
289
- if (cancelFlag >= 0 && topFlag >= 0)
290
- fail("--cancel and --top are mutually exclusive");
291
- if (editFlag >= 0 && topFlag >= 0)
292
- fail("--edit and --top are mutually exclusive");
293
- const roleFlag = args.indexOf("--role");
282
+ return { jsonFlags, listFlag, cancelFlag, editFlag, topFlag, fileFlag, atFlag, grepFlag, attachFlags, roleFlag };
283
+ }
284
+ /** The six mode mutual exclusions: the mutually-exclusive `--list`/`--cancel`/`--edit`/
285
+ * `--top` pairs, in the table's order, so the first failing pair reports as it did before. */
286
+ function assertPromptModesDisjoint(scan) {
287
+ const index = {
288
+ "--list": scan.listFlag,
289
+ "--cancel": scan.cancelFlag,
290
+ "--edit": scan.editFlag,
291
+ "--top": scan.topFlag,
292
+ };
293
+ for (const [a, b] of PROMPT_MODE_EXCLUSIONS) {
294
+ // All four keys are always present in the object; ?? -1 just satisfies the strict
295
+ // indexed-access typing without changing the (never-undefined) compare.
296
+ if ((index[a] ?? -1) >= 0 && (index[b] ?? -1) >= 0)
297
+ fail(`${a} and ${b} are mutually exclusive`);
298
+ }
299
+ }
300
+ /** The `--role <id>` value pair: its raw value, its missing-value refusal and the pair it
301
+ * claims, verbatim. */
302
+ function parsePromptRole(args, roleFlag) {
294
303
  const roleRaw = roleFlag >= 0 ? args[roleFlag + 1] : undefined;
295
304
  if (roleFlag >= 0 && (!roleRaw || roleRaw.startsWith("--")))
296
305
  fail(ROLE_VALUE_ERROR);
@@ -298,17 +307,30 @@ export function parsePromptArgs(args) {
298
307
  // The flag and its value are never prompt content: --role is a scope, not text. Only claimed
299
308
  // when actually present — negative indexes would over-claim real tokens.
300
309
  const roleClaim = roleFlag >= 0 ? [roleFlag, roleFlag + 1] : [];
310
+ return { role, roleClaim };
311
+ }
312
+ /** The `--json` list-only refusal: in enqueue or cancel mode it must never silently ride
313
+ * along as prompt text (or beside a state change). */
314
+ function assertPromptJsonListOnly(scan) {
301
315
  // --json is list-only: in enqueue or cancel mode it must never silently ride along as
302
316
  // prompt text (or beside a state change), so it is refused before either branch runs.
303
- if (jsonFlags.length > 0 && listFlag < 0)
317
+ if (scan.jsonFlags.length > 0 && scan.listFlag < 0)
304
318
  fail("--json only applies to --list");
305
- if (grepFlag >= 0 && listFlag < 0)
319
+ }
320
+ /** The `--grep <text>` value pair and its list-only refusal, verbatim. */
321
+ function parsePromptGrep(args, scan) {
322
+ if (scan.grepFlag >= 0 && scan.listFlag < 0)
306
323
  fail("--grep only applies to --list");
307
- const grepRaw = grepFlag >= 0 ? args[grepFlag + 1] : undefined;
308
- if (grepFlag >= 0 && (!grepRaw || grepRaw.startsWith("--"))) {
324
+ const grepRaw = scan.grepFlag >= 0 ? args[scan.grepFlag + 1] : undefined;
325
+ if (scan.grepFlag >= 0 && (!grepRaw || grepRaw.startsWith("--"))) {
309
326
  fail("--grep needs text (usage: tumwater prompt --list --grep <text>)");
310
327
  }
311
- const grepClaim = grepFlag >= 0 ? [grepFlag, grepFlag + 1] : [];
328
+ const grepClaim = scan.grepFlag >= 0 ? [scan.grepFlag, scan.grepFlag + 1] : [];
329
+ return { grepRaw, grepClaim };
330
+ }
331
+ /** The queue-only-mode refusals: --file and --at have nothing to mean except in the enqueue
332
+ * mode, and --attach is refused by --list and --cancel (--edit accepts it). */
333
+ function assertPromptQueueOnlyFlags(scan) {
312
334
  // The queue-only flags --file and --at have nothing to mean in the read-only and destructive
313
335
  // modes (--list and --cancel): --file is the file-shaped twin of free-form text (--edit
314
336
  // accepts it as its replacement text, so the editFlag test below exempts that one mode), and
@@ -318,16 +340,16 @@ export function parsePromptArgs(args) {
318
340
  // predicate and the `<flag> only queues a prompt` wording, so a new mode or queue-only flag
319
341
  // is added here.
320
342
  const refuseQueueOnlyFlag = (flag, present) => {
321
- if (present && (listFlag >= 0 || cancelFlag >= 0 || editFlag >= 0 || topFlag >= 0))
343
+ if (present && (scan.listFlag >= 0 || scan.cancelFlag >= 0 || scan.editFlag >= 0 || scan.topFlag >= 0))
322
344
  fail(`${flag} only queues a prompt`);
323
345
  };
324
- refuseQueueOnlyFlag("--file", fileFlag >= 0 && editFlag < 0);
325
- refuseQueueOnlyFlag("--at", atFlag >= 0);
326
- refuseQueueOnlyFlag("--attach", attachFlags.length > 0 && editFlag < 0);
327
- // An edit keeps the target prompt's existing deferral exactly as it was (the marker is
328
- // plumbing the edit carries over), so a --at alongside it has nothing to mean.
329
- // The pair is claimed like --role's: the flag and its value are never prompt content.
330
- const atClaim = atFlag >= 0 ? [atFlag, atFlag + 1] : [];
346
+ refuseQueueOnlyFlag("--file", scan.fileFlag >= 0 && scan.editFlag < 0);
347
+ refuseQueueOnlyFlag("--at", scan.atFlag >= 0);
348
+ refuseQueueOnlyFlag("--attach", scan.attachFlags.length > 0 && scan.editFlag < 0);
349
+ }
350
+ /** The `--attach <path>` value pair(s): every occurrence claims itself and its value token,
351
+ * with the missing-value refusal, verbatim. */
352
+ function parsePromptAttachments(args, attachFlags) {
331
353
  // Each --attach pair claims the flag and its value; a trailing flag with no value names the
332
354
  // flag here, like --cancel's missing-value wording, before any queue write.
333
355
  const attachPaths = attachFlags.map((i) => args[i + 1]);
@@ -336,6 +358,14 @@ export function parsePromptArgs(args) {
336
358
  if (args[i + 1] === undefined)
337
359
  fail(`--attach needs a path (e.g. \`--attach shot.png\`)`);
338
360
  }
361
+ return { attachPaths, attachClaims };
362
+ }
363
+ /** The `--at <duration>` parse and its 90-day cap, verbatim. */
364
+ function parsePromptDeferral(args, atFlag) {
365
+ // An edit keeps the target prompt's existing deferral exactly as it was (the marker is
366
+ // plumbing the edit carries over), so a --at alongside it has nothing to mean.
367
+ // The pair is claimed like --role's: the flag and its value are never prompt content.
368
+ const atClaim = atFlag >= 0 ? [atFlag, atFlag + 1] : [];
339
369
  let atDelayMs = null;
340
370
  if (atFlag >= 0) {
341
371
  atDelayMs = parseDurationFlag("--at", args[atFlag + 1]);
@@ -344,75 +374,146 @@ export function parsePromptArgs(args) {
344
374
  // (about 106751991d from now) made notBeforeMarker's toISOString throw a raw RangeError.
345
375
  failOverDurationCap("--at", atDelayMs, PAUSE_FOR_MAX_MS);
346
376
  }
347
- if (listFlag >= 0) {
348
- failStrayArg(args, "with --list there is no prompt text", listFlag, ...jsonFlags, ...roleClaim, ...grepClaim);
349
- return { mode: "list", role, json: jsonFlags.length > 0, grep: grepRaw ?? null };
350
- }
351
- if (cancelFlag >= 0) {
352
- const n = parsePositionFlag("--cancel", args[cancelFlag + 1], "--cancel 2");
353
- // The position is the only token --cancel may carry; anything else alongside it would be
354
- // prompt text, and this mode has none.
355
- failStrayArg(args, "with --cancel there is no prompt text", cancelFlag, cancelFlag + 1, ...roleClaim);
356
- return { mode: "cancel", role, position: n };
357
- }
358
- if (editFlag >= 0) {
359
- const n = parsePositionFlag("--edit", args[editFlag + 1], '--edit 2 "new text"');
360
- // The --edit pair, the --role pair, and any --attach pairs are the only flags this mode
361
- // claims. Tokens preceding --edit (other than a claimed flag) would be silently dropped —
362
- // a mistyped command would edit prompt N with only the trailing fragment — so they are
363
- // refused here by the same stray-argument wording the sibling modes use; tokens after the
364
- // position are the replacement text, minus any claimed attach pair (none: edit in $EDITOR).
365
- const fileClaim = fileFlag >= 0 ? [fileFlag, fileFlag + 1] : [];
366
- for (let i = 0; i < editFlag; i++) {
367
- if (roleClaim.includes(i) || attachClaims.includes(i) || fileClaim.includes(i))
368
- continue;
369
- fail(`unexpected argument ${JSON.stringify(args[i])} — with --edit the replacement text comes after the position`);
370
- }
371
- if (fileFlag >= 0) {
372
- // The replacement comes from the file (`-` reads stdin), kept line for line; editPromptAtFile
373
- // trims it. Typed text beside it would be ambiguous, so it is refused.
374
- const file = promptFileValue(args[fileFlag + 1]);
375
- failStrayArg(args, "with --file the replacement text comes from the file", editFlag, editFlag + 1, ...fileClaim, ...roleClaim, ...attachClaims);
376
- return { mode: "edit", role, position: n, text: readPromptFileArg(file), attachPaths };
377
- }
378
- const textTokens = [];
379
- for (let i = editFlag + 2; i < args.length; i++) {
380
- if (roleClaim.includes(i) || attachClaims.includes(i))
381
- continue;
382
- textTokens.push(args[i]);
383
- }
384
- const text = textTokens.join(" ").trim();
385
- // No text and no --file: the caller opens the prompt's current body in the editor.
386
- return { mode: "edit", role, position: n, text: text === "" ? null : text, attachPaths };
377
+ return { atDelayMs, atClaim };
378
+ }
379
+ /** `tumwater prompt`'s flag scan and the checks it owns — everything shared by two or more
380
+ * modes, so a new mode or queue-only flag is added here: the unknown-double-dash gate, the
381
+ * index collection, the once-check, the six mutual exclusions, the --role pair, the
382
+ * --json/--grep list-only refusals, the queue-only-flag refusals, the --attach pairs and the
383
+ * --at parse and cap. Each job lives in one helper — scanPromptFlags, assertPromptModesDisjoint,
384
+ * parsePromptRole, assertPromptJsonListOnly, parsePromptGrep, assertPromptQueueOnlyFlags,
385
+ * parsePromptAttachments and parsePromptDeferral — so a check is found by name. Returns a
386
+ * struct the mode branches read. */
387
+ function parsePromptFlags(args) {
388
+ const scan = scanPromptFlags(args);
389
+ assertPromptModesDisjoint(scan);
390
+ const { role, roleClaim } = parsePromptRole(args, scan.roleFlag);
391
+ assertPromptJsonListOnly(scan);
392
+ const { grepRaw, grepClaim } = parsePromptGrep(args, scan);
393
+ assertPromptQueueOnlyFlags(scan);
394
+ const { attachPaths, attachClaims } = parsePromptAttachments(args, scan.attachFlags);
395
+ const { atDelayMs, atClaim } = parsePromptDeferral(args, scan.atFlag);
396
+ return {
397
+ role,
398
+ jsonFlags: scan.jsonFlags,
399
+ listFlag: scan.listFlag,
400
+ cancelFlag: scan.cancelFlag,
401
+ editFlag: scan.editFlag,
402
+ topFlag: scan.topFlag,
403
+ fileFlag: scan.fileFlag,
404
+ grepRaw,
405
+ grepClaim,
406
+ roleClaim,
407
+ attachPaths,
408
+ attachClaims,
409
+ atDelayMs,
410
+ atClaim,
411
+ };
412
+ }
413
+ /** `--list`: the queue listing, optionally machine-readable and filtered by --grep. */
414
+ function listPromptArgs(args, f) {
415
+ failStrayArg(args, "with --list there is no prompt text", f.listFlag, ...f.jsonFlags, ...f.roleClaim, ...f.grepClaim);
416
+ return { mode: "list", role: f.role, json: f.jsonFlags.length > 0, grep: f.grepRaw ?? null };
417
+ }
418
+ /** `--cancel <n>`: remove one entry by its 1-based queue position. */
419
+ 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 };
425
+ }
426
+ /** `--edit <n>`: rewrite one entry's text in place — the replacement is the --file contents,
427
+ * the tokens after the position, or (with neither) an editor open in the caller. */
428
+ function editPromptArgs(args, f) {
429
+ const n = parsePositionFlag("--edit", args[f.editFlag + 1], '--edit 2 "new text"');
430
+ // The --edit pair, the --role pair, and any --attach pairs are the only flags this mode
431
+ // claims. Tokens preceding --edit (other than a claimed flag) would be silently dropped —
432
+ // a mistyped command would edit prompt N with only the trailing fragment — so they are
433
+ // refused here by the same stray-argument wording the sibling modes use; tokens after the
434
+ // 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] : [];
436
+ for (let i = 0; i < f.editFlag; i++) {
437
+ if (f.roleClaim.includes(i) || f.attachClaims.includes(i) || fileClaim.includes(i))
438
+ continue;
439
+ fail(`unexpected argument ${JSON.stringify(args[i])} — with --edit the replacement text comes after the position`);
387
440
  }
388
- if (topFlag >= 0) {
389
- const n = parsePositionFlag("--top", args[topFlag + 1], "--top 2");
390
- // The position is the only token --top may carry; anything else alongside it would be
391
- // prompt text, and this mode has none.
392
- failStrayArg(args, "with --top there is no prompt text", topFlag, topFlag + 1, ...roleClaim);
393
- return { mode: "top", role, position: n };
441
+ if (f.fileFlag >= 0) {
442
+ // The replacement comes from the file (`-` reads stdin), kept line for line; editPromptAtFile
443
+ // trims it. Typed text beside it would be ambiguous, so it is refused.
444
+ const file = promptFileValue(args[f.fileFlag + 1]);
445
+ failStrayArg(args, "with --file the replacement text comes from the file", f.editFlag, f.editFlag + 1, ...fileClaim, ...f.roleClaim, ...f.attachClaims);
446
+ return { mode: "edit", role: f.role, position: n, text: readPromptFileArg(file), attachPaths: f.attachPaths };
394
447
  }
395
- if (fileFlag >= 0) {
396
- // The file is the prompt: read it whole (a path of `-` reads stdin, so a pipe or heredoc
397
- // works), following parseInitArgs' --file branch — the same claim/stray/read/empty shape,
398
- // so the two commands speak one idiom. The stdin read is memoized at module level: cli.ts
399
- // pre-parses prompt's args and cmdPrompt re-parses them, and a second readFileSync(0) on a
400
- // drained pipe would see an empty prompt (readFileSync(0) fails with EAGAIN on a TTY's
401
- // stdin; errorMessage names it, and the operator passes a real pipe instead).
402
- const file = promptFileValue(args[fileFlag + 1]);
403
- failStrayArg(args, "with --file the prompt comes from the file", fileFlag, fileFlag + 1, ...roleClaim, ...atClaim, ...attachClaims);
404
- const contents = readPromptFileArg(file);
405
- return { mode: "enqueue", role, text: contents, atDelayMs, attachPaths: attachPaths };
448
+ const textTokens = [];
449
+ for (let i = f.editFlag + 2; i < args.length; i++) {
450
+ if (f.roleClaim.includes(i) || f.attachClaims.includes(i))
451
+ continue;
452
+ textTokens.push(args[i]);
406
453
  }
454
+ const text = textTokens.join(" ").trim();
455
+ // No text and no --file: the caller opens the prompt's current body in the editor.
456
+ return { mode: "edit", role: f.role, position: n, text: text === "" ? null : text, attachPaths: f.attachPaths };
457
+ }
458
+ /** `--top <n>`: move one entry to the head of its role's queue. */
459
+ 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 };
465
+ }
466
+ /** `--file <path>`: enqueue the file's (or stdin's) whole contents as the prompt. */
467
+ function filePromptArgs(args, f) {
468
+ // The file is the prompt: read it whole (a path of `-` reads stdin, so a pipe or heredoc
469
+ // works), following parseInitArgs' --file branch — the same claim/stray/read/empty shape,
470
+ // so the two commands speak one idiom. The stdin read is memoized at module level: cli.ts
471
+ // pre-parses prompt's args and cmdPrompt re-parses them, and a second readFileSync(0) on a
472
+ // drained pipe would see an empty prompt (readFileSync(0) fails with EAGAIN on a TTY's
473
+ // stdin; errorMessage names it, and the operator passes a real pipe instead).
474
+ const file = promptFileValue(args[f.fileFlag + 1]);
475
+ failStrayArg(args, "with --file the prompt comes from the file", f.fileFlag, f.fileFlag + 1, ...f.roleClaim, ...f.atClaim, ...f.attachClaims);
476
+ const contents = readPromptFileArg(file);
477
+ return { mode: "enqueue", role: f.role, text: contents, atDelayMs: f.atDelayMs, attachPaths: f.attachPaths };
478
+ }
479
+ /** The default mode: free-form text (minus the claimed --role/--at/--attach tokens) is joined
480
+ * and enqueued. */
481
+ function enqueuePromptArgs(args, f) {
407
482
  // Everything except the --role pair and the --at pair is prompt text; the join keeps
408
483
  // multi-word requests as one string exactly like the pre-1/2 behavior.
409
484
  const text = args
410
- .filter((_, i) => !roleClaim.includes(i) && !atClaim.includes(i) && !attachClaims.includes(i))
485
+ .filter((_, i) => !f.roleClaim.includes(i) && !f.atClaim.includes(i) && !f.attachClaims.includes(i))
411
486
  .join(" ")
412
487
  .trim();
413
488
  // Name the fix, like the sibling operator commands' usage errors (bug/plan carry their
414
489
  // BUG_USAGE/PLAN_USAGE lines): "prompt text required" alone never said what to type.
415
490
  if (!text)
416
491
  fail('prompt text required — usage: tumwater prompt "<text>" (add --role <id> to aim it at one role)');
417
- return { mode: "enqueue", role, text, atDelayMs, attachPaths: attachPaths };
492
+ return { mode: "enqueue", role: f.role, text, atDelayMs: f.atDelayMs, attachPaths: f.attachPaths };
493
+ }
494
+ /** `tumwater prompt` argument handling, following parseInitArgs' pattern. Like init's,
495
+ * positionals are free-form prompt content — but a double-dash token must be a real flag
496
+ * (`--role <id>`, `--list`, `--json`, `--grep <text>`, `--cancel <n>`, `--edit <n>`,
497
+ * `--top <n>`, `--file <path>`, `--at <duration>`), or
498
+ * it would be baked into the queued prompt (the same class of bug parseInitArgs fixed: today
499
+ * `tumwater prompt --foo text` enqueues "--foo text"). Single-dash positionals remain prompt
500
+ * content. `--list`, `--cancel`, `--edit` and `--top` are mutually exclusive and may
501
+ * not combine with positional text; `--role` is accepted in every mode and is never
502
+ * prompt content. `--at <duration>` (PLANS.md "tumwater prompt --at <duration>") only queues
503
+ * a prompt: it defers delivery — parsed here with parseDurationFlag, riding the enqueue as
504
+ * the queue file's not-before marker — so the read-only and destructive modes refuse it
505
+ * rather than silently ignore it. */
506
+ export function parsePromptArgs(args) {
507
+ const f = parsePromptFlags(args);
508
+ if (f.listFlag >= 0)
509
+ return listPromptArgs(args, f);
510
+ if (f.cancelFlag >= 0)
511
+ return cancelPromptArgs(args, f);
512
+ if (f.editFlag >= 0)
513
+ return editPromptArgs(args, f);
514
+ if (f.topFlag >= 0)
515
+ return topPromptArgs(args, f);
516
+ if (f.fileFlag >= 0)
517
+ return filePromptArgs(args, f);
518
+ return enqueuePromptArgs(args, f);
418
519
  }
@@ -98,6 +98,11 @@ export const ROLE_FLAG = {
98
98
  valueName: "<id>",
99
99
  missingValue: ROLE_VALUE_ERROR,
100
100
  };
101
+ /** The `--all` flag spec, taken only by `tumwater abort` (`abort --all` aborts every enabled
102
+ * role in one request). A valueless targeting flag, so its spec carries only its spelling; the
103
+ * abort command body keeps the --role-vs--all rivalry rule. Every other marker command keeps
104
+ * its own flag set, so `pause --all` and the rest still fail as unknown flags. */
105
+ export const ABORT_ALL_FLAG = { names: ["--all"] };
101
106
  /** The missing-value error for `logs --transcript` (LOGS_TRANSCRIPT_FLAG's trailing gate). */
102
107
  export const LOGS_TRANSCRIPT_VALUE_ERROR = "--transcript needs a role id (e.g. `--transcript feature`)";
103
108
  /** The `--transcript <id>` flag spec of `logs`: the role whose agent transcript to print. A
@@ -252,11 +257,8 @@ export function hasFollowFlag(args) {
252
257
  /** The `--ready` flag spec, accepted by `backlog`: keep only the entries no hold keeps from
253
258
  * being picked. Value-less, like STAT_FLAG. */
254
259
  export const READY_FLAG = { names: ["--ready"] };
255
- /** Force re-probing of every claimed image-input model in `tumwater doctor` (part 2/5),
256
- * bypassing the 7-day cache: `--reprobe` (a value-less flag like JSON_FLAG). */
257
- export const REPROBE_FLAG = { names: ["--reprobe"] };
258
260
  /** The `--problems` flag spec, accepted by `doctor`: print only the warn and fail rows. Value-less,
259
- * like REPROBE_FLAG. */
261
+ * like KILL_ORPHANS_FLAG. */
260
262
  export const PROBLEMS_FLAG = { names: ["--problems"] };
261
263
  /** The `--kill-orphans` flag spec, accepted by `doctor`: first kill the orphaned processes the
262
264
  * orphans check lists, each with its descendants. Value-less, like PROBLEMS_FLAG. */
@@ -1,4 +1,4 @@
1
- import { DRY_RUN_FLAG, JSON_FLAG, durationFlagSpec, PAUSE_FOR_OVERFLOW_HINT, REASON_FLAG, WAKE_CANCEL_FLAG, WAKE_LIST_FLAG, WAKE_IN_FLAG, rejectUnknownArgs, ROLE_FLAG, } from "./cli-flag-specs.js";
1
+ import { ABORT_ALL_FLAG, DRY_RUN_FLAG, JSON_FLAG, durationFlagSpec, PAUSE_FOR_OVERFLOW_HINT, REASON_FLAG, WAKE_CANCEL_FLAG, WAKE_LIST_FLAG, WAKE_IN_FLAG, rejectUnknownArgs, ROLE_FLAG, } from "./cli-flag-specs.js";
2
2
  import { requireReadyRepo } from "./cli-repo-gate.js";
3
3
  import { cmdAbort, cmdPause, cmdReclaim, cmdResetCounters, cmdRestart, cmdResume, cmdWake } from "../operator/operator-commands.js";
4
4
  /** The CLI command layer of each marker command, keyed by its CLI name (the command bodies
@@ -31,7 +31,7 @@ export async function runMarkerCommand(root, command, args) {
31
31
  const perCommandFlags = {
32
32
  "reset-counters": [ROLE_FLAG],
33
33
  wake: [ROLE_FLAG, WAKE_IN_FLAG, WAKE_CANCEL_FLAG, WAKE_LIST_FLAG, JSON_FLAG],
34
- abort: [ROLE_FLAG],
34
+ abort: [ROLE_FLAG, ABORT_ALL_FLAG],
35
35
  pause: [
36
36
  ROLE_FLAG,
37
37
  durationFlagSpec("pause --for", PAUSE_FOR_OVERFLOW_HINT),