sequant 2.8.0 → 2.10.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 (211) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +23 -4
  4. package/dist/bin/cli.js +99 -18
  5. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  6. package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
  7. package/dist/marketplace/external_plugins/sequant/README.md +58 -2
  8. package/dist/marketplace/external_plugins/sequant/hooks/parallel-marker.sh +50 -0
  9. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +38 -10
  10. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +405 -67
  11. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/trust-model.md +18 -0
  12. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +250 -344
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +10 -7
  14. package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +4 -4
  15. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +26 -6
  16. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +4 -2
  17. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +38 -2
  18. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +2 -2
  19. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +337 -57
  20. package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +6 -6
  21. package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +2 -2
  22. package/dist/marketplace/external_plugins/sequant/skills/qa/references/code-review-checklist.md +2 -2
  23. package/dist/marketplace/external_plugins/sequant/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  24. package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +21 -7
  25. package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +1 -1
  26. package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +11 -11
  27. package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +27 -13
  28. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/documentation-tiers.md +80 -68
  29. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/phase-reflection.md +31 -15
  30. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +11 -3
  31. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
  32. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
  33. package/dist/marketplace/external_plugins/sequant/skills/spec/references/verification-criteria.md +1 -1
  34. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  35. package/dist/src/commands/assess-render.d.ts +23 -0
  36. package/dist/src/commands/assess-render.js +60 -0
  37. package/dist/src/commands/doctor.js +8 -13
  38. package/dist/src/commands/init.js +13 -1
  39. package/dist/src/commands/locks.js +2 -0
  40. package/dist/src/commands/logs.js +20 -6
  41. package/dist/src/commands/merge.js +154 -2
  42. package/dist/src/commands/ready-tui-adapter.js +6 -1
  43. package/dist/src/commands/ready.d.ts +25 -3
  44. package/dist/src/commands/ready.js +39 -13
  45. package/dist/src/commands/resume.d.ts +113 -0
  46. package/dist/src/commands/resume.js +193 -0
  47. package/dist/src/commands/run-display.d.ts +20 -0
  48. package/dist/src/commands/run-display.js +117 -5
  49. package/dist/src/commands/run-flags.d.ts +42 -1
  50. package/dist/src/commands/run-flags.js +53 -1
  51. package/dist/src/commands/run-progress.js +26 -1
  52. package/dist/src/commands/run.js +11 -10
  53. package/dist/src/commands/state.js +7 -0
  54. package/dist/src/commands/stats.js +47 -0
  55. package/dist/src/commands/status.d.ts +9 -0
  56. package/dist/src/commands/status.js +24 -1
  57. package/dist/src/commands/sync.js +90 -19
  58. package/dist/src/commands/update.js +28 -5
  59. package/dist/src/lib/ac-parser.d.ts +14 -0
  60. package/dist/src/lib/ac-parser.js +99 -5
  61. package/dist/src/lib/assess/index.d.ts +10 -0
  62. package/dist/src/lib/assess/index.js +9 -0
  63. package/dist/src/lib/assess/renderer.d.ts +64 -0
  64. package/dist/src/lib/assess/renderer.js +481 -0
  65. package/dist/src/lib/assess/types.d.ts +224 -0
  66. package/dist/src/lib/assess/types.js +241 -0
  67. package/dist/src/lib/assess-collision-detect.d.ts +19 -2
  68. package/dist/src/lib/assess-collision-detect.js +68 -4
  69. package/dist/src/lib/ci/outputs.d.ts +15 -2
  70. package/dist/src/lib/ci/outputs.js +17 -5
  71. package/dist/src/lib/cli-flags.d.ts +33 -0
  72. package/dist/src/lib/cli-flags.js +46 -0
  73. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +21 -7
  74. package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
  75. package/dist/src/lib/cli-ui/run-renderer.js +79 -9
  76. package/dist/src/lib/errors.d.ts +84 -6
  77. package/dist/src/lib/errors.js +142 -17
  78. package/dist/src/lib/locks/index.d.ts +3 -3
  79. package/dist/src/lib/locks/index.js +2 -2
  80. package/dist/src/lib/locks/lock-manager.d.ts +20 -2
  81. package/dist/src/lib/locks/lock-manager.js +47 -3
  82. package/dist/src/lib/locks/types.d.ts +34 -3
  83. package/dist/src/lib/locks/types.js +16 -0
  84. package/dist/src/lib/manifest.js +1 -17
  85. package/dist/src/lib/mcp-config.d.ts +45 -0
  86. package/dist/src/lib/mcp-config.js +77 -1
  87. package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
  88. package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
  89. package/dist/src/lib/merge-check/command-result.d.ts +36 -0
  90. package/dist/src/lib/merge-check/command-result.js +56 -0
  91. package/dist/src/lib/merge-check/index.d.ts +4 -0
  92. package/dist/src/lib/merge-check/index.js +1 -1
  93. package/dist/src/lib/merge-check/report.js +5 -1
  94. package/dist/src/lib/merge-check/types.d.ts +9 -0
  95. package/dist/src/lib/merge-check/watch.d.ts +121 -0
  96. package/dist/src/lib/merge-check/watch.js +215 -0
  97. package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
  98. package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
  99. package/dist/src/lib/settings.d.ts +14 -0
  100. package/dist/src/lib/settings.js +61 -8
  101. package/dist/src/lib/shutdown.d.ts +42 -2
  102. package/dist/src/lib/shutdown.js +75 -5
  103. package/dist/src/lib/skills-check.d.ts +26 -0
  104. package/dist/src/lib/skills-check.js +31 -0
  105. package/dist/src/lib/stacks.d.ts +110 -1
  106. package/dist/src/lib/stacks.js +244 -5
  107. package/dist/src/lib/templates.d.ts +74 -0
  108. package/dist/src/lib/templates.js +171 -16
  109. package/dist/src/lib/test-tautology-detector.d.ts +9 -2
  110. package/dist/src/lib/test-tautology-detector.js +153 -12
  111. package/dist/src/lib/version-check.d.ts +31 -0
  112. package/dist/src/lib/version-check.js +46 -7
  113. package/dist/src/lib/workflow/batch-executor.d.ts +138 -6
  114. package/dist/src/lib/workflow/batch-executor.js +550 -54
  115. package/dist/src/lib/workflow/chain-preflight.d.ts +89 -0
  116. package/dist/src/lib/workflow/chain-preflight.js +199 -0
  117. package/dist/src/lib/workflow/chain-resume.d.ts +118 -0
  118. package/dist/src/lib/workflow/chain-resume.js +170 -0
  119. package/dist/src/lib/workflow/completed-status.d.ts +52 -0
  120. package/dist/src/lib/workflow/completed-status.js +57 -0
  121. package/dist/src/lib/workflow/config-resolver.d.ts +26 -0
  122. package/dist/src/lib/workflow/config-resolver.js +54 -2
  123. package/dist/src/lib/workflow/dependency-markers.d.ts +29 -0
  124. package/dist/src/lib/workflow/dependency-markers.js +79 -0
  125. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +7 -0
  126. package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
  127. package/dist/src/lib/workflow/drivers/aider.js +2 -0
  128. package/dist/src/lib/workflow/drivers/claude-code.d.ts +21 -0
  129. package/dist/src/lib/workflow/drivers/claude-code.js +50 -8
  130. package/dist/src/lib/workflow/error-classifier.d.ts +9 -2
  131. package/dist/src/lib/workflow/error-classifier.js +14 -1
  132. package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
  133. package/dist/src/lib/workflow/git-diff-utils.js +77 -4
  134. package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
  135. package/dist/src/lib/workflow/heartbeat.js +90 -0
  136. package/dist/src/lib/workflow/log-writer.d.ts +44 -1
  137. package/dist/src/lib/workflow/log-writer.js +81 -14
  138. package/dist/src/lib/workflow/metrics-schema.d.ts +42 -3
  139. package/dist/src/lib/workflow/metrics-schema.js +16 -0
  140. package/dist/src/lib/workflow/metrics-writer.d.ts +2 -1
  141. package/dist/src/lib/workflow/phase-executor.d.ts +234 -13
  142. package/dist/src/lib/workflow/phase-executor.js +533 -56
  143. package/dist/src/lib/workflow/phase-registry.js +2 -1
  144. package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
  145. package/dist/src/lib/workflow/platforms/github.js +152 -0
  146. package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
  147. package/dist/src/lib/workflow/qa-cache.js +22 -9
  148. package/dist/src/lib/workflow/ready-gate.d.ts +17 -4
  149. package/dist/src/lib/workflow/ready-gate.js +25 -9
  150. package/dist/src/lib/workflow/run-log-schema.d.ts +37 -0
  151. package/dist/src/lib/workflow/run-log-schema.js +92 -2
  152. package/dist/src/lib/workflow/run-orchestrator.d.ts +33 -0
  153. package/dist/src/lib/workflow/run-orchestrator.js +429 -47
  154. package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
  155. package/dist/src/lib/workflow/skills-preflight.js +112 -0
  156. package/dist/src/lib/workflow/state-cleanup.js +9 -2
  157. package/dist/src/lib/workflow/state-manager.d.ts +33 -0
  158. package/dist/src/lib/workflow/state-manager.js +83 -0
  159. package/dist/src/lib/workflow/state-schema.d.ts +26 -0
  160. package/dist/src/lib/workflow/state-schema.js +47 -0
  161. package/dist/src/lib/workflow/status-derivation.d.ts +30 -0
  162. package/dist/src/lib/workflow/status-derivation.js +27 -0
  163. package/dist/src/lib/workflow/types.d.ts +149 -4
  164. package/dist/src/lib/workflow/types.js +1 -0
  165. package/dist/src/lib/workflow/worktree-manager.d.ts +70 -2
  166. package/dist/src/lib/workflow/worktree-manager.js +201 -52
  167. package/dist/src/mcp/tools/run.d.ts +11 -1
  168. package/dist/src/mcp/tools/run.js +22 -1
  169. package/dist/src/mcp/tools/status.js +6 -0
  170. package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
  171. package/dist/src/ui/tui/ElapsedTimer.js +12 -10
  172. package/dist/src/ui/tui/IssueBox.js +10 -2
  173. package/dist/src/ui/tui/row-cap.js +2 -1
  174. package/dist/src/ui/tui/theme.d.ts +7 -0
  175. package/dist/src/ui/tui/theme.js +9 -0
  176. package/package.json +9 -10
  177. package/templates/hooks/parallel-marker.sh +50 -0
  178. package/templates/hooks/post-tool.sh +38 -10
  179. package/templates/hooks/pre-tool.sh +405 -67
  180. package/templates/scripts/cleanup-worktree.sh +282 -30
  181. package/templates/scripts/new-feature.sh +264 -16
  182. package/templates/skills/_shared/references/trust-model.md +18 -0
  183. package/templates/skills/assess/SKILL.md +250 -344
  184. package/templates/skills/assess/references/predicted-collision-detection.md +10 -7
  185. package/templates/skills/docs/SKILL.md +4 -4
  186. package/templates/skills/exec/SKILL.md +26 -6
  187. package/templates/skills/fullsolve/SKILL.md +4 -2
  188. package/templates/skills/loop/SKILL.md +38 -2
  189. package/templates/skills/merger/SKILL.md +2 -2
  190. package/templates/skills/qa/SKILL.md +337 -57
  191. package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
  192. package/templates/skills/qa/references/call-site-review.md +2 -2
  193. package/templates/skills/qa/references/code-review-checklist.md +2 -2
  194. package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  195. package/templates/skills/qa/references/quality-gates.md +21 -7
  196. package/templates/skills/qa/references/testing-requirements.md +1 -1
  197. package/templates/skills/qa/scripts/quality-checks.sh +11 -11
  198. package/templates/skills/reflect/SKILL.md +27 -13
  199. package/templates/skills/reflect/references/documentation-tiers.md +80 -68
  200. package/templates/skills/reflect/references/phase-reflection.md +31 -15
  201. package/templates/skills/release/SKILL.md +11 -3
  202. package/templates/skills/setup/SKILL.md +5 -5
  203. package/templates/skills/spec/SKILL.md +2 -0
  204. package/templates/skills/spec/references/verification-criteria.md +1 -1
  205. package/templates/skills/test/SKILL.md +1 -1
  206. package/dist/src/lib/phase-spinner.d.ts +0 -146
  207. package/dist/src/lib/phase-spinner.js +0 -255
  208. package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
  209. package/dist/src/lib/workflow/pr-operations.js +0 -326
  210. package/dist/src/lib/workflow/run-summary.d.ts +0 -36
  211. package/dist/src/lib/workflow/run-summary.js +0 -142
@@ -8,11 +8,13 @@
8
8
  * is agent-agnostic.
9
9
  */
10
10
  import chalk from "chalk";
11
- import { execSync, execFileSync } from "child_process";
11
+ import { execFileSync } from "child_process";
12
+ import { resolveDiffBase } from "./git-diff-utils.js";
12
13
  import { readAgentsMd } from "../agents-md.js";
13
14
  import { getDriver } from "./drivers/index.js";
14
15
  import { classifyError } from "./error-classifier.js";
15
- import { ApiError, BillingError } from "../errors.js";
16
+ import { ApiError, BillingError, RateLimitError, formatRateLimitMessage, formatResetTime, resetsAtToMs, } from "../errors.js";
17
+ import { formatElapsedTime } from "../cli-ui/format.js";
16
18
  import { phaseRegistry } from "./phase-registry.js";
17
19
  import { bracketedConsoleLog } from "./notice.js";
18
20
  /**
@@ -98,6 +100,279 @@ const SPEC_RETRY_STRATEGY = phaseRegistry.get("spec").retryStrategy;
98
100
  export const SPEC_RETRY_BACKOFF_MS = SPEC_RETRY_STRATEGY?.backoffMs ?? 5000;
99
101
  /** @internal Exported for testing only */
100
102
  export const SPEC_EXTRA_RETRIES = SPEC_RETRY_STRATEGY?.extraRetries ?? 1;
103
+ /**
104
+ * A rate limit whose window resets further out than this is treated as
105
+ * exhausted rather than transient (#761 AC-2): no retry can succeed inside a
106
+ * closed window, so consuming cold-start retries (each burning up to a full
107
+ * `phaseTimeout`) only delays the labeled halt. Five minutes comfortably
108
+ * exceeds any backoff this executor performs while staying far below the
109
+ * five-hour/seven-day windows the check exists to catch.
110
+ *
111
+ * @internal Exported for testing only
112
+ */
113
+ export const RATE_LIMIT_WINDOW_SKIP_THRESHOLD_MS = 5 * 60 * 1000;
114
+ /**
115
+ * Base backoff for transient rate-limit retries (#761 AC-4), doubled per
116
+ * attempt (5s, 10s). Same scale as `SPEC_RETRY_BACKOFF_MS` — long enough to
117
+ * outlive a momentary throttle, short enough to be negligible next to a
118
+ * phase's runtime.
119
+ *
120
+ * @internal Exported for testing only
121
+ */
122
+ export const RATE_LIMIT_RETRY_BACKOFF_MS = 5000;
123
+ /**
124
+ * True when a failure is a rate limit whose reset lies beyond
125
+ * {@link RATE_LIMIT_WINDOW_SKIP_THRESHOLD_MS} — i.e. window exhaustion, not a
126
+ * transient throttle. Metadata-absent rate limits (the assistant-error channel
127
+ * carries no `resetsAt`, see #761 AC-9) return false and fall through to the
128
+ * transient path: with no timing signal, retry-with-backoff is the safe
129
+ * default, skipping all retries is not.
130
+ *
131
+ * @internal Exported for testing only
132
+ */
133
+ export function isWindowExhaustedRateLimit(error, now = Date.now()) {
134
+ if (!(error instanceof RateLimitError))
135
+ return false;
136
+ const resetsAt = error.metadata.resetsAt;
137
+ if (typeof resetsAt !== "number")
138
+ return false;
139
+ return resetsAtToMs(resetsAt) - now > RATE_LIMIT_WINDOW_SKIP_THRESHOLD_MS;
140
+ }
141
+ /**
142
+ * Buffer added to a rate-limit reset before waking (#804 AC-5). `resetsAt` is a
143
+ * floor, not an exact moment: clock skew between this host and the API, plus
144
+ * server-side rounding, otherwise produce an immediate second rejection on
145
+ * wake. One minute is negligible against the five-hour/seven-day windows this
146
+ * exists for.
147
+ *
148
+ * @internal Exported for testing only
149
+ */
150
+ export const AUTO_WAIT_BUFFER_MS = 60 * 1000;
151
+ /**
152
+ * Hard cap on auto-waits per issue (#804 AC-6), independent of the minutes
153
+ * budget. A window that is still closed on wake must not produce an unbounded
154
+ * pause loop, so the count bounds the *number* of pauses while
155
+ * `autoWaitMinutes` bounds their *total duration*. Either bound being spent
156
+ * halts with today's labeled message.
157
+ *
158
+ * @internal Exported for testing only
159
+ */
160
+ export const AUTO_WAIT_MAX_WAITS = 2;
161
+ /**
162
+ * Granularity of the auto-wait sleep (#804 AC-7). The wait is performed as a
163
+ * series of ticks rather than one multi-hour `delayFn` call so that (a) the
164
+ * renderer/heartbeat can be refreshed with the remaining time, and (b) a
165
+ * Ctrl-C is observed promptly instead of at the wake time.
166
+ *
167
+ * @internal Exported for testing only
168
+ */
169
+ export const AUTO_WAIT_TICK_MS = 15 * 1000;
170
+ /**
171
+ * Build a fresh ledger from a minutes budget. A missing, negative or
172
+ * non-finite budget yields a disabled ledger (`budgetMs: 0`) — the default,
173
+ * which preserves pre-#804 behavior exactly.
174
+ */
175
+ export function createAutoWaitLedger(budgetMinutes) {
176
+ const minutes = typeof budgetMinutes === "number" && Number.isFinite(budgetMinutes)
177
+ ? Math.max(0, budgetMinutes)
178
+ : 0;
179
+ return { budgetMs: minutes * 60 * 1000, waits: 0, spentMs: 0 };
180
+ }
181
+ /**
182
+ * Decide whether to wait out an exhausted rate-limit window (#804 AC-3).
183
+ *
184
+ * Deliberately separate from {@link isWindowExhaustedRateLimit} and
185
+ * {@link RATE_LIMIT_WINDOW_SKIP_THRESHOLD_MS}, which answer *"is this transient
186
+ * or exhausted?"*. This answers a different question — *"am I willing to wait
187
+ * that long?"* — and fires ONLY once the former is already true. Keeping them
188
+ * apart is what leaves `autoWaitMinutes` the single user-facing dial for the
189
+ * wait-vs-halt outcome.
190
+ *
191
+ * Returns `null` (no wait — halt as before) when:
192
+ * - auto-wait is off, or the per-issue wait count is spent;
193
+ * - the failure is not a window-exhausted `RateLimitError`. A `BillingError`
194
+ * lands here: it is a sibling class, not a subclass, so the `instanceof`
195
+ * check inside `isWindowExhaustedRateLimit` excludes it. That is load-bearing
196
+ * — a `BillingError` may still carry `resetsAt` (an explicit
197
+ * `credits_required`, or a window that has already passed — see the #860
198
+ * narrowing in `isBillingFailure`), so gating on the timestamp's presence
199
+ * instead of the error type would wait out a credits failure that no amount
200
+ * of waiting can heal (AC-4). Since #860, a *live* recognized window
201
+ * (`five_hour`/`seven_day*` + future reset) classifies as `RateLimitError`
202
+ * upstream even when `out_of_credits` is present, which is what lets the
203
+ * real captured subscription payloads reach this decision at all;
204
+ * - the reset has already passed (nothing to wait for);
205
+ * - the required wait exceeds the budget REMAINING, not the total (AC-6).
206
+ *
207
+ * @internal Exported for testing only
208
+ */
209
+ export function shouldAutoWaitForReset(error, ledger, now = Date.now()) {
210
+ if (ledger.budgetMs <= 0)
211
+ return null;
212
+ if (ledger.waits >= AUTO_WAIT_MAX_WAITS)
213
+ return null;
214
+ // These two guards duplicate checks inside `isWindowExhaustedRateLimit`, and
215
+ // that duplication is deliberate: TypeScript cannot narrow through a boolean
216
+ // helper, so without them the lines below need `as RateLimitError` /
217
+ // `as number` casts. Real narrowing beats a cast in the one function that
218
+ // decides whether to pause a run for hours. The classifier below still owns
219
+ // the "transient or exhausted?" question (AC-3) — these only prove types.
220
+ if (!(error instanceof RateLimitError))
221
+ return null;
222
+ const resetsAt = error.metadata.resetsAt;
223
+ if (typeof resetsAt !== "number")
224
+ return null;
225
+ if (!isWindowExhaustedRateLimit(error, now))
226
+ return null;
227
+ // All arithmetic stays in epoch ms (AC-5) — resetsAtToMs first, buffer
228
+ // after, formatting only ever for display.
229
+ const wakeAtMs = resetsAtToMs(resetsAt) + AUTO_WAIT_BUFFER_MS;
230
+ const waitMs = wakeAtMs - now;
231
+ if (waitMs <= 0)
232
+ return null;
233
+ const remainingMs = ledger.budgetMs - ledger.spentMs;
234
+ if (waitMs > remainingMs)
235
+ return null;
236
+ // Carry the narrowed error forward so callers don't have to re-cast it.
237
+ return { waitMs, wakeAtMs, error };
238
+ }
239
+ /**
240
+ * Sleep until an auto-wait's wake time, in ticks (#804 AC-7).
241
+ *
242
+ * Chunking the sleep is what makes a multi-hour pause survivable:
243
+ * - `onTick` refreshes the live display so the wait is visible rather than a
244
+ * silent stall (the #574 complaint at 60x scale);
245
+ * - each tick races the injected `delayFn` against the abort signal, so Ctrl-C
246
+ * returns immediately instead of blocking until the wake.
247
+ *
248
+ * Reuses the caller's `delayFn` so the wait stays fully test-injectable.
249
+ *
250
+ * Returns the ms actually slept and whether the wait was aborted.
251
+ *
252
+ * @internal Exported for testing only
253
+ */
254
+ export async function waitForWindowReset(waitMs, options) {
255
+ const { delayFn, signal, onTick } = options;
256
+ const now = options.now ?? Date.now;
257
+ const tickMs = options.tickMs ?? AUTO_WAIT_TICK_MS;
258
+ const startedAt = now();
259
+ const deadline = startedAt + waitMs;
260
+ // Two independent notions of "how much is left", and the loop honors
261
+ // whichever expires first:
262
+ // - `budgetLeft` counts down by the slices actually requested. This is what
263
+ // terminates the loop, and it does so after a fixed number of iterations
264
+ // regardless of what the clock does — without it, an injected delayFn
265
+ // that resolves instantly (every test) would spin against the wall clock
266
+ // until the real reset time arrived.
267
+ // - the `deadline` check ends the wait early when real time has outrun the
268
+ // slices, e.g. a laptop resumed from sleep mid-wait.
269
+ let budgetLeft = waitMs;
270
+ let sleptMs = 0;
271
+ // ONE abort listener for the whole wait, not one per tick. A 5-hour wait is
272
+ // ~1200 ticks; registering inside the loop would pile up that many listeners
273
+ // (and as many pending promises) on a single signal, tripping Node's
274
+ // max-listeners warning and leaking until the wait ended.
275
+ const abortPromise = signal
276
+ ? new Promise((resolve) => {
277
+ signal.addEventListener("abort", () => resolve(true), { once: true });
278
+ })
279
+ : null;
280
+ for (;;) {
281
+ if (signal?.aborted) {
282
+ return { sleptMs: Math.max(sleptMs, now() - startedAt), aborted: true };
283
+ }
284
+ const clockLeft = deadline - now();
285
+ const remainingMs = Math.min(budgetLeft, clockLeft);
286
+ if (remainingMs <= 0)
287
+ break;
288
+ onTick?.(remainingMs);
289
+ const sliceMs = Math.min(tickMs, remainingMs);
290
+ budgetLeft -= sliceMs;
291
+ sleptMs += sliceMs;
292
+ // The injected delayFn exposes no timer handle, so an in-flight slice
293
+ // cannot be cleared on abort. Racing bounds the *observed* Ctrl-C latency
294
+ // to ~0 regardless; only the orphaned timer runs on, and it is one tick
295
+ // long, not one window long.
296
+ if (abortPromise) {
297
+ const aborted = await Promise.race([
298
+ delayFn(sliceMs).then(() => false),
299
+ abortPromise,
300
+ ]);
301
+ if (aborted) {
302
+ // This slice never completed — don't bill the ledger for it.
303
+ sleptMs -= sliceMs;
304
+ return { sleptMs: Math.max(sleptMs, now() - startedAt), aborted: true };
305
+ }
306
+ }
307
+ else {
308
+ await delayFn(sliceMs);
309
+ }
310
+ }
311
+ return {
312
+ sleptMs: Math.max(sleptMs, now() - startedAt),
313
+ aborted: signal?.aborted === true,
314
+ };
315
+ }
316
+ /**
317
+ * Run one granted auto-wait end to end (#804): emit the live notices, sleep,
318
+ * update the ledger, and report whether Ctrl-C interrupted it.
319
+ *
320
+ * Shared by both arms of the retry ladder (the cold-start loop and the
321
+ * `skipColdStartRetry` single-attempt path) so the two cannot drift on
322
+ * bookkeeping or messaging.
323
+ *
324
+ * @internal Exported for testing only
325
+ */
326
+ export async function performAutoWait(issueNumber, phase, config, decision, ledger, delayFn, shutdownManager, spinner) {
327
+ // Count the wait BEFORE sleeping. If the process dies mid-wait the ledger is
328
+ // gone anyway, but an exception on the sleep path must not hand back a free
329
+ // retry — the bound exists to stop unbounded pause loops.
330
+ ledger.waits += 1;
331
+ // AC-5: reuse the driver's own formatter for the cause rather than
332
+ // re-deriving a timestamp, and render the wake time (a distinct value —
333
+ // reset plus buffer) through the same `formatResetTime` convention.
334
+ const cause = formatRateLimitMessage(decision.error.metadata);
335
+ const wakeLabel = formatResetTime(decision.wakeAtMs);
336
+ const header = `${cause} · auto-wait ${ledger.waits}/${AUTO_WAIT_MAX_WAITS} — resuming at ${wakeLabel}`;
337
+ bracketedConsoleLog(spinner, chalk.yellow(`\n ⏸ ${header} (${formatElapsedTime(decision.waitMs / 1000)})`));
338
+ const emit = (remainingMs, done) => {
339
+ try {
340
+ config.onAutoWait?.({
341
+ issueNumber,
342
+ phase,
343
+ wakeAtMs: decision.wakeAtMs,
344
+ remainingMs,
345
+ message: done
346
+ ? `${cause} · auto-wait complete`
347
+ : `${header} · ${formatElapsedTime(remainingMs / 1000)} left`,
348
+ done,
349
+ });
350
+ }
351
+ catch {
352
+ // Liveness notices must never disrupt the run.
353
+ }
354
+ };
355
+ // AC-7: a registered controller is aborted by ShutdownManager on SIGINT, so
356
+ // Ctrl-C ends the wait instead of blocking until the wake.
357
+ const controller = new AbortController();
358
+ shutdownManager?.addAbortController(controller);
359
+ try {
360
+ const { sleptMs, aborted } = await waitForWindowReset(decision.waitMs, {
361
+ delayFn,
362
+ signal: controller.signal,
363
+ onTick: (remainingMs) => emit(remainingMs, false),
364
+ });
365
+ ledger.spentMs += sleptMs;
366
+ emit(0, true);
367
+ if (aborted) {
368
+ bracketedConsoleLog(spinner, chalk.yellow(` ✕ Auto-wait interrupted — halting`));
369
+ }
370
+ return { aborted };
371
+ }
372
+ finally {
373
+ shutdownManager?.removeAbortController(controller);
374
+ }
375
+ }
101
376
  export function parseQaVerdict(output) {
102
377
  if (!output)
103
378
  return null;
@@ -122,6 +397,59 @@ export function parseQaVerdict(output) {
122
397
  const verdict = verdictMatch[1].toUpperCase().replace(/-/g, "_");
123
398
  return verdict;
124
399
  }
400
+ /**
401
+ * Deferral/continuation markers that signal a QA agent ended its turn intending
402
+ * to continue *later* — as if a subsequent turn or a background poll were
403
+ * available to it. Seeded from the #853 live repro, whose QA turn deferred to a
404
+ * background CI poll and "the completion notification" instead of emitting a
405
+ * verdict. Matched as lowercased literal substrings (ReDoS-safe) against the
406
+ * output tail only.
407
+ *
408
+ * The second group covers deferral-to-*user* phrasing, observed by running the
409
+ * detector against every `qa` phase output in 99 real `.sequant/logs` runs: a
410
+ * turn that ended "this needs a decision from you … Tell me which one and I'll
411
+ * execute it" (run-2026-06-06) is the same one-shot violation with the wait
412
+ * pointed at a human instead of a poll. Structural alternatives were tried
413
+ * against the same corpus and rejected: 19/45 outputs with a *parseable*
414
+ * verdict have no `Verdict`-labelled line at all, so label-presence cannot
415
+ * discriminate — a literal marker list is the shape that works here.
416
+ */
417
+ const QA_DEFERRAL_MARKERS = [
418
+ "i'll pick this up",
419
+ "pick this up on the completion",
420
+ "completion notification",
421
+ "background poll",
422
+ "when the background",
423
+ "when ci ",
424
+ "later turn",
425
+ "i'll invoke",
426
+ // Deferral-to-user (corpus-observed, run-2026-06-06T03-33-20):
427
+ "needs a decision from you",
428
+ "tell me which",
429
+ "let me know which",
430
+ "tell me and i'll",
431
+ ];
432
+ /**
433
+ * Distinguish a QA turn that produced *no verdict at all* from one whose output
434
+ * was present but unparseable (#853). Returns true when the output is empty /
435
+ * whitespace, or when its tail contains deferral language — the agent treating
436
+ * its one-shot phase as if a later turn were available.
437
+ *
438
+ * Both cases are hard failures either way; this only refines which message is
439
+ * emitted, so a false positive is harmless (it swaps one failing message for
440
+ * another). Kept deliberately conservative and literal to stay ReDoS-safe.
441
+ *
442
+ * @internal Exported for testing only.
443
+ */
444
+ export function endedWithoutVerdict(output) {
445
+ if (!output || output.trim().length === 0)
446
+ return true;
447
+ // Scan the tail only: deferral language lives at the end of the turn, where
448
+ // the agent signs off planning to continue. A larger window would also catch
449
+ // a mid-review aside that says "later" without deferring the whole turn.
450
+ const tail = output.slice(-2000).toLowerCase();
451
+ return QA_DEFERRAL_MARKERS.some((marker) => tail.includes(marker));
452
+ }
125
453
  /**
126
454
  * Parse condensed QA summary from QA phase output (#434).
127
455
  *
@@ -275,26 +603,41 @@ export function resolveBaseRef(cwd) {
275
603
  return recorded.startsWith("origin/") ? recorded : `origin/${recorded}`;
276
604
  }
277
605
  /**
278
- * Check whether the exec phase produced any changes in the worktree.
279
- * Returns true if HEAD has commits unique to it relative to the resolved
280
- * base ref (see {@link resolveBaseRef}) OR uncommitted work is present.
606
+ * Parse the file paths out of `git status --porcelain` (v1) output. Each line
607
+ * is `XY <path>` (two status chars + a space); renames are `R <old> -> <new>`,
608
+ * for which we name the new path.
609
+ */
610
+ function parsePorcelainPaths(porcelain) {
611
+ return porcelain
612
+ .split("\n")
613
+ .map((line) => line.replace(/\s+$/, ""))
614
+ .filter((line) => line.length > 0)
615
+ .map((line) => {
616
+ const rest = line.slice(3);
617
+ const arrow = rest.indexOf(" -> ");
618
+ return arrow >= 0 ? rest.slice(arrow + 4) : rest;
619
+ });
620
+ }
621
+ /**
622
+ * Classify what the exec phase produced in the worktree (#879).
281
623
  *
282
- * Uses `git rev-list --count <base>..HEAD` (commits reachable from HEAD
283
- * but not the base) instead of `git diff <base>..HEAD`, because the
284
- * two-dot diff also fires in reverse when the base has advanced past HEAD
285
- * — on stale branches that would falsely report "has commits" even when the
286
- * exec phase produced nothing, reintroducing the bug #534 is fixing.
624
+ * Uses `git rev-list --count <base>..HEAD` (commits reachable from HEAD but not
625
+ * the base) instead of `git diff <base>..HEAD`, because the two-dot diff also
626
+ * fires in reverse when the base has advanced past HEAD — on stale branches
627
+ * that would falsely report "has commits" even when exec produced nothing,
628
+ * reintroducing the bug #534 is fixing.
287
629
  *
288
630
  * The base ref defaults to `origin/main` but is overridden to the worktree's
289
- * recorded base (see #537) so zero-diff execs are still detected on
290
- * custom-base worktrees (e.g. those created with `--base feature/epic`).
631
+ * recorded base (see #537) so zero-diff execs are still detected on custom-base
632
+ * worktrees (e.g. those created with `--base feature/epic`).
291
633
  *
292
- * Fails open (returns true) on git errors a missing origin ref is better
293
- * diagnosed as a real zero-diff run than as a false phase failure.
634
+ * Read-only: runs only `git rev-list` and `git status --porcelain`, so it never
635
+ * mutates the worktree an exec phase that fails on an `uncommitted` result
636
+ * leaves the dirty files exactly where the agent left them (#879 AC-3).
294
637
  *
295
638
  * @internal Exported for testing only.
296
639
  */
297
- export function hasExecChanges(cwd) {
640
+ export function classifyExecChanges(cwd) {
298
641
  const baseRef = resolveBaseRef(cwd);
299
642
  let commitsAhead;
300
643
  try {
@@ -304,23 +647,53 @@ export function hasExecChanges(cwd) {
304
647
  commitsAhead = Number.parseInt(count, 10) > 0;
305
648
  }
306
649
  catch {
307
- return true;
650
+ return { kind: "unknown" };
308
651
  }
309
652
  if (commitsAhead)
310
- return true;
653
+ return { kind: "commits" };
311
654
  try {
312
- const porcelain = execFileSync("git", ["status", "--porcelain"], {
313
- cwd,
314
- stdio: "pipe",
315
- })
316
- .toString()
317
- .trim();
318
- return porcelain.length > 0;
655
+ // `--untracked-files=all` lists each new file individually rather than
656
+ // collapsing a wholly-untracked directory to its top-level path — so the
657
+ // #879 failure message names the actual files the agent left behind (AC-2).
658
+ const porcelain = execFileSync("git", ["status", "--porcelain", "--untracked-files=all"], { cwd, stdio: "pipe" }).toString();
659
+ const paths = parsePorcelainPaths(porcelain);
660
+ return paths.length > 0 ? { kind: "uncommitted", paths } : { kind: "none" };
319
661
  }
320
662
  catch {
321
- return true;
663
+ return { kind: "unknown" };
322
664
  }
323
665
  }
666
+ /**
667
+ * Check whether the exec phase produced deliverable work in the worktree.
668
+ *
669
+ * Thin boolean wrapper over {@link classifyExecChanges}: only `commits` (real
670
+ * work) and `unknown` (git error — fail open) count as "has changes". Note the
671
+ * #879 behaviour change: an `uncommitted`-only tree now returns **false**, since
672
+ * uncommitted work is not a deliverable. Both callers (the exec guard in
673
+ * {@link mapAgentSuccessToPhaseResult} and the ready gate) want this stricter
674
+ * semantics.
675
+ *
676
+ * @internal Exported for testing only.
677
+ */
678
+ export function hasExecChanges(cwd) {
679
+ const { kind } = classifyExecChanges(cwd);
680
+ return kind === "commits" || kind === "unknown";
681
+ }
682
+ /** Cap on how many dirty paths the #879 exec-failure message lists inline. */
683
+ const MAX_UNCOMMITTED_PATHS_LISTED = 20;
684
+ /**
685
+ * Build the exec-failure message for an `uncommitted`-only worktree (#879 AC-2).
686
+ * Names the dirty paths so the work is discoverable from the run log alone,
687
+ * capped at {@link MAX_UNCOMMITTED_PATHS_LISTED} to keep the log bounded.
688
+ */
689
+ function formatUncommittedExecError(paths, cwd) {
690
+ const listed = paths.slice(0, MAX_UNCOMMITTED_PATHS_LISTED);
691
+ const remainder = paths.length - listed.length;
692
+ const suffix = remainder > 0 ? `, … and ${remainder} more` : "";
693
+ return (`exec left ${paths.length} file(s) uncommitted and made no commits — ` +
694
+ `uncommitted work cannot rebase, push, or become a PR. The work is ` +
695
+ `preserved in ${cwd}: ${listed.join(", ")}${suffix}`);
696
+ }
324
697
  /**
325
698
  * Map a successful AgentPhaseResult to a PhaseResult, applying phase-specific
326
699
  * guards that catch agent sessions which returned success without producing
@@ -348,9 +721,12 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
348
721
  const summary = agentResult.output
349
722
  ? (parseQaSummary(agentResult.output) ?? undefined)
350
723
  : undefined;
351
- if (verdict &&
352
- verdict !== "READY_FOR_MERGE" &&
353
- verdict !== "NEEDS_VERIFICATION") {
724
+ if (verdict === "AC_NOT_MET") {
725
+ // #749: only AC_NOT_MET (and the null branch below, #534) hard-fails.
726
+ // AC_MET_BUT_NOT_A_PLUS is a stopping/ready state — it must break to PR,
727
+ // not feed the quality loop (mirrors ready-gate.ts's `ac` policy). The
728
+ // verdict is retained on the success result so the PR/log surfaces the
729
+ // "not A+" note.
354
730
  return {
355
731
  phase,
356
732
  success: false,
@@ -364,12 +740,20 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
364
740
  };
365
741
  }
366
742
  if (!verdict) {
367
- // #534: a null verdict (empty or unparseable output) is not success.
743
+ // #534: a null verdict is not success. #853 splits this into two classes
744
+ // so the message is not misleading. A turn that deferred to a later turn
745
+ // (or produced no output) never emitted a verdict — reporting that as
746
+ // "unparseable" sends a debugger to inspect a verdict regex that was
747
+ // never the problem. Both remain hard failures (`success:false`); only
748
+ // the message differs.
749
+ const error = endedWithoutVerdict(agentResult.output)
750
+ ? "QA ended without producing a verdict — the phase is one-shot; the agent deferred to a later turn or emitted no output"
751
+ : "QA completed without a parseable verdict";
368
752
  return {
369
753
  phase,
370
754
  success: false,
371
755
  durationSeconds,
372
- error: "QA completed without a parseable verdict",
756
+ error,
373
757
  ...resume,
374
758
  output: agentResult.output,
375
759
  summary,
@@ -387,17 +771,38 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
387
771
  ...tails,
388
772
  };
389
773
  }
390
- if (phase === "exec" && !hasExecChanges(cwd)) {
391
- // #534: an exec phase that produced nothing is not success.
392
- return {
393
- phase,
394
- success: false,
395
- durationSeconds,
396
- error: "exec produced no changes (no commits, no uncommitted work)",
397
- ...resume,
398
- output: agentResult.output,
399
- ...tails,
400
- };
774
+ if (phase === "exec") {
775
+ // #534/#879: an exec phase that produced no deliverable commits is not
776
+ // success. Two distinct non-deliverable states, each with its own message:
777
+ // - `none`: literally nothing (#534's empty-branch class).
778
+ // - `uncommitted`: a dirty tree with no commits — used to be counted as
779
+ // success (#879), but it cannot rebase, push, or become a PR. Failing
780
+ // here (rather than auto-committing) preserves the work in place; the
781
+ // message names the paths so it is discoverable from the run log alone.
782
+ // `commits`/`unknown` fall through to success (unknown fails open).
783
+ const changes = classifyExecChanges(cwd);
784
+ if (changes.kind === "none") {
785
+ return {
786
+ phase,
787
+ success: false,
788
+ durationSeconds,
789
+ error: "exec produced no changes (no commits, no uncommitted work)",
790
+ ...resume,
791
+ output: agentResult.output,
792
+ ...tails,
793
+ };
794
+ }
795
+ if (changes.kind === "uncommitted") {
796
+ return {
797
+ phase,
798
+ success: false,
799
+ durationSeconds,
800
+ error: formatUncommittedExecError(changes.paths, cwd),
801
+ ...resume,
802
+ output: agentResult.output,
803
+ ...tails,
804
+ };
805
+ }
401
806
  }
402
807
  return {
403
808
  phase,
@@ -507,7 +912,8 @@ async function executePhase(issueNumber, phase, config, resumeHandle, worktreePa
507
912
  let files;
508
913
  if (config.agent && config.agent !== "claude-code") {
509
914
  try {
510
- const output = execSync("git diff --name-only main...HEAD", {
915
+ const diffBase = resolveDiffBase(cwd, "main");
916
+ const output = execFileSync("git", ["diff", "--name-only", `${diffBase}...HEAD`], {
511
917
  cwd,
512
918
  encoding: "utf-8",
513
919
  stdio: ["pipe", "pipe", "pipe"],
@@ -699,7 +1105,16 @@ export async function executePhaseWithRetry(issueNumber, phase, config, resumeHa
699
1105
  /** @internal Injected for testing — defaults to module-level executePhase */
700
1106
  executePhaseFn = executePhase,
701
1107
  /** @internal Injected for testing — defaults to setTimeout-based delay */
702
- delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
1108
+ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1109
+ /**
1110
+ * Per-issue auto-wait accounting (#804). Deliberately the LAST parameter:
1111
+ * the #761/#799 regression tests call this function positionally with
1112
+ * `executePhaseFn` at 8 and `delayFn` at 9, and AC-2 requires those tests to
1113
+ * pass unmodified. Defaults to a fresh disabled-or-config-derived ledger, so
1114
+ * a caller that does not thread one still gets correct (bounded) behavior —
1115
+ * just scoped to this phase rather than the issue.
1116
+ */
1117
+ autoWaitLedger = createAutoWaitLedger(config.autoWaitMinutes)) {
703
1118
  // Skip retry logic if explicitly disabled
704
1119
  if (config.retry === false) {
705
1120
  return executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
@@ -715,18 +1130,32 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
715
1130
  phaseRegistry.get(phase).retryStrategy?.maxRetries === 0;
716
1131
  let lastResult;
717
1132
  if (skipColdStartRetry) {
718
- // Single attempt — no cold-start retry loop
719
- lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
720
- if (lastResult.success) {
721
- return lastResult;
722
- }
723
- // Turn-capped phase (#739): incomplete-but-not-hard-failed. A retry cannot
724
- // un-cap a turn limit, so short-circuit before any fallback — same rationale
725
- // as the billing skip (#732), but capped must skip *all* retries (incl.
726
- // cold-start), so an explicit early return is required, not just a guard
727
- // flag at the MCP gate.
728
- if (lastResult.capped) {
729
- return lastResult;
1133
+ // Single attempt — no cold-start retry loop. The only thing that re-enters
1134
+ // this loop is a granted auto-wait (#804): `maxRetries: 0` means "a retry
1135
+ // cannot help", but a reopened rate-limit window is precisely the case
1136
+ // where it can. Bounded by AUTO_WAIT_MAX_WAITS.
1137
+ for (;;) {
1138
+ lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
1139
+ if (lastResult.success) {
1140
+ return lastResult;
1141
+ }
1142
+ // Turn-capped phase (#739): incomplete-but-not-hard-failed. A retry cannot
1143
+ // un-cap a turn limit, so short-circuit before any fallback — same rationale
1144
+ // as the billing skip (#732), but capped must skip *all* retries (incl.
1145
+ // cold-start), so an explicit early return is required, not just a guard
1146
+ // flag at the MCP gate.
1147
+ if (lastResult.capped) {
1148
+ return lastResult;
1149
+ }
1150
+ // #804: without this, `--auto-wait` would silently not cover the `loop`
1151
+ // phase — the one phase registered with `maxRetries: 0` — and a window
1152
+ // limit hitting /loop would still hard-halt with the flag set.
1153
+ const decision = shouldAutoWaitForReset(lastResult.structuredError, autoWaitLedger);
1154
+ if (!decision)
1155
+ break;
1156
+ const { aborted } = await performAutoWait(issueNumber, phase, config, decision, autoWaitLedger, delayFn, shutdownManager, spinner);
1157
+ if (aborted)
1158
+ return lastResult;
730
1159
  }
731
1160
  }
732
1161
  else {
@@ -746,6 +1175,49 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
746
1175
  if (lastResult.capped) {
747
1176
  return lastResult;
748
1177
  }
1178
+ // Window-exhausted rate limit (#761 AC-2): the reset is hours away, so
1179
+ // every retry re-spawns into the same closed window — worst case
1180
+ // ~4 × phaseTimeout (≈2h) of doomed attempts before the run halts.
1181
+ // Modelled on the `capped` early return above: skip all remaining
1182
+ // cold-start retries and (via the return) the MCP fallback. Checked
1183
+ // before the duration branch because a rate-limit rejection typically
1184
+ // fails fast and would otherwise be mistaken for a cold-start failure.
1185
+ if (isWindowExhaustedRateLimit(lastResult.structuredError)) {
1186
+ // #804: opt-in auto-wait. Sits between phase attempts (never inside a
1187
+ // spawn), so `phaseTimeout` is not implicated. Returns null — and this
1188
+ // falls through to today's halt — whenever auto-wait is off, the
1189
+ // budget/bound is spent, or the failure is billing rather than a rate
1190
+ // limit.
1191
+ const decision = shouldAutoWaitForReset(lastResult.structuredError, autoWaitLedger);
1192
+ if (decision) {
1193
+ const { aborted } = await performAutoWait(issueNumber, phase, config, decision, autoWaitLedger, delayFn, shutdownManager, spinner);
1194
+ if (!aborted) {
1195
+ // A wait is not a cold-start retry, so it must not consume one:
1196
+ // the loop's `attempt++` cancels this out. Bounded by
1197
+ // AUTO_WAIT_MAX_WAITS, which is what makes this safe from looping.
1198
+ attempt--;
1199
+ continue;
1200
+ }
1201
+ return lastResult;
1202
+ }
1203
+ if (config.verbose) {
1204
+ bracketedConsoleLog(spinner, chalk.yellow(`\n ✕ ${lastResult.error ?? "Rate limited"} — window exhausted, skipping retries`));
1205
+ }
1206
+ return lastResult;
1207
+ }
1208
+ // Transient rate limit (#761 AC-4): retry, but with real backoff — the
1209
+ // bare `continue` this replaces re-spawned immediately into the same
1210
+ // throttle. Reuses the injected `delayFn`; delay doubles per attempt.
1211
+ // Metadata-absent rate limits land here by design (AC-9 fallback rule).
1212
+ if (lastResult.structuredError instanceof RateLimitError &&
1213
+ attempt < COLD_START_MAX_RETRIES) {
1214
+ const backoffMs = RATE_LIMIT_RETRY_BACKOFF_MS * 2 ** attempt;
1215
+ if (config.verbose) {
1216
+ bracketedConsoleLog(spinner, chalk.yellow(`\n ⟳ ${lastResult.error ?? "Rate limited"} — backing off ${backoffMs}ms before retry... (attempt ${attempt + 2}/${COLD_START_MAX_RETRIES + 1})`));
1217
+ }
1218
+ await delayFn(backoffMs);
1219
+ continue;
1220
+ }
749
1221
  // Genuine failure (took long enough to be real work) → skip cold-start retries.
750
1222
  // Use error classification (AC-9): if the error is retryable (e.g., API
751
1223
  // rate limit, transient 503), allow one more attempt even for genuine failures.
@@ -793,11 +1265,16 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
793
1265
  // intent is documented and future code paths can't accidentally re-spawn a
794
1266
  // capped phase without MCP.
795
1267
  const failureIsCapped = lastResult.capped === true;
1268
+ // A throttle must not trigger "retrying without MCP" (#761 AC-3): MCP was
1269
+ // never the cause, and the re-spawn burns up to another full phaseTimeout
1270
+ // against the same limit while mislabeling the failure as MCP-related.
1271
+ const failureIsRateLimited = lastResult.structuredError instanceof RateLimitError;
796
1272
  if (config.mcp &&
797
1273
  !lastResult.success &&
798
1274
  !skipColdStartRetry &&
799
1275
  !failureIsBilling &&
800
- !failureIsCapped) {
1276
+ !failureIsCapped &&
1277
+ !failureIsRateLimited) {
801
1278
  bracketedConsoleLog(spinner, chalk.yellow(`\n ! Phase failed with MCP enabled, retrying without MCP...`));
802
1279
  // Create config copy with MCP disabled
803
1280
  const configWithoutMcp = {