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
@@ -10,13 +10,19 @@
10
10
  import chalk from "chalk";
11
11
  import { spawnSync } from "child_process";
12
12
  import { createPhaseLogFromTiming } from "./log-writer.js";
13
+ import { withEscalatedEffort } from "./effort-escalation.js";
13
14
  import { classifyError, errorTypeToCategory, } from "./error-classifier.js";
14
- import { getGitDiffStats, getCommitHash } from "./git-diff-utils.js";
15
+ import { getGitDiffStats, getCommitHash, resolveDiffBase, } from "./git-diff-utils.js";
15
16
  import { createCheckpointCommit, rebaseBeforePR, createPR, readCacheMetrics, filterResumedPhases, } from "./worktree-manager.js";
16
- import { executePhaseWithRetry } from "./phase-executor.js";
17
+ import { AUTO_WAIT_BUFFER_MS, createAutoWaitLedger, executePhaseWithRetry, hasExecChanges, isWindowExhaustedRateLimit, } from "./phase-executor.js";
18
+ import { BillingError, RateLimitError, resetsAtToMs } from "../errors.js";
17
19
  import { parseBodyDependencyMarkers } from "./dependency-markers.js";
18
- import { detectPhasesFromLabels, parseRecommendedWorkflow, determinePhasesForIssue, DOCS_LABELS, } from "./phase-mapper.js";
20
+ import { determinePhasesForIssue, DOCS_LABELS } from "./phase-mapper.js";
21
+ import { resolveSpecRecommendation } from "./spec-recommendation.js";
19
22
  import { activateRelay, deactivateRelay, } from "../relay/activation.js";
23
+ import { getSettings } from "../settings.js";
24
+ import { GitHubProvider } from "./platforms/github.js";
25
+ import { runReadyGate, parseNonGoals, } from "./ready-gate.js";
20
26
  /**
21
27
  * Emit a structured progress line to stderr for MCP progress notifications.
22
28
  * Only emits when running under an orchestrator (e.g., MCP server).
@@ -28,29 +34,95 @@ import { activateRelay, deactivateRelay, } from "../relay/activation.js";
28
34
  * @param extra - Optional fields: durationSeconds (on complete), error (on failed)
29
35
  */
30
36
  /**
31
- * Wrap an `ExecutionConfig` with an `onActivity` hook that re-emits each
32
- * agent-output ping as a `"activity"` progress event for the dashboard (#543).
37
+ * Wrap an `ExecutionConfig` with the runtime liveness hooks:
38
+ * - `onActivity` — re-emits each agent-output ping as an `"activity"` progress
39
+ * event for the dashboard (#543).
40
+ * - `onAutoWait` — re-emits each auto-wait tick as a `"waiting"` progress
41
+ * event so the renderer and heartbeat can show the pause and its wake time
42
+ * (#804 AC-7). Under `SEQUANT_ORCHESTRATOR` it additionally emits throttled
43
+ * `SEQUANT_PROGRESS` waiting lines (#860): an MCP-driven wait was previously
44
+ * invisible on the JSON channel — indistinguishable from a hang — AND was
45
+ * killed by the MCP inactivity timeout, which resets on progress lines.
46
+ * Optionally notifies `onWaitTransition` on wait start/end so the caller
47
+ * can persist the wait to issue state (`sequant status` truthfulness).
33
48
  *
34
- * Returns the input config unchanged when no `onProgress` callback is set,
35
- * so non-TUI runs pay no overhead.
49
+ * Returns the input config unchanged when there is no consumer at all (no
50
+ * `onProgress`, no orchestrator channel, no transition callback), so plain
51
+ * non-TUI runs pay no overhead.
36
52
  *
37
53
  * @internal Exported for testing only
38
54
  */
39
- export function withActivityHook(base, issueNumber, phase, onProgress) {
40
- if (!onProgress)
55
+ export function withActivityHook(base, issueNumber, phase, onProgress, onWaitTransition) {
56
+ const orchestrated = Boolean(process.env.SEQUANT_ORCHESTRATOR);
57
+ if (!onProgress && !orchestrated && !onWaitTransition)
41
58
  return base;
59
+ // Throttle the orchestrator waiting lines: the wait ticks every ~15s, and
60
+ // one JSON line per minute is enough to keep the MCP inactivity timeout
61
+ // alive (it resets on every SEQUANT_PROGRESS line) without bloating the
62
+ // captured stderr over a multi-hour pause.
63
+ let lastWaitLineAt = 0;
64
+ let waitAnnounced = false;
42
65
  return {
43
66
  ...base,
44
67
  onActivity: (text) => {
45
68
  try {
46
- onProgress(issueNumber, phase, "activity", { text });
69
+ onProgress?.(issueNumber, phase, "activity", { text });
47
70
  }
48
71
  catch {
49
72
  // Activity events must never disrupt the run.
50
73
  }
51
74
  },
75
+ onAutoWait: (notice) => {
76
+ try {
77
+ onProgress?.(issueNumber, phase, "waiting", {
78
+ text: notice.message,
79
+ // Omitted on the terminal notice — its absence is what tells the
80
+ // consumers to clear the waiting state.
81
+ wakeAtMs: notice.done ? undefined : notice.wakeAtMs,
82
+ });
83
+ }
84
+ catch {
85
+ // Liveness notices must never disrupt the run.
86
+ }
87
+ try {
88
+ if (notice.done) {
89
+ if (waitAnnounced) {
90
+ waitAnnounced = false;
91
+ lastWaitLineAt = 0;
92
+ emitProgressLine(issueNumber, phase, "waiting", {
93
+ remainingMs: 0,
94
+ });
95
+ onWaitTransition?.(null);
96
+ }
97
+ }
98
+ else {
99
+ if (!waitAnnounced) {
100
+ waitAnnounced = true;
101
+ onWaitTransition?.(notice.wakeAtMs);
102
+ }
103
+ const now = Date.now();
104
+ if (now - lastWaitLineAt >= AUTO_WAIT_PROGRESS_LINE_INTERVAL_MS) {
105
+ lastWaitLineAt = now;
106
+ emitProgressLine(issueNumber, phase, "waiting", {
107
+ wakeAtMs: notice.wakeAtMs,
108
+ remainingMs: notice.remainingMs,
109
+ });
110
+ }
111
+ }
112
+ }
113
+ catch {
114
+ // Liveness notices must never disrupt the run.
115
+ }
116
+ },
52
117
  };
53
118
  }
119
+ /**
120
+ * Cadence of orchestrator-channel waiting lines during an auto-wait (#860).
121
+ * See {@link withActivityHook}.
122
+ *
123
+ * @internal Exported for testing only
124
+ */
125
+ export const AUTO_WAIT_PROGRESS_LINE_INTERVAL_MS = 60_000;
54
126
  /**
55
127
  * Build enriched prompt context for the /loop phase from a failed phase result (#488).
56
128
  * Passes QA verdict, failed ACs, and error directly so the /loop skill doesn't need
@@ -94,6 +166,17 @@ export function emitProgressLine(issue, phase, event = "start", extra) {
94
166
  if (extra?.iteration !== undefined) {
95
167
  payload.iteration = extra.iteration;
96
168
  }
169
+ // #860: auto-wait liveness. `wakeAtMs` is present while waiting and absent
170
+ // on the terminal notice (`remainingMs: 0`), mirroring the in-process
171
+ // ProgressCallback convention. Every line — waiting included — resets the
172
+ // MCP inactivity timeout (prefix-matched in spawnAsync), which is what
173
+ // keeps a legitimate multi-hour pause from being killed as "no progress".
174
+ if (extra?.wakeAtMs !== undefined) {
175
+ payload.wakeAtMs = extra.wakeAtMs;
176
+ }
177
+ if (extra?.remainingMs !== undefined) {
178
+ payload.remainingMs = extra.remainingMs;
179
+ }
97
180
  const line = `SEQUANT_PROGRESS:${JSON.stringify(payload)}\n`;
98
181
  process.stderr.write(line);
99
182
  }
@@ -254,8 +337,53 @@ export function getEnvConfig() {
254
337
  if (process.env.SEQUANT_SECURITY_REVIEW === "true") {
255
338
  config.securityReview = true;
256
339
  }
340
+ // #804: the env layer for --auto-wait. `resolveRunOptions` does NOT route
341
+ // through the `ConfigResolver` class (it uses `??` chains + this function),
342
+ // so numeric coercion is explicit here rather than free via `coerceEnvValue`.
343
+ // Non-numeric and negative values are ignored so a typo cannot silently
344
+ // enable an unbounded wait.
345
+ if (process.env.SEQUANT_AUTO_WAIT_MINUTES) {
346
+ const autoWait = parseInt(process.env.SEQUANT_AUTO_WAIT_MINUTES, 10);
347
+ if (!isNaN(autoWait) && autoWait >= 0) {
348
+ config.autoWaitMinutes = autoWait;
349
+ }
350
+ }
257
351
  return config;
258
352
  }
353
+ /**
354
+ * Record an issue's completion in the run log in ONE place (#879): PR info, the
355
+ * PR-failure status flip, then finalize. Extracted so every batch loop shares a
356
+ * single completion sequence and cannot drift.
357
+ *
358
+ * The #879 defect was exactly such a drift: `markIssueFailed` was wired into
359
+ * `executeBatch`'s loop, but the live `sequant run` path is
360
+ * `RunOrchestrator.executeOneIssue`, which called `setPRInfo` + `completeIssue`
361
+ * without it — so a real run left the run-log status at `success` on a
362
+ * PR-creation failure. Both call sites now go through this helper.
363
+ *
364
+ * A PR-creation failure occurs after every phase has been logged, so
365
+ * `deriveIssueLogStatus` (last run at phase-log time) leaves the issue at
366
+ * `success`; the flip here is what counts it under `failed`. Safe post-hoc:
367
+ * no further phase is logged before `completeIssue`.
368
+ */
369
+ export function recordIssueCompletion(logWriter, result, issueNumber) {
370
+ if (result.prNumber && result.prUrl) {
371
+ logWriter.setPRInfo(result.prNumber, result.prUrl, issueNumber);
372
+ }
373
+ if (result.prCreationError) {
374
+ logWriter.markIssueFailed(issueNumber);
375
+ }
376
+ logWriter.completeIssue(issueNumber);
377
+ }
378
+ /**
379
+ * @deprecated No live caller — `sequant run` executes issues via
380
+ * `RunOrchestrator.executeOneIssue`; this survives only as a
381
+ * `commands/run-compat` re-export. Do not build a new execution loop on it:
382
+ * any path that completes an issue MUST go through
383
+ * {@link recordIssueCompletion}, or the #879 status-drift returns (a
384
+ * completion path that skips the PR-failure flip logs a failed issue as
385
+ * `success`). Slated for removal with the run-compat surface.
386
+ */
259
387
  export async function executeBatch(issueNumbers, batchCtx) {
260
388
  const { config, options, issueInfoMap, worktreeMap, logWriter, stateManager, shutdownManager, packageManager, baseBranch, onProgress, onPhasePlan, phasePauseHandle, } = batchCtx;
261
389
  const results = [];
@@ -291,13 +419,10 @@ export async function executeBatch(issueNumbers, batchCtx) {
291
419
  };
292
420
  const result = await runIssueWithLogging(ctx);
293
421
  results.push(result);
294
- // Record PR info in log before completing issue
295
- if (logWriter && result.prNumber && result.prUrl) {
296
- logWriter.setPRInfo(result.prNumber, result.prUrl);
297
- }
298
- // Complete issue logging
422
+ // Record PR info, flip status on PR failure (#879), and finalize — all via
423
+ // the shared helper so this loop and the orchestrator path cannot drift.
299
424
  if (logWriter) {
300
- logWriter.completeIssue();
425
+ recordIssueCompletion(logWriter, result, issueNumber);
301
426
  }
302
427
  }
303
428
  return results;
@@ -323,6 +448,192 @@ export function deriveFailureCategory(phaseResults) {
323
448
  classifyError(failedPhase.stderrTail ?? [], failedPhase.exitCode);
324
449
  return errorTypeToCategory(typedError);
325
450
  }
451
+ /**
452
+ * "Halt, don't loop" predicate for the outer `-Q` quality loop (#799).
453
+ *
454
+ * A billing / out-of-credits failure (`BillingError`) or a window-exhausted
455
+ * rate limit (reset hours away, per `isWindowExhaustedRateLimit`) cannot be
456
+ * recovered by re-running the phase — every retry re-spawns into the same
457
+ * closed window and, worse, mislabels the halt as a downstream
458
+ * `QA completed without a parseable verdict`. Mirrors the `haltedByCap` (#739)
459
+ * treatment: surface the real cause and halt so the user resumes once credits
460
+ * or the rate-limit window are restored.
461
+ *
462
+ * A transient / metadata-absent rate limit is NOT a halt: it returns false and
463
+ * keeps today's outer-loop behavior (the inner retry ladder in `phase-executor`
464
+ * handles its backoff, per #761 AC-4/AC-9).
465
+ *
466
+ * @internal Exported for testing
467
+ */
468
+ export function isBillingOrWindowHalt(result) {
469
+ return isBillingHalt(result) || isWindowHalt(result);
470
+ }
471
+ /**
472
+ * The billing half of {@link isBillingOrWindowHalt} (#804 AC-8).
473
+ *
474
+ * Split out because the two causes stopped being interchangeable once
475
+ * `--auto-wait` existed: a closed window can now reopen on its own, while
476
+ * out-of-credits cannot — credits are purchased, not waited out. Callers that
477
+ * need to reason about recoverability must be able to tell them apart.
478
+ *
479
+ * @internal Exported for testing
480
+ */
481
+ export function isBillingHalt(result) {
482
+ return result.structuredError instanceof BillingError;
483
+ }
484
+ /**
485
+ * The rate-limit-window half of {@link isBillingOrWindowHalt} (#804 AC-8).
486
+ *
487
+ * NOTE — this predicate needed no behavioral change for auto-wait, and that is
488
+ * a deliberate finding rather than an oversight. AC-8 anticipated that a phase
489
+ * which waits and then succeeds would still halt the `-Q` loop. It cannot:
490
+ * every call site (`:~700` spec, `:~1030` progress label, `:~1150` halt flag)
491
+ * sits inside the `else` of an `if (result.success)`, so a successful
492
+ * post-wait result never reaches this predicate at all. When auto-wait does
493
+ * NOT fire — the default, an exhausted budget, or a spent wait bound — the
494
+ * result is still a failure carrying a window-exhausted `RateLimitError`, and
495
+ * halting is then the correct outcome (#799 behavior, preserved exactly).
496
+ *
497
+ * @internal Exported for testing
498
+ */
499
+ export function isWindowHalt(result) {
500
+ return isWindowExhaustedRateLimit(result.structuredError);
501
+ }
502
+ /**
503
+ * Human-readable halt reason for a billing / rate-limit-window failure (#799
504
+ * AC-3). Surfaces the driver's real cause verbatim — `result.error` is already
505
+ * the well-formatted message the driver built via `formatRateLimitMessage`
506
+ * (`Out of credits` for billing, `Rate limited — resets at <local time>` for a
507
+ * throttle with a known reset), so the phase-failed line and run summary name
508
+ * the actual cause instead of a downstream `QA completed without a parseable
509
+ * verdict`.
510
+ *
511
+ * Do NOT re-append `resetsAt` here: the rate-limit message already carries the
512
+ * reset time, and doing so produced a doubled, timezone-inconsistent string
513
+ * (`… resets at 07-24 14:32 — resets at 2026-…Z`). Credits failures carry no
514
+ * reset time by design (they need purchasing, not a window wait).
515
+ *
516
+ * @internal Exported for testing
517
+ */
518
+ export function billingHaltReason(result) {
519
+ return result.error ?? "Out of credits";
520
+ }
521
+ /**
522
+ * Epoch ms after which a waitable-window halt can be re-entered (#892 AC-1):
523
+ * the window's `resetsAt` normalized to ms plus the same buffer auto-wait
524
+ * applies (`AUTO_WAIT_BUFFER_MS`), so in-process waits and durable halts wake
525
+ * on the same clock. Returns `null` when the result carries no future-reset
526
+ * rate-limit window — callers must then skip the `windowHalt` write rather
527
+ * than invent a resume time.
528
+ *
529
+ * @internal Exported for testing
530
+ */
531
+ export function windowHaltResumeAtMs(result) {
532
+ if (!isWindowHalt(result))
533
+ return null;
534
+ const err = result.structuredError;
535
+ if (!(err instanceof RateLimitError))
536
+ return null;
537
+ const resetsAt = err.metadata.resetsAt;
538
+ if (typeof resetsAt !== "number")
539
+ return null;
540
+ return resetsAtToMs(resetsAt) + AUTO_WAIT_BUFFER_MS;
541
+ }
542
+ /**
543
+ * Persist or clear the durable `windowHalt` record for a phase result (#892).
544
+ *
545
+ * A waitable-window failure writes `resumeAt` (preserving any re-entry count);
546
+ * every other outcome — success, or a failure whose cause is not a waitable
547
+ * window — clears the record so `sequant resume` never re-enters on a stale
548
+ * or non-waitable halt. Never throws: state bookkeeping must not mask the
549
+ * phase result it describes.
550
+ */
551
+ async function recordWindowHaltState(stateManager, issueNumber, phase, result) {
552
+ if (!stateManager)
553
+ return;
554
+ try {
555
+ const resumeAtMs = result.success ? null : windowHaltResumeAtMs(result);
556
+ if (resumeAtMs !== null) {
557
+ await stateManager.updateWindowHalt(issueNumber, phase, resumeAtMs);
558
+ }
559
+ else {
560
+ await stateManager.clearWindowHalt(issueNumber);
561
+ }
562
+ }
563
+ catch {
564
+ // State tracking errors shouldn't stop execution
565
+ }
566
+ }
567
+ /**
568
+ * Run the post-QA ready gate (#817) for a single issue at the run path's
569
+ * post-success / pre-PR seam.
570
+ *
571
+ * Mirrors `src/commands/ready.ts`'s driver: resolve the policy from
572
+ * `settings.ready.policy` (no per-run override — AC-4 forbids new surface),
573
+ * parse the issue's Non-Goals for report-only classification, wrap
574
+ * `executePhaseWithRetry` as the gate's phase runner, and delegate the whole
575
+ * qa→loop→qa loop to `runReadyGate`. The token budget stays disabled (parity
576
+ * with `sequant ready` invoked without `--budget`); the `maxIterations` cap
577
+ * already bounds cost.
578
+ *
579
+ * A gate failure is non-fatal: the standard-phase work is already committed to
580
+ * the worktree, so we log a warning and fall through to normal PR creation
581
+ * rather than aborting the run (the issue then keeps its `ready_for_merge`
582
+ * status — the run has degraded to a standard run, and nothing about the work
583
+ * is actually blocked).
584
+ *
585
+ * The failure is returned rather than swallowed. A dropped gate must not be
586
+ * invisible: the caller opted in with `--ready-gate`, so a run whose gate never
587
+ * executed has to look different in the summary from one that gated cleanly —
588
+ * otherwise a crashed gate is indistinguishable from an approved one, and the
589
+ * whole point of the flag (a second look actually happened) is silently lost.
590
+ */
591
+ async function runReadyGateForIssue(args) {
592
+ const { issueNumber, worktreePath, config, shutdownManager, phasePauseHandle, onProgress, log, } = args;
593
+ const runGate = args.runGate ?? runReadyGate;
594
+ const getSettingsFn = args.getSettingsFn ?? getSettings;
595
+ const fetchBody = args.fetchBody ??
596
+ ((n) => new GitHubProvider().fetchIssueBodySync(String(n)));
597
+ try {
598
+ const settings = await getSettingsFn();
599
+ const policy = settings.ready.policy;
600
+ // Non-Goals feed the gate's report-only classification (ac mode never
601
+ // auto-fixes Non-Goal-touching findings). Best-effort — an unavailable
602
+ // body just yields no Non-Goals.
603
+ const body = fetchBody(issueNumber);
604
+ const nonGoals = body ? parseNonGoals(body) : [];
605
+ // The gate's phase runner: same executePhaseWithRetry wrapper ready.ts
606
+ // uses, bound to this issue's worktree, shutdown manager, and pause handle.
607
+ const runPhase = (phase, phaseConfig, wt) => executePhaseWithRetry(issueNumber, phase, phaseConfig, undefined, wt, shutdownManager, phasePauseHandle);
608
+ log(chalk.blue(`\n Ready gate (#817) — policy: ${policy}, max iterations: ${config.maxIterations}`));
609
+ const result = await runGate({
610
+ issueNumber,
611
+ worktreePath,
612
+ policy,
613
+ maxIterations: config.maxIterations,
614
+ // AC-4: budget stays disabled on the run path (parity with `ready` sans
615
+ // `--budget`); maxIterations bounds cost.
616
+ tokenBudget: undefined,
617
+ nonGoals,
618
+ phaseTimeout: config.phaseTimeout,
619
+ mcp: config.mcp,
620
+ verbose: config.verbose,
621
+ runPhase,
622
+ onProgress,
623
+ });
624
+ log(result.ready
625
+ ? chalk.green(` ✓ Ready gate: ${result.reason} — awaiting human merge (never merged)`)
626
+ : chalk.yellow(` ⚠️ Ready gate halted: ${result.reason} — needs human review`));
627
+ return { result };
628
+ }
629
+ catch (err) {
630
+ // Non-fatal: keep the run going to PR with the standard status, but hand
631
+ // the reason back so the summary can say the gate did NOT run.
632
+ const error = err instanceof Error ? err.message : String(err);
633
+ log(chalk.yellow(` ⚠️ Ready gate failed for #${issueNumber}: ${error} — continuing to PR without the gate.`));
634
+ return { error };
635
+ }
636
+ }
326
637
  export async function runIssueWithLogging(ctx) {
327
638
  // Destructure context for use throughout the function
328
639
  const { issueNumber, config, options, title: issueTitle, labels, services: { logWriter, stateManager, shutdownManager }, worktree, chain, packageManager, baseBranch, onProgress, onPhasePlan, phasePauseHandle, } = ctx;
@@ -335,6 +646,10 @@ export async function runIssueWithLogging(ctx) {
335
646
  let loopTriggered = false;
336
647
  // Cross-phase resume token, driver-tagged and cwd-bound (#674).
337
648
  let resumeHandle;
649
+ // #804 AC-6: ONE ledger for the whole issue. Created here rather than inside
650
+ // `executePhaseWithRetry` because the bound and the budget are per-issue —
651
+ // a per-phase ledger would silently grant every phase its own full budget.
652
+ const autoWaitLedger = createAutoWaitLedger(config.autoWaitMinutes);
338
653
  // In parallel mode, suppress per-issue terminal output to prevent interleaving.
339
654
  // The caller (run.ts) handles progress display via updateProgress().
340
655
  const log = config.parallel ? () => { } : console.log.bind(console);
@@ -368,6 +683,19 @@ export async function runIssueWithLogging(ctx) {
368
683
  }
369
684
  }
370
685
  }
686
+ // #860: persist auto-wait transitions to issue state so `sequant status`
687
+ // reports "waiting until <wake>" instead of an hours-stale in-progress
688
+ // phase. Fire-and-forget — state bookkeeping must never disturb the wait
689
+ // it describes.
690
+ const makeWaitTransition = (phase) => stateManager
691
+ ? (wakeAtMs) => {
692
+ void stateManager
693
+ .updateAutoWait(issueNumber, phase, wakeAtMs)
694
+ .catch(() => {
695
+ // Never let state bookkeeping disturb a live wait.
696
+ });
697
+ }
698
+ : undefined;
371
699
  // Activate relay (#383) if enabled. Tolerates errors — relay must never
372
700
  // block the underlying run.
373
701
  let relayActivation = null;
@@ -420,8 +748,10 @@ export async function runIssueWithLogging(ctx) {
420
748
  }
421
749
  const specStartTime = new Date();
422
750
  // Note: spec runs in main repo (not worktree) for planning
423
- const specResult = await executePhaseWithRetry(issueNumber, "spec", withActivityHook(config, issueNumber, "spec", onProgress), resumeHandle, worktreePath, // Will be ignored for spec (non-isolated phase)
424
- shutdownManager, phasePauseHandle);
751
+ const specResult = await executePhaseWithRetry(issueNumber, "spec", withActivityHook(config, issueNumber, "spec", onProgress, makeWaitTransition("spec")), resumeHandle, worktreePath, // Will be ignored for spec (non-isolated phase)
752
+ shutdownManager, phasePauseHandle, undefined, // executePhaseFn — use the default
753
+ undefined, // delayFn — use the default
754
+ autoWaitLedger);
425
755
  const specEndTime = new Date();
426
756
  if (specResult.resumeHandle) {
427
757
  resumeHandle = specResult.resumeHandle;
@@ -450,16 +780,19 @@ export async function runIssueWithLogging(ctx) {
450
780
  }
451
781
  }
452
782
  else {
453
- // Mirror the main phase loop (#739): a turn-capped spec phase surfaces the
454
- // distinct "partial output preserved" signal rather than a generic failure
455
- // reason, so the cap is recognizable on the spec path too (it has its own
456
- // failure handling, separate from the main loop). The partial output is
457
- // preserved in `phaseResults` (pushed above) and the run still halts via
458
- // the early return below.
783
+ // Mirror the main phase loop (#739/#799): a turn-capped spec phase surfaces
784
+ // the distinct "partial output preserved" signal, and a billing /
785
+ // rate-limit-window failure names the real cause so both are recognizable
786
+ // on the spec path too (it has its own failure handling, separate from the
787
+ // main loop). The spec phase already halts on any failure via the early
788
+ // return below; routing billing through billingHaltReason only keeps the
789
+ // message/fallback symmetric with the main loop.
459
790
  const extra = {
460
791
  error: specResult.capped
461
792
  ? "turn cap reached — partial output preserved (resume to continue)"
462
- : (specResult.error ?? "unknown"),
793
+ : isBillingOrWindowHalt(specResult)
794
+ ? billingHaltReason(specResult)
795
+ : (specResult.error ?? "unknown"),
463
796
  };
464
797
  emitProgressLine(issueNumber, "spec", "failed", extra);
465
798
  try {
@@ -517,6 +850,9 @@ export async function runIssueWithLogging(ctx) {
517
850
  // State tracking errors shouldn't stop execution
518
851
  }
519
852
  }
853
+ // Durable halt-and-resume (#892 AC-1): a waitable-window spec halt writes
854
+ // `resumeAt` so `sequant resume` can re-enter after the window reopens.
855
+ await recordWindowHaltState(stateManager, issueNumber, "spec", specResult);
520
856
  if (!specResult.success) {
521
857
  const durationSeconds = (Date.now() - startTime) / 1000;
522
858
  // Archive relay state on early exit (spec failure).
@@ -542,22 +878,35 @@ export async function runIssueWithLogging(ctx) {
542
878
  failureCategory: deriveFailureCategory(phaseResults),
543
879
  };
544
880
  }
545
- // Parse recommended workflow from spec output
546
- const parsedWorkflow = specResult.output
547
- ? parseRecommendedWorkflow(specResult.output)
548
- : null;
549
- if (parsedWorkflow) {
550
- // Remove spec from phases since we already ran it
551
- phases = parsedWorkflow.phases.filter((p) => p !== "spec");
552
- detectedQualityLoop = parsedWorkflow.qualityLoop;
553
- log(chalk.gray(` Spec recommends: ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
881
+ // Resolve the spec→run phase recommendation through the ordered chain
882
+ // comment-marker comment-prose → chat-text → label-fallback (#921).
883
+ // Chat-text parsing alone is nondeterministic: the spec agent's plan
884
+ // comment is the durable artifact, but the old code only ever looked at
885
+ // ephemeral chat output, silently dropping recommended phases (e.g.
886
+ // testgen) whenever the agent posted the plan via a body file (#814).
887
+ const resolved = resolveSpecRecommendation({
888
+ chatOutput: specResult.output ?? "",
889
+ issueNumber,
890
+ labels,
891
+ });
892
+ // `resolveSpecRecommendation` already excludes "spec" regardless of
893
+ // which step in the chain produced the result.
894
+ phases = resolved.phases;
895
+ detectedQualityLoop = resolved.qualityLoop;
896
+ if (logWriter) {
897
+ logWriter.setSpecRecommendation({ source: resolved.source, phases, qualityLoop: detectedQualityLoop }, issueNumber);
898
+ }
899
+ if (resolved.source === "marker") {
900
+ log(chalk.gray(` Spec recommends (marker): ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
901
+ }
902
+ else if (resolved.source === "comment-prose") {
903
+ log(chalk.gray(` Spec recommends (comment): ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
904
+ }
905
+ else if (resolved.source === "chat") {
906
+ log(chalk.gray(` Spec recommends (chat): ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
554
907
  }
555
908
  else {
556
- // Fall back to label-based detection
557
909
  log(chalk.yellow(` Could not parse spec recommendation, using label-based detection`));
558
- const detected = detectPhasesFromLabels(labels);
559
- phases = detected.phases.filter((p) => p !== "spec");
560
- detectedQualityLoop = detected.qualityLoop;
561
910
  log(chalk.gray(` Fallback: ${phases.join(" → ")}`));
562
911
  }
563
912
  }
@@ -640,6 +989,11 @@ export async function runIssueWithLogging(ctx) {
640
989
  // retry too, not just the inner /loop spawn — re-running a capped phase
641
990
  // would only cap again, and "surface + halt" means the user resumes.
642
991
  let haltedByCap = false;
992
+ // Set when a phase fails with a billing / out-of-credits error or a
993
+ // window-exhausted rate limit (#799): like the turn cap, re-running the phase
994
+ // (or spawning /loop) cannot succeed while the window is closed, so halt the
995
+ // outer quality loop and let the user resume once credits/window are restored.
996
+ let haltedByBilling = false;
643
997
  while (iteration < maxIterations) {
644
998
  iteration++;
645
999
  if (useQualityLoop && iteration > 1) {
@@ -671,8 +1025,24 @@ export async function runIssueWithLogging(ctx) {
671
1025
  // State tracking errors shouldn't stop execution
672
1026
  }
673
1027
  }
1028
+ // #915: iteration > 1 is the outer quality-loop's retry signal — the
1029
+ // same condition that triggers the "Quality loop iteration" log line
1030
+ // above. This loop re-runs the WHOLE `phases` list on every iteration
1031
+ // (it does not resume from the specific phase that failed), so the
1032
+ // escalation is per RETRIED ITERATION, not per specific-phase-that-
1033
+ // previously-failed: every phase dispatched while iteration > 1
1034
+ // escalates, including one that already succeeded on iteration 1 (e.g.
1035
+ // exec re-running alongside a retried qa). `withEscalatedEffort` is a
1036
+ // no-op (returns the input config by reference) whenever escalation is
1037
+ // off or this is the first attempt.
1038
+ const { config: dispatchConfig, record: escalationRecord } = withEscalatedEffort(withActivityHook(issueConfig, issueNumber, phase, onProgress, makeWaitTransition(phase)), phase, iteration > 1);
1039
+ if (escalationRecord && config.verbose) {
1040
+ log(chalk.gray(` effort: ${escalationRecord.base} → ${escalationRecord.escalated} (loop retry)`));
1041
+ }
674
1042
  const phaseStartTime = new Date();
675
- const result = await executePhaseWithRetry(issueNumber, phase, withActivityHook(issueConfig, issueNumber, phase, onProgress), resumeHandle, worktreePath, shutdownManager, phasePauseHandle);
1043
+ const result = await executePhaseWithRetry(issueNumber, phase, dispatchConfig, resumeHandle, worktreePath, shutdownManager, phasePauseHandle, undefined, // executePhaseFn — use the default
1044
+ undefined, // delayFn — use the default
1045
+ autoWaitLedger);
676
1046
  const phaseEndTime = new Date();
677
1047
  // Capture resume handle for subsequent phases (#674).
678
1048
  if (result.resumeHandle) {
@@ -686,7 +1056,15 @@ export async function runIssueWithLogging(ctx) {
686
1056
  }
687
1057
  }
688
1058
  }
689
- phaseResults.push(result);
1059
+ phaseResults.push(escalationRecord
1060
+ ? {
1061
+ ...result,
1062
+ escalatedEffort: {
1063
+ base: escalationRecord.base,
1064
+ escalated: escalationRecord.escalated,
1065
+ },
1066
+ }
1067
+ : result);
690
1068
  // Emit completion/failure progress event (AC-8)
691
1069
  const phaseDurationSec = Math.round((phaseEndTime.getTime() - phaseStartTime.getTime()) / 1000);
692
1070
  if (result.success) {
@@ -709,7 +1087,12 @@ export async function runIssueWithLogging(ctx) {
709
1087
  const extra = {
710
1088
  error: result.capped
711
1089
  ? "turn cap reached — partial output preserved (resume to continue)"
712
- : (result.error ?? "unknown"),
1090
+ : isBillingOrWindowHalt(result)
1091
+ ? // Billing / rate-limit-window halt (#799): name the real cause so
1092
+ // the run summary doesn't cascade into a downstream
1093
+ // `QA completed without a parseable verdict`.
1094
+ billingHaltReason(result)
1095
+ : (result.error ?? "unknown"),
713
1096
  iteration,
714
1097
  };
715
1098
  emitProgressLine(issueNumber, phase, "failed", extra);
@@ -722,13 +1105,21 @@ export async function runIssueWithLogging(ctx) {
722
1105
  }
723
1106
  // Log phase result with observability data (AC-1, AC-2, AC-3, AC-7)
724
1107
  if (logWriter) {
1108
+ // Resolve the diff base once (#878): worktrees branch from
1109
+ // origin/<base>, so both the diff stats and the phase-commit check
1110
+ // must compare against the resolved ref, not the local branch name.
1111
+ const resolvedDiffBase = worktreePath
1112
+ ? resolveDiffBase(worktreePath, baseBranch ?? "main")
1113
+ : undefined;
725
1114
  // Capture git diff stats for worktree phases (AC-1, AC-3)
726
- const diffStats = worktreePath
727
- ? getGitDiffStats(worktreePath, baseBranch)
1115
+ const diffStats = worktreePath && resolvedDiffBase
1116
+ ? getGitDiffStats(worktreePath, resolvedDiffBase)
728
1117
  : undefined;
729
- // Capture commit hash after phase (AC-2)
1118
+ // Capture commit hash after phase (AC-2) — undefined when the branch
1119
+ // never moved off its base, so a base tip is not logged as the
1120
+ // phase's commit (#878).
730
1121
  const commitHash = worktreePath
731
- ? getCommitHash(worktreePath)
1122
+ ? getCommitHash(worktreePath, resolvedDiffBase)
732
1123
  : undefined;
733
1124
  // Read cache metrics for QA phase (AC-7)
734
1125
  const cacheMetrics = phase === "qa" ? readCacheMetrics(worktreePath) : undefined;
@@ -789,6 +1180,9 @@ export async function runIssueWithLogging(ctx) {
789
1180
  // State tracking errors shouldn't stop execution
790
1181
  }
791
1182
  }
1183
+ // Durable halt-and-resume (#892 AC-1): a waitable-window halt writes
1184
+ // `resumeAt`; success or a non-window failure clears any stale record.
1185
+ await recordWindowHaltState(stateManager, issueNumber, phase, result);
792
1186
  if (result.success) {
793
1187
  // Phase succeeded — RunRenderer (#618) updates state via onProgress.
794
1188
  }
@@ -797,12 +1191,24 @@ export async function runIssueWithLogging(ctx) {
797
1191
  if (result.capped) {
798
1192
  haltedByCap = true;
799
1193
  }
1194
+ // Billing / rate-limit-window failure (#799): halt the outer quality
1195
+ // loop for the same reason as the turn cap — re-running the phase or
1196
+ // spawning /loop cannot succeed while credits/window are exhausted, and
1197
+ // doing so mislabels the halt as a downstream unparseable-verdict error.
1198
+ if (isBillingOrWindowHalt(result)) {
1199
+ haltedByBilling = true;
1200
+ }
800
1201
  // If quality loop enabled, run loop phase to fix issues.
801
1202
  // A turn-capped phase (#739) is incomplete, not a genuine quality
802
1203
  // failure: skip the loop and halt cleanly ("surface + halt"). Spawning
803
1204
  // /loop on partial output would act on incomplete work — exactly the
804
1205
  // risk the capped path is meant to avoid. The user resumes instead.
805
- if (useQualityLoop && iteration < maxIterations && !result.capped) {
1206
+ // A billing / rate-limit-window halt (#799) is skipped for the same
1207
+ // reason: /loop would re-spawn into the same closed window.
1208
+ if (useQualityLoop &&
1209
+ iteration < maxIterations &&
1210
+ !result.capped &&
1211
+ !haltedByBilling) {
806
1212
  // #624 Item 3 (AC-3.3): the loop phase carries the current outer
807
1213
  // iteration so the live-zone status cell can show `loop N/M`.
808
1214
  const loopStartExtra = { iteration };
@@ -823,7 +1229,9 @@ export async function runIssueWithLogging(ctx) {
823
1229
  promptContext: buildLoopContext(result),
824
1230
  };
825
1231
  const loopStartTime = new Date();
826
- const loopResult = await executePhaseWithRetry(issueNumber, "loop", withActivityHook(loopConfig, issueNumber, "loop", onProgress), resumeHandle, worktreePath, shutdownManager, phasePauseHandle);
1232
+ const loopResult = await executePhaseWithRetry(issueNumber, "loop", withActivityHook(loopConfig, issueNumber, "loop", onProgress, makeWaitTransition("loop")), resumeHandle, worktreePath, shutdownManager, phasePauseHandle, undefined, // executePhaseFn — use the default
1233
+ undefined, // delayFn — use the default
1234
+ autoWaitLedger);
827
1235
  const loopEndTime = new Date();
828
1236
  phaseResults.push(loopResult);
829
1237
  // #766: record the loop phase in the run log — spec (:655) and the
@@ -884,7 +1292,9 @@ export async function runIssueWithLogging(ctx) {
884
1292
  }
885
1293
  // A turn-capped phase (#739) halts the outer quality-loop retry as well —
886
1294
  // re-running would only cap again; the partial work is already preserved.
887
- if (haltedByCap) {
1295
+ // A billing / rate-limit-window failure (#799) halts for the same reason:
1296
+ // the retry re-spawns into the same closed window and cannot progress.
1297
+ if (haltedByCap || haltedByBilling) {
888
1298
  break;
889
1299
  }
890
1300
  // If we're not in quality loop mode, don't retry
@@ -896,10 +1306,42 @@ export async function runIssueWithLogging(ctx) {
896
1306
  // Success is determined by whether all phases completed in any iteration,
897
1307
  // not whether all accumulated phase results passed (which would fail after loop recovery)
898
1308
  const success = completedSuccessfully;
899
- // Update final issue status in state
1309
+ // #817: opt-in post-QA ready gate. When the standard phases succeed AND
1310
+ // `--ready-gate` was passed, drive the existing `sequant ready` engine
1311
+ // (qa→loop→qa to the configured policy) against this worktree BEFORE
1312
+ // checkpoint/rebase/PR — so the gate's auto-fix commits land in the PR. The
1313
+ // engine NEVER merges; it terminates with the issue `waiting_for_human_merge`
1314
+ // (ready) or `blocked` (guard halt). Without the flag this block is skipped
1315
+ // entirely, keeping the run path byte-identical (AC-5).
1316
+ const readyGateOutcome = config.readyGate && success && worktreePath
1317
+ ? await runReadyGateForIssue({
1318
+ issueNumber,
1319
+ worktreePath,
1320
+ config,
1321
+ shutdownManager,
1322
+ phasePauseHandle,
1323
+ onProgress,
1324
+ log,
1325
+ })
1326
+ : undefined;
1327
+ const readyGateResult = readyGateOutcome?.result;
1328
+ // Surfaced separately from `readyGateResult` so a gate that *crashed* renders
1329
+ // differently in the summary from one that ran — a silently-skipped gate on a
1330
+ // run the user explicitly opted into is the failure mode worth naming.
1331
+ const readyGateError = readyGateOutcome?.error;
1332
+ // Update final issue status in state. When the gate ran it owns the terminal
1333
+ // status (never `ready_for_merge` — that would read as auto-merge-ready and
1334
+ // defeat the human merge gate the gate deliberately stops at).
1335
+ // Hoisted out of the `if (stateManager)` block below because the checkpoint
1336
+ // warning also has to name this status, and naming the wrong one is exactly
1337
+ // the #837 inaccuracy being fixed here.
1338
+ const finalStatus = readyGateResult
1339
+ ? readyGateResult.issueStatus
1340
+ : success
1341
+ ? "ready_for_merge"
1342
+ : "in_progress";
900
1343
  if (stateManager) {
901
1344
  try {
902
- const finalStatus = success ? "ready_for_merge" : "in_progress";
903
1345
  await stateManager.updateIssueStatus(issueNumber, finalStatus);
904
1346
  }
905
1347
  catch {
@@ -910,11 +1352,17 @@ export async function runIssueWithLogging(ctx) {
910
1352
  // #760: chain resume rebases the next link onto this checkpoint, so a failure
911
1353
  // here is not silent — warn prominently and record it on the result (AC-4).
912
1354
  //
913
- // Note the status above is already `ready_for_merge`, so a re-run reads this
914
- // link as a completed prefix and does NOT redo it. Its uncommitted work is
915
- // therefore absent from the branch tip, which `computeChainResumePlan` detects
916
- // (dirty worktree fail fast) rather than wrong-basing the next link. The
917
- // message states that outcome exactly: the work must be committed, or --force.
1355
+ // Note a completed status was already written above `ready_for_merge`, or
1356
+ // `waiting_for_human_merge` when #817's `--ready-gate` owned the terminal
1357
+ // status (#837) so a re-run reads this link as a completed prefix and does
1358
+ // NOT redo it. Its uncommitted work is therefore absent from the branch tip,
1359
+ // which `computeChainResumePlan` detects (dirty worktree fail fast) rather
1360
+ // than wrong-basing the next link. The message states that outcome exactly:
1361
+ // the work must be committed, or --force.
1362
+ //
1363
+ // A gate that halted (`blocked`) is NOT a completed prefix, so that link is
1364
+ // re-executed on resume rather than skipped — see COMPLETED_STATUSES in
1365
+ // chain-resume.ts.
918
1366
  let checkpointFailed = false;
919
1367
  if (success && chainMode && worktreePath) {
920
1368
  const checkpointOk = createCheckpointCommit(worktreePath, issueNumber, config.verbose, baseBranch);
@@ -922,7 +1370,7 @@ export async function runIssueWithLogging(ctx) {
922
1370
  checkpointFailed = true;
923
1371
  log(chalk.yellow(` ⚠️ Checkpoint commit for #${issueNumber} could not be created — its uncommitted ` +
924
1372
  `changes are NOT on branch ${branch ?? "the feature branch"}. #${issueNumber} stays ` +
925
- `ready_for_merge, so a re-run will skip it and refuse to resume the chain here until the ` +
1373
+ `${finalStatus}, so a re-run will skip it and refuse to resume the chain here until the ` +
926
1374
  `work is committed in ${worktreePath} (or re-run with --force to redo the whole chain).`));
927
1375
  }
928
1376
  }
@@ -941,7 +1389,27 @@ export async function runIssueWithLogging(ctx) {
941
1389
  // Create PR after successful QA + rebase (unless --no-pr)
942
1390
  let prNumber;
943
1391
  let prUrl;
944
- const shouldCreatePR = success && worktreePath && branch && !options.noPr;
1392
+ // #879: a PR-creation failure after passing QA must fail the run, not print a
1393
+ // warning and leave the issue at `success`. Recorded here and folded into the
1394
+ // returned `success` below.
1395
+ let prCreationError;
1396
+ const wouldCreatePR = success && worktreePath && branch && !options.noPr;
1397
+ // #920: a phase-restricted run (e.g. `--phases spec`) provisions a worktree
1398
+ // and branch unconditionally of which phases ran, so a clean spec-only pass
1399
+ // satisfies every conjunct above with zero commits — `gh pr create` then
1400
+ // fails with "No commits between main and …" and the run reports failed
1401
+ // for work that landed exactly where it was supposed to (the plan comment).
1402
+ // Gate on the evidence (commits ahead of base) rather than the phase list:
1403
+ // `hasExecChanges` fails open (`unknown` counts as "has changes") so a git
1404
+ // error here falls through to today's attempt-PR behavior, and it resolves
1405
+ // the base the same #537-aware way `classifyExecChanges` already does for
1406
+ // the exec zero-diff guard.
1407
+ let prSkippedReason;
1408
+ if (wouldCreatePR && !hasExecChanges(worktreePath)) {
1409
+ prSkippedReason = `no commits ahead of ${baseBranch ?? "main"} (no implementing phase ran)`;
1410
+ log(chalk.gray(` ℹ️ PR skipped — ${prSkippedReason}`));
1411
+ }
1412
+ const shouldCreatePR = wouldCreatePR && !prSkippedReason;
945
1413
  if (shouldCreatePR) {
946
1414
  // #605: under --stacked, target predecessor branch (only for non-first,
947
1415
  // non-last issues). Last PR keeps `main` so partial progress can land.
@@ -954,7 +1422,10 @@ export async function runIssueWithLogging(ctx) {
954
1422
  // #749: surface a non-A+ qa verdict (e.g. AC_MET_BUT_NOT_A_PLUS) in the PR
955
1423
  // body so a reviewer sees why the run broke to PR rather than reaching A+.
956
1424
  const qaVerdict = phaseResults.find((p) => p.phase === "qa")?.verdict;
957
- const prResult = createPR(worktreePath, issueNumber, issueTitle, branch, config.verbose, labels, stackOptions, qaVerdict);
1425
+ const prResult = createPR(worktreePath, issueNumber, issueTitle, branch, config.verbose, labels, stackOptions, qaVerdict,
1426
+ // #817 AC-6: surface the ready-gate outcome in the PR body the same way
1427
+ // `sequant ready` reports it (threshold reached vs guard halt).
1428
+ readyGateResult?.report);
958
1429
  if (prResult.success && prResult.prNumber && prResult.prUrl) {
959
1430
  prNumber = prResult.prNumber;
960
1431
  prUrl = prResult.prUrl;
@@ -971,6 +1442,11 @@ export async function runIssueWithLogging(ctx) {
971
1442
  }
972
1443
  }
973
1444
  }
1445
+ else if (prResult.attempted && !prResult.success) {
1446
+ // #879: PR creation was attempted (branch/QA passed) but failed. This is
1447
+ // a run failure — the deliverable never reached GitHub.
1448
+ prCreationError = prResult.error ?? "PR creation failed";
1449
+ }
974
1450
  }
975
1451
  // Deactivate relay (#383) — archive inbox/outbox transcripts to
976
1452
  // .sequant/logs/relay/ before worktree teardown (AC-D2). Never throws.
@@ -989,15 +1465,31 @@ export async function runIssueWithLogging(ctx) {
989
1465
  }
990
1466
  }
991
1467
  }
1468
+ // #879: fold a PR-creation failure into the issue's overall verdict. Phases
1469
+ // all passed, but the run did not deliver — report it as failed.
1470
+ const overallSuccess = success && !prCreationError;
992
1471
  return {
993
1472
  issueNumber,
994
- success,
1473
+ success: overallSuccess,
995
1474
  phaseResults,
996
1475
  durationSeconds,
997
1476
  loopTriggered,
998
1477
  prNumber,
999
1478
  prUrl,
1479
+ prCreationError,
1480
+ prSkippedReason,
1000
1481
  checkpointFailed,
1001
- failureCategory: success ? undefined : deriveFailureCategory(phaseResults),
1482
+ failureCategory: overallSuccess
1483
+ ? undefined
1484
+ : // #920: a PR-creation failure has no failed phase for
1485
+ // `deriveFailureCategory` to classify — fall back to the dedicated
1486
+ // category so the metrics residual from #879 (empty failureCategory
1487
+ // on a PR-only failure) doesn't reopen for this failure path.
1488
+ (deriveFailureCategory(phaseResults) ??
1489
+ (prCreationError ? "pr_creation" : undefined)),
1490
+ // #817: present only when `--ready-gate` ran the gate; the summary renders
1491
+ // its terminal reason (AC-6).
1492
+ readyGate: readyGateResult,
1493
+ readyGateError,
1002
1494
  };
1003
1495
  }