sequant 2.9.0 → 2.11.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 (209) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +20 -5
  4. package/dist/bin/cli.js +143 -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 +56 -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 +20 -7
  10. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +75 -10
  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 +215 -390
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +1 -1
  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 +3 -1
  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/release/SKILL.md +1 -1
  28. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
  29. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
  30. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  31. package/dist/src/commands/assess-render.d.ts +23 -0
  32. package/dist/src/commands/assess-render.js +60 -0
  33. package/dist/src/commands/doctor.js +8 -13
  34. package/dist/src/commands/init.js +13 -1
  35. package/dist/src/commands/locks.d.ts +20 -1
  36. package/dist/src/commands/locks.js +208 -4
  37. package/dist/src/commands/logs.js +14 -5
  38. package/dist/src/commands/merge.js +154 -2
  39. package/dist/src/commands/ready-tui-adapter.js +6 -1
  40. package/dist/src/commands/ready.d.ts +31 -3
  41. package/dist/src/commands/ready.js +53 -13
  42. package/dist/src/commands/resume.d.ts +113 -0
  43. package/dist/src/commands/resume.js +193 -0
  44. package/dist/src/commands/run-display.js +38 -4
  45. package/dist/src/commands/run-flags.d.ts +42 -1
  46. package/dist/src/commands/run-flags.js +53 -1
  47. package/dist/src/commands/run-progress.js +26 -1
  48. package/dist/src/commands/run.js +11 -10
  49. package/dist/src/commands/state.js +7 -0
  50. package/dist/src/commands/status.d.ts +9 -0
  51. package/dist/src/commands/status.js +24 -1
  52. package/dist/src/commands/sync.js +90 -19
  53. package/dist/src/commands/update.js +28 -5
  54. package/dist/src/commands/worktree.d.ts +31 -0
  55. package/dist/src/commands/worktree.js +95 -0
  56. package/dist/src/lib/ac-parser.d.ts +14 -0
  57. package/dist/src/lib/ac-parser.js +99 -5
  58. package/dist/src/lib/assess/index.d.ts +10 -0
  59. package/dist/src/lib/assess/index.js +9 -0
  60. package/dist/src/lib/assess/renderer.d.ts +64 -0
  61. package/dist/src/lib/assess/renderer.js +481 -0
  62. package/dist/src/lib/assess/types.d.ts +224 -0
  63. package/dist/src/lib/assess/types.js +241 -0
  64. package/dist/src/lib/ci/outputs.d.ts +15 -2
  65. package/dist/src/lib/ci/outputs.js +17 -5
  66. package/dist/src/lib/cli-flags.d.ts +56 -0
  67. package/dist/src/lib/cli-flags.js +89 -0
  68. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +23 -7
  69. package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
  70. package/dist/src/lib/cli-ui/run-renderer.js +69 -1
  71. package/dist/src/lib/errors.d.ts +78 -6
  72. package/dist/src/lib/errors.js +133 -15
  73. package/dist/src/lib/locks/checkout-lock.d.ts +193 -0
  74. package/dist/src/lib/locks/checkout-lock.js +389 -0
  75. package/dist/src/lib/locks/index.d.ts +7 -4
  76. package/dist/src/lib/locks/index.js +5 -3
  77. package/dist/src/lib/locks/lock-manager.d.ts +101 -3
  78. package/dist/src/lib/locks/lock-manager.js +277 -8
  79. package/dist/src/lib/locks/types.d.ts +106 -3
  80. package/dist/src/lib/locks/types.js +44 -0
  81. package/dist/src/lib/mcp-config.d.ts +45 -0
  82. package/dist/src/lib/mcp-config.js +77 -1
  83. package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
  84. package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
  85. package/dist/src/lib/merge-check/command-result.d.ts +36 -0
  86. package/dist/src/lib/merge-check/command-result.js +56 -0
  87. package/dist/src/lib/merge-check/index.d.ts +4 -0
  88. package/dist/src/lib/merge-check/index.js +1 -1
  89. package/dist/src/lib/merge-check/report.js +5 -1
  90. package/dist/src/lib/merge-check/types.d.ts +9 -0
  91. package/dist/src/lib/merge-check/watch.d.ts +121 -0
  92. package/dist/src/lib/merge-check/watch.js +215 -0
  93. package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
  94. package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
  95. package/dist/src/lib/settings.d.ts +87 -0
  96. package/dist/src/lib/settings.js +106 -8
  97. package/dist/src/lib/shutdown.d.ts +42 -2
  98. package/dist/src/lib/shutdown.js +75 -5
  99. package/dist/src/lib/skills-check.d.ts +26 -0
  100. package/dist/src/lib/skills-check.js +31 -0
  101. package/dist/src/lib/stacks.d.ts +110 -1
  102. package/dist/src/lib/stacks.js +244 -5
  103. package/dist/src/lib/templates.d.ts +74 -0
  104. package/dist/src/lib/templates.js +171 -16
  105. package/dist/src/lib/test-tautology-detector.d.ts +10 -2
  106. package/dist/src/lib/test-tautology-detector.js +213 -12
  107. package/dist/src/lib/version-check.d.ts +31 -0
  108. package/dist/src/lib/version-check.js +45 -2
  109. package/dist/src/lib/workflow/batch-executor.d.ts +125 -6
  110. package/dist/src/lib/workflow/batch-executor.js +552 -60
  111. package/dist/src/lib/workflow/chain-resume.d.ts +13 -11
  112. package/dist/src/lib/workflow/chain-resume.js +24 -20
  113. package/dist/src/lib/workflow/completed-status.d.ts +52 -0
  114. package/dist/src/lib/workflow/completed-status.js +57 -0
  115. package/dist/src/lib/workflow/config-resolver.d.ts +51 -0
  116. package/dist/src/lib/workflow/config-resolver.js +143 -2
  117. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +22 -0
  118. package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
  119. package/dist/src/lib/workflow/drivers/aider.js +2 -0
  120. package/dist/src/lib/workflow/drivers/claude-code.d.ts +14 -0
  121. package/dist/src/lib/workflow/drivers/claude-code.js +25 -2
  122. package/dist/src/lib/workflow/effort-escalation.d.ts +73 -0
  123. package/dist/src/lib/workflow/effort-escalation.js +82 -0
  124. package/dist/src/lib/workflow/error-classifier.d.ts +4 -1
  125. package/dist/src/lib/workflow/error-classifier.js +4 -0
  126. package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
  127. package/dist/src/lib/workflow/git-diff-utils.js +77 -4
  128. package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
  129. package/dist/src/lib/workflow/heartbeat.js +90 -0
  130. package/dist/src/lib/workflow/log-writer.d.ts +54 -2
  131. package/dist/src/lib/workflow/log-writer.js +95 -6
  132. package/dist/src/lib/workflow/metrics-schema.d.ts +52 -9
  133. package/dist/src/lib/workflow/metrics-schema.js +33 -0
  134. package/dist/src/lib/workflow/metrics-writer.d.ts +11 -0
  135. package/dist/src/lib/workflow/phase-detection.d.ts +12 -0
  136. package/dist/src/lib/workflow/phase-detection.js +5 -1
  137. package/dist/src/lib/workflow/phase-executor.d.ts +203 -14
  138. package/dist/src/lib/workflow/phase-executor.js +467 -52
  139. package/dist/src/lib/workflow/phase-registry.js +2 -1
  140. package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
  141. package/dist/src/lib/workflow/platforms/github.js +152 -0
  142. package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
  143. package/dist/src/lib/workflow/qa-cache.js +22 -9
  144. package/dist/src/lib/workflow/ready-gate.d.ts +45 -4
  145. package/dist/src/lib/workflow/ready-gate.js +49 -12
  146. package/dist/src/lib/workflow/run-log-schema.d.ts +69 -0
  147. package/dist/src/lib/workflow/run-log-schema.js +78 -2
  148. package/dist/src/lib/workflow/run-orchestrator.d.ts +19 -0
  149. package/dist/src/lib/workflow/run-orchestrator.js +168 -20
  150. package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
  151. package/dist/src/lib/workflow/skills-preflight.js +112 -0
  152. package/dist/src/lib/workflow/spec-recommendation.d.ts +71 -0
  153. package/dist/src/lib/workflow/spec-recommendation.js +142 -0
  154. package/dist/src/lib/workflow/state-cleanup.js +9 -2
  155. package/dist/src/lib/workflow/state-manager.d.ts +33 -0
  156. package/dist/src/lib/workflow/state-manager.js +83 -0
  157. package/dist/src/lib/workflow/state-schema.d.ts +26 -0
  158. package/dist/src/lib/workflow/state-schema.js +47 -0
  159. package/dist/src/lib/workflow/types.d.ts +190 -4
  160. package/dist/src/lib/workflow/types.js +1 -0
  161. package/dist/src/lib/workflow/worktree-manager.d.ts +36 -3
  162. package/dist/src/lib/workflow/worktree-manager.js +111 -24
  163. package/dist/src/lib/workflow/worktree-resolver.d.ts +73 -0
  164. package/dist/src/lib/workflow/worktree-resolver.js +126 -0
  165. package/dist/src/mcp/tools/run.d.ts +9 -1
  166. package/dist/src/mcp/tools/run.js +20 -1
  167. package/dist/src/mcp/tools/status.js +6 -0
  168. package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
  169. package/dist/src/ui/tui/ElapsedTimer.js +12 -10
  170. package/dist/src/ui/tui/IssueBox.js +10 -2
  171. package/dist/src/ui/tui/row-cap.js +2 -1
  172. package/dist/src/ui/tui/theme.d.ts +7 -0
  173. package/dist/src/ui/tui/theme.js +9 -0
  174. package/package.json +10 -8
  175. package/templates/hooks/parallel-marker.sh +50 -0
  176. package/templates/hooks/post-tool.sh +20 -7
  177. package/templates/hooks/pre-tool.sh +303 -10
  178. package/templates/scripts/cleanup-worktree.sh +217 -33
  179. package/templates/scripts/new-feature.sh +289 -35
  180. package/templates/skills/_shared/references/subagent-types.md +7 -18
  181. package/templates/skills/_shared/references/trust-model.md +18 -0
  182. package/templates/skills/assess/SKILL.md +220 -391
  183. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  184. package/templates/skills/docs/SKILL.md +4 -4
  185. package/templates/skills/exec/SKILL.md +87 -13
  186. package/templates/skills/fullsolve/SKILL.md +130 -22
  187. package/templates/skills/loop/SKILL.md +94 -13
  188. package/templates/skills/merger/SKILL.md +100 -12
  189. package/templates/skills/qa/SKILL.md +396 -63
  190. package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
  191. package/templates/skills/qa/references/call-site-review.md +2 -2
  192. package/templates/skills/qa/references/code-review-checklist.md +2 -2
  193. package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  194. package/templates/skills/qa/references/quality-gates.md +21 -7
  195. package/templates/skills/qa/references/testing-requirements.md +1 -1
  196. package/templates/skills/qa/scripts/quality-checks.sh +11 -11
  197. package/templates/skills/release/SKILL.md +80 -1
  198. package/templates/skills/setup/SKILL.md +5 -5
  199. package/templates/skills/spec/SKILL.md +33 -15
  200. package/templates/skills/spec/references/recommended-workflow.md +14 -1
  201. package/templates/skills/test/SKILL.md +1 -1
  202. package/templates/skills/testgen/SKILL.md +23 -6
  203. package/dist/src/lib/phase-spinner.d.ts +0 -146
  204. package/dist/src/lib/phase-spinner.js +0 -255
  205. package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
  206. package/dist/src/lib/workflow/pr-operations.js +0 -326
  207. package/dist/src/lib/workflow/run-summary.d.ts +0 -36
  208. package/dist/src/lib/workflow/run-summary.js +0 -142
  209. package/templates/agents/sequant-explorer.md +0 -24
@@ -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, RateLimitError, resetsAtToMs, } 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
  /**
@@ -136,6 +138,241 @@ export function isWindowExhaustedRateLimit(error, now = Date.now()) {
136
138
  return false;
137
139
  return resetsAtToMs(resetsAt) - now > RATE_LIMIT_WINDOW_SKIP_THRESHOLD_MS;
138
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
+ }
139
376
  export function parseQaVerdict(output) {
140
377
  if (!output)
141
378
  return null;
@@ -160,6 +397,59 @@ export function parseQaVerdict(output) {
160
397
  const verdict = verdictMatch[1].toUpperCase().replace(/-/g, "_");
161
398
  return verdict;
162
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
+ }
163
453
  /**
164
454
  * Parse condensed QA summary from QA phase output (#434).
165
455
  *
@@ -313,26 +603,41 @@ export function resolveBaseRef(cwd) {
313
603
  return recorded.startsWith("origin/") ? recorded : `origin/${recorded}`;
314
604
  }
315
605
  /**
316
- * Check whether the exec phase produced any changes in the worktree.
317
- * Returns true if HEAD has commits unique to it relative to the resolved
318
- * 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).
319
623
  *
320
- * Uses `git rev-list --count <base>..HEAD` (commits reachable from HEAD
321
- * but not the base) instead of `git diff <base>..HEAD`, because the
322
- * two-dot diff also fires in reverse when the base has advanced past HEAD
323
- * — on stale branches that would falsely report "has commits" even when the
324
- * 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.
325
629
  *
326
630
  * The base ref defaults to `origin/main` but is overridden to the worktree's
327
- * recorded base (see #537) so zero-diff execs are still detected on
328
- * 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`).
329
633
  *
330
- * Fails open (returns true) on git errors a missing origin ref is better
331
- * 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).
332
637
  *
333
638
  * @internal Exported for testing only.
334
639
  */
335
- export function hasExecChanges(cwd) {
640
+ export function classifyExecChanges(cwd) {
336
641
  const baseRef = resolveBaseRef(cwd);
337
642
  let commitsAhead;
338
643
  try {
@@ -342,23 +647,53 @@ export function hasExecChanges(cwd) {
342
647
  commitsAhead = Number.parseInt(count, 10) > 0;
343
648
  }
344
649
  catch {
345
- return true;
650
+ return { kind: "unknown" };
346
651
  }
347
652
  if (commitsAhead)
348
- return true;
653
+ return { kind: "commits" };
349
654
  try {
350
- const porcelain = execFileSync("git", ["status", "--porcelain"], {
351
- cwd,
352
- stdio: "pipe",
353
- })
354
- .toString()
355
- .trim();
356
- 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" };
357
661
  }
358
662
  catch {
359
- return true;
663
+ return { kind: "unknown" };
360
664
  }
361
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
+ }
362
697
  /**
363
698
  * Map a successful AgentPhaseResult to a PhaseResult, applying phase-specific
364
699
  * guards that catch agent sessions which returned success without producing
@@ -405,12 +740,20 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
405
740
  };
406
741
  }
407
742
  if (!verdict) {
408
- // #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";
409
752
  return {
410
753
  phase,
411
754
  success: false,
412
755
  durationSeconds,
413
- error: "QA completed without a parseable verdict",
756
+ error,
414
757
  ...resume,
415
758
  output: agentResult.output,
416
759
  summary,
@@ -428,17 +771,38 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
428
771
  ...tails,
429
772
  };
430
773
  }
431
- if (phase === "exec" && !hasExecChanges(cwd)) {
432
- // #534: an exec phase that produced nothing is not success.
433
- return {
434
- phase,
435
- success: false,
436
- durationSeconds,
437
- error: "exec produced no changes (no commits, no uncommitted work)",
438
- ...resume,
439
- output: agentResult.output,
440
- ...tails,
441
- };
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
+ }
442
806
  }
443
807
  return {
444
808
  phase,
@@ -548,7 +912,8 @@ async function executePhase(issueNumber, phase, config, resumeHandle, worktreePa
548
912
  let files;
549
913
  if (config.agent && config.agent !== "claude-code") {
550
914
  try {
551
- 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`], {
552
917
  cwd,
553
918
  encoding: "utf-8",
554
919
  stdio: ["pipe", "pipe", "pipe"],
@@ -667,6 +1032,12 @@ async function executePhase(issueNumber, phase, config, resumeHandle, worktreePa
667
1032
  const eligibleHandle = resumeHandle && driver.canResume(resumeHandle, cwd)
668
1033
  ? resumeHandle
669
1034
  : undefined;
1035
+ // #914: resolved per-phase model/effort, if this phase has one. Both
1036
+ // ExecutionConfig producers (buildExecutionConfig, ready-gate.ts's
1037
+ // buildPhaseConfig) populate `phasePolicies` the same way, so this is the
1038
+ // single site that turns it into driver-facing fields — see the doc
1039
+ // comment on ExecutionConfig.phasePolicies.
1040
+ const phasePolicy = config.phasePolicies?.[phase];
670
1041
  // Build AgentExecutionConfig for the driver
671
1042
  const agentConfig = {
672
1043
  cwd,
@@ -678,6 +1049,10 @@ async function executePhase(issueNumber, phase, config, resumeHandle, worktreePa
678
1049
  resumeHandle: eligibleHandle,
679
1050
  sessionId: eligibleHandle?.token,
680
1051
  files,
1052
+ ...(phasePolicy?.model ? { model: phasePolicy.model } : {}),
1053
+ ...(phasePolicy?.effort
1054
+ ? { effort: phasePolicy.effort }
1055
+ : {}),
681
1056
  onOutput: config.verbose || reportActivity
682
1057
  ? (text) => {
683
1058
  if (config.verbose) {
@@ -740,7 +1115,16 @@ export async function executePhaseWithRetry(issueNumber, phase, config, resumeHa
740
1115
  /** @internal Injected for testing — defaults to module-level executePhase */
741
1116
  executePhaseFn = executePhase,
742
1117
  /** @internal Injected for testing — defaults to setTimeout-based delay */
743
- delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
1118
+ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1119
+ /**
1120
+ * Per-issue auto-wait accounting (#804). Deliberately the LAST parameter:
1121
+ * the #761/#799 regression tests call this function positionally with
1122
+ * `executePhaseFn` at 8 and `delayFn` at 9, and AC-2 requires those tests to
1123
+ * pass unmodified. Defaults to a fresh disabled-or-config-derived ledger, so
1124
+ * a caller that does not thread one still gets correct (bounded) behavior —
1125
+ * just scoped to this phase rather than the issue.
1126
+ */
1127
+ autoWaitLedger = createAutoWaitLedger(config.autoWaitMinutes)) {
744
1128
  // Skip retry logic if explicitly disabled
745
1129
  if (config.retry === false) {
746
1130
  return executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
@@ -756,18 +1140,32 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
756
1140
  phaseRegistry.get(phase).retryStrategy?.maxRetries === 0;
757
1141
  let lastResult;
758
1142
  if (skipColdStartRetry) {
759
- // Single attempt — no cold-start retry loop
760
- lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
761
- if (lastResult.success) {
762
- return lastResult;
763
- }
764
- // Turn-capped phase (#739): incomplete-but-not-hard-failed. A retry cannot
765
- // un-cap a turn limit, so short-circuit before any fallback — same rationale
766
- // as the billing skip (#732), but capped must skip *all* retries (incl.
767
- // cold-start), so an explicit early return is required, not just a guard
768
- // flag at the MCP gate.
769
- if (lastResult.capped) {
770
- return lastResult;
1143
+ // Single attempt — no cold-start retry loop. The only thing that re-enters
1144
+ // this loop is a granted auto-wait (#804): `maxRetries: 0` means "a retry
1145
+ // cannot help", but a reopened rate-limit window is precisely the case
1146
+ // where it can. Bounded by AUTO_WAIT_MAX_WAITS.
1147
+ for (;;) {
1148
+ lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
1149
+ if (lastResult.success) {
1150
+ return lastResult;
1151
+ }
1152
+ // Turn-capped phase (#739): incomplete-but-not-hard-failed. A retry cannot
1153
+ // un-cap a turn limit, so short-circuit before any fallback — same rationale
1154
+ // as the billing skip (#732), but capped must skip *all* retries (incl.
1155
+ // cold-start), so an explicit early return is required, not just a guard
1156
+ // flag at the MCP gate.
1157
+ if (lastResult.capped) {
1158
+ return lastResult;
1159
+ }
1160
+ // #804: without this, `--auto-wait` would silently not cover the `loop`
1161
+ // phase — the one phase registered with `maxRetries: 0` — and a window
1162
+ // limit hitting /loop would still hard-halt with the flag set.
1163
+ const decision = shouldAutoWaitForReset(lastResult.structuredError, autoWaitLedger);
1164
+ if (!decision)
1165
+ break;
1166
+ const { aborted } = await performAutoWait(issueNumber, phase, config, decision, autoWaitLedger, delayFn, shutdownManager, spinner);
1167
+ if (aborted)
1168
+ return lastResult;
771
1169
  }
772
1170
  }
773
1171
  else {
@@ -795,6 +1193,23 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
795
1193
  // before the duration branch because a rate-limit rejection typically
796
1194
  // fails fast and would otherwise be mistaken for a cold-start failure.
797
1195
  if (isWindowExhaustedRateLimit(lastResult.structuredError)) {
1196
+ // #804: opt-in auto-wait. Sits between phase attempts (never inside a
1197
+ // spawn), so `phaseTimeout` is not implicated. Returns null — and this
1198
+ // falls through to today's halt — whenever auto-wait is off, the
1199
+ // budget/bound is spent, or the failure is billing rather than a rate
1200
+ // limit.
1201
+ const decision = shouldAutoWaitForReset(lastResult.structuredError, autoWaitLedger);
1202
+ if (decision) {
1203
+ const { aborted } = await performAutoWait(issueNumber, phase, config, decision, autoWaitLedger, delayFn, shutdownManager, spinner);
1204
+ if (!aborted) {
1205
+ // A wait is not a cold-start retry, so it must not consume one:
1206
+ // the loop's `attempt++` cancels this out. Bounded by
1207
+ // AUTO_WAIT_MAX_WAITS, which is what makes this safe from looping.
1208
+ attempt--;
1209
+ continue;
1210
+ }
1211
+ return lastResult;
1212
+ }
798
1213
  if (config.verbose) {
799
1214
  bracketedConsoleLog(spinner, chalk.yellow(`\n ✕ ${lastResult.error ?? "Rate limited"} — window exhausted, skipping retries`));
800
1215
  }
@@ -190,13 +190,14 @@ Run relevant commands and capture their output for review.`,
190
190
  phaseRegistry.register({
191
191
  name: "qa",
192
192
  skill: "qa",
193
- promptTemplate: "Review the implementation for GitHub issue #{issue} against acceptance criteria. Run the /qa {issue} workflow.",
193
+ promptTemplate: "Review the implementation for GitHub issue #{issue} against acceptance criteria. Run the /qa {issue} workflow. Your turn is one-shot: you must emit a `### Verdict:` line before the turn ends — deferring to a later turn or a background CI poll is not available.",
194
194
  requiresWorktree: true,
195
195
  driverOverrides: {
196
196
  aider: {
197
197
  promptTemplate: `Review the changes for GitHub issue #{issue}.
198
198
  Run 'npm test' and 'npm run build' to verify everything works.
199
199
  Check each acceptance criterion from the issue comments.
200
+ Your turn is one-shot: emit a verdict now — do not defer to a later turn or a background CI poll. If the tree is unreviewable, emit AC_NOT_MET (code-caused red CI) or NEEDS_VERIFICATION (pending/infra-blocked CI); never end without a verdict.
200
201
  Output a verdict: READY_FOR_MERGE, AC_MET_BUT_NOT_A_PLUS, or AC_NOT_MET
201
202
  with format "### Verdict: <VERDICT>" followed by an explanation.`,
202
203
  },