tumwater 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/backend-traits.js +13 -0
  4. package/dist/src/agent/claude/claude-backend.js +2 -2
  5. package/dist/src/agent/claude/claude-stream.js +3 -2
  6. package/dist/src/agent/codex/codex-backend.js +3 -3
  7. package/dist/src/agent/codex/codex-stream.js +3 -1
  8. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  9. package/dist/src/backlog/backlog-archive.js +60 -25
  10. package/dist/src/backlog/backlog-cli-view.js +3 -28
  11. package/dist/src/backlog/backlog-cli.js +9 -64
  12. package/dist/src/backlog/backlog-commit.js +17 -9
  13. package/dist/src/backlog/backlog-eligibility.js +44 -26
  14. package/dist/src/backlog/backlog-parts.js +23 -13
  15. package/dist/src/backlog/backlog-release.js +33 -16
  16. package/dist/src/baseline/main-guardrails.js +0 -73
  17. package/dist/src/baseline/main-red.js +31 -21
  18. package/dist/src/baseline/main-watch.js +82 -34
  19. package/dist/src/brief/brief-edit.js +3 -1
  20. package/dist/src/budget/budget-fallback-view.js +288 -0
  21. package/dist/src/budget/budget-wiring.js +158 -0
  22. package/dist/src/budget/budget.js +2 -1
  23. package/dist/src/build/dep-install.js +64 -1
  24. package/dist/src/cli/cli-args.js +63 -0
  25. package/dist/src/cli/cli-flag-specs.js +33 -0
  26. package/dist/src/cli/cli-query-commands.js +16 -41
  27. package/dist/src/cli/cli-review-commands.js +113 -0
  28. package/dist/src/cli/cli-role-command.js +118 -13
  29. package/dist/src/cli/cli-start-summary.js +10 -3
  30. package/dist/src/cli/cli-start.js +33 -5
  31. package/dist/src/cli/completion.js +21 -1
  32. package/dist/src/cli/config-commands.js +17 -8
  33. package/dist/src/cli/help.js +74 -14
  34. package/dist/src/cli/log-commands.js +26 -13
  35. package/dist/src/cli/question-commands.js +7 -1
  36. package/dist/src/cli.js +71 -6
  37. package/dist/src/collections.js +31 -4
  38. package/dist/src/config/config-editable-keys.js +3 -0
  39. package/dist/src/config/config-fallbacks.js +3 -2
  40. package/dist/src/config/config-validation.js +47 -42
  41. package/dist/src/config/config-views.js +10 -2
  42. package/dist/src/config/config-write.js +33 -1
  43. package/dist/src/config/config.js +7 -2
  44. package/dist/src/doctor/doctor-backlog-bugs.js +85 -42
  45. package/dist/src/doctor/doctor-backlog-plans.js +214 -79
  46. package/dist/src/doctor/doctor-backlog.js +155 -22
  47. package/dist/src/doctor/doctor-check-names.js +5 -1
  48. package/dist/src/doctor/doctor-checks.js +10 -15
  49. package/dist/src/doctor/doctor-claude-checks.js +6 -9
  50. package/dist/src/doctor/doctor-codex-checks.js +6 -9
  51. package/dist/src/doctor/doctor-contention-checks.js +244 -118
  52. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  53. package/dist/src/doctor/doctor-holds.js +4 -6
  54. package/dist/src/doctor/doctor-model-checks.js +45 -200
  55. package/dist/src/doctor/doctor-notify.js +7 -2
  56. package/dist/src/doctor/doctor-opencode-checks.js +7 -5
  57. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  58. package/dist/src/doctor/doctor-project-checks.js +106 -12
  59. package/dist/src/doctor/doctor.js +10 -5
  60. package/dist/src/doctor/probe.js +14 -1
  61. package/dist/src/events/event-format.js +7 -6
  62. package/dist/src/events/event-labels.js +0 -1
  63. package/dist/src/events/event-read.js +13 -0
  64. package/dist/src/events/event-window.js +20 -2
  65. package/dist/src/failure/failure-data.js +9 -4
  66. package/dist/src/failure/failure-render.js +8 -8
  67. package/dist/src/failure/time-spend.js +0 -64
  68. package/dist/src/files/file-queue.js +17 -0
  69. package/dist/src/gates/budget-gates.js +27 -257
  70. package/dist/src/gates/gate-polls.js +49 -141
  71. package/dist/src/gates/maintenance-quota.js +27 -17
  72. package/dist/src/git/commit-message.js +12 -0
  73. package/dist/src/git/git.js +18 -0
  74. package/dist/src/git/slot-salvage.js +3 -3
  75. package/dist/src/gui/gui-backlog-commands.js +3 -3
  76. package/dist/src/gui/gui-backlog-endpoints.js +5 -4
  77. package/dist/src/gui/gui-command.js +19 -1
  78. package/dist/src/gui/gui-config-commands.js +75 -9
  79. package/dist/src/gui/gui-endpoint-commands.js +2 -2
  80. package/dist/src/gui/gui-operator-commands.js +134 -7
  81. package/dist/src/gui/gui-prompt-commands.js +15 -6
  82. package/dist/src/gui/gui-report-endpoints.js +10 -3
  83. package/dist/src/gui/gui-server.js +16 -2
  84. package/dist/src/history/history-queued.js +8 -0
  85. package/dist/src/history/history.js +2 -5
  86. package/dist/src/inbox/inbox-top.js +4 -9
  87. package/dist/src/merge/merge-core.js +2 -25
  88. package/dist/src/merge/merge-drain.js +22 -135
  89. package/dist/src/merge/merge-pipeline.js +1 -1
  90. package/dist/src/merge/merge-post-review.js +154 -30
  91. package/dist/src/merge/merge-queue-render.js +11 -12
  92. package/dist/src/merge/merge-queue.js +22 -1
  93. package/dist/src/merge/merge-requeue.js +1 -0
  94. package/dist/src/merge/merge-stack.js +1 -7
  95. package/dist/src/merge/merge-to-main.js +1 -9
  96. package/dist/src/merge/merge-top.js +33 -19
  97. package/dist/src/merge/merge-verify.js +64 -9
  98. package/dist/src/notes-cli.js +8 -3
  99. package/dist/src/orchestrator/orchestrator-poll.js +47 -15
  100. package/dist/src/orchestrator/orchestrator-scheduling.js +4 -2
  101. package/dist/src/orchestrator/orchestrator.js +5 -4
  102. package/dist/src/orchestrator/scheduling-scope.js +51 -7
  103. package/dist/src/orchestrator/scheduling-skip.js +15 -5
  104. package/dist/src/paths.js +16 -6
  105. package/dist/src/pi/pi-backend.js +2 -2
  106. package/dist/src/process/run-marker.js +8 -0
  107. package/dist/src/prompt/gate-notes.js +1 -1
  108. package/dist/src/prompt/gate-prompts.js +30 -7
  109. package/dist/src/prompt/notes-backlog.js +13 -10
  110. package/dist/src/prompt/prompt.js +3 -1
  111. package/dist/src/report/report-data.js +49 -69
  112. package/dist/src/report/report-fold.js +11 -12
  113. package/dist/src/report/report-render.js +6 -4
  114. package/dist/src/report/report.js +2 -5
  115. package/dist/src/review/diff-rules.js +53 -0
  116. package/dist/src/review/known-flakes.js +12 -0
  117. package/dist/src/review/post-merge-review-accept.js +51 -0
  118. package/dist/src/review/post-merge-review-render.js +112 -0
  119. package/dist/src/review/post-merge-review.js +157 -24
  120. package/dist/src/review/protected-edits.js +22 -7
  121. package/dist/src/review/quarantine-render.js +65 -0
  122. package/dist/src/review/review.js +33 -49
  123. package/dist/src/roles/role-catalog.js +60 -2
  124. package/dist/src/roles/role-guidance.js +10 -0
  125. package/dist/src/roles/role-labels.js +6 -3
  126. package/dist/src/roles/role-render.js +43 -8
  127. package/dist/src/roles/role-view.js +36 -9
  128. package/dist/src/roles/roles.js +3 -3
  129. package/dist/src/run/ci-parity.js +121 -0
  130. package/dist/src/run/qa-coverage.js +50 -15
  131. package/dist/src/run/run-detail-data.js +9 -13
  132. package/dist/src/run/run-detail-render.js +1 -8
  133. package/dist/src/run/run-finalize.js +1 -0
  134. package/dist/src/run/run-prompt.js +14 -0
  135. package/dist/src/run/run-stage.js +66 -37
  136. package/dist/src/run/run-verdict.js +14 -1
  137. package/dist/src/run/stage-check.js +17 -11
  138. package/dist/src/run/stage-checks-shared.js +65 -0
  139. package/dist/src/run/stage-name-checks.js +204 -0
  140. package/dist/src/run/stage-path-checks.js +16 -515
  141. package/dist/src/run/stage-prose-checks.js +261 -0
  142. package/dist/src/runner/leftover.js +50 -19
  143. package/dist/src/runner/revision.js +12 -23
  144. package/dist/src/runner/runner-outcome.js +4 -2
  145. package/dist/src/runner/runner-recovery.js +18 -3
  146. package/dist/src/runner/runner-run.js +21 -8
  147. package/dist/src/runner/runner-state.js +4 -3
  148. package/dist/src/scheduling/claim-entries.js +49 -0
  149. package/dist/src/status/status-data.js +6 -1
  150. package/dist/src/status/status-polls.js +15 -0
  151. package/dist/src/status/status-roles.js +11 -0
  152. package/dist/src/team/reclaim.js +2 -2
  153. package/dist/src/text/datetime.js +3 -2
  154. package/dist/src/text/phrases.js +6 -3
  155. package/dist/src/text/text.js +11 -0
  156. package/dist/src/ui/badges.js +34 -1
  157. package/dist/src/ui/gui/gui-client-backlog.js +25 -3
  158. package/dist/src/ui/gui/gui-client-boot.js +63 -41
  159. package/dist/src/ui/gui/gui-client-drawer-config.js +56 -14
  160. package/dist/src/ui/gui/gui-client-drawer.js +29 -4
  161. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  162. package/dist/src/ui/gui/gui-client-history.js +74 -72
  163. package/dist/src/ui/gui/gui-client-labels.js +5 -1
  164. package/dist/src/ui/gui/gui-client-operator.js +6 -2
  165. package/dist/src/ui/gui/gui-client-pending.js +188 -11
  166. package/dist/src/ui/gui/gui-client-report-chart.js +36 -8
  167. package/dist/src/ui/gui/gui-client-report.js +20 -5
  168. package/dist/src/ui/gui/gui-client-settings.js +109 -3
  169. package/dist/src/ui/gui/gui-client-team.js +11 -0
  170. package/dist/src/ui/gui/gui-client.js +11 -1
  171. package/dist/src/ui/gui/gui-page.js +1 -0
  172. package/dist/src/ui/gui/gui-styles.js +4 -0
  173. package/dist/src/ui/run-progress-model.js +2 -1
  174. package/dist/src/ui/status-payload.js +237 -9
  175. package/dist/src/ui/status-render.js +12 -4
  176. package/dist/src/ui/team-alerts.js +512 -37
  177. package/dist/src/ui/tone.js +7 -0
  178. package/dist/src/ui/tui/tui-backlog.js +7 -5
  179. package/dist/src/ui/tui/tui-editor-attach.js +1 -7
  180. package/dist/src/ui/tui/tui-editor-backends.js +1 -6
  181. package/dist/src/ui/tui/tui-editor-brief.js +4 -24
  182. package/dist/src/ui/tui/tui-editor-budget.js +1 -8
  183. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  184. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  185. package/dist/src/ui/tui/tui-editor-concurrency.js +1 -0
  186. package/dist/src/ui/tui/tui-editor-config.js +19 -6
  187. package/dist/src/ui/tui/tui-editor-entry.js +3 -17
  188. package/dist/src/ui/tui/tui-editor-instances.js +9 -0
  189. package/dist/src/ui/tui/tui-editor-instructions.js +7 -26
  190. package/dist/src/ui/tui/tui-editor-interval.js +18 -7
  191. package/dist/src/ui/tui/tui-editor-notes.js +6 -24
  192. package/dist/src/ui/tui/tui-editor-quiet.js +2 -12
  193. package/dist/src/ui/tui/tui-editor-retire.js +1 -7
  194. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  195. package/dist/src/ui/tui/tui-editor-role-key.js +1 -6
  196. package/dist/src/ui/tui/tui-editor-session.js +41 -0
  197. package/dist/src/ui/tui/tui-editor-slots.js +1 -0
  198. package/dist/src/ui/tui/tui-editors.js +10 -1
  199. package/dist/src/ui/tui/tui-frame.js +18 -6
  200. package/dist/src/ui/tui/tui-keys.js +9 -0
  201. package/dist/src/ui/tui/tui-pane-activity.js +21 -8
  202. package/dist/src/ui/tui/tui-pane-backlog.js +36 -9
  203. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  204. package/dist/src/ui/tui/tui-pane-health.js +113 -3
  205. package/dist/src/ui/tui/tui-pane-history.js +44 -0
  206. package/dist/src/ui/tui/tui-pane-pending.js +285 -56
  207. package/dist/src/ui/tui/tui-pane-queued.js +7 -2
  208. package/dist/src/ui/tui/tui-pane-report.js +9 -3
  209. package/dist/src/ui/tui/tui-pane-session.js +9 -0
  210. package/dist/src/ui/tui/tui-pane.js +16 -10
  211. package/dist/src/ui/tui/tui-panes.js +56 -7
  212. package/dist/src/ui/tui/tui.js +8 -5
  213. package/dist/src/verdict/fix-claim.js +47 -1
  214. package/dist/src/verdict/reply-contract.js +35 -0
  215. package/package.json +1 -1
  216. package/dist/src/baseline/main-culprit.js +0 -94
  217. package/dist/src/baseline/main-revert.js +0 -133
@@ -0,0 +1,288 @@
1
+ /** The fallback config view the budget gate derives each poll, split out of pollBudgetGate's
2
+ * event-and-verdict half (src/gates/budget-gates.ts) so the two halves each have a plain
3
+ * function boundary and their own tests (the same split stage-path-checks.ts made of
4
+ * stage-check.ts). This module owns everything pollBudgetGate does from its `onFallback` seam
5
+ * through the end of the function: the seam/backends walk and used-up window moves
6
+ * (`windowOut`, `movedBackends`, `windowMove`, `windowTiers`, `windowResumedBackends`,
7
+ * `windowResumed`), the motion keys (`resolvedKey`, `movedKey`, `fromKey`), the two view
8
+ * derivations (`fallbackConfig`/`directorConfig`), the `movedTiers`/`applyFallbackModel`
9
+ * application, `chainInfo`, and the `pairs` handback list. Its one entry reads the inputs
10
+ * pollBudgetGate's first half already resolved and returns the view fields `BudgetGatePoll`
11
+ * carries, so pollBudgetGate keeps its call signature and src/gates/gate-polls.ts's
12
+ * destructure of it is unchanged. The plan references the moved comments carry (part 5b/8,
13
+ * part 5c/8, 6a/7) hold here. */
14
+ import { MODEL_TIERS, } from "../config/config-schema.js";
15
+ import { modelPairName, pairDailyCost } from "../budget/budget.js";
16
+ import { fallbackServingPair, } from "../budget/fallback-breaker.js";
17
+ import { applyFallbackModel, seamsInUse, tierChainSteps, tierFallbackLabels, } from "../config/config-fallbacks.js";
18
+ import { DIRECTOR_ROLE } from "../roles/roles.js";
19
+ import { enabledRoleIds } from "../config/config.js";
20
+ import { formatModelSelector, seamAgent } from "../config/model-selector.js";
21
+ import { configForRole, isTierName, reviewConfig, roleSeamTier, withModelOverrides, } from "../config/config-views.js";
22
+ import { piPairFree, seamFree } from "../pi/pi-models.js";
23
+ import { dayAt } from "../text/datetime.js";
24
+ /** Can the account not take extra usage: some window is rejected, or the newest non-reset
25
+ * window's `overageStatus` is `rejected`. Shared with budget-gates.ts's verdict half (its
26
+ * per-pair `pairFreeAt` reads the same overage refusal). */
27
+ export function claudeOverageRefused(account) {
28
+ const live = Object.values(account.windows).filter((w) => w.status !== "reset");
29
+ if (live.some((w) => w.status === "rejected"))
30
+ return true;
31
+ const newest = live.reduce((a, w) => (a === null || w.observedAt > a.observedAt ? w : a), null);
32
+ return newest?.overageStatus === "rejected";
33
+ }
34
+ /** The subscription backend a fallback pair or model seam routes to, when that backend's
35
+ * account reads subscription billing: a resolved seam's own routed agent, else a fallback
36
+ * pair's `backend` (`x.backend`), whose account is `billing === "subscription"`, else null.
37
+ * One backend can be a subscription while another bills by API, so a used-up window is judged
38
+ * per backend. Exported for the gate-polls window loop (part 2e/9), which reads it per
39
+ * backend in use. */
40
+ export function subscriptionBackendOf(x, accounts) {
41
+ const seam = x;
42
+ // A resolved seam carries its routed backend in `agent` (fallback pairs never do); a raw
43
+ // fallback pair carries the backend in its own `backend` field.
44
+ const id = seam.agent !== undefined ? seamAgent({ agent: seam.agent }) : seam.backend;
45
+ if (id === undefined)
46
+ return null;
47
+ const account = accounts(id);
48
+ return account?.billing === "subscription" ? id : null;
49
+ }
50
+ /** Derive the fallback view for one poll from the verdict half's already-resolved inputs
51
+ * (`priceResolved`, `servingResolved`, `chainPairs`, `pairNames`) and the `reached` flag
52
+ * and the shared reads (`state`, `config`, `accounts`, `windowOutFor`/`directorWindowOutFor`,
53
+ * `providers`, `catalog`), writing the cross-poll view state (`from`/`fromKey`/`fallbackConfig`,
54
+ * `directorFrom`/`directorKey`/`directorView`, `prevWindowMoved`) and returning the view fields
55
+ * pollBudgetGate hands back. */
56
+ export function budgetFallbackView(a) {
57
+ const { state, config: liveConfig, now, states, providers, catalog, accounts, windowOutFor, directorWindowOutFor, priceResolved, servingResolved, chainPairs, pairNames, reached, } = a;
58
+ // While the fallback holds, roles run under the derived view — the free pair installed
59
+ // top-level and every per-role/reviewer model override dropped, so no seam can reach a priced
60
+ // model. A breaker-demoted fallback keeps the view too: its gate is `paused`, but a run parked
61
+ // in the semaphore when it tripped, the half-open probe, and the merges must still run on the
62
+ // free pair — a demotion must never promote them to the priced model the cap already spent.
63
+ const onFallback = reached && pairNames.length > 0;
64
+ // A used-up window moves the subscription jobs of the backend whose window stands to their
65
+ // tier's fallback below the cap too; with no fallback to move to, nothing changes and that
66
+ // backend's hold applies. Every backend with a subscription seam in the config counts
67
+ // (seamsInUse enumerates them: every enabled role, the reviewer, the resolver, the director,
68
+ // and every tier fallback step), so a used-up window with no seam on that backend (or no
69
+ // fallback) moves nothing.
70
+ const seamBackends = new Set();
71
+ for (const seam of seamsInUse(liveConfig)) {
72
+ if (seam.agent === undefined)
73
+ continue;
74
+ const acc = accounts(seam.agent);
75
+ if (acc?.billing === "subscription")
76
+ seamBackends.add(seam.agent);
77
+ }
78
+ // The used-up windows behind this poll's move, keyed by backend: a backend counts only when
79
+ // a subscription seam of it is in the config, its own window is used up, and there is a
80
+ // fallback pair to move to. `windowOut` names only those backends; a healthy or absent
81
+ // backend's window is untouched.
82
+ const windowOut = {};
83
+ const movedBackends = new Set();
84
+ for (const backend of seamBackends) {
85
+ const win = windowOutFor(backend);
86
+ if (win !== null && pairNames.length > 0) {
87
+ windowOut[backend] = win;
88
+ movedBackends.add(backend);
89
+ }
90
+ }
91
+ const windowMove = movedBackends.size > 0;
92
+ // The resolved pairs are part of the key: a priced step crossing its own daily cap changes
93
+ // the resolution without a config or window change, and the view must follow it.
94
+ const resolvedKey = MODEL_TIERS.map((t) => {
95
+ const rp = priceResolved[t].pair;
96
+ return rp ? modelPairName(rp) : "-";
97
+ }).join(",");
98
+ // The set of window-moved backends is part of the key too (not just the `w` flag): a second
99
+ // backend's window can come or go while the move still stands, and the view must re-derive
100
+ // for the roles it newly blocks or frees.
101
+ const movedKey = [...movedBackends].sort().join("+");
102
+ const fromKey = `${windowMove ? "w" : ""}${reached ? "c" : ""}|${movedKey}|${resolvedKey}`;
103
+ if ((onFallback || windowMove) && (state.from !== liveConfig || state.fromKey !== fromKey)) {
104
+ state.from = liveConfig;
105
+ state.fromKey = fromKey;
106
+ // A job keeps its own model while that model is available: below the cap every seam but a
107
+ // subscription one whose own backend's window is used up; at the cap it must also be free.
108
+ const seamBlocked = (seam) => {
109
+ const b = subscriptionBackendOf(seam, accounts);
110
+ return b !== null && windowOut[b] !== undefined;
111
+ };
112
+ const available = (seam) => !seamBlocked(seam) && (!reached || seamFree(providers, seam, accounts, catalog));
113
+ const view = applyFallbackModel(liveConfig, priceResolved, available);
114
+ // A role pinned to a subscription backend under a tier of another backend has no tier pair
115
+ // to fall onto — dropping its override would put it on the tier's priced primary — so it
116
+ // keeps its pin and that backend's hold stops it, as before the window move.
117
+ if (movedBackends.size > 0) {
118
+ for (const role of enabledRoleIds(liveConfig)) {
119
+ const tier = roleSeamTier(liveConfig, role);
120
+ const pin = liveConfig.roles[role];
121
+ const roleBackend = subscriptionBackendOf(configForRole(liveConfig, role), accounts);
122
+ const tierBackend = subscriptionBackendOf(withModelOverrides(liveConfig, tier, {}), accounts);
123
+ if (pin && roleBackend !== null && roleBackend !== tierBackend)
124
+ view.roles[role] = pin;
125
+ }
126
+ // A reviewer pinned to a subscription backend under such a tier would likewise end up on
127
+ // the tier's priced primary; it goes to the tier's fallback pair instead (a held reviewer
128
+ // would park the roles a pre-merge review gates).
129
+ const rm = liveConfig.review.model;
130
+ const reviewTier = isTierName(rm) ? rm : "strong";
131
+ const reviewPair = priceResolved[reviewTier].pair;
132
+ const reviewBackend = subscriptionBackendOf(reviewConfig(liveConfig), accounts);
133
+ const reviewTierBackend = subscriptionBackendOf(withModelOverrides(liveConfig, reviewTier, {}), accounts);
134
+ // Only the reviewer's own backend's window counts: a reviewer on a healthy backend must
135
+ // stay put even while another backend's window moves its tier.
136
+ if (reviewPair &&
137
+ reviewBackend !== null &&
138
+ windowOut[reviewBackend] !== undefined &&
139
+ reviewBackend !== reviewTierBackend)
140
+ view.review = {
141
+ ...view.review,
142
+ backend: reviewPair.backend ?? "pi",
143
+ provider: undefined,
144
+ model: formatModelSelector({
145
+ provider: reviewPair.backend === undefined ? reviewPair.provider : undefined,
146
+ model: reviewPair.model ?? "",
147
+ thinking: reviewPair.thinking,
148
+ }),
149
+ thinking: undefined,
150
+ };
151
+ }
152
+ state.fallbackConfig = view;
153
+ }
154
+ // The tiers the window move put on a fallback pair: those whose own seam is a subscription
155
+ // backend whose window stands.
156
+ const windowTiers = {};
157
+ if (windowMove) {
158
+ const moved = new Set();
159
+ for (const tier of MODEL_TIERS) {
160
+ const b = subscriptionBackendOf(withModelOverrides(liveConfig, tier, {}), accounts);
161
+ if (b !== null && windowOut[b] !== undefined)
162
+ moved.add(tier);
163
+ }
164
+ const labels = tierFallbackLabels(priceResolved);
165
+ for (const tier of MODEL_TIERS)
166
+ if (moved.has(tier) && labels[tier] !== undefined)
167
+ windowTiers[tier] = labels[tier];
168
+ }
169
+ // The director follows availability, not the cap: while the window move stands and its own
170
+ // model job is a subscription backend, its tier's resolved pair replaces the live config (only
171
+ // subscription jobs of a used-up window are unavailable, so a priced director primary keeps
172
+ // its model over the cap). With no fallback (windowMove false) it stays on the live config and
173
+ // the scheduler holds it on that backend's hold.
174
+ const directorBackend = subscriptionBackendOf(configForRole(liveConfig, DIRECTOR_ROLE), accounts);
175
+ const directorMoved = directorBackend !== null && pairNames.length > 0 && directorWindowOutFor(directorBackend) !== null;
176
+ if (!directorMoved)
177
+ state.directorFrom = null;
178
+ else if (state.directorFrom !== liveConfig || state.directorKey !== resolvedKey) {
179
+ state.directorFrom = liveConfig;
180
+ state.directorKey = resolvedKey;
181
+ state.directorView = applyFallbackModel(liveConfig, priceResolved, (seam) => {
182
+ const b = subscriptionBackendOf(seam, accounts);
183
+ return b === null || windowOut[b] === undefined;
184
+ });
185
+ }
186
+ const windowResumedBackends = [...state.prevWindowMoved].filter((b) => !movedBackends.has(b));
187
+ const windowResumed = windowResumedBackends.length > 0;
188
+ state.prevWindowMoved = movedBackends;
189
+ const movedTiers = new Set();
190
+ // The tiers `applyFallbackModel` moved: those whose own seam fails the `available` test above.
191
+ for (const tier of MODEL_TIERS) {
192
+ const seam = withModelOverrides(liveConfig, tier, {});
193
+ const b = subscriptionBackendOf(seam, accounts);
194
+ const out = (windowMove && b !== null && windowOut[b] !== undefined) || (reached && !seamFree(providers, seam, accounts, catalog));
195
+ if (out)
196
+ movedTiers.add(tier);
197
+ }
198
+ const chain = chainInfo({
199
+ config: liveConfig,
200
+ resolved: servingResolved,
201
+ moved: movedTiers,
202
+ breakers: state.breakers,
203
+ states,
204
+ now,
205
+ windowOutFor,
206
+ accounts,
207
+ isPriced: (p) => !piPairFree(providers, p, accounts, catalog),
208
+ });
209
+ return {
210
+ onFallback,
211
+ chain,
212
+ roleConfig: onFallback || windowMove ? state.fallbackConfig : liveConfig,
213
+ pairs: distinctPairs(priceResolved, chainPairs),
214
+ windowOut,
215
+ windowTiers,
216
+ windowResumed,
217
+ windowResumedBackends,
218
+ directorConfig: directorMoved ? state.directorView : liveConfig,
219
+ };
220
+ }
221
+ /** The `chain` block: for each tier this poll moved off its primary, the step it runs on and
222
+ * why the position before it cannot run (plans/fallback-chain.md "Events and surfaces").
223
+ * `moved` names the tiers the poll moved; a tier the gate pauses (`pair: null`) gets no entry.
224
+ * The reason is read from the position just before the active step — the previous chain step,
225
+ * or the tier's primary at step 0 — in the order demoted, step-cap, window/reserve, overage,
226
+ * cap. */
227
+ export function chainInfo(a) {
228
+ const out = {};
229
+ const steps = tierChainSteps(a.config);
230
+ const midnight = dayAt(-1, new Date(a.now)).getTime();
231
+ for (const tier of MODEL_TIERS) {
232
+ const r = a.resolved[tier];
233
+ if (!a.moved.has(tier) || r.pair === null)
234
+ continue;
235
+ const own = steps.filter((s) => s.tier === (r.from ?? tier)).map((s) => s.pair);
236
+ const idx = r.step ?? 0;
237
+ const prior = idx > 0 ? own[idx - 1] : null;
238
+ // The backend whose used-up window this tier's chain reads for the window/reserve reason:
239
+ // the prior step's own backend when a prior step is subscription, else the primary's.
240
+ const priorSub = prior ? subscriptionBackendOf(prior, a.accounts) : null;
241
+ const primarySub = subscriptionBackendOf(withModelOverrides(a.config, tier, {}), a.accounts);
242
+ const windowBackend = (prior !== null ? priorSub : null) ?? primarySub;
243
+ const info = { pair: modelPairName(r.pair), step: idx + 1, of: own.length, reason: "cap" };
244
+ const priorName = prior ? modelPairName(prior) : "";
245
+ if (prior && a.breakers[priorName] !== undefined && !fallbackServingPair(a.breakers, priorName)) {
246
+ info.reason = "demoted";
247
+ }
248
+ else if (prior?.maxDailyCostUsd !== undefined &&
249
+ pairDailyCost(a.states, priorName, a.now) >= prior.maxDailyCostUsd) {
250
+ info.reason = "step-cap";
251
+ info.returnsAt = midnight;
252
+ }
253
+ else if (windowBackend !== null && a.windowOutFor(windowBackend) !== null) {
254
+ const win = a.windowOutFor(windowBackend);
255
+ info.reason = win.reserve ? "reserve" : "window";
256
+ info.returnsAt = win.resetsAt;
257
+ }
258
+ else if (prior &&
259
+ subscriptionBackendOf(prior, a.accounts) !== null &&
260
+ prior.extraUsage &&
261
+ a.accounts("claude") &&
262
+ claudeOverageRefused(a.accounts("claude"))) {
263
+ info.reason = "overage";
264
+ }
265
+ else {
266
+ info.returnsAt = midnight;
267
+ }
268
+ if (r.pair.maxDailyCostUsd !== undefined && a.isPriced(r.pair)) {
269
+ info.spentUsd = pairDailyCost(a.states, modelPairName(r.pair), a.now);
270
+ info.capUsd = r.pair.maxDailyCostUsd;
271
+ }
272
+ out[tier] = info;
273
+ }
274
+ return out;
275
+ }
276
+ /** The distinct non-null fallback pairs a resolution hands out (tier order, first occurrence
277
+ * first) — the handback match list: a reopen hands back every in-flight run on any of
278
+ * them (part 5c/8's per-tier handback). */
279
+ function distinctPairs(resolved, chainPairs) {
280
+ const out = [];
281
+ // The resolved pairs first, then every other price-free chain step: a run can be on any step.
282
+ const candidates = [...MODEL_TIERS.map((t) => resolved[t].pair), ...chainPairs];
283
+ for (const p of candidates) {
284
+ if (p !== null && !out.some((q) => modelPairName(q) === modelPairName(p)))
285
+ out.push(p);
286
+ }
287
+ return out;
288
+ }
@@ -0,0 +1,158 @@
1
+ import { MODEL_TIERS } from "../config/config-schema.js";
2
+ import { publishInfoFields } from "../team/orchestrator-info.js";
3
+ import { DIRECTOR_ROLE } from "../roles/roles.js";
4
+ import { logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
5
+ import { fallbackDemotion } from "./fallback-breaker.js";
6
+ import { gateRoleConfig, runOnPair, subscriptionBackendOf, } from "../gates/budget-gates.js";
7
+ import { configForRole, roleSeamTier, withModelOverrides } from "../config/config-views.js";
8
+ /** Perform the budget gate's wiring for one poll, exactly as `pollTeamGates` did inline:
9
+ * publish the demotions and the switch/serve info fields, log the one `fallback_switched`
10
+ * transition event per tier change, assign every runner's config and cap flag, and hand
11
+ * in-flight fallback runs back on a cap reopen or window reset. */
12
+ export function applyBudgetGateWiring(input) {
13
+ const { root, runners, liveConfig, info, accounts, budget, view } = input;
14
+ // Publish what observers cannot derive themselves: every pair's demotion — a per-tier
15
+ // fallback can demote a pair the legacy single `fallback` never names, and a dashboard that
16
+ // only sees the engaged pair's verdict would show a tier's roles running while the
17
+ // scheduler holds them (part 5c/8) — plus the engaged pair's own (the legacy
18
+ // `fallbackDemoted` field, what `tumwater doctor` and the failure digest read) and the
19
+ // gate's own spend/cap pair — summed over the runners' live states, which every
20
+ // persisted-file reader lags by the in-flight runs' charges. Both rewritten only when they
21
+ // change, like the build status; the exit removes the whole file, so no stale pair survives
22
+ // a stop.
23
+ const demotions = {};
24
+ for (const [name, b] of Object.entries(budget.breakers)) {
25
+ const d = fallbackDemotion(b);
26
+ if (d)
27
+ demotions[name] = d;
28
+ }
29
+ const engagedDemotion = budget.engaged !== null ? demotions[budget.engaged] : undefined;
30
+ const spend = { spentUsd: view.spentUsd, capUsd: view.capUsd };
31
+ publishInfoFields(root, info, [
32
+ ["fallbackDemotions", demotions],
33
+ ["fallbackDemoted", engagedDemotion],
34
+ ["budget", spend],
35
+ ["chain", Object.keys(view.chain).length > 0 ? view.chain : undefined],
36
+ ]);
37
+ // One fallback_switched per poll in which a tier's pair or step changed (a return to the
38
+ // primary reads `to: null`, reason "available").
39
+ const switched = {};
40
+ const prevChain = budget.prevChain;
41
+ for (const tier of MODEL_TIERS) {
42
+ const was = prevChain[tier];
43
+ const cur = view.chain[tier];
44
+ if (was?.pair === cur?.pair && was?.step === cur?.step)
45
+ continue;
46
+ switched[tier] = {
47
+ from: was?.pair ?? null,
48
+ to: cur?.pair ?? null,
49
+ reason: cur?.reason ?? "available",
50
+ };
51
+ }
52
+ if (Object.keys(switched).length > 0)
53
+ logEventBestEffort(root, {
54
+ source: TUMWATER_SOURCE,
55
+ type: "fallback_switched",
56
+ tiers: switched,
57
+ });
58
+ budget.prevChain = view.chain;
59
+ // The director keeps the live config — an explicit human prompt outranks the
60
+ // autonomous-spend cap (gateRoleConfig). Assigned every poll (not only on transitions)
61
+ // so a runner created mid-gate, or one left behind by a broken-file poll that skipped
62
+ // the reload, can never run on the wrong model.
63
+ for (const r of runners) {
64
+ r.config = gateRoleConfig(r.role, liveConfig, view.roleConfig, view.directorConfig);
65
+ // An image reroute must never spend past the daily cap: while the gate has the team on
66
+ // its free fallback pairs, a reroute looks only among those pairs (plans/image-input.md
67
+ // "Reroute target" "Regime"). Set beside the config assignment so the scheduler's regime
68
+ // and the roles' never disagree.
69
+ r.budgetFallbackActive = view.onFallback;
70
+ }
71
+ // The budget reopened: a run that started on a fallback pair keeps it until it ends, so hand
72
+ // those runs back — abort them resumably (session and worktree edits kept) and let their next
73
+ // run continue the same session on the primary, whose config the assignment above already
74
+ // installed (PLANS.md 2026-09-30). Per-tier fallbacks put different roles on different pairs
75
+ // (part 5c/8), so the match runs over EVERY pair the price-based resolution hands out, not only
76
+ // one. A run started on the primary keeps running, the director is exempt, and the merges (the
77
+ // slot's own runs) are untouched. One event per pair names who was handed back, so the resulting
78
+ // aborted runs read as the budget reopening, not as unexplained failures. The subscription
79
+ // window reset (below the cap): the same hand-back for the pairs the window move used. A window
80
+ // that resets while the cap still holds hands back nothing.
81
+ const handBack = view.resumed || (view.windowResumedBackends.length > 0 && !view.budgetActive);
82
+ // The window handback names the backend whose window resumed (the entry's goal) — not the
83
+ // handed-back pair's own backend: for claude-only the fallback pair carries no backend (it would
84
+ // read undefined), and with two subscription backends it would name the opposite one. A pair
85
+ // belongs to those resumed backends whose tiers' price-resolved pair it is.
86
+ const resumedBackendSet = new Set(view.windowResumedBackends);
87
+ const resumedBackendForPair = (leftPair) => {
88
+ for (const tier of MODEL_TIERS) {
89
+ const p = view.priceResolved[tier]?.pair;
90
+ if (p && p.provider === leftPair.provider && p.model === leftPair.model) {
91
+ const b = subscriptionBackendOf(withModelOverrides(liveConfig, tier, {}), accounts);
92
+ if (b !== null && resumedBackendSet.has(b))
93
+ return b;
94
+ }
95
+ }
96
+ return undefined;
97
+ };
98
+ if (handBack && view.pairs.length > 0) {
99
+ // A cap reopen while the window is still used up leaves the window's tiers on their
100
+ // fallback pairs: handing those runs back would abort them to restart on the same pair.
101
+ const stillMoved = Object.keys(view.windowTiers).flatMap((t) => {
102
+ const p = view.priceResolved[t].pair;
103
+ return p ? [p] : [];
104
+ });
105
+ for (const leftPair of view.pairs) {
106
+ if (stillMoved.some((p) => p.provider === leftPair.provider && p.model === leftPair.model))
107
+ continue;
108
+ const matching = runners.filter((r) => r.role !== DIRECTOR_ROLE && runOnPair(r.runModelPair(), leftPair));
109
+ if (matching.length > 0) {
110
+ logEventBestEffort(root, {
111
+ source: TUMWATER_SOURCE,
112
+ type: "budget_handback",
113
+ roles: matching.map((r) => r.role),
114
+ provider: leftPair.provider,
115
+ model: leftPair.model,
116
+ ...(view.resumed
117
+ ? {}
118
+ : { reason: "window", backend: resumedBackendForPair(leftPair) }),
119
+ });
120
+ for (const r of matching)
121
+ r.handBackRun();
122
+ }
123
+ }
124
+ }
125
+ }
126
+ /** The per-tier budget pause subject sets (part 5c/8), computed exactly as `pollTeamGates` did
127
+ * inline: `budgetPausedRoles` holds every role whose model tier resolved to no usable free pair
128
+ * while the cap is reached, and `windowMovedRoles` the role whose own subscription backend owns
129
+ * a used-up window's moved tier. The director is exempt (as under every autonomous gate), a role
130
+ * below the cap is held only when it was window-moved, and the demoted pair's probe pierces the
131
+ * set for its own tier's roles only — probeRoles in the orchestrator's scheduling pass. */
132
+ export function budgetPausedSubjects(input) {
133
+ const { runners, liveConfig, accounts, budgetActive, servingResolved, windowTiers } = input;
134
+ const budgetPausedRoles = new Set();
135
+ const windowMovedRoles = new Set();
136
+ const movedTiers = Object.keys(windowTiers);
137
+ // The backend whose used-up window moved a given tier: the tier's own seam's backend — the same
138
+ // read budget-gates made to put it in `windowTiers`. A role on a moved tier whose own backend is
139
+ // DIFFERENT (a healthy backend riding the same tier) keeps its pair: only the role whose own
140
+ // backend owns the out window is moved.
141
+ const tierMovedBackend = (tier) => subscriptionBackendOf(withModelOverrides(liveConfig, tier, {}), accounts);
142
+ for (const r of runners) {
143
+ if (r.role === DIRECTOR_ROLE)
144
+ continue;
145
+ const tier = roleSeamTier(liveConfig, r.role);
146
+ // A role pinned to the moved tier's own subscription backend keeps it through the window move
147
+ // (it is not on a pair); a role on the same tier pinning a healthy backend does not.
148
+ if (movedTiers.includes(tier) &&
149
+ subscriptionBackendOf(configForRole(liveConfig, r.role), accounts) === tierMovedBackend(tier))
150
+ windowMovedRoles.add(r.role);
151
+ // Below the cap only a window-moved role can sit on a demoted fallback.
152
+ if (!budgetActive && !windowMovedRoles.has(r.role))
153
+ continue;
154
+ if (servingResolved[tier].pair === null)
155
+ budgetPausedRoles.add(r.role);
156
+ }
157
+ return { budgetPausedRoles, windowMovedRoles };
158
+ }
@@ -151,7 +151,8 @@ export function modelPairName(p) {
151
151
  * `strong` tiers resolved to a usable free pair (resolveTierFallbacks over the full usable
152
152
  * predicate — price AND breaker serving) — plus whether the review gate is on. The gate is
153
153
  * `paused` when `default` resolves to pause (nothing for the team's bulk to run on), or when
154
- * review is on and `strong` does (nothing could merge, so every authoring run is wasted work);
154
+ * review is on and `strong` does (nothing could merge for the roles a pre-merge review gates, so
155
+ * their authoring runs are wasted work — a `review.afterMerge` role's is not);
155
156
  * `fallback` otherwise, with the tiers that did resolve running their pairs and the rest held
156
157
  * per role. The single definition of the gate: the orchestrator enforces it and both dashboards
157
158
  * display it, so what an operator sees is what the scheduler is doing. */
@@ -3,8 +3,11 @@ import { resolveFromNodeModules } from "./build-check-detect.js";
3
3
  import { readJsonFile } from "../files/json-files.js";
4
4
  import { EXEC_MAX_BUFFER } from "../process/process.js";
5
5
  import { KILL_GRACE_MS, runScriptGroup } from "../process/process-group.js";
6
- import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
6
+ import { logEventBestEffort, warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
7
+ import { withLock } from "../concurrency/lock.js";
8
+ import { mergeLockDir } from "../paths.js";
7
9
  import { errorMessage } from "../text/text.js";
10
+ import { shortSpanPhrase } from "../text/phrases.js";
8
11
  /** Keeping a tree's install in step with its lockfile (BUGS.md 2026-10-01). node_modules is
9
12
  * gitignored, so a tumwater worktree has none of its own: its toolchain and every import resolve by
10
13
  * walking UP to the root checkout's install (detectBuildCheck, resolveFromNodeModules). That
@@ -116,3 +119,63 @@ export async function syncRootInstall(root, role, install = npmInstall) {
116
119
  if (!r.ok)
117
120
  warnEventBestEffort(root, role, `the root install did not pick up main's dependencies (${r.packages.join(", ")})${r.detail ? `: ${r.detail}` : ""}; checks install into their own trees until a later merge re-syncs it`);
118
121
  }
122
+ /** Re-sync the root checkout's install on demand — the `tumwater doctor --resync-install` verb
123
+ * (and, in part 2/2, the Health surfaces' button), the always-on `install` alert's advice made a
124
+ * harness verb. Runs the same `syncInstall` the merge path runs, under the merge lock (the lock
125
+ * tryMerge holds around its own syncRootInstall, merge-to-main.ts), so a merge and the operator
126
+ * never run two root installs at once. No drift spawns nothing and logs nothing; a re-sync logs
127
+ * the same dep_install event syncRootInstall does, under TUMWATER_SOURCE, warning on failure.
128
+ * Never throws. */
129
+ export async function resyncRootInstall(root, install = npmInstall) {
130
+ // Nothing is pinned when no readable package-lock.json names a top-level project — that is not
131
+ // "the tree matches the lockfile" (there is none), so `pinned` tells the two no-drift cases
132
+ // apart for the renderer.
133
+ const readPinned = () => !!readJsonFile(path.join(root, "package-lock.json"))
134
+ ?.packages?.[""];
135
+ try {
136
+ return await withLock(mergeLockDir(root), async () => {
137
+ // Read inside the lock: a concurrent merge can swap package-lock.json between a read before
138
+ // the lock and syncInstall, and an `attempted`-false run must match the lockfile the sync
139
+ // would have aligned the tree against.
140
+ const pinned = readPinned();
141
+ const r = await syncInstall(root, install);
142
+ if (!r)
143
+ return { attempted: false, ok: true, packages: [], durationMs: 0, pinned };
144
+ logEventBestEffort(root, {
145
+ source: TUMWATER_SOURCE,
146
+ type: "dep_install",
147
+ packages: r.packages,
148
+ status: r.ok ? "passed" : "failed",
149
+ durationMs: r.durationMs,
150
+ ...(r.detail ? { error: r.detail } : {}),
151
+ });
152
+ if (!r.ok)
153
+ warnEventBestEffort(root, TUMWATER_SOURCE, `the manual root install re-sync did not pick up the root lockfile's dependencies (${r.packages.join(", ")})${r.detail ? `: ${r.detail}` : ""}; checks install into their own trees until a later merge re-syncs it`);
154
+ return {
155
+ attempted: true,
156
+ ok: r.ok,
157
+ packages: r.packages,
158
+ durationMs: r.durationMs,
159
+ pinned,
160
+ ...(r.detail ? { detail: r.detail } : {}),
161
+ };
162
+ });
163
+ }
164
+ catch (err) {
165
+ // A withLock timeout/throw must not throw to the CLI: report it as a failed re-sync so the
166
+ // doctor run still renders its verdict rather than crashing.
167
+ return { attempted: true, ok: false, packages: [], durationMs: 0, pinned: readPinned(), detail: errorMessage(err) };
168
+ }
169
+ }
170
+ /** The one renderer the CLI and part 2/2's Health surfaces share for a RootInstallResync — a
171
+ * no-drift run (attempted false) says nothing to do, a success names the re-synced packages, and
172
+ * a failure names its detail. */
173
+ export function rootInstallResyncLines(r) {
174
+ if (!r.attempted)
175
+ return r.pinned
176
+ ? ["node_modules already matches package-lock.json — nothing to re-sync"]
177
+ : ["no package-lock.json to match — nothing to re-sync"];
178
+ if (r.ok)
179
+ return [`re-synced the root install (${r.packages.join(", ")}) in ${shortSpanPhrase(r.durationMs)}`];
180
+ return [`the root install re-sync failed: ${r.detail ?? "unknown error"}`];
181
+ }
@@ -125,6 +125,14 @@ export function parseResultFlag(args, missingValueError) {
125
125
  const { rest, value } = takeValuedFlag(args, "--result", missingValueError, true);
126
126
  return { rest, raw: value };
127
127
  }
128
+ /** Parse an optional `--status <state>[,...]` flag for the post-merge review view: the raw value
129
+ * (null when absent) alongside `rest`, the args with the value token removed so a state name
130
+ * cannot be read as a positional. Validation of the states belongs to the caller
131
+ * (cmdReviews); a valueless or empty value fails with `missingValueError`. */
132
+ export function parseStatusFlag(args, missingValueError) {
133
+ const { rest, value } = takeValuedFlag(args, "--status", missingValueError, true);
134
+ return { rest, raw: value };
135
+ }
128
136
  /** Parse an optional `--model <selector>[,...]` flag for the history trail form: the raw value
129
137
  * (null when absent) alongside `rest`, the args with the value token removed so a selector
130
138
  * cannot be read as a positional. Validation belongs to the caller (parseHistoryModelList); a
@@ -165,6 +173,44 @@ export function parsePositionFlag(flag, raw, example) {
165
173
  fail(`${flag} needs a positive integer${gotSuffix(raw)}`);
166
174
  return n;
167
175
  }
176
+ /** Scan a queue-style command's edge-triggered flags in one pass: the one valued position
177
+ * flag `--<flag> <n>` (at most once, parsed through parsePositionFlag with the caller's
178
+ * `example` spelling), the `--json` boolean (at most once), any extra boolean edge flags in
179
+ * `extraBooleans` (each at most once), `--help`/`-h` tolerated (dropped), and every other
180
+ * token collected into `rest` for the caller's rejectUnknownArgs. Returns the parsed position,
181
+ * the --json flag, the extra booleans' presence (for the --done/--ready variants), and rest.
182
+ * One home for the scan loop the position-flag command shells used to repeat by hand
183
+ * (`--cancel`, `--top`, `--release`, `--accept`, `--show`), so the at-most-once wording, the
184
+ * plain-decimal rule, and the help-tolerance rule cannot drift among them. */
185
+ export function scanPositionArgs(args, flag, example, extraBooleans = []) {
186
+ let json = false;
187
+ let position = null;
188
+ const flags = {};
189
+ const rest = [];
190
+ for (let i = 0; i < args.length; i++) {
191
+ const arg = args[i] ?? "";
192
+ if (arg === "--json") {
193
+ rejectRepeatFlag(json, "--json");
194
+ json = true;
195
+ continue;
196
+ }
197
+ if (extraBooleans.includes(arg)) {
198
+ rejectRepeatFlag(flags[arg] === true, arg);
199
+ flags[arg] = true;
200
+ continue;
201
+ }
202
+ if (arg === flag) {
203
+ rejectRepeatFlag(position !== null, flag);
204
+ position = parsePositionFlag(flag, args[i + 1], example);
205
+ i++;
206
+ continue;
207
+ }
208
+ if (arg === "--help" || arg === "-h")
209
+ continue;
210
+ rest.push(arg);
211
+ }
212
+ return { json, flags, position, rest };
213
+ }
168
214
  /** Resolve a read-only command's `--role <id>` scope: the role id, or null when the flag is
169
215
  * absent. The id is validated against built-ins PLUS user-defined roles, read through
170
216
  * loadConfigCached (which never throws) so a transiently broken tumwater.json cannot take a
@@ -316,6 +362,23 @@ export function failOverDurationCap(flag, ms, maxMs, hint) {
316
362
  export function failRivalShapes(subject, rival, why) {
317
363
  fail(`${subject} cannot be combined with ${rival} (${why})`);
318
364
  }
365
+ /** Guard a trailing-window command's `--until <window>` against its `--since <window>` pair —
366
+ * the shared fail sentences the three windowed views (logs, history, report) print, so the
367
+ * wording cannot drift per command. When `--until` is present but `--since` is not, it fails
368
+ * `` `${label} --until requires --since` ``; when both are present and the until window reaches
369
+ * or exceeds the since window, it fails `` `${label} --until (<until>) must be shorter than
370
+ * --since (<since>)` `` — each named through the same durationLabel the parser reads, so an
371
+ * operator could retype the window. `sinceMs` is the command's parsed `--since` window (null
372
+ * when it read the day-shape instead), the single value the until window must stay below.
373
+ * Returns silently when both checks pass, like failOverDurationCap above. */
374
+ export function failUntilShorterThanSince(label, untilMs, sinceMs) {
375
+ if (untilMs === null)
376
+ return;
377
+ if (sinceMs === null)
378
+ fail(`${label} --until requires --since`);
379
+ if (sinceMs !== null && untilMs >= sinceMs)
380
+ fail(`${label} --until (${durationLabel(untilMs)}) must be shorter than --since (${durationLabel(sinceMs)})`);
381
+ }
319
382
  /** Read an optional `--since <duration>` window from a command's args and cap it: the one
320
383
  * home of the flagValue → parseDurationFlag → failOverDurationCap idiom the three windowed
321
384
  * read-only views (logs, history, report) share. Returns the parsed milliseconds, or null